Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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) {
Expand Down
59 changes: 50 additions & 9 deletions src/shared/api/axiosInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getAccessToken,
syncAccessTokenFromResponse,
} from "./accessToken";
import { AUTH_REQUIRED_MESSAGE } from "./errorMessage";

const resolveServerUrl = (url?: string) => {
if (!url) return "";
Expand Down Expand Up @@ -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({
Expand All @@ -72,6 +86,20 @@ const tryRefreshSession = async () => {
}
};

// 모든 요청이 토큰을 요구하게 되면서 재발급이 동시에 여러 번 호출될 수 있어,
// 진행 중인 요청 하나를 공유한다.
let refreshSessionPromise: Promise<boolean> | null = null;

const tryRefreshSession = () => {
if (!refreshSessionPromise) {
refreshSessionPromise = requestRefreshSession().finally(() => {
refreshSessionPromise = null;
});
}

return refreshSessionPromise;
};

const getReadyAccessToken = async (shouldRefreshBeforeRequest: boolean) => {
const currentAccessToken = getAccessToken();
if (currentAccessToken || !shouldRefreshBeforeRequest) {
Expand All @@ -86,21 +114,29 @@ export const ensureAccessToken = async () => {
const authorizationHeader = await getReadyAccessToken(true);

if (!authorizationHeader) {
throw new Error("로그인 토큰을 찾지 못했습니다. 다시 로그인해 주세요.");
handleAuthenticationFailure();
throw new Error(AUTH_REQUIRED_MESSAGE);
}

return authorizationHeader;
};

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")) {
Expand Down Expand Up @@ -180,8 +216,7 @@ const addRefreshInterceptor = (instance: AxiosInstance) => {
}

if (error.response?.status === 401 && !isSessionlessRequest) {
clearAccessToken();
goToLoginPage();
handleAuthenticationFailure();
}
return Promise.reject(error);
},
Expand All @@ -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,
});
19 changes: 17 additions & 2 deletions src/shared/api/errorMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, string> = {
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);
Expand All @@ -105,7 +120,7 @@ export const extractErrorMessage = (error: unknown, fallback: string) => {
return responseMessage;
}

return fallback;
return statusFallback;
}

const nestedMessage = getMessageFromValue(error);
Expand Down