From 54f75aa0895a59571fcc81ddaed8f242d9799e9e Mon Sep 17 00:00:00 2001 From: lee-hyeon-cheol Date: Wed, 16 Sep 2026 21:24:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=EC=9D=84=20Bearer=20=EB=A1=9C=20=EB=93=A4?= =?UTF-8?q?=EA=B3=A0=20=EB=8B=A4=EB=8B=88=EA=B2=8C=20=E2=80=94=20Django=20?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Django(OpenCode 중계) 백엔드는 토큰을 쿠키가 아니라 응답 body 로 주고, Tauri 앱은 서버와 origin 이 달라 쿠키가 안 붙는다. 기존 tokenProvider seam 에 refresh 토큰과 localStorage 영속을 얹고, client/sse 의 refresh 를 body {refreshToken} 방식으로 바꿈. 로그인 때 저장하고 로그아웃 때 비움. 토큰 없으면 예전 쿠키 모드 그대로. Co-Authored-By: Claude Fable 5.1 --- .../src/features/auth/api/auth.api.test.ts | 35 ++++++++ 2_frontend/src/features/auth/api/auth.api.ts | 24 ++++-- .../features/auth/hooks/useSlidingRefresh.ts | 6 +- 2_frontend/src/lib/api/client.test.ts | 27 ++++++- 2_frontend/src/lib/api/client.ts | 26 +++--- 2_frontend/src/lib/auth/tokenProvider.test.ts | 37 ++++++++- 2_frontend/src/lib/auth/tokenProvider.ts | 81 +++++++++++++++++-- 2_frontend/src/lib/streaming/sse.test.ts | 27 ++++++- 2_frontend/src/lib/streaming/sse.ts | 14 +++- 9 files changed, 247 insertions(+), 30 deletions(-) diff --git a/2_frontend/src/features/auth/api/auth.api.test.ts b/2_frontend/src/features/auth/api/auth.api.test.ts index f0f1d96..676369f 100644 --- a/2_frontend/src/features/auth/api/auth.api.test.ts +++ b/2_frontend/src/features/auth/api/auth.api.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest" import MockAdapter from "axios-mock-adapter" import { authApi } from "./auth.api" import { apiClient } from "@/lib/api/client" +import { clearTokens, getAccessToken, getRefreshToken, setTokens } from "@/lib/auth/tokenProvider" const fakeToken = { token: "a", @@ -34,6 +35,7 @@ beforeEach(() => { afterEach(() => { mock.restore() + clearTokens() // login 이 토큰을 저장하므로 테스트 간 누수 방지 }) 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", () => { it("POST /auth/entra/login 후 응답 user를 반환", async () => { const user = { diff --git a/2_frontend/src/features/auth/api/auth.api.ts b/2_frontend/src/features/auth/api/auth.api.ts index 56178d3..fbd1158 100644 --- a/2_frontend/src/features/auth/api/auth.api.ts +++ b/2_frontend/src/features/auth/api/auth.api.ts @@ -1,5 +1,6 @@ import { apiPost, apiGet, type CallerConfig } from "@/lib/api/client" import { ApiError } from "@/lib/api/errors" +import { clearTokens, getRefreshToken, setTokens } from "@/lib/auth/tokenProvider" import type { LoginRequest, TokenResponse, @@ -26,19 +27,32 @@ export const authApi = { */ login: async (req: LoginRequest): Promise => { const tokens = await apiPost("/auth/login", req, SKIP_AUTH) + // Django 백엔드는 토큰을 body 로 줌 → Bearer 모드. (쿠키 백엔드면 token 이 있어도 무해) + setTokens(tokens) return tokens.user }, /** - * refresh — 쿠키 기반. 인터셉터의 자동 refresh와 별개로 명시적 호출용(sliding refresh 등). - * 실패 시 ApiError throw. 성공 시 새 쿠키가 Set-Cookie로 갱신됨. + * refresh — 명시적 호출용(sliding refresh 등). 인터셉터 자동 refresh 와 별개. + * Bearer 모드면 refreshToken 을 body 로 보내고 응답 토큰을 저장. 실패 시 ApiError throw. */ refresh: async (): Promise => { - await apiPost("/auth/refresh", undefined) + const refreshToken = getRefreshToken() + const tokens = await apiPost( + "/auth/refresh", + refreshToken ? { refreshToken } : undefined + ) + if (refreshToken && tokens?.token) setTokens(tokens) }, - /** 인증 불필요 (`__skipAuth`) — 401에서 모달 안 뜨도록 우회. */ - logout: () => apiPost("/auth/logout", undefined, SKIP_AUTH), + /** 인증 불필요 (`__skipAuth`) — 401에서 모달 안 뜨도록 우회. 서버 결과와 무관하게 토큰은 비움. */ + logout: async (): Promise => { + try { + return await apiPost("/auth/logout", undefined, SKIP_AUTH) + } finally { + clearTokens() + } + }, getMe: () => apiGet("/users/me", { __skipSessionExpiry: true }), diff --git a/2_frontend/src/features/auth/hooks/useSlidingRefresh.ts b/2_frontend/src/features/auth/hooks/useSlidingRefresh.ts index 4acb317..ce06c66 100644 --- a/2_frontend/src/features/auth/hooks/useSlidingRefresh.ts +++ b/2_frontend/src/features/auth/hooks/useSlidingRefresh.ts @@ -1,6 +1,7 @@ import { useEffect } from "react" import { authApi } from "../api/auth.api" import { useAuthStore } from "../store/authStore" +import { getAccessTokenExpMs } from "@/lib/auth/tokenProvider" const COOKIE_NAME = "accessTokenExp" const REFRESH_BEFORE_MS = 60_000 @@ -32,8 +33,9 @@ export function useSlidingRefresh() { let cancelled = false const schedule = () => { - const exp = readExpCookie() - if (exp === null) return // 쿠키 없으면 다음 cycle에서 다시 시도하지 않음 + // Bearer 모드면 tokenProvider 의 만료 시각, 쿠키 모드면 accessTokenExp 쿠키 + const exp = getAccessTokenExpMs() ?? readExpCookie() + if (exp === null) return // 둘 다 없으면 다음 cycle에서 다시 시도하지 않음 const delay = Math.max(0, exp - Date.now() - REFRESH_BEFORE_MS) timerId = setTimeout(async () => { if (cancelled) return diff --git a/2_frontend/src/lib/api/client.test.ts b/2_frontend/src/lib/api/client.test.ts index 4178e44..c640f21 100644 --- a/2_frontend/src/lib/api/client.test.ts +++ b/2_frontend/src/lib/api/client.test.ts @@ -4,7 +4,7 @@ import MockAdapter from "axios-mock-adapter" import { apiClient, apiGet, apiPost, apiList } from "./client" import { ApiError } from "./errors" 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" @@ -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 실패", () => { it("refresh가 401이면 sessionExpiryStore에 큐 push + open=true", async () => { clientMock.onGet("/users/me").reply(401, errorEnvelope({ statusCode: 401, message: "expired" })) diff --git a/2_frontend/src/lib/api/client.ts b/2_frontend/src/lib/api/client.ts index 389671a..d2bf43b 100644 --- a/2_frontend/src/lib/api/client.ts +++ b/2_frontend/src/lib/api/client.ts @@ -1,9 +1,9 @@ import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from "axios" import { env } from "@/config/env" 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 type { CommonResponse, Meta } from "@/types/api" +import type { CommonResponse, Meta, TokenResponse } from "@/types/api" /** * 단일 axios 인스턴스. 모든 인증 보호 API는 이걸 통해 호출. @@ -45,13 +45,21 @@ async function performRefresh(): Promise { inflightRefresh = (async () => { try { // baseURL 그대로 사용. validateStatus로 인터셉터 reject 우회 (무한루프 방지) - const res = await axios.post(`${env.apiBaseUrl}/auth/refresh`, undefined, { - withCredentials: true, - validateStatus: () => true, - headers: { "Content-Type": "application/json" }, - }) - const body = res.data as CommonResponse | undefined - return res.status >= 200 && res.status < 300 && body?.success === true + // Bearer 모드면 refreshToken 을 body 로, 쿠키 모드면 body 없이(서버가 쿠키로 판단) + const refreshToken = getRefreshToken() + const res = await axios.post( + `${env.apiBaseUrl}/auth/refresh`, + refreshToken ? { refreshToken } : undefined, + { + withCredentials: true, + validateStatus: () => true, + headers: { "Content-Type": "application/json" }, + } + ) + const body = res.data as CommonResponse | undefined + const ok = res.status >= 200 && res.status < 300 && body?.success === true + if (ok && refreshToken && body?.data?.token) setTokens(body.data) + return ok } catch { return false } finally { diff --git a/2_frontend/src/lib/auth/tokenProvider.test.ts b/2_frontend/src/lib/auth/tokenProvider.test.ts index f525d38..095ea5e 100644 --- a/2_frontend/src/lib/auth/tokenProvider.test.ts +++ b/2_frontend/src/lib/auth/tokenProvider.test.ts @@ -1,13 +1,22 @@ 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", () => { it("기본값은 null (쿠키 모드)", () => { expect(getAccessToken()).toBeNull() + expect(getRefreshToken()).toBeNull() + expect(getAccessTokenExpMs()).toBeNull() }) - it("set 하면 그 토큰을 돌려준다", () => { + it("setAccessToken 하면 그 토큰을 돌려준다 (하위호환)", () => { setAccessToken("abc") expect(getAccessToken()).toBe("abc") }) @@ -16,4 +25,26 @@ describe("tokenProvider", () => { setAccessToken(null) 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") + }) }) diff --git a/2_frontend/src/lib/auth/tokenProvider.ts b/2_frontend/src/lib/auth/tokenProvider.ts index 4a91477..662a734 100644 --- a/2_frontend/src/lib/auth/tokenProvider.ts +++ b/2_frontend/src/lib/auth/tokenProvider.ts @@ -1,12 +1,81 @@ -// .NET 웹뷰 호스트가 주입할 accessToken 의 단일 소스. -// 브라우저(오늘)에선 기본 null → 쿠키 인증. 나중에 .NET 이 setAccessToken 으로 채우면 -// client.ts / sse.ts 가 Authorization: Bearer 로 전환한다. -let accessToken: string | null = null +// Bearer 토큰의 단일 소스. Django 백엔드(5_django_backend)는 쿠키 대신 JWT 를 body 로 주므로 +// 로그인 응답의 token/refreshToken 을 여기 두고 client.ts / sse.ts 가 Authorization 헤더로 씀. +// 비어 있으면(=토큰 없음) 예전처럼 쿠키 모드로 동작. +// +// 저장은 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 + 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 { - 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 { - accessToken = token + if (token === null) { + clearTokens() + return + } + setTokens({ token, refreshToken: tokens?.refreshToken ?? "", tokenExpirationTime: undefined }) } diff --git a/2_frontend/src/lib/streaming/sse.test.ts b/2_frontend/src/lib/streaming/sse.test.ts index d73c340..4ec8ca1 100644 --- a/2_frontend/src/lib/streaming/sse.test.ts +++ b/2_frontend/src/lib/streaming/sse.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" import { streamSSE } from "./sse" 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", () => ({ fetchEventSource: vi.fn(), @@ -87,6 +87,31 @@ describe("streamSSE", () => { 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 + 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).mock.calls[0] + expect(JSON.parse(init.body)).toEqual({ refreshToken: "ref" }) + expect(seen).toEqual(["Bearer old", "Bearer new"]) + clearTokens() + }) + it("토큰 있으면 Authorization: Bearer 헤더를 추가", async () => { setAccessToken("tok") const fetchEventSource = fes.fetchEventSource as ReturnType diff --git a/2_frontend/src/lib/streaming/sse.ts b/2_frontend/src/lib/streaming/sse.ts index e22941f..9e16db7 100644 --- a/2_frontend/src/lib/streaming/sse.ts +++ b/2_frontend/src/lib/streaming/sse.ts @@ -1,6 +1,7 @@ import { fetchEventSource } from "@microsoft/fetch-event-source" 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 한 이벤트의 정규화된 모양. @@ -23,17 +24,24 @@ export interface StreamSSEOptions { class RetryableUnauthorized extends Error {} /** - * 쿠키 기반 refresh — body 없이 쿠키만 들고 백엔드 호출. + * refresh — Bearer 모드면 refreshToken 을 body 로, 쿠키 모드면 body 없이. * axios 인터셉터의 자동 refresh와 별개로 SSE 흐름 안에서만 1회 시도. */ async function refreshOnce(): Promise { try { + const refreshToken = getRefreshToken() const res = await fetch(`${env.apiBaseUrl}/auth/refresh`, { method: "POST", credentials: "include", 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 | null + if (body?.data?.token) setTokens(body.data) + } + return true } catch { return false }