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 { 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 = {
|
||||
|
||||
@@ -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<UserPayload> => {
|
||||
const tokens = await apiPost<TokenResponse>("/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<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에서 모달 안 뜨도록 우회. */
|
||||
logout: () => apiPost<null>("/auth/logout", undefined, SKIP_AUTH),
|
||||
/** 인증 불필요 (`__skipAuth`) — 401에서 모달 안 뜨도록 우회. 서버 결과와 무관하게 토큰은 비움. */
|
||||
logout: async (): Promise<null> => {
|
||||
try {
|
||||
return await apiPost<null>("/auth/logout", undefined, SKIP_AUTH)
|
||||
} finally {
|
||||
clearTokens()
|
||||
}
|
||||
},
|
||||
|
||||
getMe: () => apiGet<UserResponse>("/users/me", { __skipSessionExpiry: true }),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user