Files
sam-react-prod/src/lib/api/authenticated-fetch.ts
유병철 3ef9570f3b feat(WEB): API 인프라 리팩토링, CEO 대시보드 현황판 개선 및 문서 시스템 강화
- API: fetch-wrapper/proxy/refresh-token 리팩토링, authenticated-fetch 신규 추가
- CEO 대시보드: EnhancedSections 현황판 기능 개선, dashboard transformers/types 확장
- 문서 시스템: ApprovalLine/DocumentHeader/DocumentToolbar/DocumentViewer 개선
- 작업지시서: 검사보고서/작업일지 문서 컴포넌트 개선 (벤딩/스크린/슬랫)
- 레이아웃: Sidebar/AuthenticatedLayout 수정
- 작업자화면: WorkerScreen 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 14:16:17 +09:00

96 lines
3.4 KiB
TypeScript

/**
* Authenticated Fetch Gateway
*
* 인증이 필요한 백엔드 API 호출의 유일한 게이트웨이.
* 401 감지 → refresh (globalThis 캐시) → retry 를 한 곳에서 처리.
*
* 이 모듈이 담당하는 것:
* - 요청 실행
* - 401 감지 → refreshAccessToken (globalThis 캐시로 프로세스 내 중복 방지)
* - 새 토큰으로 재시도
*
* 이 모듈이 담당하지 않는 것 (호출자가 처리):
* - 쿠키 읽기 (PROXY: request.cookies / Server Actions: cookies() API)
* - 쿠키 설정 (PROXY: Set-Cookie 헤더 / Server Actions: cookies() API)
* - 리다이렉트 (Server Actions: redirect('/login'))
* - 헤더 구성 (각 호출자가 자기 방식으로)
*/
import { refreshAccessToken, type RefreshResult } from './refresh-token';
export type AuthenticatedFetchResult = {
/** 백엔드 응답 (성공이든 실패든) */
response: Response;
/** refresh 성공 시 새 토큰 (호출자가 쿠키에 저장) */
newTokens?: RefreshResult;
/** true면 인증 실패 (호출자가 쿠키 삭제 + 리다이렉트 처리) */
authFailed?: boolean;
};
/**
* 인증된 백엔드 요청 실행
*
* 반환값 상태:
* - { response } → 정상 (401 아님)
* - { response, newTokens } → 401 → refresh 성공 → 재시도 성공
* - { response, authFailed: true } → 인증 실패 (refresh 불가/실패/재시도 실패)
*
* @param url 백엔드 API URL
* @param options fetch 옵션 (호출자가 Authorization 등 헤더 포함)
* @param refreshToken refresh_token (없으면 401 시 바로 실패 반환)
* @param caller 호출자 이름 (로그용: 'PROXY' | 'serverFetch' | 'ServerApiClient')
*/
export async function authenticatedFetch(
url: string,
options: RequestInit,
refreshToken: string | undefined,
caller: string = 'unknown'
): Promise<AuthenticatedFetchResult> {
// 1. 요청 실행 (호출자가 이미 모든 헤더 설정)
const response = await fetch(url, options);
// 2. 401이 아니면 그대로 반환
if (response.status !== 401) {
return { response };
}
// 3. 401이지만 refresh_token 없음 → 인증 실패
if (!refreshToken) {
console.warn(`🔴 [${caller}] 401 (no refresh token)`);
return { response, authFailed: true };
}
// 4. 401 + refresh_token 있음 → 갱신 시도 (globalThis 캐시로 중복 방지)
console.log(`🔄 [${caller}] Got 401, attempting token refresh...`);
const refreshResult = await refreshAccessToken(refreshToken, caller);
if (!refreshResult.success || !refreshResult.accessToken) {
console.warn(`🔴 [${caller}] Token refresh failed`);
return { response, authFailed: true };
}
// 5. 새 토큰으로 재시도
console.log(`✅ [${caller}] Token refreshed, retrying...`);
const retryHeaders = new Headers(options.headers || {});
retryHeaders.set('Authorization', `Bearer ${refreshResult.accessToken}`);
const retryResponse = await fetch(url, {
...options,
headers: retryHeaders,
});
console.log(`🔵 [${caller}] Retry status: ${retryResponse.status}`);
// 6. 재시도도 401 → 인증 실패
if (retryResponse.status === 401) {
console.warn(`🔴 [${caller}] Retry still 401, auth failed`);
return { response: retryResponse, authFailed: true };
}
// 7. 재시도 성공 → 새 토큰과 함께 반환 (호출자가 쿠키 설정)
return {
response: retryResponse,
newTokens: refreshResult,
};
}