feat: fetchWrapper 마이그레이션 및 토큰 리프레시 캐싱 구현
- 40+ actions.ts 파일을 fetchWrapper 패턴으로 마이그레이션 - 토큰 리프레시 캐싱 로직 추가 (refresh-token.ts) - ApiErrorContext 추가로 전역 에러 처리 개선 - HR EmployeeForm 컴포넌트 개선 - 참조함(ReferenceBox) 기능 수정 - juil 테스트 URL 페이지 추가 - claudedocs 문서 업데이트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { refreshAccessToken } from '@/lib/api/refresh-token';
|
||||
|
||||
/**
|
||||
* 🔵 Catch-All API Proxy (HttpOnly Cookie Pattern)
|
||||
@@ -30,48 +31,6 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
* - 쿼리 파라미터와 요청 바디 모두 전달
|
||||
*/
|
||||
|
||||
/**
|
||||
* 토큰 갱신 함수 (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.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 요청 실행 함수
|
||||
*
|
||||
@@ -109,13 +68,14 @@ async function executeBackendRequest(
|
||||
*/
|
||||
function createTokenCookies(tokens: { accessToken?: string; refreshToken?: string; expiresIn?: number }) {
|
||||
const cookies: string[] = [];
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
if (tokens.accessToken) {
|
||||
cookies.push([
|
||||
`access_token=${tokens.accessToken}`,
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Strict',
|
||||
...(isProduction ? ['Secure'] : []), // HTTPS only in production
|
||||
'SameSite=Lax', // Lax for better compatibility (matches login route)
|
||||
'Path=/',
|
||||
`Max-Age=${tokens.expiresIn || 7200}`,
|
||||
].join('; '));
|
||||
@@ -125,8 +85,8 @@ function createTokenCookies(tokens: { accessToken?: string; refreshToken?: strin
|
||||
cookies.push([
|
||||
`refresh_token=${tokens.refreshToken}`,
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Strict',
|
||||
...(isProduction ? ['Secure'] : []), // HTTPS only in production
|
||||
'SameSite=Lax', // Lax for better compatibility (matches login route)
|
||||
'Path=/',
|
||||
'Max-Age=604800', // 7 days
|
||||
].join('; '));
|
||||
@@ -139,9 +99,11 @@ function createTokenCookies(tokens: { accessToken?: string; refreshToken?: strin
|
||||
* 쿠키 삭제 헬퍼 함수 (토큰 만료 시)
|
||||
*/
|
||||
function createClearTokenCookies(): string[] {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const secureFlag = isProduction ? '; Secure' : '';
|
||||
return [
|
||||
'access_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0',
|
||||
'refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0',
|
||||
`access_token=; HttpOnly${secureFlag}; SameSite=Lax; Path=/; Max-Age=0`,
|
||||
`refresh_token=; HttpOnly${secureFlag}; SameSite=Lax; Path=/; Max-Age=0`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -220,7 +182,7 @@ async function proxyRequest(
|
||||
if (backendResponse.status === 401 && refreshToken) {
|
||||
console.log('🔄 [PROXY] Got 401, attempting token refresh...');
|
||||
|
||||
const refreshResult = await refreshAccessToken(refreshToken);
|
||||
const refreshResult = await refreshAccessToken(refreshToken, 'PROXY');
|
||||
|
||||
if (refreshResult.success && refreshResult.accessToken) {
|
||||
console.log('✅ [PROXY] Token refreshed, retrying original request...');
|
||||
@@ -232,19 +194,40 @@ async function proxyRequest(
|
||||
|
||||
console.log('🔵 [PROXY] Retry response status:', backendResponse.status);
|
||||
} else {
|
||||
// 리프레시 실패 → 쿠키 삭제하고 401 반환
|
||||
console.warn('🔴 [PROXY] Token refresh failed, clearing cookies...');
|
||||
// 🔄 리프레시 실패 → 다른 요청이 동시에 refresh 중일 수 있음
|
||||
// 짧은 딜레이 후 한 번 더 refresh 시도
|
||||
console.log('🔄 [PROXY] Refresh failed, waiting and retrying...');
|
||||
await new Promise(resolve => setTimeout(resolve, 500)); // 500ms 대기
|
||||
|
||||
const clearResponse = NextResponse.json(
|
||||
{ error: 'Authentication failed', needsReauth: true },
|
||||
{ status: 401 }
|
||||
);
|
||||
// 다시 refresh 시도 (다른 요청이 새 refresh_token을 발급받았을 수 있음)
|
||||
const latestRefreshToken = request.cookies.get('refresh_token')?.value;
|
||||
if (latestRefreshToken) {
|
||||
const retryResult = await refreshAccessToken(latestRefreshToken, 'PROXY');
|
||||
|
||||
createClearTokenCookies().forEach(cookie => {
|
||||
clearResponse.headers.append('Set-Cookie', cookie);
|
||||
});
|
||||
if (retryResult.success && retryResult.accessToken) {
|
||||
console.log('✅ [PROXY] Retry refresh succeeded!');
|
||||
token = retryResult.accessToken;
|
||||
newTokens = retryResult;
|
||||
backendResponse = await executeBackendRequest(url, method, token, body, contentType, isFormData);
|
||||
console.log('🔵 [PROXY] Retry response status:', backendResponse.status);
|
||||
}
|
||||
}
|
||||
|
||||
return clearResponse;
|
||||
// 여전히 401이면 쿠키 삭제하고 401 반환
|
||||
if (backendResponse.status === 401) {
|
||||
console.warn('🔴 [PROXY] Token refresh failed after retry, clearing cookies...');
|
||||
|
||||
const clearResponse = NextResponse.json(
|
||||
{ error: 'Authentication failed', needsReauth: true },
|
||||
{ status: 401 }
|
||||
);
|
||||
|
||||
createClearTokenCookies().forEach(cookie => {
|
||||
clearResponse.headers.append('Set-Cookie', cookie);
|
||||
});
|
||||
|
||||
return clearResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user