- auth/negotiation 도메인별 api/keys/queries/mutations/type 5파일 구조 - axios 인스턴스: access token 주입, 434 만료 시 refresh 자동 재발급(단일 비행) 후 원요청 재시도, 433/435/1203(TOKEN_REVOKED) 시 세션 종료 처리 - HTTP 200 + result.success=false 비즈니스 에러를 ApiError 로 변환, 에러코드별 한국어 메시지 맵(getApiErrorMessage) 제공 - 토큰 localStorage 저장소(tokenStorage) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
119 lines
4.7 KiB
TypeScript
119 lines
4.7 KiB
TypeScript
// 공용 axios 인스턴스.
|
|
// - 요청 시 access token 을 Authorization 헤더에 주입
|
|
// - 응답의 result.success=false 봉투를 ApiError 로 변환
|
|
// - access token 만료(434) 시 refresh_token 으로 1회 자동 재발급 후 원요청 재시도
|
|
// - refresh 실패 / 토큰 폐기(1203) / refresh 만료(435) 시 세션 종료 처리
|
|
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
|
import { tokenStorage } from './tokenStorage'
|
|
import { ApiError, ErrorCode, type ApiResult } from './types'
|
|
|
|
const BASE_URL = import.meta.env.VITE_API_BASE_URL
|
|
|
|
export const http = axios.create({
|
|
baseURL: BASE_URL,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
|
|
// 인증 만료 시 앱이 처리할 핸들러 (로그인 페이지로 이동 등). App 에서 등록한다.
|
|
let onUnauthorized: (() => void) | null = null
|
|
export function setUnauthorizedHandler(handler: (() => void) | null): void {
|
|
onUnauthorized = handler
|
|
}
|
|
|
|
function handleUnauthorized(): void {
|
|
tokenStorage.clear()
|
|
onUnauthorized?.()
|
|
}
|
|
|
|
// --- 요청 인터셉터: access token 주입 ------------------------------------
|
|
http.interceptors.request.use((config) => {
|
|
const token = tokenStorage.getAccessToken()
|
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
|
return config
|
|
})
|
|
|
|
// --- 토큰 재발급 (단일 비행: 동시 요청은 하나의 refresh 만 공유) ----------
|
|
let refreshPromise: Promise<string> | null = null
|
|
|
|
async function refreshAccessToken(): Promise<string> {
|
|
const refreshToken = tokenStorage.getRefreshToken()
|
|
if (!refreshToken) {
|
|
throw new ApiError(ErrorCode.HTTP_REFRESH_TOKEN_EXPIRED, 'NO_REFRESH_TOKEN')
|
|
}
|
|
// 인터셉터 재귀를 피하려고 인스턴스가 아닌 기본 axios 로 호출한다.
|
|
const res = await axios.post<{ result: ApiResult; access_token?: string }>(
|
|
`${BASE_URL}/v1/auth/refresh_token`,
|
|
null,
|
|
{ headers: { Authorization: `Bearer ${refreshToken}` } },
|
|
)
|
|
const { result, access_token } = res.data
|
|
if (!result.success || !access_token) {
|
|
throw new ApiError(result.code, result.desc)
|
|
}
|
|
tokenStorage.setAccessToken(access_token)
|
|
return access_token
|
|
}
|
|
|
|
function toApiError(error: unknown): ApiError {
|
|
if (error instanceof ApiError) return error
|
|
if (axios.isAxiosError(error)) {
|
|
const result = (error.response?.data as { result?: ApiResult } | undefined)?.result
|
|
if (result) return new ApiError(result.code, result.desc, error.message)
|
|
// 토큰 관련 HTTPException 은 result 봉투 대신 {detail: "HTTP_*"} 형태로 온다
|
|
const detail = (error.response?.data as { detail?: string } | undefined)?.detail
|
|
const status = error.response?.status ?? 0
|
|
return new ApiError(status, detail ?? error.code ?? 'NETWORK_ERROR', error.message)
|
|
}
|
|
return new ApiError(ErrorCode.FAIL, 'UNKNOWN', String(error))
|
|
}
|
|
|
|
// --- 응답 인터셉터 -------------------------------------------------------
|
|
http.interceptors.response.use(
|
|
(response) => {
|
|
// HTTP 200 이지만 result.success=false 인 비즈니스 에러를 ApiError 로 변환
|
|
const result = (response.data as { result?: ApiResult } | undefined)?.result
|
|
if (result && !result.success) {
|
|
// 저장 토큰 무효화(로그아웃/타기기 로그인)는 200 + TOKEN_REVOKED 로 온다 → 세션 종료
|
|
if (result.code === ErrorCode.TOKEN_REVOKED && tokenStorage.hasToken()) {
|
|
handleUnauthorized()
|
|
}
|
|
throw new ApiError(result.code, result.desc)
|
|
}
|
|
return response
|
|
},
|
|
async (error: AxiosError) => {
|
|
const status = error.response?.status
|
|
const original = error.config as
|
|
| (InternalAxiosRequestConfig & { _retried?: boolean })
|
|
| undefined
|
|
const bodyCode = (error.response?.data as { result?: ApiResult } | undefined)?.result?.code
|
|
const isRefreshCall = original?.url?.includes('/v1/auth/refresh_token') ?? false
|
|
|
|
// access token 만료 → refresh 후 1회 재시도
|
|
if (status === 434 && original && !original._retried && !isRefreshCall) {
|
|
original._retried = true
|
|
try {
|
|
refreshPromise ??= refreshAccessToken().finally(() => {
|
|
refreshPromise = null
|
|
})
|
|
const newToken = await refreshPromise
|
|
original.headers.Authorization = `Bearer ${newToken}`
|
|
return http(original)
|
|
} catch (refreshError) {
|
|
handleUnauthorized()
|
|
throw toApiError(refreshError)
|
|
}
|
|
}
|
|
|
|
// 인증 실패(헤더 누락 403/401, 잘못된 토큰 433/436, refresh 만료 435) /
|
|
// 토큰 폐기(200+1203 이 아닌 경로) → 세션 종료
|
|
const isAuthFailStatus =
|
|
status === 401 || status === 403 || status === 433 || status === 435 || status === 436
|
|
if (isAuthFailStatus || bodyCode === ErrorCode.TOKEN_REVOKED || isRefreshCall) {
|
|
handleUnauthorized()
|
|
}
|
|
|
|
throw toApiError(error)
|
|
},
|
|
)
|