diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index c031012b..2944a4fe 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -20,12 +20,231 @@ export { type CommonCode, } from './common-codes'; -// Server-side API 클라이언트 인스턴스 -// 서버 액션에서 사용 -import { ApiClient } from './client'; +// Server-side API 클라이언트 +// 서버 액션에서 쿠키 기반 Bearer 토큰 자동 포함 +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; import { AUTH_CONFIG } from './auth/auth-config'; +import { refreshAccessToken } from './refresh-token'; +import { isNextRedirectError } from '@/lib/utils/redirect-error'; -export const apiClient = new ApiClient({ - mode: 'api-key', - apiKey: process.env.API_KEY, -}); \ No newline at end of file +/** + * Server Actions 전용 API 클라이언트 + * + * 특징: + * - 쿠키에서 access_token 자동 읽기 + * - X-API-KEY + Bearer 토큰 자동 포함 + * - 401 발생 시 토큰 자동 갱신 후 재시도 + */ +class ServerApiClient { + private baseURL: string; + private apiKey: string; + + constructor() { + // API URL에 /api/v1 prefix 자동 추가 + const apiUrl = AUTH_CONFIG.apiUrl.replace(/\/$/, ''); // trailing slash 제거 + this.baseURL = `${apiUrl}/api/v1`; + this.apiKey = process.env.API_KEY || ''; + } + + /** + * 쿠키에서 인증 헤더 생성 (async) + */ + private async getAuthHeaders(token?: string): Promise> { + const cookieStore = await cookies(); + const accessToken = token || cookieStore.get('access_token')?.value; + + return { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-API-KEY': this.apiKey, + ...(accessToken ? { 'Authorization': `Bearer ${accessToken}` } : {}), + }; + } + + /** + * 토큰 쿠키 삭제 (인증 실패 시) + */ + private async clearTokenCookies() { + const cookieStore = await cookies(); + cookieStore.delete('access_token'); + cookieStore.delete('refresh_token'); + cookieStore.delete('token_refreshed_at'); + cookieStore.delete('is_authenticated'); + console.log('🗑️ [ServerApiClient] 토큰 쿠키 삭제 완료'); + } + + /** + * 새 토큰을 쿠키에 저장 + */ + private async setNewTokenCookies(tokens: { + accessToken?: string; + refreshToken?: string; + expiresIn?: number; + }) { + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production'; + + if (tokens.accessToken) { + cookieStore.set('access_token', tokens.accessToken, { + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + path: '/', + maxAge: tokens.expiresIn || 7200, + }); + + cookieStore.set('token_refreshed_at', Date.now().toString(), { + httpOnly: false, + secure: isProduction, + sameSite: 'lax', + path: '/', + maxAge: 60, + }); + } + + if (tokens.refreshToken) { + cookieStore.set('refresh_token', tokens.refreshToken, { + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + path: '/', + maxAge: 604800, + }); + } + } + + /** + * HTTP 요청 실행 (토큰 자동 갱신 포함) + */ + private async request( + endpoint: string, + options?: RequestInit & { skipAuthRetry?: boolean } + ): Promise { + try { + const cookieStore = await cookies(); + const refreshToken = cookieStore.get('refresh_token')?.value; + const headers = await this.getAuthHeaders(); + + const url = `${this.baseURL}${endpoint}`; + let response = await fetch(url, { + ...options, + headers: { + ...headers, + ...options?.headers, + }, + cache: 'no-store', + }); + + // 401 발생 시 토큰 갱신 후 재시도 + if (response.status === 401 && !options?.skipAuthRetry && refreshToken) { + console.log('🔄 [ServerApiClient] 401 발생, 토큰 갱신 시도...'); + + const refreshResult = await refreshAccessToken(refreshToken, 'ServerApiClient'); + + if (refreshResult.success && refreshResult.accessToken) { + console.log('✅ [ServerApiClient] 토큰 갱신 성공, 재시도...'); + await this.setNewTokenCookies(refreshResult); + + const newHeaders = await this.getAuthHeaders(refreshResult.accessToken); + response = await fetch(url, { + ...options, + headers: { + ...newHeaders, + ...options?.headers, + }, + cache: 'no-store', + }); + + if (response.status === 401) { + console.warn('🔴 [ServerApiClient] 재시도 실패, 로그인 리다이렉트'); + await this.clearTokenCookies(); + redirect('/login'); + } + } else { + console.warn('🔴 [ServerApiClient] 토큰 갱신 실패, 로그인 리다이렉트'); + await this.clearTokenCookies(); + redirect('/login'); + } + } else if (response.status === 401 && !options?.skipAuthRetry) { + console.warn('🔴 [ServerApiClient] 401 (refresh token 없음), 로그인 리다이렉트'); + await this.clearTokenCookies(); + redirect('/login'); + } + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw { + status: response.status, + message: errorData.message || 'An error occurred', + errors: errorData.errors, + code: errorData.code, + }; + } + + if (response.status === 204) { + return undefined as T; + } + + return await response.json(); + } catch (error) { + if (isNextRedirectError(error)) throw error; + throw error; + } + } + + /** + * GET 요청 + */ + async get(endpoint: string, options?: { params?: Record }): Promise { + let url = endpoint; + if (options?.params) { + const searchParams = new URLSearchParams(options.params); + url = `${endpoint}?${searchParams.toString()}`; + } + return this.request(url, { method: 'GET' }); + } + + /** + * POST 요청 + */ + async post(endpoint: string, data?: unknown): Promise { + return this.request(endpoint, { + method: 'POST', + body: data ? JSON.stringify(data) : undefined, + }); + } + + /** + * PUT 요청 + */ + async put(endpoint: string, data?: unknown): Promise { + return this.request(endpoint, { + method: 'PUT', + body: data ? JSON.stringify(data) : undefined, + }); + } + + /** + * PATCH 요청 + */ + async patch(endpoint: string, data?: unknown): Promise { + return this.request(endpoint, { + method: 'PATCH', + body: data ? JSON.stringify(data) : undefined, + }); + } + + /** + * DELETE 요청 + */ + async delete(endpoint: string, options?: { data?: unknown }): Promise { + return this.request(endpoint, { + method: 'DELETE', + body: options?.data ? JSON.stringify(options.data) : undefined, + }); + } +} + +// 서버 액션용 API 클라이언트 인스턴스 +export const apiClient = new ServerApiClient(); \ No newline at end of file