diff --git a/apps/wr/app/(with-layout)/jwttest/page.tsx b/apps/wr/app/(with-layout)/jwttest/page.tsx index 25d47c1d2..40baa2034 100644 --- a/apps/wr/app/(with-layout)/jwttest/page.tsx +++ b/apps/wr/app/(with-layout)/jwttest/page.tsx @@ -5,7 +5,8 @@ */ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { LoginApi } from "@/src/entities/auth/api/login-service"; import { CLAIM_NAME } from "@/src/shared/config"; import { getCookie } from "@/src/shared/lib"; @@ -112,14 +113,51 @@ function readJwtState(): JwtState { export default function Page() { const [jwtState, setJwtState] = useState(() => readJwtState()); + const isRefreshingRef = useRef(false); + + const handleSilentRefresh = async () => { + try { + await LoginApi.silentRefresh(); + } catch (error) { + console.error("Manual silent refresh failed:", error); + } finally { + setJwtState(readJwtState()); + } + }; useEffect(() => { - const syncJwtState = () => { + const syncJwtState = async () => { + const nextState = readJwtState(); + + if ( + nextState.remainingMs !== null && + nextState.remainingMs > 0 && + nextState.remainingMs < 5 * 60 * 1000 && + !isRefreshingRef.current + ) { + isRefreshingRef.current = true; + try { + await LoginApi.silentRefresh(); + } catch (error) { + console.error("JWT test silent refresh failed:", error); + } + } + setJwtState(readJwtState()); + + if ( + nextState.remainingMs === null || + nextState.remainingMs >= 5 * 60 * 1000 || + nextState.remainingMs <= 0 + ) { + isRefreshingRef.current = false; + } }; - syncJwtState(); - const timer = window.setInterval(syncJwtState, 1000); + void syncJwtState(); + const timer = window.setInterval(() => { + void syncJwtState(); + }, 1000); return () => window.clearInterval(timer); }, []); @@ -128,6 +166,16 @@ export default function Page() {

JWT Exp Test

+ +
쿠키명
{CLAIM_NAME}
diff --git a/apps/wr/app/api/proxy/[...url]/route.ts b/apps/wr/app/api/proxy/[...url]/route.ts index cf6447b1a..e847e8ad2 100644 --- a/apps/wr/app/api/proxy/[...url]/route.ts +++ b/apps/wr/app/api/proxy/[...url]/route.ts @@ -15,9 +15,10 @@ import { NextResponse } from "next/server"; const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token"; const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; +const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN; const REFRESH_TOKEN = process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token"; const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL || ""; -const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "SN"; +const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "WR"; async function handler( request: Request, @@ -35,6 +36,7 @@ async function handler( // silent refresh if (isRefreshHandler) { const refreshToken = cookieStore.get(REFRESH_TOKEN); + console.log("refreshToken", refreshToken?.value); headers[CLAIM_NAME] = refreshToken?.value ?? ""; if (!headers[CLAIM_NAME]) { @@ -52,12 +54,11 @@ async function handler( headers, body: JSON.stringify(body), }); - +// console.log(result); if (!result.ok) { - console.log("Invalid Credentials "); return NextResponse.json( - { message: "Invalid Credentials" }, - { status: 401 }, + { message: "first Invalid Credentials" }, + { status: result.status }, ); } @@ -72,11 +73,13 @@ async function handler( }); if (refreshToken) { - cookieStore.set(REFRESH_TOKEN, refreshToken, { + response.cookies.set(REFRESH_TOKEN, refreshToken, { httpOnly: true, - sameSite: "lax", + sameSite: "strict", // 다른 사이트에서 들어오는 모든 요청에 대해 쿠키를 보내지 않는 가장 강력한 설정 + domain: getCookieDomain(), path: "/", }); + console.log("refresh cookie setting : " + refreshToken); } return response; @@ -109,3 +112,14 @@ export async function PUT( const params = await props.params; return handler(request, params, "PUT"); } + +function getCookieDomain(): string | undefined { + if (typeof window === "undefined") return undefined; + + if (COOKIE_DOMAIN) return COOKIE_DOMAIN; + + const { hostname } = window.location; + if (hostname === "localhost" || hostname === "127.0.0.1") return undefined; + + return hostname; +} diff --git a/apps/wr/middleware.ts b/apps/wr/middleware.ts index d1e96dacc..498e45fed 100644 --- a/apps/wr/middleware.ts +++ b/apps/wr/middleware.ts @@ -30,6 +30,7 @@ function buildAuthHeaders(req: NextRequest): Headers { const requestHeaders = new Headers(req.headers); const token = req.cookies.get(CLAIM_NAME)?.value; const userId = req.cookies.get(AUTH_USER_ID)?.value; + if (token) requestHeaders.set(CLAIM_NAME, token); if (userId) requestHeaders.set(AUTH_USER_ID, userId); return requestHeaders; @@ -38,7 +39,9 @@ function buildAuthHeaders(req: NextRequest): Headers { /** 공통: rewrite 응답 생성 (헤더 주입 여부 선택) */ function rewriteWith(url: string, req: NextRequest, withAuth = false): NextResponse { return withAuth - ? NextResponse.rewrite(new URL(url), { headers: buildAuthHeaders(req) }) + ? NextResponse.rewrite(new URL(url), { + headers: buildAuthHeaders(req), + }) : NextResponse.rewrite(new URL(url)); } diff --git a/apps/wr/src/entities/auth/api/login-service.ts b/apps/wr/src/entities/auth/api/login-service.ts index 94be8b53f..1ad5f2cd0 100644 --- a/apps/wr/src/entities/auth/api/login-service.ts +++ b/apps/wr/src/entities/auth/api/login-service.ts @@ -1,7 +1,7 @@ import type { LoginService, ILogin } from "../model/login-interface"; import { tokenManager } from "../lib/token-manager"; -import {PROXY_HOST, ACCESS_TOKEN, AUTH_USER_ID,SITE_ID, CLAIM_NAME, ENDPOINT, JWT_EXPIRATION_TIME } from "../../../shared/config"; +import { ACCESS_TOKEN, AUTH_USER_ID, SITE_ID, CLAIM_NAME, ENDPOINT } from "../../../shared/config"; export class LoginApiService implements LoginService { private static instance: LoginApiService; @@ -18,31 +18,33 @@ export class LoginApiService implements LoginService { public async login(payload: ILogin): Promise { try { tokenManager.clearAuthToken(); - const response = await fetch( PROXY_HOST+ ENDPOINT.USER_SERVICE.LOGIN, { + const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { method: "POST", headers: { "Content-Type": "application/json", - "X-Site-Id": SITE_ID, }, body: JSON.stringify({ userId: payload.userId, password: payload.password, }), }); - // const data = response.headers.get(ACCESS_TOKEN); - const accessToken = response.headers.get(ACCESS_TOKEN); - const tokenId = response.headers.get(AUTH_USER_ID); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data?.message ?? "Login failed"); + } + + const accessToken = data[ACCESS_TOKEN]; + const tokenId = data[AUTH_USER_ID]; if (!accessToken || !tokenId) { throw new Error("Missing required authentication tokens"); } - tokenManager.setToken(CLAIM_NAME, accessToken) - tokenManager.setToken(AUTH_USER_ID, tokenId) - tokenManager.setToken("site-id-key", SITE_ID) - tokenManager.setupRefreshTimer( - () => this.silentRefresh(), - JWT_EXPIRATION_TIME, - ); + tokenManager.setToken(CLAIM_NAME, accessToken); + tokenManager.setToken(AUTH_USER_ID, tokenId); + tokenManager.setToken("site-id-key", SITE_ID); + tokenManager.setupRefreshTimer(() => this.silentRefresh()); } catch (error) { throw new Error(`Login failed: ${error}`); @@ -54,17 +56,19 @@ export class LoginApiService implements LoginService { console.log(' silentRefresh ') try { - const response = await fetch(PROXY_HOST+ - `${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, + const response = await fetch( + `/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, { method: "PUT", - headers: { - "X-Site-Id": SITE_ID, - }, }, ); const data = await response.json(); + + if (!response.ok) { + throw new Error(data?.message ?? "Silent refresh failed"); + } + const accessToken = data[ACCESS_TOKEN]; const tokenId = data[AUTH_USER_ID]; @@ -74,6 +78,7 @@ export class LoginApiService implements LoginService { tokenManager.setToken(CLAIM_NAME, accessToken); tokenManager.setToken(AUTH_USER_ID, tokenId); + tokenManager.setupRefreshTimer(() => this.silentRefresh()); } catch (error) { tokenManager.clearAuthToken(); throw new Error(`Silent refresh failed: ${error}`); diff --git a/apps/wr/src/entities/auth/lib/auth-wrapper.tsx b/apps/wr/src/entities/auth/lib/auth-wrapper.tsx index 796cd44a9..7ff4abaef 100644 --- a/apps/wr/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/wr/src/entities/auth/lib/auth-wrapper.tsx @@ -2,10 +2,12 @@ import { useEffect } from "react"; import { usePathname } from "next/navigation"; +import { LoginApi } from "../api/login-service"; +import { tokenManager } from "./token-manager"; import { client } from "../../../shared/api"; import type { IUserInfo } from "../../../shared/store"; import { useUserStore } from "../../../shared/lib"; -import { ENDPOINT, AUTH_USER_ID } from "../../../shared/config"; +import { ENDPOINT, AUTH_USER_ID, CLAIM_NAME } from "../../../shared/config"; import { isAtLoginRedirectUrl } from "@common/network"; import { getCookie } from "../../../shared/lib"; @@ -26,6 +28,11 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => { throw new Error("Missing required authentication tokens"); } + const token = getCookie(CLAIM_NAME); + if (!token || tokenManager.isExpiredToken()) { + await LoginApi.silentRefresh(); + } + const response = await client.get( `${ENDPOINT.USER_SERVICE.USERS}/info/me`, ); @@ -33,6 +40,18 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => { } useEffect(() => { + const token = getCookie(CLAIM_NAME); + const userId = getCookie(AUTH_USER_ID); + + if (!token || !userId) { + return; + } + + tokenManager.restoreRefreshTimer(() => LoginApi.silentRefresh()); + }, []); + + useEffect(() => { + const isJwtTestPage = pathname?.startsWith("/jwttest"); (async () => { try { await authCheck(); @@ -40,7 +59,7 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => { console.error(error); setUserInfo(null); - if (isAtLoginRedirectUrl(LOGIN_PAGE_URL)) return; + if (isAtLoginRedirectUrl(LOGIN_PAGE_URL) || isJwtTestPage) return; // const hostname = window.location.hostname; diff --git a/apps/wr/src/entities/auth/lib/token-manager.ts b/apps/wr/src/entities/auth/lib/token-manager.ts index 919386622..c270711ca 100644 --- a/apps/wr/src/entities/auth/lib/token-manager.ts +++ b/apps/wr/src/entities/auth/lib/token-manager.ts @@ -1,4 +1,4 @@ -import { AUTH_USER_ID, CLAIM_NAME } from "@/src/shared/config"; +import { AUTH_USER_ID, CLAIM_NAME, REFRESH_TOKEN } from "@/src/shared/config"; import { getCookie, removeCookie, setCookie } from "@/src/shared/lib/cookie"; type JwtPayload = { @@ -11,6 +11,7 @@ type JwtPayload = { export class TokenManager { private static instance: TokenManager; private refreshTimer: NodeJS.Timeout | null = null; + private static readonly REFRESH_BUFFER_MS = 60000; // 60초 private constructor() {} @@ -79,20 +80,71 @@ export class TokenManager { removeCookie(key); } - public setupRefreshTimer( - callback: () => Promise, - expirationTime: number, - ): void { + private handleRefreshFailure(error: unknown, message: string): void { + console.error(message, error); + this.clearAuthToken(); + } + + public setupRefreshTimer(callback: () => Promise): void { if (this.refreshTimer) { clearTimeout(this.refreshTimer); + this.refreshTimer = null; } + const remainingMs = this.getRemainingMsFromToken(CLAIM_NAME); + if (remainingMs === null) { + return; + } + + if (remainingMs <= 0) { + this.clearAuthToken(); + return; + } + + const refreshDelay = Math.max( + remainingMs - TokenManager.REFRESH_BUFFER_MS, + 0, + ); + this.refreshTimer = setTimeout(() => { callback().catch((error) => { - console.error("Silent refresh failed:", error); - this.clearAuthToken(); + this.handleRefreshFailure(error, "Silent refresh failed:"); }); - }, expirationTime - 60000); + }, refreshDelay); + } + + public restoreRefreshTimer(callback: () => Promise): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + + const remainingMs = this.getRemainingMsFromToken(CLAIM_NAME); + if (remainingMs === null) { + return; + } + + if (remainingMs <= 0) { + callback().catch((error) => { + this.handleRefreshFailure( + error, + "Silent refresh failed during restore:", + ); + }); + return; + } + + if (remainingMs <= TokenManager.REFRESH_BUFFER_MS) { + callback().catch((error) => { + this.handleRefreshFailure( + error, + "Silent refresh failed during restore:", + ); + }); + return; + } + + this.setupRefreshTimer(callback); } public clearAuthToken(): void { @@ -102,6 +154,7 @@ export class TokenManager { } this.removeToken(CLAIM_NAME); + this.removeToken(REFRESH_TOKEN); this.removeToken(AUTH_USER_ID); } } diff --git a/apps/wr/src/shared/config/api.ts b/apps/wr/src/shared/config/api.ts index eebb15c19..73bbab9cf 100644 --- a/apps/wr/src/shared/config/api.ts +++ b/apps/wr/src/shared/config/api.ts @@ -82,4 +82,4 @@ export const ENDPOINT = { }; -export const JWT_EXPIRATION_TIME = 1800000; +export const JWT_EXPIRATION_TIME = 3600000;