Skip to content

Commit 7c6c220

Browse files
authored
fix: handle auth refresh edge cases (#489)
1 parent 255ba0e commit 7c6c220

7 files changed

Lines changed: 143 additions & 41 deletions

File tree

apps/web/src/apis/Auth/server/postReissueToken.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import useAuthStore from "@/lib/zustand/useAuthStore";
22
import { publicAxiosInstance } from "@/utils/axiosInstance";
3+
import { isTokenExpired } from "@/utils/jwtUtils";
34

45
/**
56
* @description 토큰 재발급 서버사이드 함수
@@ -13,6 +14,9 @@ const postReissueToken = async (): Promise<string> => {
1314
if (!newAccessToken) {
1415
throw new Error("재발급된 토큰이 유효하지 않습니다.");
1516
}
17+
if (isTokenExpired(newAccessToken)) {
18+
throw new Error("재발급된 토큰이 이미 만료되었습니다.");
19+
}
1620

1721
// 재발급 성공 시, 새로운 토큰을 Zustand 스토어에 저장
1822
useAuthStore.getState().setAccessToken(newAccessToken);

apps/web/src/app/mentor/_ui/MentorClient/index.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { postReissueToken } from "@/apis/Auth";
66
import CloudSpinnerPage from "@/components/ui/CloudSpinnerPage";
77
import useAuthStore from "@/lib/zustand/useAuthStore";
88
import { UserRole } from "@/types/mentor";
9-
import { tokenParse } from "@/utils/jwtUtils";
9+
import { isTokenExpired, tokenParse } from "@/utils/jwtUtils";
1010

1111
// 레이지 로드 컴포넌트
1212
const MenteePage = lazy(() => import("./_ui/MenteePage"));
@@ -16,6 +16,7 @@ const MentorClient = () => {
1616
const router = useRouter();
1717
const { isLoading, accessToken, isInitialized, refreshStatus, setRefreshStatus } = useAuthStore();
1818
const [isRefreshing, setIsRefreshing] = useState(false);
19+
const hasValidAccessToken = Boolean(accessToken && !isTokenExpired(accessToken));
1920

2021
// 어드민 전용: 뷰 전환 상태 (true: 멘토 뷰, false: 멘티 뷰)
2122
const [showMentorView, setShowMentorView] = useState<boolean>(true);
@@ -28,8 +29,8 @@ const MentorClient = () => {
2829
return;
2930
}
3031

31-
// 이미 초기화되었고 토큰이 없는 경우에만 재발급 시도
32-
if (!isInitialized || accessToken || isRefreshing || refreshStatus === "refreshing") {
32+
// 초기화 이후 유효한 access token이 없을 때만 재발급 시도
33+
if (!isInitialized || hasValidAccessToken || isRefreshing || refreshStatus === "refreshing") {
3334
return;
3435
}
3536

@@ -49,15 +50,15 @@ const MentorClient = () => {
4950
};
5051

5152
attemptTokenRefresh();
52-
}, [isInitialized, accessToken, isRefreshing, refreshStatus, setRefreshStatus, router]);
53+
}, [isInitialized, hasValidAccessToken, isRefreshing, refreshStatus, setRefreshStatus, router]);
5354

5455
// 초기화 전이거나 로딩 중이거나 재발급 중일 때 스피너 표시
5556
if (!isInitialized || isLoading || refreshStatus === "refreshing" || isRefreshing) {
5657
return <CloudSpinnerPage />;
5758
}
5859

5960
// 초기화 완료 후에도 토큰이 없으면 리다이렉트 (useEffect에서 처리되지만 fallback)
60-
if (!accessToken) {
61+
if (!hasValidAccessToken) {
6162
return <CloudSpinnerPage />;
6263
}
6364

apps/web/src/lib/web-socket/useConnectWebSocket.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import SockJS from "sockjs-client";
55
import { normalizeChatMessage, type RawChatMessage } from "@/apis/chat/normalize";
66

77
import { type ChatMessage, ConnectionStatus } from "@/types/chat";
8+
import { isTokenExpired } from "@/utils/jwtUtils";
89
import useAuthStore from "../zustand/useAuthStore";
910

1011
interface UseConnectWebSocketProps {
@@ -26,14 +27,15 @@ const useConnectWebSocket = ({ roomId, clientRef }: UseConnectWebSocketProps): U
2627
const [submittedMessages, setSubmittedMessages] = useState<ChatMessage[]>([]);
2728
const accessToken = useAuthStore((state) => state.accessToken);
2829
const isInitialized = useAuthStore((state) => state.isInitialized);
30+
const hasValidAccessToken = Boolean(accessToken && !isTokenExpired(accessToken));
2931

3032
useEffect(() => {
3133
if (!roomId) {
3234
setConnectionStatus(ConnectionStatus.Disconnected);
3335
return;
3436
}
3537

36-
if (!isInitialized || !accessToken || accessToken.trim() === "") {
38+
if (!isInitialized || !hasValidAccessToken) {
3739
setConnectionStatus(ConnectionStatus.Pending);
3840
return;
3941
}
@@ -90,7 +92,7 @@ const useConnectWebSocket = ({ roomId, clientRef }: UseConnectWebSocketProps): U
9092
}
9193
clientRef.current = null;
9294
};
93-
}, [roomId, clientRef, accessToken, isInitialized]);
95+
}, [roomId, clientRef, accessToken, hasValidAccessToken, isInitialized]);
9496

9597
// 관리하는 connectionStatus를 반환
9698
return { connectionStatus, submittedMessages, setSubmittedMessages };

apps/web/src/lib/zustand/useAuthStore.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { create } from "zustand";
22
import { persist } from "zustand/middleware";
33
import { UserRole } from "@/types/mentor";
4+
import { isTokenExpired } from "@/utils/jwtUtils";
45

56
const parseUserRoleFromToken = (token: string | null): UserRole | null => {
6-
if (!token) return null;
7+
if (!token || isTokenExpired(token)) return null;
78

89
try {
910
const payload = JSON.parse(atob(token.split(".")[1])) as { role?: string };
@@ -87,7 +88,19 @@ const useAuthStore = create<AuthState>()(
8788
onRehydrateStorage: () => (state) => {
8889
// hydration 완료 후 isInitialized를 true로 설정
8990
if (state) {
90-
state.userRole = parseUserRoleFromToken(state.accessToken);
91+
const hasValidToken = Boolean(state.accessToken && !isTokenExpired(state.accessToken));
92+
93+
if (!hasValidToken) {
94+
state.accessToken = null;
95+
state.userRole = null;
96+
state.isAuthenticated = false;
97+
state.refreshStatus = "idle";
98+
} else {
99+
state.userRole = parseUserRoleFromToken(state.accessToken);
100+
state.isAuthenticated = true;
101+
state.refreshStatus = "success";
102+
}
103+
91104
state.isInitialized = true;
92105
}
93106
},

apps/web/src/middleware.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { NextRequest } from "next/server";
22
import { NextResponse } from "next/server";
3+
import { isTokenExpired } from "@/utils/jwtUtils";
34

45
const loginNeedPages = ["/mentor", "/my", "/community"]; // 로그인 필요페이지
56

@@ -13,13 +14,14 @@ export function middleware(request: NextRequest) {
1314

1415
// HTTP-only 쿠키의 refreshToken 확인
1516
const refreshToken = request.cookies.get("refreshToken")?.value;
17+
const hasValidRefreshToken = Boolean(refreshToken && !isTokenExpired(refreshToken));
1618

1719
// 정확한 경로 매칭
1820
const needLogin = loginNeedPages.some((path) => {
1921
return url.pathname === path || url.pathname.startsWith(`${path}/`);
2022
});
2123

22-
if (needLogin && !refreshToken) {
24+
if (needLogin && !hasValidRefreshToken) {
2325
url.pathname = "/login";
2426
url.searchParams.delete("reason");
2527
return NextResponse.redirect(url);

apps/web/src/utils/axiosInstance.ts

Lines changed: 63 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import axios, { type AxiosError, type AxiosInstance } from "axios";
33
import { postReissueToken } from "@/apis/Auth/server";
44
import useAuthStore from "@/lib/zustand/useAuthStore";
55
import { toast } from "@/lib/zustand/useToastStore";
6+
import { isTokenExpired } from "@/utils/jwtUtils";
67

78
// --- 글로벌 변수 ---
89
let reissuePromise: Promise<void> | null = null;
@@ -34,6 +35,34 @@ const redirectToLogin = (message: string) => {
3435

3536
export const convertToBearer = (token: string) => `Bearer ${token}`;
3637

38+
const tryReissueAccessToken = async (): Promise<string | null> => {
39+
if (reissuePromise) {
40+
await reissuePromise;
41+
return useAuthStore.getState().accessToken;
42+
}
43+
44+
const { setLoading, clearAccessToken, setInitialized, setRefreshStatus } = useAuthStore.getState();
45+
46+
reissuePromise = (async () => {
47+
setRefreshStatus("refreshing");
48+
setLoading(true);
49+
try {
50+
await postReissueToken();
51+
setRefreshStatus("success");
52+
} catch {
53+
clearAccessToken();
54+
setRefreshStatus("failed");
55+
} finally {
56+
setLoading(false);
57+
setInitialized(true);
58+
reissuePromise = null;
59+
}
60+
})();
61+
62+
await reissuePromise;
63+
return useAuthStore.getState().accessToken;
64+
};
65+
3766
// --- Axios 인스턴스 ---
3867
// 인증이 필요 없는 공용 API 요청에 사용
3968
export const publicAxiosInstance: AxiosInstance = axios.create({
@@ -52,40 +81,24 @@ export const axiosInstance: AxiosInstance = axios.create({
5281
// 1. 요청 인터셉터 (Request Interceptor)
5382
axiosInstance.interceptors.request.use(
5483
async (config) => {
55-
const { accessToken, setLoading, clearAccessToken, setInitialized, refreshStatus, setRefreshStatus } =
56-
useAuthStore.getState();
84+
const { accessToken, clearAccessToken, refreshStatus } = useAuthStore.getState();
85+
86+
// 만료된 access token은 즉시 제거하고 refresh 재발급 경로를 타게 한다.
87+
if (accessToken && isTokenExpired(accessToken)) {
88+
clearAccessToken();
89+
}
90+
91+
const validAccessToken = useAuthStore.getState().accessToken;
5792

5893
// 토큰이 있으면 헤더에 추가하고 진행
59-
if (accessToken) {
60-
config.headers.Authorization = convertToBearer(accessToken);
94+
if (validAccessToken) {
95+
config.headers.Authorization = convertToBearer(validAccessToken);
6196
return config;
6297
}
6398

6499
if (refreshStatus !== "failed") {
65100
try {
66-
// 이미 reissue가 진행 중인지 확인
67-
if (reissuePromise) {
68-
await reissuePromise;
69-
} else {
70-
// 새로운 reissue 프로세스 시작 (HTTP-only 쿠키의 refreshToken 사용)
71-
reissuePromise = (async () => {
72-
setRefreshStatus("refreshing");
73-
setLoading(true);
74-
try {
75-
await postReissueToken();
76-
setRefreshStatus("success");
77-
} catch {
78-
clearAccessToken();
79-
setRefreshStatus("failed");
80-
} finally {
81-
setLoading(false);
82-
setInitialized(true);
83-
reissuePromise = null;
84-
}
85-
})();
86-
87-
await reissuePromise;
88-
}
101+
await tryReissueAccessToken();
89102

90103
// reissue 완료 후 업데이트된 토큰으로 헤더 설정
91104
const updatedAccessToken = useAuthStore.getState().accessToken;
@@ -119,12 +132,31 @@ axiosInstance.interceptors.request.use(
119132
);
120133

121134
// 2. 응답 인터셉터 (Response Interceptor)
122-
// 역할: 401 에러 시 로그인 페이지로 리다이렉트
135+
// 역할: 401 에러 시 access 재발급 1회 재시도 후 실패하면 로그인 페이지로 리다이렉트
123136
axiosInstance.interceptors.response.use(
124137
(response) => response,
125-
(error: AxiosError) => {
126-
// 401 에러 시 로그인 페이지로 리다이렉트
127-
if (error.response?.status === 401) {
138+
async (error: AxiosError) => {
139+
const status = error.response?.status;
140+
141+
if (status === 401) {
142+
const originalRequest = error.config as (typeof error.config & { _retry?: boolean }) | undefined;
143+
144+
if (originalRequest && !originalRequest._retry && useAuthStore.getState().refreshStatus !== "failed") {
145+
originalRequest._retry = true;
146+
147+
try {
148+
const reissuedAccessToken = await tryReissueAccessToken();
149+
150+
if (reissuedAccessToken) {
151+
originalRequest.headers = originalRequest.headers ?? {};
152+
originalRequest.headers.Authorization = convertToBearer(reissuedAccessToken);
153+
return axiosInstance(originalRequest);
154+
}
155+
} catch {
156+
// 재발급 실패 시 아래 로그인 리다이렉트로 처리
157+
}
158+
}
159+
128160
redirectToLogin("세션이 만료되었습니다. 다시 로그인해주세요.");
129161
}
130162

docs/auth-refresh-edge-cases.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Access/Refresh 토큰 엣지케이스 정리
2+
3+
이 문서는 웹 앱의 로그인 유지 로직에서 자주 발생하는 토큰 상태별 동작을 정리합니다.
4+
특히 `멘토(/mentor*)`, `커뮤니티(/community*)` 경로에서 발생 빈도가 높은 케이스를 우선 다룹니다.
5+
6+
## 1. 현재 인증 판단 경계
7+
8+
- 서버 진입(Next middleware): `refreshToken` 쿠키 유효성으로 1차 진입 제어
9+
- 클라이언트 API 요청(axios interceptor): `accessToken` 유효성 + 필요 시 `/auth/reissue` 재발급
10+
- 멘토 진입 페이지: 렌더 전에 access 유효성 확인 후 필요 시 재발급
11+
- 채팅 소켓 연결: access 유효성 확인 후 연결
12+
13+
## 2. 토큰 상태별 케이스 매트릭스
14+
15+
| 케이스 | 토큰 상태 | 주로 발생 화면 | 기대 동작 | 현재 처리 |
16+
| --- | --- | --- | --- | --- |
17+
| A | refresh 없음, access 없음 | `/mentor`, `/community`, `/my` 직접 진입 | 즉시 로그인 이동 | middleware에서 로그인 리다이렉트 |
18+
| B | refresh 만료/손상, access 없음 | `/mentor`, `/community` 새로고침 | 즉시 로그인 이동 | middleware에서 만료 refresh 차단 |
19+
| C | refresh 유효, access 없음 | 멘토 첫 진입, 커뮤니티 글쓰기 직전 | 백그라운드 재발급 후 계속 진행 | interceptor/멘토 클라에서 재발급 |
20+
| D | refresh 유효, access 만료 | 멘토 목록/채팅, 커뮤니티 작성/수정 | 만료 access 폐기 -> 재발급 -> 요청 진행 | 만료 access 선제 정리 + 재발급 |
21+
| E | refresh 유효, access 유효하지만 서버에서 401(폐기/불일치) | 멘토 API, 커뮤니티 mutation | 재발급 1회 후 원요청 재시도 | response interceptor에서 1회 retry |
22+
| F | refresh 유효, access 없음 + 동시 다중 요청 | 멘토 페이지 초기 렌더 | 재발급 요청 1회만 수행 | `reissuePromise` 락으로 중복 방지 |
23+
| G | refresh 재발급 실패 | 멘토/커뮤니티 보호 요청 | 무한 재시도 금지 + 로그인 이동 | `refreshStatus=failed`로 차단 후 리다이렉트 |
24+
| H | 멘토 채팅 소켓 연결 시 access 만료 | `/mentor/chat/*` | 만료 토큰으로 연결 시도 금지 | 소켓 훅에서 만료 access 차단 |
25+
26+
## 3. 멘토/커뮤니티에서 자주 터지는 이유
27+
28+
1. 두 경로 모두 보호 페이지로 분류되어 진입 시점의 인증 상태 흔들림이 바로 노출됨
29+
2. 멘토는 초기 렌더 시 인증 의존 API가 많아, access 만료 시 체감 문제가 빠르게 발생
30+
3. 커뮤니티는 목록 조회는 public이지만 작성/수정/댓글은 인증 API라, 클릭 시점에 문제 노출
31+
32+
## 4. 이번 보완 포인트
33+
34+
- middleware에서 `refreshToken`의 단순 존재가 아니라 **만료 여부까지 검사**
35+
- axios request interceptor에서 **만료된 access를 즉시 폐기**하고 재발급 경로로 전환
36+
- axios response interceptor에서 401 발생 시 **재발급 1회 후 원요청 재시도**
37+
- 멘토 클라이언트 렌더 분기에서 **만료 access를 유효 토큰으로 취급하지 않도록 보정**
38+
- 소켓 연결 훅에서 **만료 access로 연결 시도 금지**
39+
40+
## 5. 수동 검증 체크리스트
41+
42+
1. refresh 없음 상태에서 `/mentor`, `/community` 진입 시 즉시 `/login`으로 이동
43+
2. refresh 만료 상태에서 `/mentor`, `/community` 진입 시 즉시 `/login`으로 이동
44+
3. refresh 유효 + access 없음 상태에서 `/mentor` 진입 시 화면 유지 후 정상 렌더
45+
4. refresh 유효 + access 만료 상태에서 `/mentor` 진입 시 로그인 튕김 없이 복구
46+
5. refresh 유효 + access 만료 상태에서 커뮤니티 글 작성/댓글 시도 시 재발급 후 정상 요청
47+
6. access가 서버에서 무효 처리된 상태(401)에서 요청 시 1회 재시도 후 실패 시 로그인 이동
48+

0 commit comments

Comments
 (0)