Skip to content

fix: refresh/access 토큰 엣지케이스 로그인 이탈 보완#489

Merged
manNomi merged 1 commit intomainfrom
codex/auth-refresh-edge-cases
Mar 27, 2026
Merged

fix: refresh/access 토큰 엣지케이스 로그인 이탈 보완#489
manNomi merged 1 commit intomainfrom
codex/auth-refresh-edge-cases

Conversation

@manNomi
Copy link
Copy Markdown
Contributor

@manNomi manNomi commented Mar 27, 2026

변경 내용

  • middleware 보호 경로 진입 조건을 refreshToken 존재 여부에서 refreshToken 유효성(만료 여부 포함) 검증으로 강화
  • axios request interceptor에서 만료 accessToken을 선제 정리하고 reissue 경로로 일원화
  • axios response interceptor에서 401 발생 시 access 재발급 1회 후 원요청 재시도
  • mentor 진입 로직에서 만료 accessToken을 유효 토큰으로 취급하지 않도록 보정
  • chat socket 연결 훅에서 만료 accessToken으로 연결 시도하지 않도록 보정
  • reissue 응답 accessToken 자체 만료 검증 추가
  • 토큰 상태별 엣지케이스 문서 추가 (docs/auth-refresh-edge-cases.md)

배경

  • refreshToken은 존재하지만 accessToken이 없거나 만료된 상태에서 멘토/커뮤니티 진입 시 로그인으로 튕기는 케이스가 빈번하게 발생
  • access 만료 상태를 유효 토큰으로 처리하면서 401 즉시 리다이렉트로 이어지는 흐름이 원인

검증

  • pnpm --filter @solid-connect/web run typecheck
  • pre-commit CI parity check 통과 (lint:check, typecheck:ci)
  • pre-push CI parity check 통과 (lint:check, typecheck:ci, build)

@manNomi manNomi requested review from enunsnv and wibaek as code owners March 27, 2026 07:20
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
solid-connect-web-admin Ready Ready Preview, Comment Mar 27, 2026 7:20am
solid-connection-web Ready Ready Preview, Comment Mar 27, 2026 7:20am

@github-actions github-actions bot added the web label Mar 27, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 27, 2026

📋 Walkthrough

이 변경은 애플리케이션 전역에서 토큰 만료 여부를 검증하는 로직을 추가하여 인증 흐름을 강화합니다. 기존에는 토큰의 존재 여부만 확인했다면, 이제는 만료된 토큰을 유효하지 않은 것으로 취급합니다.

주요 변경 사항:

  1. 토큰 재발급 엔드포인트 — 새로 발급받은 토큰이 이미 만료되었는지 검증하여 저장 전에 차단
  2. 멘토 페이지 진입 제어 — 만료된 액세스 토큰을 유효하지 않은 것으로 인식하고 새로고침 흐름 실행
  3. 웹소켓 연결 조건 — 토큰 만료 여부를 포함하여 연결 가능 조건 검증
  4. 인증 스토어 수화 — 로컬 스토리지에서 복구 시 만료된 토큰 자동 정리
  5. 미들웨어 진입 제어 — 리프레시 토큰의 만료 여부를 확인하여 로그인 페이지로 리다이렉트
  6. 요청 인터셉터 중앙화 — 토큰 만료 감지 후 재발급 로직을 통합하고 재시도 처리 강화
  7. 엣지 케이스 문서화 — 다양한 토큰 상태 조합에 따른 예상 동작 명시

🎯 Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

변경이 여러 파일에 걸쳐 있고, 각 파일마다 토큰 만료 검증 로직을 상이한 문맥에서 적용했습니다. 특히 axiosInstance.ts의 재발급 로직 중앙화와 재시도 처리는 높은 논리 밀도를 가지고 있어 세심한 검토가 필요합니다.

