From 361bcc9f8404ce010aae84ca90f8c76f0bb122b5 Mon Sep 17 00:00:00 2001 From: gituser Date: Fri, 11 Sep 2026 08:34:00 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=ED=86=A0=ED=81=B0=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20401=20=EC=9D=91=EB=8B=B5=20=EA=B7=9C=EA=B2=A9=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 토큰이 없을 때 400 대신 401 {message}가 내려오고, 만료된 토큰의 401과 스키마가 하나로 통일됐다. - 401/403 상태코드별 폴백 문구를 extractErrorMessage에 추가 - 세션 정리와 로그인 이동을 handleAuthenticationFailure로 통합 - 이미 로그인/회원가입 화면이면 리다이렉트하지 않도록 방어 - 인터셉터를 타지 않는 SSE 구독에서 401/403을 직접 처리 Co-Authored-By: Claude Opus 5 --- .../interview/api/prepare-interview/index.ts | 43 ++++++++++++++++++- src/shared/api/axiosInstance.ts | 22 ++++++++-- src/shared/api/errorMessage.ts | 19 +++++++- 3 files changed, 76 insertions(+), 8 deletions(-) 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..1f1fbbc 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,8 +56,21 @@ 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 () => { @@ -86,7 +100,8 @@ export const ensureAccessToken = async () => { const authorizationHeader = await getReadyAccessToken(true); if (!authorizationHeader) { - throw new Error("로그인 토큰을 찾지 못했습니다. 다시 로그인해 주세요."); + handleAuthenticationFailure(); + throw new Error(AUTH_REQUIRED_MESSAGE); } return authorizationHeader; @@ -180,8 +195,7 @@ const addRefreshInterceptor = (instance: AxiosInstance) => { } if (error.response?.status === 401 && !isSessionlessRequest) { - clearAccessToken(); - goToLoginPage(); + handleAuthenticationFailure(); } return Promise.reject(error); }, 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); From 3af1484b1d4642f078745acd1e3091ca4c672cab Mon Sep 17 00:00:00 2001 From: gituser Date: Fri, 11 Sep 2026 08:34:13 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EC=9D=B8=EC=A6=9D=EC=9D=B4=20?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=EC=9A=94=EC=B2=AD=EC=97=90=20Aut?= =?UTF-8?q?horization=20=ED=97=A4=EB=8D=94=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 토큰을 안 보내도 200을 주던 엔드포인트들이 이제 401을 반환한다. 기존 인터셉터는 토큰이 없으면 헤더 없이 그대로 요청을 보내 401만 받아왔다. apiInstance와 chatInstance는 재발급까지 시도한 뒤에도 토큰이 없으면 요청을 보내지 않고 로그인 흐름으로 넘긴다. Co-Authored-By: Claude Opus 5 --- src/shared/api/axiosInstance.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/shared/api/axiosInstance.ts b/src/shared/api/axiosInstance.ts index 1f1fbbc..8eb0233 100644 --- a/src/shared/api/axiosInstance.ts +++ b/src/shared/api/axiosInstance.ts @@ -109,13 +109,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")) { @@ -210,5 +217,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, +}); From 6ae900dd1f67ccf1fa1e1350345a4442ddc1e046 Mon Sep 17 00:00:00 2001 From: gituser Date: Fri, 11 Sep 2026 08:34:27 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EC=A4=91=EB=B3=B5=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모든 요청이 토큰을 요구하게 되면서, 토큰이 없는 상태의 병렬 요청이 각각 재발급을 호출한다. 리프레시 토큰을 회전시키면 뒤이은 호출이 실패해 멀쩡한 세션이 로그아웃될 수 있어, 진행 중인 재발급 요청 하나를 공유한다. Co-Authored-By: Claude Opus 5 --- src/shared/api/axiosInstance.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/shared/api/axiosInstance.ts b/src/shared/api/axiosInstance.ts index 8eb0233..e145da8 100644 --- a/src/shared/api/axiosInstance.ts +++ b/src/shared/api/axiosInstance.ts @@ -73,7 +73,7 @@ export const handleAuthenticationFailure = () => { goToLoginPage(); }; -const tryRefreshSession = async () => { +const requestRefreshSession = async () => { try { const response = await refreshInstance.post("/api/v1/auth/refresh"); const refreshedAccessToken = syncAccessTokenFromResponse({ @@ -86,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) { From 00889d3d753d324493b7d8a9ac44f6718ce192c2 Mon Sep 17 00:00:00 2001 From: gituser Date: Fri, 11 Sep 2026 08:34:27 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EA=B3=84=EC=95=BD=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- AGENTS.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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`