import { NextRequest, NextResponse } from 'next/server'; /** * ๐Ÿ”ต Catch-All API Proxy (HttpOnly Cookie Pattern) * * โšก ์„ค๊ณ„ ๋ชฉ์ : * - HttpOnly ์ฟ ํ‚ค ๋ณด์•ˆ ์œ ์ง€: JavaScript ์ ‘๊ทผ ์ฐจ๋‹จ * - ๋ชจ๋“  ๋ฐฑ์—”๋“œ API๋ฅผ ๋‹จ์ผ ํ”„๋ก์‹œ๋กœ ์ฒ˜๋ฆฌ * - ์„œ๋ฒ„์—์„œ ์ฟ ํ‚ค ์ฝ์–ด Authorization ํ—ค๋” ์ž๋™ ์ถ”๊ฐ€ * * ๐Ÿ”„ ๋™์ž‘ ํ๋ฆ„: * 1. ํด๋ผ์ด์–ธํŠธ โ†’ Next.js /api/proxy/* (ํ† ํฐ ์—†์ด) * 2. Next.js: HttpOnly ์ฟ ํ‚ค์—์„œ access_token ์ฝ๊ธฐ (์„œ๋ฒ„์—์„œ๋งŒ ๊ฐ€๋Šฅ) * 3. Next.js โ†’ PHP Backend /api/v1/* (Authorization ํ—ค๋” ํฌํ•จ) * 4. PHP Backend โ†’ Next.js (์‘๋‹ต) * 5. Next.js โ†’ ํด๋ผ์ด์–ธํŠธ (์‘๋‹ต ์ „๋‹ฌ) * * ๐Ÿ” ๋ณด์•ˆ ํŠน์ง•: * - HttpOnly ์ฟ ํ‚ค: JavaScript ์ ‘๊ทผ ๋ถˆ๊ฐ€ (XSS ๋ฐฉ์ง€) * - ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ํ† ํฐ ์ฒ˜๋ฆฌ: ๋ธŒ๋ผ์šฐ์ €์— ํ† ํฐ ๋…ธ์ถœ ์•ˆ๋จ * - ์ž๋™ ์ธ์ฆ ํ—ค๋” ์ถ”๊ฐ€: ํด๋ผ์ด์–ธํŠธ๋Š” ์‹ ๊ฒฝ ์“ธ ํ•„์š” ์—†์Œ * * ๐Ÿ“ ์‚ฌ์šฉ ์˜ˆ์‹œ: * - Frontend: fetch('/api/proxy/item-master/init') * - Backend: GET https://api.codebridge-x.com/api/v1/item-master/init * * โš ๏ธ ์ฃผ์˜: * - ๋กœ๊ทธ์•„์›ƒ API(/api/auth/logout)์™€ ๋™์ผํ•œ ํŒจํ„ด * - ๋ชจ๋“  HTTP ๋ฉ”์„œ๋“œ ์ง€์› (GET, POST, PUT, DELETE) * - ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ์™€ ์š”์ฒญ ๋ฐ”๋”” ๋ชจ๋‘ ์ „๋‹ฌ */ /** * ํ† ํฐ ๊ฐฑ์‹  ํ•จ์ˆ˜ (access_token ๋งŒ๋ฃŒ ์‹œ refresh_token์œผ๋กœ ๊ฐฑ์‹ ) */ async function refreshAccessToken(refreshToken: string): Promise<{ success: boolean; accessToken?: string; refreshToken?: string; expiresIn?: number; }> { try { const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', }, body: JSON.stringify({ refresh_token: refreshToken, }), }); if (!response.ok) { console.warn('๐Ÿ”ด [PROXY] Token refresh failed'); return { success: false }; } const data = await response.json(); console.log('โœ… [PROXY] Token refreshed successfully'); return { success: true, accessToken: data.access_token, refreshToken: data.refresh_token, expiresIn: data.expires_in, }; } catch (error) { console.error('๐Ÿ”ด [PROXY] Token refresh error:', error); return { success: false }; } } /** * ๋ฐฑ์—”๋“œ API ์š”์ฒญ ์‹คํ–‰ ํ•จ์ˆ˜ */ async function executeBackendRequest( url: URL, method: string, token: string | undefined, body: string | undefined, contentType: string ): Promise { return fetch(url.toString(), { method, headers: { 'Content-Type': contentType, 'Accept': 'application/json', 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', 'Authorization': token ? `Bearer ${token}` : '', }, body, }); } /** * ์ฟ ํ‚ค ์ƒ์„ฑ ํ—ฌํผ ํ•จ์ˆ˜ */ function createTokenCookies(tokens: { accessToken?: string; refreshToken?: string; expiresIn?: number }) { const cookies: string[] = []; if (tokens.accessToken) { cookies.push([ `access_token=${tokens.accessToken}`, 'HttpOnly', 'Secure', 'SameSite=Strict', 'Path=/', `Max-Age=${tokens.expiresIn || 7200}`, ].join('; ')); } if (tokens.refreshToken) { cookies.push([ `refresh_token=${tokens.refreshToken}`, 'HttpOnly', 'Secure', 'SameSite=Strict', 'Path=/', 'Max-Age=604800', // 7 days ].join('; ')); } return cookies; } /** * ์ฟ ํ‚ค ์‚ญ์ œ ํ—ฌํผ ํ•จ์ˆ˜ (ํ† ํฐ ๋งŒ๋ฃŒ ์‹œ) */ function createClearTokenCookies(): string[] { return [ 'access_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0', 'refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0', ]; } /** * Catch-all proxy handler for all HTTP methods * * ๐Ÿ”„ ํ† ํฐ ๊ฐฑ์‹  ๋กœ์ง: * 1. ํ˜„์žฌ access_token์œผ๋กœ ๋ฐฑ์—”๋“œ ์š”์ฒญ * 2. 401 ์‘๋‹ต ์‹œ โ†’ refresh_token์œผ๋กœ ์ƒˆ ํ† ํฐ ๋ฐœ๊ธ‰ * 3. ์ƒˆ ํ† ํฐ์œผ๋กœ ์›๋ž˜ ์š”์ฒญ ์žฌ์‹œ๋„ * 4. ์žฌ์‹œ๋„๋„ ์‹คํŒจํ•˜๋ฉด โ†’ ์ฟ ํ‚ค ์‚ญ์ œ ํ›„ 401 ๋ฐ˜ํ™˜ */ async function proxyRequest( request: NextRequest, params: { path: string[] }, method: string ) { try { // 1. HttpOnly ์ฟ ํ‚ค์—์„œ ํ† ํฐ ์ฝ๊ธฐ (์„œ๋ฒ„์—์„œ๋งŒ ๊ฐ€๋Šฅ!) let token = request.cookies.get('access_token')?.value; const refreshToken = request.cookies.get('refresh_token')?.value; // 2. ๋ฐฑ์—”๋“œ URL ๊ตฌ์„ฑ const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/${params.path.join('/')}`; const url = new URL(backendUrl); request.nextUrl.searchParams.forEach((value, key) => { url.searchParams.append(key, value); }); // 3. ์š”์ฒญ ๋ฐ”๋”” ์ฝ๊ธฐ (POST, PUT, DELETE, PATCH) let body: string | undefined; const contentType = request.headers.get('content-type') || 'application/json'; if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) { if (contentType.includes('application/json')) { body = await request.text(); console.log('๐Ÿ”ต [PROXY] Request:', method, url.toString()); console.log('๐Ÿ”ต [PROXY] Request Body:', body); // ๋””๋ฒ„๊น…์šฉ } else if (contentType.includes('multipart/form-data')) { // multipart๋Š” formData๋กœ ์ฒ˜๋ฆฌํ•ด์•ผ ํ•˜์ง€๋งŒ, ํ˜„์žฌ๋Š” ์ง€์›ํ•˜์ง€ ์•Š์Œ console.warn('๐ŸŸก [PROXY] multipart/form-data is not fully supported'); body = await request.text(); } } else { console.log('๐Ÿ”ต [PROXY] Request:', method, url.toString()); } // 4. ๋ฐฑ์—”๋“œ๋กœ ํ”„๋ก์‹œ ์š”์ฒญ let backendResponse = await executeBackendRequest(url, method, token, body, contentType); let newTokens: { accessToken?: string; refreshToken?: string; expiresIn?: number } | null = null; // 5. ๐Ÿ”„ 401 ์‘๋‹ต ์‹œ ํ† ํฐ ๊ฐฑ์‹  ํ›„ ์žฌ์‹œ๋„ if (backendResponse.status === 401 && refreshToken) { console.log('๐Ÿ”„ [PROXY] Got 401, attempting token refresh...'); const refreshResult = await refreshAccessToken(refreshToken); if (refreshResult.success && refreshResult.accessToken) { console.log('โœ… [PROXY] Token refreshed, retrying original request...'); // ์ƒˆ ํ† ํฐ์œผ๋กœ ์›๋ž˜ ์š”์ฒญ ์žฌ์‹œ๋„ token = refreshResult.accessToken; newTokens = refreshResult; backendResponse = await executeBackendRequest(url, method, token, body, contentType); console.log('๐Ÿ”ต [PROXY] Retry response status:', backendResponse.status); } else { // ๋ฆฌํ”„๋ ˆ์‹œ ์‹คํŒจ โ†’ ์ฟ ํ‚ค ์‚ญ์ œํ•˜๊ณ  401 ๋ฐ˜ํ™˜ console.warn('๐Ÿ”ด [PROXY] Token refresh failed, clearing cookies...'); const clearResponse = NextResponse.json( { error: 'Authentication failed', needsReauth: true }, { status: 401 } ); createClearTokenCookies().forEach(cookie => { clearResponse.headers.append('Set-Cookie', cookie); }); return clearResponse; } } // 6. ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ฝ๊ธฐ const responseData = await backendResponse.text(); console.log('๐Ÿ”ต [PROXY] Response status:', backendResponse.status); // 7. ํด๋ผ์ด์–ธํŠธ๋กœ ์‘๋‹ต ์ „๋‹ฌ const clientResponse = new NextResponse(responseData, { status: backendResponse.status, headers: { 'Content-Type': backendResponse.headers.get('content-type') || 'application/json', }, }); // 8. ํ† ํฐ์ด ๊ฐฑ์‹ ๋˜์—ˆ์œผ๋ฉด ์ƒˆ ์ฟ ํ‚ค ์„ค์ • if (newTokens && newTokens.accessToken) { createTokenCookies(newTokens).forEach(cookie => { clientResponse.headers.append('Set-Cookie', cookie); }); console.log('๐Ÿช [PROXY] New tokens set in cookies'); } return clientResponse; } catch (error) { console.error('Proxy request error:', error); return NextResponse.json( { error: 'Proxy server error' }, { status: 500 } ); } } /** * GET ์š”์ฒญ ํ”„๋ก์‹œ * Next.js 15: params๋Š” Promise์ด๋ฏ€๋กœ await ํ•„์š” */ export async function GET( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } ) { const resolvedParams = await params; return proxyRequest(request, resolvedParams, 'GET'); } /** * POST ์š”์ฒญ ํ”„๋ก์‹œ * Next.js 15: params๋Š” Promise์ด๋ฏ€๋กœ await ํ•„์š” */ export async function POST( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } ) { const resolvedParams = await params; return proxyRequest(request, resolvedParams, 'POST'); } /** * PUT ์š”์ฒญ ํ”„๋ก์‹œ * Next.js 15: params๋Š” Promise์ด๋ฏ€๋กœ await ํ•„์š” */ export async function PUT( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } ) { const resolvedParams = await params; return proxyRequest(request, resolvedParams, 'PUT'); } /** * DELETE ์š”์ฒญ ํ”„๋ก์‹œ * Next.js 15: params๋Š” Promise์ด๋ฏ€๋กœ await ํ•„์š” */ export async function DELETE( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } ) { const resolvedParams = await params; return proxyRequest(request, resolvedParams, 'DELETE'); } /** * PATCH ์š”์ฒญ ํ”„๋ก์‹œ * Next.js 15: params๋Š” Promise์ด๋ฏ€๋กœ await ํ•„์š” * ์šฉ๋„: toggle ์—”๋“œํฌ์ธํŠธ (/clients/{id}/toggle, /client-groups/{id}/toggle) */ export async function PATCH( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } ) { const resolvedParams = await params; return proxyRequest(request, resolvedParams, 'PATCH'); }