From d8ce315f4b3f7ba947f9e89432f5275aee68cd31 Mon Sep 17 00:00:00 2001 From: manNomi Date: Wed, 11 Mar 2026 23:25:13 +0900 Subject: [PATCH] chore: add auth debug instrumentation for login instability --- apps/web/LOGIN_INSTABILITY_INVESTIGATION.md | 51 +++++++++++++++++++++ apps/web/src/lib/zustand/useAuthStore.ts | 42 ++++++++++++++++- apps/web/src/middleware.ts | 16 +++++++ apps/web/src/utils/authDebug.ts | 47 +++++++++++++++++++ apps/web/src/utils/axiosInstance.ts | 39 ++++++++++++++++ 5 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 apps/web/LOGIN_INSTABILITY_INVESTIGATION.md create mode 100644 apps/web/src/utils/authDebug.ts diff --git a/apps/web/LOGIN_INSTABILITY_INVESTIGATION.md b/apps/web/LOGIN_INSTABILITY_INVESTIGATION.md new file mode 100644 index 00000000..1f4864f9 --- /dev/null +++ b/apps/web/LOGIN_INSTABILITY_INVESTIGATION.md @@ -0,0 +1,51 @@ +# Login Instability Investigation Guide + +## Scope +- Symptoms: sudden redirect to `/login`, intermittent 401, or authenticated user UI showing as unauthenticated. +- Target routes: `/my`, `/mentor`, `/community/*`. + +## Enable Auth Debug Logs +1. Open browser devtools console. +2. Run `localStorage.setItem("authDebug", "1")`. +3. Hard refresh. +4. Reproduce once. +5. Export logs with: + - `copy(JSON.stringify(window.__AUTH_DEBUG_LOGS__ ?? [], null, 2))` + +Optional (server-side middleware logs): +- Set `NEXT_PUBLIC_AUTH_DEBUG=true` and run the app. +- Middleware prints `[AUTH_DEBUG][middleware.*]` logs in server output. + +## Reproduction Matrix +1. Logged-in user hard refreshes `/my`. +2. Logged-in user hard refreshes `/mentor`. +3. Logged-in user navigates to `/community` and `/community/[boardCode]/create`. +4. User with expired access token but valid refresh token opens protected route. +5. User keeps app idle until access token expires, then triggers API call. + +## What To Check +- `request.start` with `hasAccessToken=true` and `tokenExpired=true`. +- Immediate `response.401` after sending expired access token. +- Whether `reissue.start` appears before redirect. +- `auth.redirectToLogin` reason/message and preceding request URL. +- Middleware redirect events where `hasRefreshToken=false`. + +## Interpretation Rules +- `tokenExpired=true` + no `reissue.start` + `response.401`: + likely missing proactive refresh for expired access tokens. +- Frequent `reissue.failed` while middleware has refresh cookie: + likely backend `/auth/reissue` or cookie attribute/domain issue. +- Multiple `reissue.start` from same user action: + possible reissue concurrency/race condition. +- Middleware redirects before app boot: + refresh token cookie missing/expired at edge layer. + +## Deliverables +- Reproduction steps used. +- Exported `window.__AUTH_DEBUG_LOGS__` JSON. +- Network HAR covering `/auth/reissue` and first failing 401. +- Suspected root cause category: + - token expiry handling + - refresh failure/cookie issue + - race condition + - route guard mismatch diff --git a/apps/web/src/lib/zustand/useAuthStore.ts b/apps/web/src/lib/zustand/useAuthStore.ts index a3a8c865..93c3bd80 100644 --- a/apps/web/src/lib/zustand/useAuthStore.ts +++ b/apps/web/src/lib/zustand/useAuthStore.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { UserRole } from "@/types/mentor"; +import { authDebugLog } from "@/utils/authDebug"; const parseUserRoleFromToken = (token: string | null): UserRole | null => { if (!token) return null; @@ -20,6 +21,17 @@ const parseUserRoleFromToken = (token: string | null): UserRole | null => { type RefreshStatus = "idle" | "refreshing" | "success" | "failed"; +const parseTokenExpiry = (token: string | null): number | null => { + if (!token) return null; + + try { + const payload = JSON.parse(atob(token.split(".")[1])) as { exp?: number }; + return payload.exp ?? null; + } catch { + return null; + } +}; + interface AuthState { accessToken: string | null; userRole: UserRole | null; @@ -36,7 +48,7 @@ interface AuthState { const useAuthStore = create()( persist( - (set) => ({ + (set, get) => ({ accessToken: null, userRole: null, isAuthenticated: false, @@ -45,6 +57,13 @@ const useAuthStore = create()( refreshStatus: "idle", setAccessToken: (token) => { + const expiresAt = parseTokenExpiry(token); + authDebugLog("store.setAccessToken", { + hasToken: !!token, + expiresAt, + currentTimeSec: Math.floor(Date.now() / 1000), + }); + set({ accessToken: token, userRole: parseUserRoleFromToken(token), @@ -56,6 +75,11 @@ const useAuthStore = create()( }, clearAccessToken: () => { + authDebugLog("store.clearAccessToken", { + wasAuthenticated: get().isAuthenticated, + refreshStatus: get().refreshStatus, + }); + set({ accessToken: null, userRole: null, @@ -67,14 +91,26 @@ const useAuthStore = create()( }, setLoading: (loading) => { + if (get().isLoading !== loading) { + authDebugLog("store.setLoading", { previous: get().isLoading, next: loading }); + } + set({ isLoading: loading }); }, setInitialized: (initialized) => { + if (get().isInitialized !== initialized) { + authDebugLog("store.setInitialized", { previous: get().isInitialized, next: initialized }); + } + set({ isInitialized: initialized }); }, setRefreshStatus: (status) => { + if (get().refreshStatus !== status) { + authDebugLog("store.setRefreshStatus", { previous: get().refreshStatus, next: status }); + } + set({ refreshStatus: status }); }, }), @@ -87,6 +123,10 @@ const useAuthStore = create()( onRehydrateStorage: () => (state) => { // hydration 완료 후 isInitialized를 true로 설정 if (state) { + authDebugLog("store.rehydrate.complete", { + hasToken: !!state.accessToken, + isAuthenticated: state.isAuthenticated, + }); state.userRole = parseUserRoleFromToken(state.accessToken); state.isInitialized = true; } diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 279d052a..29d6183f 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -6,6 +6,7 @@ const COMMUNITY_LOGIN_REASON = "community-members-only"; export function middleware(request: NextRequest) { const url = request.nextUrl.clone(); + const isAuthDebugEnabled = process.env.NEXT_PUBLIC_AUTH_DEBUG === "true"; // localhost 환경에서는 미들웨어 적용 X // if (url.hostname === "localhost") { @@ -27,6 +28,15 @@ export function middleware(request: NextRequest) { return url.pathname === path || url.pathname.startsWith(`${path}/`); }); + if (isAuthDebugEnabled) { + console.info("[AUTH_DEBUG][middleware.check]", { + path: url.pathname, + needLogin, + hasRefreshToken: !!refreshToken, + cookieAuthEnabled: isServerSideAuthEnabled, + }); + } + if (needLogin && !refreshToken) { const isCommunityRoute = url.pathname === "/community" || url.pathname.startsWith("/community/"); url.pathname = "/login"; @@ -35,6 +45,12 @@ export function middleware(request: NextRequest) { } else { url.searchParams.delete("reason"); } + if (isAuthDebugEnabled) { + console.info("[AUTH_DEBUG][middleware.redirect]", { + to: url.pathname, + reason: isCommunityRoute ? COMMUNITY_LOGIN_REASON : "login-required", + }); + } return NextResponse.redirect(url); } diff --git a/apps/web/src/utils/authDebug.ts b/apps/web/src/utils/authDebug.ts new file mode 100644 index 00000000..91146999 --- /dev/null +++ b/apps/web/src/utils/authDebug.ts @@ -0,0 +1,47 @@ +type AuthDebugDetails = Record; + +type AuthDebugEvent = { + at: string; + event: string; + details: AuthDebugDetails; +}; + +declare global { + interface Window { + __AUTH_DEBUG_LOGS__?: AuthDebugEvent[]; + } +} + +const AUTH_DEBUG_STORAGE_KEY = "authDebug"; + +const isAuthDebugEnabled = (): boolean => { + const envEnabled = process.env.NEXT_PUBLIC_AUTH_DEBUG === "true"; + + if (typeof window === "undefined") { + return envEnabled; + } + + try { + const storageEnabled = window.localStorage.getItem(AUTH_DEBUG_STORAGE_KEY) === "1"; + return envEnabled || storageEnabled; + } catch { + return envEnabled; + } +}; + +export const authDebugLog = (event: string, details: AuthDebugDetails = {}): void => { + if (!isAuthDebugEnabled()) return; + + const payload: AuthDebugEvent = { + at: new Date().toISOString(), + event, + details, + }; + + if (typeof window !== "undefined") { + window.__AUTH_DEBUG_LOGS__ = window.__AUTH_DEBUG_LOGS__ ?? []; + window.__AUTH_DEBUG_LOGS__.push(payload); + } + + console.info("[AUTH_DEBUG]", payload); +}; diff --git a/apps/web/src/utils/axiosInstance.ts b/apps/web/src/utils/axiosInstance.ts index be02b52a..bb331d8a 100644 --- a/apps/web/src/utils/axiosInstance.ts +++ b/apps/web/src/utils/axiosInstance.ts @@ -3,6 +3,8 @@ import axios, { type AxiosError, type AxiosInstance } from "axios"; import { postReissueToken } from "@/apis/Auth/server"; import useAuthStore from "@/lib/zustand/useAuthStore"; import { toast } from "@/lib/zustand/useToastStore"; +import { authDebugLog } from "@/utils/authDebug"; +import { isTokenExpired } from "@/utils/jwtUtils"; // --- 글로벌 변수 --- let reissuePromise: Promise | null = null; @@ -22,6 +24,13 @@ let isRedirecting = false; const redirectToLogin = (message: string) => { if (typeof window !== "undefined" && !isRedirecting) { isRedirecting = true; + authDebugLog("auth.redirectToLogin", { + message, + path: window.location.pathname, + refreshStatus: useAuthStore.getState().refreshStatus, + isInitialized: useAuthStore.getState().isInitialized, + hasAccessToken: !!useAuthStore.getState().accessToken, + }); // Zustand 스토어 및 쿠키 상태 초기화 useAuthStore.getState().clearAccessToken(); try { @@ -54,9 +63,24 @@ axiosInstance.interceptors.request.use( async (config) => { const { accessToken, setLoading, clearAccessToken, setInitialized, refreshStatus, setRefreshStatus } = useAuthStore.getState(); + const tokenExpired = isTokenExpired(accessToken); + + authDebugLog("request.start", { + method: config.method, + url: config.url, + hasAccessToken: !!accessToken, + tokenExpired, + refreshStatus, + isInitialized: useAuthStore.getState().isInitialized, + }); // 토큰이 있으면 헤더에 추가하고 진행 if (accessToken) { + authDebugLog("request.attachAccessToken", { + method: config.method, + url: config.url, + tokenExpired, + }); config.headers.Authorization = convertToBearer(accessToken); return config; } @@ -65,18 +89,22 @@ axiosInstance.interceptors.request.use( try { // 이미 reissue가 진행 중인지 확인 if (reissuePromise) { + authDebugLog("reissue.awaitExisting", { method: config.method, url: config.url }); await reissuePromise; } else { // 새로운 reissue 프로세스 시작 (HTTP-only 쿠키의 refreshToken 사용) reissuePromise = (async () => { + authDebugLog("reissue.start", { triggeredByUrl: config.url, triggeredByMethod: config.method }); setRefreshStatus("refreshing"); setLoading(true); try { await postReissueToken(); setRefreshStatus("success"); + authDebugLog("reissue.success", { triggeredByUrl: config.url, triggeredByMethod: config.method }); } catch { clearAccessToken(); setRefreshStatus("failed"); + authDebugLog("reissue.failed", { triggeredByUrl: config.url, triggeredByMethod: config.method }); } finally { setLoading(false); setInitialized(true); @@ -90,15 +118,18 @@ axiosInstance.interceptors.request.use( // reissue 완료 후 업데이트된 토큰으로 헤더 설정 const updatedAccessToken = useAuthStore.getState().accessToken; if (updatedAccessToken) { + authDebugLog("request.attachReissuedToken", { method: config.method, url: config.url }); config.headers.Authorization = convertToBearer(updatedAccessToken); } } catch { // 에러 발생 시에도 상태 정리는 promise 내부의 finally에서 처리됨 + authDebugLog("reissue.errorUnhandled", { method: config.method, url: config.url }); } // reissue 후 토큰이 있으면 헤더에 추가 const finalAccessToken = useAuthStore.getState().accessToken; if (finalAccessToken) { + authDebugLog("request.proceedWithReissuedToken", { method: config.method, url: config.url }); config.headers.Authorization = convertToBearer(finalAccessToken); return config; } @@ -108,6 +139,7 @@ axiosInstance.interceptors.request.use( // 초기화는 되었지만 토큰이 없는 경우 로그인 필요 if (currentInitialized && !currentAccessToken) { + authDebugLog("request.rejectAuthenticationRequired", { method: config.method, url: config.url }); redirectToLogin("로그인이 필요합니다. 다시 로그인해주세요."); return Promise.reject(new AuthenticationRequiredError()); } @@ -125,6 +157,13 @@ axiosInstance.interceptors.response.use( (error: AxiosError) => { // 401 에러 시 로그인 페이지로 리다이렉트 if (error.response?.status === 401) { + authDebugLog("response.401", { + method: error.config?.method, + url: error.config?.url, + refreshStatus: useAuthStore.getState().refreshStatus, + isInitialized: useAuthStore.getState().isInitialized, + hasAccessToken: !!useAuthStore.getState().accessToken, + }); redirectToLogin("세션이 만료되었습니다. 다시 로그인해주세요."); }