-
Notifications
You must be signed in to change notification settings - Fork 3
fix: refresh/access 토큰 엣지케이스 로그인 이탈 보완 #489
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,10 @@ | ||
| import { create } from "zustand"; | ||
| import { persist } from "zustand/middleware"; | ||
| import { UserRole } from "@/types/mentor"; | ||
| import { isTokenExpired } from "@/utils/jwtUtils"; | ||
|
|
||
| const parseUserRoleFromToken = (token: string | null): UserRole | null => { | ||
| if (!token) return null; | ||
| if (!token || isTokenExpired(token)) return null; | ||
|
|
||
| try { | ||
| const payload = JSON.parse(atob(token.split(".")[1])) as { role?: string }; | ||
|
|
@@ -87,7 +88,19 @@ const useAuthStore = create<AuthState>()( | |
| onRehydrateStorage: () => (state) => { | ||
| // hydration 완료 후 isInitialized를 true로 설정 | ||
| if (state) { | ||
| state.userRole = parseUserRoleFromToken(state.accessToken); | ||
| const hasValidToken = Boolean(state.accessToken && !isTokenExpired(state.accessToken)); | ||
|
|
||
| if (!hasValidToken) { | ||
| state.accessToken = null; | ||
| state.userRole = null; | ||
| state.isAuthenticated = false; | ||
| state.refreshStatus = "idle"; | ||
| } else { | ||
| state.userRole = parseUserRoleFromToken(state.accessToken); | ||
| state.isAuthenticated = true; | ||
| state.refreshStatus = "success"; | ||
| } | ||
|
|
||
| state.isInitialized = true; | ||
|
Comment on lines
+91
to
104
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's find and read the actual file
find . -type f -name "useAuthStore.ts" | head -5Repository: solid-connection/solid-connect-web Length of output: 120 🏁 Script executed: # Also check the file structure
git ls-files apps/web/src/lib/zustand/ | head -20Repository: solid-connection/solid-connect-web Length of output: 354 🏁 Script executed: # Read the useAuthStore.ts file to see the actual implementation
cat -n ./apps/web/src/lib/zustand/useAuthStore.tsRepository: solid-connection/solid-connect-web Length of output: 3820 🏁 Script executed: # Let's check if there are any other usages or tests of this store
grep -r "useAuthStore" apps/web/src --include="*.ts" --include="*.tsx" | head -20Repository: solid-connection/solid-connect-web Length of output: 1972 🏁 Script executed: # Also check if there's documentation or examples about Zustand persist behavior
find . -type f \( -name "*.md" -o -name "*.txt" \) -path "*/zustand*" | head -10Repository: solid-connection/solid-connect-web Length of output: 60 🌐 Web query:
💡 Result: In Zustand's persist middleware, the onRehydrateStorage callback is invoked before the persisted state is loaded from storage and applied to the store. It receives the current (initial) state as an argument and can return an optional post-rehydration callback that runs after the state is merged in or if an error occurs. State mutations (via set) within onRehydrateStorage or its returned callback use the standard set function, which does trigger subscribers because Zustand's core setState always notifies listeners/subscribers to state changes. However, for synchronous storages like localStorage: - Mutations in onRehydrateStorage can be overwritten by the final persisted state assignment. - This was a known issue (e.g., GitHub Citations:
🏁 Script executed: # Check if there's a post-rehydration pattern used elsewhere or documentation
grep -r "onRehydrateStorage" apps/web --include="*.ts" --include="*.tsx" -A 5 -B 2Repository: solid-connection/solid-connect-web Length of output: 710 🌐 Web query:
💡 Result: Zustand persist middleware best practices for token validation and hydration, particularly for authentication stores, involve several key patterns grounded in official documentation and community implementations. ## Store Setup Use persist middleware with partialize to store only serializable token data (avoid functions/actions). Store tokens as strings. Example auth store: import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import jwtDecode from 'jwt-decode'; interface AuthState { accessToken: string | null; refreshToken: string | null; user: any | null; isAuthenticated: boolean; setTokens: (access: string, refresh?: string, user?: any) => void; logout: => void; init: => void; // For validation } export const useAuthStore = create( persist( (set, get) => ({ accessToken: null, refreshToken: null, user: null, isAuthenticated: false, setTokens: (accessToken, refreshToken, user) => { set({ accessToken, refreshToken, user, isAuthenticated: true }); }, logout: => { set({ accessToken: null, refreshToken: null, user: null, isAuthenticated: false }); }, init: async => { const { accessToken, refreshToken } = get; if (accessToken) { try { // Validate JWT expiration const decoded = jwtDecode<{ exp: number }>(accessToken); if (decoded.exp * 1000 > Date.now) { // Valid, fetch user if needed set({ isAuthenticated: true }); } else if (refreshToken) { // Attempt refresh } else { get.logout; } } catch { get.logout; } } } }), { name: 'auth-storage', storage: createJSONStorage( => localStorage), partialize: (state) => ({ accessToken: state.accessToken, refreshToken: state.refreshToken }), // Custom merge for functions/serialization issues merge: (persistedState, currentState) => ({ ...currentState, ...persistedState }), } ) ); {value: "Example auth store code", citations: [1,5,6,7], confidence: "high"} ## Hydration Pattern Track hydration to prevent rendering mismatches (critical in SSR like Next.js). 1. Add hasHydrated flag: onRehydrateStorage: (state) => (state, error) => { if (!error && state) state.setHasHydrated(true); } In store: _hasHydrated: false, setHasHydrated: (hydrated) => set({ _hasHydrated: hydrated }), Use: const hydrated = useAuthStore((s) => s._hasHydrated); if (!hydrated) return ; {value: "Hydration tracking with onRehydrateStorage", citations: [1,3,14], confidence: "high"} For SSR/Next.js: - skipHydration: true - Manual rehydrate in client component: 'use client'; useEffect( => { useAuthStore.persist.rehydrate.then( => useAuthStore.getState.init); }, []); {value: "SSR hydration handling", citations: [1,3,12,21], confidence: "high"} Alternative for hydration mismatches: useSyncExternalStore for selectors differing server/client. {value: "useSyncExternalStore for external stores", citations: [4], confidence: "medium"} ## Token Validation - Do not trust persisted tokens blindly. Always validate on hydration/init. - Decode JWT, check exp claim. - If expired, attempt refresh with refreshToken, else logout/clearStorage. - Call init after rehydrate. - Use onRehydrateStorage to conditionally clear invalid state (e.g., delete localStorage if no valid JWT). {value: "Token validation on init/hydration", citations: [6,16,19], confidence: "high"} ## Best Practices 1. partialize: Persist only tokens, not user data or functions. Fetch user on validation. 2. Actions separate: Store data only; validate in actions/getters. 3. SSR: Use skipHydration + manual rehydrate + hydration flag. 4. Security: Tokens in localStorage ok for SPA but consider httpOnly cookies for refreshToken. 5. Refresh logic: Intercept API calls, refresh if 401. 6. Selectors: Use shallow for performance. 7. Versioning: Use version for breaking changes. This pattern ensures tokens persist reliably, validate on load, and handle hydration without mismatches. {value: "Summary of best practices", citations: [1,6,7,9], confidence: "high"} Citations:
hydration 콜백 구조를 수정하여 직접 mutation 대신 setState로 반영해주세요. 현재 코드는 onRehydrateStorage에서 state를 직접 변경하고 있어서 Zustand의 구독자 알림이 제대로 전파되지 않을 수 있습니다. 특히 localStorage 같은 동기 저장소에서는 이 변경사항이 최종 hydration 완료 전에 덮어씌워질 위험이 있습니다. 만료된 토큰을 정리하려면 다음처럼 콜백 구조를 바꾸어 setState를 통해 업데이트하는 것이 안전합니다: ✅ 개선된 패치 onRehydrateStorage: () => (state, error) => {
- // hydration 완료 후 isInitialized를 true로 설정
- if (state) {
+ if (!error && state) {
const hasValidToken = Boolean(state.accessToken && !isTokenExpired(state.accessToken));
- if (!hasValidToken) {
- state.accessToken = null;
- state.userRole = null;
- state.isAuthenticated = false;
- state.refreshStatus = "idle";
- } else {
- state.userRole = parseUserRoleFromToken(state.accessToken);
- state.isAuthenticated = true;
- state.refreshStatus = "success";
- }
-
- state.isInitialized = true;
+ useAuthStore.setState(
+ hasValidToken
+ ? {
+ userRole: parseUserRoleFromToken(state.accessToken),
+ isAuthenticated: true,
+ refreshStatus: "success",
+ isInitialized: true,
+ }
+ : {
+ accessToken: null,
+ userRole: null,
+ isAuthenticated: false,
+ refreshStatus: "idle",
+ isInitialized: true,
+ },
+ );
}
},변경 요점:
🤖 Prompt for AI Agents |
||
| } | ||
| }, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ 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 { isTokenExpired } from "@/utils/jwtUtils"; | ||
|
|
||
| // --- 글로벌 변수 --- | ||
| let reissuePromise: Promise<void> | null = null; | ||
|
|
@@ -34,6 +35,34 @@ const redirectToLogin = (message: string) => { | |
|
|
||
| export const convertToBearer = (token: string) => `Bearer ${token}`; | ||
|
|
||
| const tryReissueAccessToken = async (): Promise<string | null> => { | ||
| if (reissuePromise) { | ||
| await reissuePromise; | ||
| return useAuthStore.getState().accessToken; | ||
| } | ||
|
|
||
| const { setLoading, clearAccessToken, setInitialized, setRefreshStatus } = useAuthStore.getState(); | ||
|
|
||
| reissuePromise = (async () => { | ||
| setRefreshStatus("refreshing"); | ||
| setLoading(true); | ||
| try { | ||
| await postReissueToken(); | ||
| setRefreshStatus("success"); | ||
| } catch { | ||
| clearAccessToken(); | ||
| setRefreshStatus("failed"); | ||
| } finally { | ||
| setLoading(false); | ||
| setInitialized(true); | ||
| reissuePromise = null; | ||
| } | ||
| })(); | ||
|
|
||
| await reissuePromise; | ||
| return useAuthStore.getState().accessToken; | ||
| }; | ||
|
|
||
| // --- Axios 인스턴스 --- | ||
| // 인증이 필요 없는 공용 API 요청에 사용 | ||
| export const publicAxiosInstance: AxiosInstance = axios.create({ | ||
|
|
@@ -52,40 +81,24 @@ export const axiosInstance: AxiosInstance = axios.create({ | |
| // 1. 요청 인터셉터 (Request Interceptor) | ||
| axiosInstance.interceptors.request.use( | ||
| async (config) => { | ||
| const { accessToken, setLoading, clearAccessToken, setInitialized, refreshStatus, setRefreshStatus } = | ||
| useAuthStore.getState(); | ||
| const { accessToken, clearAccessToken, refreshStatus } = useAuthStore.getState(); | ||
|
|
||
| // 만료된 access token은 즉시 제거하고 refresh 재발급 경로를 타게 한다. | ||
| if (accessToken && isTokenExpired(accessToken)) { | ||
| clearAccessToken(); | ||
|
Comment on lines
+87
to
+88
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Calling Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| const validAccessToken = useAuthStore.getState().accessToken; | ||
|
|
||
| // 토큰이 있으면 헤더에 추가하고 진행 | ||
| if (accessToken) { | ||
| config.headers.Authorization = convertToBearer(accessToken); | ||
| if (validAccessToken) { | ||
| config.headers.Authorization = convertToBearer(validAccessToken); | ||
| return config; | ||
| } | ||
|
|
||
| if (refreshStatus !== "failed") { | ||
| try { | ||
| // 이미 reissue가 진행 중인지 확인 | ||
| if (reissuePromise) { | ||
| await reissuePromise; | ||
| } else { | ||
| // 새로운 reissue 프로세스 시작 (HTTP-only 쿠키의 refreshToken 사용) | ||
| reissuePromise = (async () => { | ||
| setRefreshStatus("refreshing"); | ||
| setLoading(true); | ||
| try { | ||
| await postReissueToken(); | ||
| setRefreshStatus("success"); | ||
| } catch { | ||
| clearAccessToken(); | ||
| setRefreshStatus("failed"); | ||
| } finally { | ||
| setLoading(false); | ||
| setInitialized(true); | ||
| reissuePromise = null; | ||
| } | ||
| })(); | ||
|
|
||
| await reissuePromise; | ||
| } | ||
| await tryReissueAccessToken(); | ||
|
|
||
| // reissue 완료 후 업데이트된 토큰으로 헤더 설정 | ||
| const updatedAccessToken = useAuthStore.getState().accessToken; | ||
|
|
@@ -119,12 +132,31 @@ axiosInstance.interceptors.request.use( | |
| ); | ||
|
|
||
| // 2. 응답 인터셉터 (Response Interceptor) | ||
| // 역할: 401 에러 시 로그인 페이지로 리다이렉트 | ||
| // 역할: 401 에러 시 access 재발급 1회 재시도 후 실패하면 로그인 페이지로 리다이렉트 | ||
| axiosInstance.interceptors.response.use( | ||
| (response) => response, | ||
| (error: AxiosError) => { | ||
| // 401 에러 시 로그인 페이지로 리다이렉트 | ||
| if (error.response?.status === 401) { | ||
| async (error: AxiosError) => { | ||
| const status = error.response?.status; | ||
|
|
||
| if (status === 401) { | ||
| const originalRequest = error.config as (typeof error.config & { _retry?: boolean }) | undefined; | ||
|
|
||
| if (originalRequest && !originalRequest._retry && useAuthStore.getState().refreshStatus !== "failed") { | ||
| originalRequest._retry = true; | ||
|
|
||
| try { | ||
| const reissuedAccessToken = await tryReissueAccessToken(); | ||
|
|
||
| if (reissuedAccessToken) { | ||
| originalRequest.headers = originalRequest.headers ?? {}; | ||
| originalRequest.headers.Authorization = convertToBearer(reissuedAccessToken); | ||
| return axiosInstance(originalRequest); | ||
| } | ||
| } catch { | ||
| // 재발급 실패 시 아래 로그인 리다이렉트로 처리 | ||
| } | ||
| } | ||
|
|
||
| redirectToLogin("세션이 만료되었습니다. 다시 로그인해주세요."); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| # Access/Refresh 토큰 엣지케이스 정리 | ||
|
|
||
| 이 문서는 웹 앱의 로그인 유지 로직에서 자주 발생하는 토큰 상태별 동작을 정리합니다. | ||
| 특히 `멘토(/mentor*)`, `커뮤니티(/community*)` 경로에서 발생 빈도가 높은 케이스를 우선 다룹니다. | ||
|
|
||
| ## 1. 현재 인증 판단 경계 | ||
|
|
||
| - 서버 진입(Next middleware): `refreshToken` 쿠키 유효성으로 1차 진입 제어 | ||
| - 클라이언트 API 요청(axios interceptor): `accessToken` 유효성 + 필요 시 `/auth/reissue` 재발급 | ||
| - 멘토 진입 페이지: 렌더 전에 access 유효성 확인 후 필요 시 재발급 | ||
| - 채팅 소켓 연결: access 유효성 확인 후 연결 | ||
|
|
||
| ## 2. 토큰 상태별 케이스 매트릭스 | ||
|
|
||
| | 케이스 | 토큰 상태 | 주로 발생 화면 | 기대 동작 | 현재 처리 | | ||
| | --- | --- | --- | --- | --- | | ||
| | A | refresh 없음, access 없음 | `/mentor`, `/community`, `/my` 직접 진입 | 즉시 로그인 이동 | middleware에서 로그인 리다이렉트 | | ||
| | B | refresh 만료/손상, access 없음 | `/mentor`, `/community` 새로고침 | 즉시 로그인 이동 | middleware에서 만료 refresh 차단 | | ||
| | C | refresh 유효, access 없음 | 멘토 첫 진입, 커뮤니티 글쓰기 직전 | 백그라운드 재발급 후 계속 진행 | interceptor/멘토 클라에서 재발급 | | ||
| | D | refresh 유효, access 만료 | 멘토 목록/채팅, 커뮤니티 작성/수정 | 만료 access 폐기 -> 재발급 -> 요청 진행 | 만료 access 선제 정리 + 재발급 | | ||
| | E | refresh 유효, access 유효하지만 서버에서 401(폐기/불일치) | 멘토 API, 커뮤니티 mutation | 재발급 1회 후 원요청 재시도 | response interceptor에서 1회 retry | | ||
| | F | refresh 유효, access 없음 + 동시 다중 요청 | 멘토 페이지 초기 렌더 | 재발급 요청 1회만 수행 | `reissuePromise` 락으로 중복 방지 | | ||
| | G | refresh 재발급 실패 | 멘토/커뮤니티 보호 요청 | 무한 재시도 금지 + 로그인 이동 | `refreshStatus=failed`로 차단 후 리다이렉트 | | ||
| | H | 멘토 채팅 소켓 연결 시 access 만료 | `/mentor/chat/*` | 만료 토큰으로 연결 시도 금지 | 소켓 훅에서 만료 access 차단 | | ||
|
|
||
| ## 3. 멘토/커뮤니티에서 자주 터지는 이유 | ||
|
|
||
| 1. 두 경로 모두 보호 페이지로 분류되어 진입 시점의 인증 상태 흔들림이 바로 노출됨 | ||
| 2. 멘토는 초기 렌더 시 인증 의존 API가 많아, access 만료 시 체감 문제가 빠르게 발생 | ||
| 3. 커뮤니티는 목록 조회는 public이지만 작성/수정/댓글은 인증 API라, 클릭 시점에 문제 노출 | ||
|
|
||
| ## 4. 이번 보완 포인트 | ||
|
|
||
| - middleware에서 `refreshToken`의 단순 존재가 아니라 **만료 여부까지 검사** | ||
| - axios request interceptor에서 **만료된 access를 즉시 폐기**하고 재발급 경로로 전환 | ||
| - axios response interceptor에서 401 발생 시 **재발급 1회 후 원요청 재시도** | ||
| - 멘토 클라이언트 렌더 분기에서 **만료 access를 유효 토큰으로 취급하지 않도록 보정** | ||
| - 소켓 연결 훅에서 **만료 access로 연결 시도 금지** | ||
|
|
||
| ## 5. 수동 검증 체크리스트 | ||
|
|
||
| 1. refresh 없음 상태에서 `/mentor`, `/community` 진입 시 즉시 `/login`으로 이동 | ||
| 2. refresh 만료 상태에서 `/mentor`, `/community` 진입 시 즉시 `/login`으로 이동 | ||
| 3. refresh 유효 + access 없음 상태에서 `/mentor` 진입 시 화면 유지 후 정상 렌더 | ||
| 4. refresh 유효 + access 만료 상태에서 `/mentor` 진입 시 로그인 튕김 없이 복구 | ||
| 5. refresh 유효 + access 만료 상태에서 커뮤니티 글 작성/댓글 시도 시 재발급 후 정상 요청 | ||
| 6. access가 서버에서 무효 처리된 상태(401)에서 요청 시 1회 재시도 후 실패 시 로그인 이동 | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rejecting a freshly reissued access token based on
isTokenExpired()can force unnecessary logout loops when client and auth-server clocks are skewed (for example, a user device clock ahead of server time). In that case/auth/reissuesucceeds server-side but this client check throws and clears auth state anyway. Add skew tolerance or trust the server-issued token instead of hard-failing immediately.Useful? React with 👍 / 👎.