feat(frontend): 로그인 토큰을 Bearer 로 들고 다니게 — Django 백엔드 연결
Django(OpenCode 중계) 백엔드는 토큰을 쿠키가 아니라 응답 body 로 주고, Tauri 앱은
서버와 origin 이 달라 쿠키가 안 붙는다. 기존 tokenProvider seam 에 refresh 토큰과
localStorage 영속을 얹고, client/sse 의 refresh 를 body {refreshToken} 방식으로 바꿈.
로그인 때 저장하고 로그아웃 때 비움. 토큰 없으면 예전 쿠키 모드 그대로.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
|||||||
import MockAdapter from "axios-mock-adapter"
|
import MockAdapter from "axios-mock-adapter"
|
||||||
import { authApi } from "./auth.api"
|
import { authApi } from "./auth.api"
|
||||||
import { apiClient } from "@/lib/api/client"
|
import { apiClient } from "@/lib/api/client"
|
||||||
|
import { clearTokens, getAccessToken, getRefreshToken, setTokens } from "@/lib/auth/tokenProvider"
|
||||||
|
|
||||||
const fakeToken = {
|
const fakeToken = {
|
||||||
token: "a",
|
token: "a",
|
||||||
@@ -34,6 +35,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
|
clearTokens() // login 이 토큰을 저장하므로 테스트 간 누수 방지
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("authApi", () => {
|
describe("authApi", () => {
|
||||||
@@ -76,6 +78,39 @@ describe("authApi", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("authApi — Bearer 모드 (Django 백엔드가 토큰을 body 로 줌)", () => {
|
||||||
|
afterEach(() => clearTokens())
|
||||||
|
|
||||||
|
it("login 응답의 token/refreshToken 을 tokenProvider 에 저장", async () => {
|
||||||
|
mock
|
||||||
|
.onPost("/auth/login")
|
||||||
|
.reply(200, envelope({ ...fakeToken, token: "acc", refreshToken: "ref" }))
|
||||||
|
await authApi.login({ email: "x@x.com", password: "abcd" })
|
||||||
|
expect(getAccessToken()).toBe("acc")
|
||||||
|
expect(getRefreshToken()).toBe("ref")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("refresh 는 refreshToken 을 body 로 보내고 새 토큰을 저장", async () => {
|
||||||
|
setTokens({ token: "old", refreshToken: "ref" })
|
||||||
|
let body: unknown
|
||||||
|
mock.onPost("/auth/refresh").reply((config) => {
|
||||||
|
body = JSON.parse(config.data as string)
|
||||||
|
return [200, envelope({ ...fakeToken, token: "new", refreshToken: "ref" })]
|
||||||
|
})
|
||||||
|
await authApi.refresh()
|
||||||
|
expect(body).toEqual({ refreshToken: "ref" })
|
||||||
|
expect(getAccessToken()).toBe("new")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("logout 은 서버가 실패해도 토큰을 비움", async () => {
|
||||||
|
setTokens({ token: "acc", refreshToken: "ref" })
|
||||||
|
mock.onPost("/auth/logout").reply(500, {})
|
||||||
|
await expect(authApi.logout()).rejects.toBeTruthy()
|
||||||
|
expect(getAccessToken()).toBeNull()
|
||||||
|
expect(getRefreshToken()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("entraLogin", () => {
|
describe("entraLogin", () => {
|
||||||
it("POST /auth/entra/login 후 응답 user를 반환", async () => {
|
it("POST /auth/entra/login 후 응답 user를 반환", async () => {
|
||||||
const user = {
|
const user = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { apiPost, apiGet, type CallerConfig } from "@/lib/api/client"
|
import { apiPost, apiGet, type CallerConfig } from "@/lib/api/client"
|
||||||
import { ApiError } from "@/lib/api/errors"
|
import { ApiError } from "@/lib/api/errors"
|
||||||
|
import { clearTokens, getRefreshToken, setTokens } from "@/lib/auth/tokenProvider"
|
||||||
import type {
|
import type {
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
@@ -26,19 +27,32 @@ export const authApi = {
|
|||||||
*/
|
*/
|
||||||
login: async (req: LoginRequest): Promise<UserPayload> => {
|
login: async (req: LoginRequest): Promise<UserPayload> => {
|
||||||
const tokens = await apiPost<TokenResponse>("/auth/login", req, SKIP_AUTH)
|
const tokens = await apiPost<TokenResponse>("/auth/login", req, SKIP_AUTH)
|
||||||
|
// Django 백엔드는 토큰을 body 로 줌 → Bearer 모드. (쿠키 백엔드면 token 이 있어도 무해)
|
||||||
|
setTokens(tokens)
|
||||||
return tokens.user
|
return tokens.user
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* refresh — 쿠키 기반. 인터셉터의 자동 refresh와 별개로 명시적 호출용(sliding refresh 등).
|
* refresh — 명시적 호출용(sliding refresh 등). 인터셉터 자동 refresh 와 별개.
|
||||||
* 실패 시 ApiError throw. 성공 시 새 쿠키가 Set-Cookie로 갱신됨.
|
* Bearer 모드면 refreshToken 을 body 로 보내고 응답 토큰을 저장. 실패 시 ApiError throw.
|
||||||
*/
|
*/
|
||||||
refresh: async (): Promise<void> => {
|
refresh: async (): Promise<void> => {
|
||||||
await apiPost<TokenResponse>("/auth/refresh", undefined)
|
const refreshToken = getRefreshToken()
|
||||||
|
const tokens = await apiPost<TokenResponse>(
|
||||||
|
"/auth/refresh",
|
||||||
|
refreshToken ? { refreshToken } : undefined
|
||||||
|
)
|
||||||
|
if (refreshToken && tokens?.token) setTokens(tokens)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 인증 불필요 (`__skipAuth`) — 401에서 모달 안 뜨도록 우회. */
|
/** 인증 불필요 (`__skipAuth`) — 401에서 모달 안 뜨도록 우회. 서버 결과와 무관하게 토큰은 비움. */
|
||||||
logout: () => apiPost<null>("/auth/logout", undefined, SKIP_AUTH),
|
logout: async (): Promise<null> => {
|
||||||
|
try {
|
||||||
|
return await apiPost<null>("/auth/logout", undefined, SKIP_AUTH)
|
||||||
|
} finally {
|
||||||
|
clearTokens()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
getMe: () => apiGet<UserResponse>("/users/me", { __skipSessionExpiry: true }),
|
getMe: () => apiGet<UserResponse>("/users/me", { __skipSessionExpiry: true }),
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { authApi } from "../api/auth.api"
|
import { authApi } from "../api/auth.api"
|
||||||
import { useAuthStore } from "../store/authStore"
|
import { useAuthStore } from "../store/authStore"
|
||||||
|
import { getAccessTokenExpMs } from "@/lib/auth/tokenProvider"
|
||||||
|
|
||||||
const COOKIE_NAME = "accessTokenExp"
|
const COOKIE_NAME = "accessTokenExp"
|
||||||
const REFRESH_BEFORE_MS = 60_000
|
const REFRESH_BEFORE_MS = 60_000
|
||||||
@@ -32,8 +33,9 @@ export function useSlidingRefresh() {
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
const exp = readExpCookie()
|
// Bearer 모드면 tokenProvider 의 만료 시각, 쿠키 모드면 accessTokenExp 쿠키
|
||||||
if (exp === null) return // 쿠키 없으면 다음 cycle에서 다시 시도하지 않음
|
const exp = getAccessTokenExpMs() ?? readExpCookie()
|
||||||
|
if (exp === null) return // 둘 다 없으면 다음 cycle에서 다시 시도하지 않음
|
||||||
const delay = Math.max(0, exp - Date.now() - REFRESH_BEFORE_MS)
|
const delay = Math.max(0, exp - Date.now() - REFRESH_BEFORE_MS)
|
||||||
timerId = setTimeout(async () => {
|
timerId = setTimeout(async () => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import MockAdapter from "axios-mock-adapter"
|
|||||||
import { apiClient, apiGet, apiPost, apiList } from "./client"
|
import { apiClient, apiGet, apiPost, apiList } from "./client"
|
||||||
import { ApiError } from "./errors"
|
import { ApiError } from "./errors"
|
||||||
import { useSessionExpiryStore } from "@/features/auth/store/sessionExpiryStore"
|
import { useSessionExpiryStore } from "@/features/auth/store/sessionExpiryStore"
|
||||||
import { setAccessToken } from "@/lib/auth/tokenProvider"
|
import { setAccessToken, setTokens, clearTokens, getAccessToken } from "@/lib/auth/tokenProvider"
|
||||||
|
|
||||||
const BASE = "http://localhost:8001/api/v1"
|
const BASE = "http://localhost:8001/api/v1"
|
||||||
|
|
||||||
@@ -152,6 +152,31 @@ describe("401 인터셉터 — refresh 성공", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("401 인터셉터 — Bearer 모드 refresh", () => {
|
||||||
|
afterEach(() => clearTokens())
|
||||||
|
|
||||||
|
it("refreshToken 있으면 body 로 보내고, 응답 토큰을 저장한 뒤 새 Bearer 로 재시도", async () => {
|
||||||
|
setTokens({ token: "old", refreshToken: "ref" })
|
||||||
|
const authHeaders: string[] = []
|
||||||
|
clientMock.onGet("/users/me").reply((config) => {
|
||||||
|
authHeaders.push(String(config.headers?.Authorization))
|
||||||
|
return authHeaders.length === 1
|
||||||
|
? [401, errorEnvelope({ statusCode: 401, message: "expired" })]
|
||||||
|
: [200, envelope({ id: "u1" })]
|
||||||
|
})
|
||||||
|
globalMock
|
||||||
|
.onPost(`${BASE}/auth/refresh`)
|
||||||
|
.reply(200, envelope({ token: "new", refreshToken: "ref", tokenExpirationTime: 1 }))
|
||||||
|
|
||||||
|
await apiGet<{ id: string }>("/users/me")
|
||||||
|
|
||||||
|
const refreshReq = globalMock.history.post.find((r) => r.url?.endsWith("/auth/refresh"))
|
||||||
|
expect(JSON.parse(refreshReq?.data as string)).toEqual({ refreshToken: "ref" })
|
||||||
|
expect(getAccessToken()).toBe("new")
|
||||||
|
expect(authHeaders).toEqual(["Bearer old", "Bearer new"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("401 인터셉터 — refresh 실패", () => {
|
describe("401 인터셉터 — refresh 실패", () => {
|
||||||
it("refresh가 401이면 sessionExpiryStore에 큐 push + open=true", async () => {
|
it("refresh가 401이면 sessionExpiryStore에 큐 push + open=true", async () => {
|
||||||
clientMock.onGet("/users/me").reply(401, errorEnvelope({ statusCode: 401, message: "expired" }))
|
clientMock.onGet("/users/me").reply(401, errorEnvelope({ statusCode: 401, message: "expired" }))
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from "axios"
|
import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from "axios"
|
||||||
import { env } from "@/config/env"
|
import { env } from "@/config/env"
|
||||||
import { useSessionExpiryStore } from "@/features/auth/store/sessionExpiryStore"
|
import { useSessionExpiryStore } from "@/features/auth/store/sessionExpiryStore"
|
||||||
import { getAccessToken } from "@/lib/auth/tokenProvider"
|
import { getAccessToken, getRefreshToken, setTokens } from "@/lib/auth/tokenProvider"
|
||||||
import { ApiError } from "./errors"
|
import { ApiError } from "./errors"
|
||||||
import type { CommonResponse, Meta } from "@/types/api"
|
import type { CommonResponse, Meta, TokenResponse } from "@/types/api"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 단일 axios 인스턴스. 모든 인증 보호 API는 이걸 통해 호출.
|
* 단일 axios 인스턴스. 모든 인증 보호 API는 이걸 통해 호출.
|
||||||
@@ -45,13 +45,21 @@ async function performRefresh(): Promise<boolean> {
|
|||||||
inflightRefresh = (async () => {
|
inflightRefresh = (async () => {
|
||||||
try {
|
try {
|
||||||
// baseURL 그대로 사용. validateStatus로 인터셉터 reject 우회 (무한루프 방지)
|
// baseURL 그대로 사용. validateStatus로 인터셉터 reject 우회 (무한루프 방지)
|
||||||
const res = await axios.post(`${env.apiBaseUrl}/auth/refresh`, undefined, {
|
// Bearer 모드면 refreshToken 을 body 로, 쿠키 모드면 body 없이(서버가 쿠키로 판단)
|
||||||
|
const refreshToken = getRefreshToken()
|
||||||
|
const res = await axios.post(
|
||||||
|
`${env.apiBaseUrl}/auth/refresh`,
|
||||||
|
refreshToken ? { refreshToken } : undefined,
|
||||||
|
{
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
validateStatus: () => true,
|
validateStatus: () => true,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
})
|
}
|
||||||
const body = res.data as CommonResponse<unknown> | undefined
|
)
|
||||||
return res.status >= 200 && res.status < 300 && body?.success === true
|
const body = res.data as CommonResponse<TokenResponse> | undefined
|
||||||
|
const ok = res.status >= 200 && res.status < 300 && body?.success === true
|
||||||
|
if (ok && refreshToken && body?.data?.token) setTokens(body.data)
|
||||||
|
return ok
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { describe, it, expect, afterEach } from "vitest"
|
import { describe, it, expect, afterEach } from "vitest"
|
||||||
import { getAccessToken, setAccessToken } from "./tokenProvider"
|
import {
|
||||||
|
getAccessToken,
|
||||||
|
getAccessTokenExpMs,
|
||||||
|
getRefreshToken,
|
||||||
|
setAccessToken,
|
||||||
|
setTokens,
|
||||||
|
clearTokens,
|
||||||
|
} from "./tokenProvider"
|
||||||
|
|
||||||
afterEach(() => setAccessToken(null))
|
afterEach(() => clearTokens())
|
||||||
|
|
||||||
describe("tokenProvider", () => {
|
describe("tokenProvider", () => {
|
||||||
it("기본값은 null (쿠키 모드)", () => {
|
it("기본값은 null (쿠키 모드)", () => {
|
||||||
expect(getAccessToken()).toBeNull()
|
expect(getAccessToken()).toBeNull()
|
||||||
|
expect(getRefreshToken()).toBeNull()
|
||||||
|
expect(getAccessTokenExpMs()).toBeNull()
|
||||||
})
|
})
|
||||||
it("set 하면 그 토큰을 돌려준다", () => {
|
it("setAccessToken 하면 그 토큰을 돌려준다 (하위호환)", () => {
|
||||||
setAccessToken("abc")
|
setAccessToken("abc")
|
||||||
expect(getAccessToken()).toBe("abc")
|
expect(getAccessToken()).toBe("abc")
|
||||||
})
|
})
|
||||||
@@ -16,4 +25,26 @@ describe("tokenProvider", () => {
|
|||||||
setAccessToken(null)
|
setAccessToken(null)
|
||||||
expect(getAccessToken()).toBeNull()
|
expect(getAccessToken()).toBeNull()
|
||||||
})
|
})
|
||||||
|
it("setTokens 는 access·refresh·만료를 저장하고 localStorage 에 영속", () => {
|
||||||
|
setTokens({ token: "a", refreshToken: "r", tokenExpirationTime: 1_700_000_000 })
|
||||||
|
expect(getAccessToken()).toBe("a")
|
||||||
|
expect(getRefreshToken()).toBe("r")
|
||||||
|
expect(getAccessTokenExpMs()).toBe(1_700_000_000_000)
|
||||||
|
expect(JSON.parse(localStorage.getItem("ca.tokens") ?? "{}")).toMatchObject({
|
||||||
|
token: "a",
|
||||||
|
refreshToken: "r",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it("clearTokens 는 메모리·localStorage 둘 다 비움", () => {
|
||||||
|
setTokens({ token: "a", refreshToken: "r" })
|
||||||
|
clearTokens()
|
||||||
|
expect(getAccessToken()).toBeNull()
|
||||||
|
expect(localStorage.getItem("ca.tokens")).toBeNull()
|
||||||
|
})
|
||||||
|
it("setAccessToken 은 refresh 토큰을 유지한다", () => {
|
||||||
|
setTokens({ token: "a", refreshToken: "r" })
|
||||||
|
setAccessToken("b")
|
||||||
|
expect(getAccessToken()).toBe("b")
|
||||||
|
expect(getRefreshToken()).toBe("r")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,81 @@
|
|||||||
// .NET 웹뷰 호스트가 주입할 accessToken 의 단일 소스.
|
// Bearer 토큰의 단일 소스. Django 백엔드(5_django_backend)는 쿠키 대신 JWT 를 body 로 주므로
|
||||||
// 브라우저(오늘)에선 기본 null → 쿠키 인증. 나중에 .NET 이 setAccessToken 으로 채우면
|
// 로그인 응답의 token/refreshToken 을 여기 두고 client.ts / sse.ts 가 Authorization 헤더로 씀.
|
||||||
// client.ts / sse.ts 가 Authorization: Bearer 로 전환한다.
|
// 비어 있으면(=토큰 없음) 예전처럼 쿠키 모드로 동작.
|
||||||
let accessToken: string | null = null
|
//
|
||||||
|
// 저장은 localStorage (테스트 단계 결정). Tauri 저장소로 옮길 땐 이 파일만 바꾸면 됨.
|
||||||
|
|
||||||
|
const STORAGE_KEY = "ca.tokens"
|
||||||
|
|
||||||
|
export interface StoredTokens {
|
||||||
|
token: string
|
||||||
|
refreshToken: string
|
||||||
|
/** access 만료(epoch 초). 백엔드 tokenExpirationTime */
|
||||||
|
tokenExpirationTime?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokens: StoredTokens | null = null
|
||||||
|
|
||||||
|
function load(): StoredTokens | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const parsed = JSON.parse(raw) as Partial<StoredTokens>
|
||||||
|
if (typeof parsed.token !== "string" || typeof parsed.refreshToken !== "string") return null
|
||||||
|
return parsed as StoredTokens
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(): void {
|
||||||
|
try {
|
||||||
|
if (tokens) localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens))
|
||||||
|
else localStorage.removeItem(STORAGE_KEY)
|
||||||
|
} catch {
|
||||||
|
// 저장 못 해도 메모리 값으로 이번 세션은 동작
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens = load()
|
||||||
|
|
||||||
export function getAccessToken(): string | null {
|
export function getAccessToken(): string | null {
|
||||||
return accessToken
|
return tokens?.token ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRefreshToken(): string | null {
|
||||||
|
return tokens?.refreshToken ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** access 만료 시각(ms epoch). 모르면 null. useSlidingRefresh 가 씀 */
|
||||||
|
export function getAccessTokenExpMs(): number | null {
|
||||||
|
const exp = tokens?.tokenExpirationTime
|
||||||
|
return typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 로그인·refresh 응답으로 통째 교체 + 저장 */
|
||||||
|
export function setTokens(next: StoredTokens | null): void {
|
||||||
|
tokens = next
|
||||||
|
? {
|
||||||
|
token: next.token,
|
||||||
|
refreshToken: next.refreshToken,
|
||||||
|
tokenExpirationTime: next.tokenExpirationTime,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTokens(): void {
|
||||||
|
setTokens(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* access 만 바꿈(호스트 주입용 하위호환). refresh 토큰은 유지.
|
||||||
|
* null 이면 전부 비움.
|
||||||
|
*/
|
||||||
export function setAccessToken(token: string | null): void {
|
export function setAccessToken(token: string | null): void {
|
||||||
accessToken = token
|
if (token === null) {
|
||||||
|
clearTokens()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTokens({ token, refreshToken: tokens?.refreshToken ?? "", tokenExpirationTime: undefined })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
|
||||||
import { streamSSE } from "./sse"
|
import { streamSSE } from "./sse"
|
||||||
import * as fes from "@microsoft/fetch-event-source"
|
import * as fes from "@microsoft/fetch-event-source"
|
||||||
import { setAccessToken } from "@/lib/auth/tokenProvider"
|
import { setAccessToken, setTokens, clearTokens } from "@/lib/auth/tokenProvider"
|
||||||
|
|
||||||
vi.mock("@microsoft/fetch-event-source", () => ({
|
vi.mock("@microsoft/fetch-event-source", () => ({
|
||||||
fetchEventSource: vi.fn(),
|
fetchEventSource: vi.fn(),
|
||||||
@@ -87,6 +87,31 @@ describe("streamSSE", () => {
|
|||||||
expect(refreshUrl).toContain("/api/v1/auth/refresh")
|
expect(refreshUrl).toContain("/api/v1/auth/refresh")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("Bearer 모드 401 → refreshToken 을 body 로 refresh 하고 새 토큰으로 재시도", async () => {
|
||||||
|
setTokens({ token: "old", refreshToken: "ref" })
|
||||||
|
const fetchEventSource = fes.fetchEventSource as ReturnType<typeof vi.fn>
|
||||||
|
const seen: string[] = []
|
||||||
|
fetchEventSource.mockImplementation(async (_url: string, opts) => {
|
||||||
|
seen.push(opts.headers.Authorization)
|
||||||
|
if (seen.length === 1) await opts.onopen?.({ ok: false, status: 401 } as Response)
|
||||||
|
})
|
||||||
|
global.fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ success: true, data: { token: "new", refreshToken: "ref" } }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await streamSSE({ path: "/chat/stream", body: {}, onEvent: vi.fn() })
|
||||||
|
|
||||||
|
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0]
|
||||||
|
expect(JSON.parse(init.body)).toEqual({ refreshToken: "ref" })
|
||||||
|
expect(seen).toEqual(["Bearer old", "Bearer new"])
|
||||||
|
clearTokens()
|
||||||
|
})
|
||||||
|
|
||||||
it("토큰 있으면 Authorization: Bearer 헤더를 추가", async () => {
|
it("토큰 있으면 Authorization: Bearer 헤더를 추가", async () => {
|
||||||
setAccessToken("tok")
|
setAccessToken("tok")
|
||||||
const fetchEventSource = fes.fetchEventSource as ReturnType<typeof vi.fn>
|
const fetchEventSource = fes.fetchEventSource as ReturnType<typeof vi.fn>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fetchEventSource } from "@microsoft/fetch-event-source"
|
import { fetchEventSource } from "@microsoft/fetch-event-source"
|
||||||
import { env } from "@/config/env"
|
import { env } from "@/config/env"
|
||||||
import { getAccessToken } from "@/lib/auth/tokenProvider"
|
import { getAccessToken, getRefreshToken, setTokens } from "@/lib/auth/tokenProvider"
|
||||||
|
import type { CommonResponse, TokenResponse } from "@/types/api"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSE 한 이벤트의 정규화된 모양.
|
* SSE 한 이벤트의 정규화된 모양.
|
||||||
@@ -23,17 +24,24 @@ export interface StreamSSEOptions {
|
|||||||
class RetryableUnauthorized extends Error {}
|
class RetryableUnauthorized extends Error {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 쿠키 기반 refresh — body 없이 쿠키만 들고 백엔드 호출.
|
* refresh — Bearer 모드면 refreshToken 을 body 로, 쿠키 모드면 body 없이.
|
||||||
* axios 인터셉터의 자동 refresh와 별개로 SSE 흐름 안에서만 1회 시도.
|
* axios 인터셉터의 자동 refresh와 별개로 SSE 흐름 안에서만 1회 시도.
|
||||||
*/
|
*/
|
||||||
async function refreshOnce(): Promise<boolean> {
|
async function refreshOnce(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
const refreshToken = getRefreshToken()
|
||||||
const res = await fetch(`${env.apiBaseUrl}/auth/refresh`, {
|
const res = await fetch(`${env.apiBaseUrl}/auth/refresh`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: refreshToken ? JSON.stringify({ refreshToken }) : undefined,
|
||||||
})
|
})
|
||||||
return res.ok
|
if (!res.ok) return false
|
||||||
|
if (refreshToken) {
|
||||||
|
const body = (await res.json().catch(() => null)) as CommonResponse<TokenResponse> | null
|
||||||
|
if (body?.data?.token) setTokens(body.data)
|
||||||
|
}
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user