diff --git a/AGENTS.md b/AGENTS.md index 1c755aa..a3d9e39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,9 +79,16 @@ Shared axios clients live in `src/shared/api/axiosInstance.ts`. - `apiInstance`: main API. In development its base URL is empty so Vite proxy handles `/api`; in production it uses `VITE_API_URL`. - `chatInstance`: chat/interview API, based on `VITE_CHAT_URL`. - All clients use credentials and sync access tokens from responses. -- 401 responses try `/api/v1/auth/refresh`; if refresh fails, the token is cleared and the user is sent to `/login`. +- 401 responses try `/api/v1/auth/refresh`; if refresh fails, the token is cleared and the user is sent to `/login`. Concurrent requests share one refresh call. +- `apiInstance` and `chatInstance` require an access token. When refresh cannot supply one, the request fails locally with `로그인이 필요합니다.` instead of being sent without an `Authorization` header. - `apiInstance` removes `Content-Type` automatically for `FormData`. +Backend auth contract: + +- A missing token and an expired token both return `401 { "message": ... }`. There is no 400 auth error anymore. +- Ownership checks on answer/question queries return `403 { "message": ... }` where a 404 used to be expected. A 403 must not clear the session. +- Read error text through `extractErrorMessage` in `src/shared/api/errorMessage.ts`; it falls back to status-specific Korean copy for 401 and 403. + Relevant environment variables: - `VITE_AUTH_URL` diff --git a/src/features/interview-page/interview/api/prepare-interview/index.ts b/src/features/interview-page/interview/api/prepare-interview/index.ts index c1c63a1..1f1dc5b 100644 --- a/src/features/interview-page/interview/api/prepare-interview/index.ts +++ b/src/features/interview-page/interview/api/prepare-interview/index.ts @@ -1,4 +1,12 @@ -import { API_URL, ensureAccessToken } from "@/shared/api/axiosInstance"; +import { + API_URL, + ensureAccessToken, + handleAuthenticationFailure, +} from "@/shared/api/axiosInstance"; +import { + AUTH_REQUIRED_MESSAGE, + FORBIDDEN_MESSAGE, +} from "@/shared/api/errorMessage"; import { getCurrentUserId, @@ -43,6 +51,37 @@ const getSsePayload = (data: string) => { } }; +// axios 인터셉터를 타지 않는 요청이라 401/403 응답을 여기서 직접 정리한다. +const getResponseErrorMessage = async (response: Response, fallback: string) => { + try { + const payload = getRecord(await response.json()); + + return getTrimmedString(payload?.message) ?? fallback; + } catch { + return fallback; + } +}; + +const throwSubscribeResponseError = async (response: Response) => { + if (response.status === 401) { + const errorMessage = await getResponseErrorMessage( + response, + AUTH_REQUIRED_MESSAGE, + ); + + handleAuthenticationFailure(); + throw new Error(errorMessage); + } + + if (response.status === 403) { + throw new Error( + await getResponseErrorMessage(response, FORBIDDEN_MESSAGE), + ); + } + + throw new Error(`AI SSE request failed: ${response.status}`); +}; + const getSseErrorMessage = (data: string, fallback: string) => { const payload = getSsePayload(data); @@ -90,7 +129,7 @@ export const waitForInterviewReady = async ( }); if (!response.ok) { - throw new Error(`AI SSE request failed: ${response.status}`); + await throwSubscribeResponseError(response); } if (!response.body) { diff --git a/src/shared/api/axiosInstance.ts b/src/shared/api/axiosInstance.ts index bdf0f4d..e145da8 100644 --- a/src/shared/api/axiosInstance.ts +++ b/src/shared/api/axiosInstance.ts @@ -10,6 +10,7 @@ import { getAccessToken, syncAccessTokenFromResponse, } from "./accessToken"; +import { AUTH_REQUIRED_MESSAGE } from "./errorMessage"; const resolveServerUrl = (url?: string) => { if (!url) return ""; @@ -55,11 +56,24 @@ interface RetryableRequestConfig extends InternalAxiosRequestConfig { _retry?: boolean; } +const LOGIN_PATH = "/login"; +const AUTH_PAGE_PATHS = [LOGIN_PATH, "/signup"]; + const goToLoginPage = () => { - window.location.href = "/login"; + if (AUTH_PAGE_PATHS.includes(window.location.pathname)) { + return; + } + + window.location.href = LOGIN_PATH; +}; + +// 토큰 누락과 만료가 모두 401로 통일됐으므로 두 경우를 같은 흐름으로 정리한다. +export const handleAuthenticationFailure = () => { + clearAccessToken(); + goToLoginPage(); }; -const tryRefreshSession = async () => { +const requestRefreshSession = async () => { try { const response = await refreshInstance.post("/api/v1/auth/refresh"); const refreshedAccessToken = syncAccessTokenFromResponse({ @@ -72,6 +86,20 @@ const tryRefreshSession = async () => { } }; +// 모든 요청이 토큰을 요구하게 되면서 재발급이 동시에 여러 번 호출될 수 있어, +// 진행 중인 요청 하나를 공유한다. +let refreshSessionPromise: Promise | null = null; + +const tryRefreshSession = () => { + if (!refreshSessionPromise) { + refreshSessionPromise = requestRefreshSession().finally(() => { + refreshSessionPromise = null; + }); + } + + return refreshSessionPromise; +}; + const getReadyAccessToken = async (shouldRefreshBeforeRequest: boolean) => { const currentAccessToken = getAccessToken(); if (currentAccessToken || !shouldRefreshBeforeRequest) { @@ -86,7 +114,8 @@ export const ensureAccessToken = async () => { const authorizationHeader = await getReadyAccessToken(true); if (!authorizationHeader) { - throw new Error("로그인 토큰을 찾지 못했습니다. 다시 로그인해 주세요."); + handleAuthenticationFailure(); + throw new Error(AUTH_REQUIRED_MESSAGE); } return authorizationHeader; @@ -94,13 +123,20 @@ export const ensureAccessToken = async () => { const addAuthorizationInterceptor = ( instance: AxiosInstance, - options: { refreshBeforeRequest?: boolean } = {}, + options: { refreshBeforeRequest?: boolean; requireAccessToken?: boolean } = {}, ) => { instance.interceptors.request.use(async (config) => { const authorizationHeader = await getReadyAccessToken( options.refreshBeforeRequest ?? false, ); - if (!authorizationHeader) return config; + + // 서버가 모든 엔드포인트에서 토큰을 요구하므로, 헤더 없이 보내면 401만 돌아온다. + if (!authorizationHeader) { + if (!options.requireAccessToken) return config; + + handleAuthenticationFailure(); + throw new Error(AUTH_REQUIRED_MESSAGE); + } const nextHeaders = axios.AxiosHeaders.from(config.headers) as AxiosHeaders; if (!nextHeaders.has("Authorization")) { @@ -180,8 +216,7 @@ const addRefreshInterceptor = (instance: AxiosInstance) => { } if (error.response?.status === 401 && !isSessionlessRequest) { - clearAccessToken(); - goToLoginPage(); + handleAuthenticationFailure(); } return Promise.reject(error); }, @@ -196,5 +231,11 @@ addRefreshInterceptor(apiInstance); addRefreshInterceptor(chatInstance); addFormDataInterceptor(apiInstance); addAuthorizationInterceptor(authInstance); -addAuthorizationInterceptor(apiInstance, { refreshBeforeRequest: true }); -addAuthorizationInterceptor(chatInstance, { refreshBeforeRequest: true }); +addAuthorizationInterceptor(apiInstance, { + refreshBeforeRequest: true, + requireAccessToken: true, +}); +addAuthorizationInterceptor(chatInstance, { + refreshBeforeRequest: true, + requireAccessToken: true, +}); diff --git a/src/shared/api/errorMessage.ts b/src/shared/api/errorMessage.ts index c2bf095..b009f2d 100644 --- a/src/shared/api/errorMessage.ts +++ b/src/shared/api/errorMessage.ts @@ -89,14 +89,29 @@ const getMessageFromValue = (value: unknown, depth = 0): string | null => { return getTrimmedString(record.code); }; +// 백엔드 인증 계약: 토큰 누락과 만료가 모두 401 { message } 로 통일됐고, +// 소유권 검사에 걸린 조회는 404가 아니라 403 { message } 로 내려온다. +export const AUTH_REQUIRED_MESSAGE = "로그인이 필요합니다."; +export const FORBIDDEN_MESSAGE = "접근 권한이 없습니다."; + +const FALLBACK_MESSAGE_BY_STATUS: Record = { + 401: AUTH_REQUIRED_MESSAGE, + 403: FORBIDDEN_MESSAGE, +}; + +const getFallbackMessage = (status: number, fallback: string) => + FALLBACK_MESSAGE_BY_STATUS[status] ?? fallback; + export const extractErrorMessage = (error: unknown, fallback: string) => { if (axios.isAxiosError(error)) { if (!error.response) { return "서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요."; } + const statusFallback = getFallbackMessage(error.response.status, fallback); + if (getContentType(error.response.headers).includes("text/html")) { - return fallback; + return statusFallback; } const responseMessage = getMessageFromValue(error.response.data); @@ -105,7 +120,7 @@ export const extractErrorMessage = (error: unknown, fallback: string) => { return responseMessage; } - return fallback; + return statusFallback; } const nestedMessage = getMessageFromValue(error);