Skip to content

Commit c5c02f2

Browse files
authored
fix(auth): resolve redirect loop on index and double encoding issue (#325)
Detailed analysis: 401 error in handleAuthFailure was forcing redirect even on public routes. Also cleaned up redundant encodeURIComponent in proxy.ts.
1 parent c66e042 commit c5c02f2

2 files changed

Lines changed: 76 additions & 96 deletions

File tree

apps/DocFlow/src/proxy.ts

Lines changed: 58 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,21 @@ import { ROUTES } from '@/utils/constants/routes';
66
// Constants
77
// ============================================================================
88

9-
/**
10-
* 认证相关的 Cookie 键名
11-
*/
129
const AUTH_COOKIES = {
1310
TOKEN: 'auth_token',
14-
REFRESH_TOKEN: 'refresh_token',
1511
EXPIRES_IN: 'expires_in',
16-
REFRESH_EXPIRES_IN: 'refresh_expires_in',
1712
TIMESTAMP: 'auth_timestamp',
1813
} as const;
1914

20-
/**
21-
* 默认的 token 过期时间(7天,单位:毫秒)
22-
*/
2315
const DEFAULT_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
2416

17+
const INVALID_TOKEN_VALUES = new Set(['undefined', 'null', '']);
18+
2519
/**
26-
* 无效的 token 字符串值
20+
* Public routes that never require authentication.
21+
* Proxy will always allow access to these paths.
2722
*/
28-
const INVALID_TOKEN_VALUES = new Set(['undefined', 'null', '']);
23+
const PUBLIC_ROUTES = new Set([ROUTES.AUTH, '/auth/callback', '/', '/blog', '/share']);
2924

3025
// ============================================================================
3126
// Type Definitions
@@ -41,29 +36,16 @@ interface AuthCookies {
4136
// Helper Functions
4237
// ============================================================================
4338

44-
/**
45-
* 检查 token 是否有效(非空且非无效值)
46-
*/
4739
function isValidToken(token: string | undefined): token is string {
48-
if (!token || INVALID_TOKEN_VALUES.has(token)) {
49-
return false;
50-
}
40+
if (!token || INVALID_TOKEN_VALUES.has(token)) return false;
5141

5242
return token.trim().length > 0;
5343
}
5444

55-
/**
56-
* 清除所有认证相关的 cookies
57-
*/
5845
function clearAuthCookies(response: NextResponse): void {
59-
Object.values(AUTH_COOKIES).forEach((cookieName) => {
60-
response.cookies.delete(cookieName);
61-
});
46+
Object.values(AUTH_COOKIES).forEach((name) => response.cookies.delete(name));
6247
}
6348

64-
/**
65-
* 从请求中提取认证相关的 cookies
66-
*/
6749
function extractAuthCookies(request: NextRequest): AuthCookies {
6850
return {
6951
token: request.cookies.get(AUTH_COOKIES.TOKEN)?.value,
@@ -72,108 +54,96 @@ function extractAuthCookies(request: NextRequest): AuthCookies {
7254
};
7355
}
7456

75-
/**
76-
* 构建带有重定向参数的认证 URL
77-
*/
78-
function buildAuthUrl(request: NextRequest): URL {
79-
const { pathname, search } = request.nextUrl;
80-
const redirectPath = pathname + search;
57+
function isTokenExpired({ timestamp, expiresIn }: AuthCookies): boolean {
58+
if (!timestamp) return false;
8159

82-
const authUrl = new URL(ROUTES.AUTH, request.url);
60+
const authTime = Number(timestamp);
61+
if (Number.isNaN(authTime)) return true;
8362

84-
if (redirectPath && redirectPath !== ROUTES.AUTH) {
85-
authUrl.searchParams.set('redirect_to', encodeURIComponent(redirectPath));
86-
}
63+
const expiryMs = expiresIn ? Number(expiresIn) * 1000 : DEFAULT_TOKEN_EXPIRY_MS;
8764

88-
return authUrl;
65+
return Date.now() - authTime > expiryMs;
8966
}
9067

9168
/**
92-
* 重定向到认证页面
69+
* Check if a pathname is a public route (no auth required).
70+
* Handles exact matches and prefix matches (e.g. /blog/123, /share/abc).
9371
*/
94-
function redirectToAuth(
95-
request: NextRequest,
96-
options: { clearCookies?: boolean; reason?: string } = {},
97-
): NextResponse {
98-
const { clearCookies = false, reason } = options;
99-
100-
const authUrl = buildAuthUrl(request);
101-
const response = NextResponse.redirect(authUrl);
102-
103-
if (clearCookies) {
104-
clearAuthCookies(response);
105-
}
106-
107-
if (reason && process.env.NODE_ENV === 'development') {
108-
console.log(`[Middleware] Redirecting to auth: ${reason}`);
109-
}
72+
function isPublicRoute(pathname: string): boolean {
73+
if (PUBLIC_ROUTES.has(pathname)) return true;
11074

111-
return response;
75+
return (
76+
pathname.startsWith('/blog/') || pathname.startsWith('/share/') || pathname.startsWith('/auth/')
77+
);
11278
}
11379

11480
/**
115-
* 检查 token 是否过期
81+
* Redirect to the auth page.
82+
* Only sets redirect_to for protected routes — never for public routes,
83+
* which would otherwise cause an infinite redirect loop.
11684
*/
117-
function isTokenExpired(authCookies: AuthCookies): boolean {
118-
const { timestamp, expiresIn } = authCookies;
85+
function buildAuthRedirect(
86+
request: NextRequest,
87+
options: { clearCookies?: boolean } = {},
88+
): NextResponse {
89+
const { pathname, search } = request.nextUrl;
90+
const authUrl = new URL(ROUTES.AUTH, request.url);
11991

120-
if (!timestamp) {
121-
return false;
92+
if (!isPublicRoute(pathname)) {
93+
// Let URLSearchParams handle encoding — avoid double-encoding (%252F)
94+
authUrl.searchParams.set('redirect_to', pathname + search);
12295
}
12396

124-
const authTime = Number(timestamp);
97+
const response = NextResponse.redirect(authUrl);
12598

126-
if (Number.isNaN(authTime)) {
127-
return true;
99+
if (options.clearCookies) {
100+
clearAuthCookies(response);
128101
}
129102

130-
const now = Date.now();
131-
const expiryMs = expiresIn ? Number(expiresIn) * 1000 : DEFAULT_TOKEN_EXPIRY_MS;
132-
133-
return now - authTime > expiryMs;
103+
return response;
134104
}
135105

136106
// ============================================================================
137-
// Main Middleware
107+
// Proxy (Next.js 16+)
138108
// ============================================================================
139109

140-
/**
141-
* 认证中间件
142-
* 验证用户的 token,未登录或 token 过期时重定向到登录页
143-
*/
144110
export function proxy(request: NextRequest): NextResponse {
111+
const { pathname } = request.nextUrl;
112+
113+
// Always allow public routes through — no token check needed
114+
if (isPublicRoute(pathname)) {
115+
return NextResponse.next();
116+
}
117+
145118
const authCookies = extractAuthCookies(request);
146119

147-
// 验证 token 存在性和有效性
148120
if (!isValidToken(authCookies.token)) {
149-
return redirectToAuth(request, {
150-
reason: 'No valid token found',
151-
});
121+
return buildAuthRedirect(request);
152122
}
153123

154-
// 验证 token 是否过期
155124
if (isTokenExpired(authCookies)) {
156-
return redirectToAuth(request, {
157-
clearCookies: true,
158-
reason: 'Token expired',
159-
});
125+
return buildAuthRedirect(request, { clearCookies: true });
160126
}
161127

162-
// Token 有效,放行请求
163128
return NextResponse.next();
164129
}
165130

166131
// ============================================================================
167-
// Middleware Configuration
132+
// Proxy Configuration
168133
// ============================================================================
169134

170-
/**
171-
* 配置需要认证保护的路径
172-
*/
173135
export const config = {
174136
matcher: [
175-
'/docs/:path*', // 文档页面
176-
'/dashboard/:path*', // 控制台页面
177-
'/chat-ai/:path*', // AI 聊天页面
137+
/*
138+
* Only run on protected routes. Public routes excluded:
139+
* - Homepage: /
140+
* - Auth: /auth, /auth/callback
141+
* - Public content: /blog/*, /share/*
142+
* - Next.js internals: /_next/*, /api/*
143+
*/
144+
'/docs/:path*',
145+
'/dashboard/:path*',
146+
'/chat-ai/:path*',
147+
'/rooms/:path*',
178148
],
179149
};

apps/DocFlow/src/services/request/client.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,20 +99,30 @@ class ClientRequest {
9999

100100
/**
101101
* 统一处理认证失败
102+
* 仅在受保护页面才跳转登录,公开页面静默清除凭证即可
102103
*/
103104
private handleAuthFailure(): void {
104105
clearAuthData();
105106

106-
if (typeof window !== 'undefined') {
107-
const currentPath = window.location.pathname + window.location.search;
108-
const loginUrl = new URL(ROUTES.AUTH, window.location.origin);
107+
if (typeof window === 'undefined') return;
109108

110-
if (currentPath && currentPath !== ROUTES.AUTH) {
111-
loginUrl.searchParams.set('redirect_to', encodeURIComponent(currentPath));
112-
}
109+
const { pathname, search } = window.location;
113110

114-
window.location.href = loginUrl.toString();
115-
}
111+
// 公开路由不触发跳转,避免首页等公开页面被意外重定向
112+
const isPublicPath =
113+
pathname === '/' ||
114+
pathname === ROUTES.AUTH ||
115+
pathname.startsWith('/auth/') ||
116+
pathname.startsWith('/blog/') ||
117+
pathname.startsWith('/share/');
118+
119+
if (isPublicPath) return;
120+
121+
const loginUrl = new URL(ROUTES.AUTH, window.location.origin);
122+
// 让 URLSearchParams 自行编码,避免双重 encodeURIComponent 产生 %252F
123+
loginUrl.searchParams.set('redirect_to', pathname + search);
124+
125+
window.location.href = loginUrl.toString();
116126
}
117127

118128
/**

0 commit comments

Comments
 (0)