👥 Suggested reviewers

  • wibaek
  • enunsnv
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 토큰 엣지케이스를 다루는 로그인 이탈 보완에 관해 명확하고 구체적으로 설명하며, 실제 변경사항들과 완전히 일치합니다.
Description check ✅ Passed PR 설명이 필수 섹션(관련 이슈, 작업 내용, 특이 사항)은 충실하게 채웠으나, 구성 안내 템플릿과는 다소 차이가 있습니다.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/auth-refresh-edge-cases

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1d17417492

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +87 to +88
if (accessToken && isTokenExpired(accessToken)) {
clearAccessToken();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid clearing auth state before refresh completes

Calling clearAccessToken() as soon as an access token is detected expired logs the client into a temporary unauthenticated state before refresh has even been attempted. In this codebase, UI/query flows are gated by isAuthenticated (for example in university and mentor widgets), so a normal silent refresh can now transiently disable authenticated behavior and reset auth-dependent UI even when refresh succeeds moments later. Keep the old auth state until reissue actually fails to avoid this regression.

Useful? React with 👍 / 👎.

Comment on lines +17 to +18
if (isTokenExpired(newAccessToken)) {
throw new Error("재발급된 토큰이 이미 만료되었습니다.");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove strict local expiry rejection for reissued token

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/reissue succeeds 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 👍 / 👎.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/web/src/utils/axiosInstance.ts (1)

38-64: 1. tryReissueAccessToken()의 실패 계약을 한쪽으로 정리해주세요.

지금 helper는 내부에서 에러를 삼키고 null만 반환하는데, 두 호출부는 try/catch를 두고 있어서 reject도 가능한 것처럼 읽힙니다. 실패를 값으로 표현할 거면 catch를 걷어내고 null 체크만 남기는 편이 흐름을 더 빨리 읽게 해줍니다.

Also applies to: 99-110, 147-157

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/utils/axiosInstance.ts` around lines 38 - 64, Unify the failure
contract by treating tryReissueAccessToken() as a value-returning failure (it
returns null on failure) and remove the surrounding try/catch blocks in its
callers so they check the returned value for null instead; specifically keep the
behavior in tryReissueAccessToken (setRefreshStatus/clearAccessToken and return
accessToken or null) and change the caller sites (the blocks around lines 99-110
and 147-157 that currently wrap calls to tryReissueAccessToken() in try/catch)
to simply await the call and branch on a null result rather than catching
errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/lib/zustand/useAuthStore.ts`:
- Around line 91-104: The hydration callback is directly mutating the Zustand
state (state.accessToken, state.userRole, state.isAuthenticated,
state.refreshStatus, state.isInitialized) which can break subscriber
notifications and be overwritten by sync storages; change the onRehydrateStorage
callback signature to (state, error) and perform all updates via the store's
setState API instead of assigning to state directly: call setState with a new
partial state that clears an expired token (use
isTokenExpired(state.accessToken)), sets userRole via parseUserRoleFromToken
when valid, toggles refreshStatus to "idle" or "success", and marks
isInitialized true so subscribers receive the change atomically.

---

Nitpick comments:
In `@apps/web/src/utils/axiosInstance.ts`:
- Around line 38-64: Unify the failure contract by treating
tryReissueAccessToken() as a value-returning failure (it returns null on
failure) and remove the surrounding try/catch blocks in its callers so they
check the returned value for null instead; specifically keep the behavior in
tryReissueAccessToken (setRefreshStatus/clearAccessToken and return accessToken
or null) and change the caller sites (the blocks around lines 99-110 and 147-157
that currently wrap calls to tryReissueAccessToken() in try/catch) to simply
await the call and branch on a null result rather than catching errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 07194c86-797d-443f-b900-4d8f093abb90

📥 Commits

Reviewing files that changed from the base of the PR and between c791da2 and 1d17417.

📒 Files selected for processing (7)
  • apps/web/src/apis/Auth/server/postReissueToken.ts
  • apps/web/src/app/mentor/_ui/MentorClient/index.tsx
  • apps/web/src/lib/web-socket/useConnectWebSocket.ts
  • apps/web/src/lib/zustand/useAuthStore.ts
  • apps/web/src/middleware.ts
  • apps/web/src/utils/axiosInstance.ts
  • docs/auth-refresh-edge-cases.md

Comment on lines +91 to 104
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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find and read the actual file
find . -type f -name "useAuthStore.ts" | head -5

Repository: 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 -20

Repository: 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.ts

Repository: 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 -20

Repository: 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 -10

Repository: solid-connection/solid-connect-web

Length of output: 60


🌐 Web query:

Zustand persist middleware onRehydrateStorage direct state mutation propagation subscribers

💡 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 #1688), fixed in later versions where updates during rehydration are preserved properly. The rehydration itself (merging persisted state) happens via internal set calls after storage retrieval, so subscribers are notified of the hydration state changes. React components using useStore will re-render accordingly once hydrated. Use store.persist.hasHydrated, onHydrate, or onFinishHydration to detect/track hydration status reactively. Example usage: onRehydrateStorage: (state) => { // Pre-rehydration, mutations here may be overwritten (sync storage) return (newState, error) => { if (!error) { // Post-rehydration mutations propagate normally via set state.someAction; // Triggers subscribers } }; } Direct state mutation (e.g., store.setState({ ... })) during rehydration also notifies subscribers, as it's the same mechanism.

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 2

Repository: solid-connection/solid-connect-web

Length of output: 710


🌐 Web query:

Zustand persist middleware best practices token validation hydration pattern

💡 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,
+                },
+          );
         }
       },

변경 요점:

  1. 콜백 함수에 error 파라미터 추가 → hydration 오류 상황 대응
  2. 콜백 내부에서 setState로 상태 업데이트 → 모든 구독자에게 변경 알림 전파
  3. 동기 저장소에서도 올바른 순서로 업데이트 처리 → 최종 hydration 상태 덮어씌워지지 않음
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/lib/zustand/useAuthStore.ts` around lines 91 - 104, The
hydration callback is directly mutating the Zustand state (state.accessToken,
state.userRole, state.isAuthenticated, state.refreshStatus, state.isInitialized)
which can break subscriber notifications and be overwritten by sync storages;
change the onRehydrateStorage callback signature to (state, error) and perform
all updates via the store's setState API instead of assigning to state directly:
call setState with a new partial state that clears an expired token (use
isTokenExpired(state.accessToken)), sets userRole via parseUserRoleFromToken
when valid, toggles refreshStatus to "idle" or "success", and marks
isInitialized true so subscribers receive the change atomically.

@manNomi manNomi merged commit 7c6c220 into main Mar 27, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant