Skip to content

Commit b0aeb06

Browse files
boojackclaude
andcommitted
refactor(web): improve auth flow and eliminate route duplication
- Extract route paths to router/routes.ts as single source of truth - Refactor connect.ts auth interceptor with better structure and error handling - Add TokenRefreshManager class to prevent race conditions - Implement smart redirect logic for public/private routes - Support unauthenticated access to explore and user profile pages - Add proper error handling for missing access tokens - Extract magic strings to named constants - Maintain backward compatibility by aliasing Routes to ROUTES 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 50606a8 commit b0aeb06

File tree

3 files changed

+158
-77
lines changed

3 files changed

+158
-77
lines changed

web/src/connect.ts

Lines changed: 140 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { timestampDate } from "@bufbuild/protobuf/wkt";
22
import { Code, ConnectError, createClient, type Interceptor } from "@connectrpc/connect";
33
import { createConnectTransport } from "@connectrpc/connect-web";
44
import { getAccessToken, setAccessToken } from "./auth-state";
5+
import { ROUTES } from "./router/routes";
6+
import { instanceStore } from "./store";
57
import { ActivityService } from "./types/proto/api/v1/activity_service_pb";
68
import { AttachmentService } from "./types/proto/api/v1/attachment_service_pb";
79
import { AuthService } from "./types/proto/api/v1/auth_service_pb";
@@ -11,98 +13,169 @@ import { MemoService } from "./types/proto/api/v1/memo_service_pb";
1113
import { ShortcutService } from "./types/proto/api/v1/shortcut_service_pb";
1214
import { UserService } from "./types/proto/api/v1/user_service_pb";
1315

14-
let isRefreshing = false;
15-
let refreshPromise: Promise<void> | null = null;
16+
// ============================================================================
17+
// Constants
18+
// ============================================================================
1619

17-
/**
18-
* Authentication interceptor that:
19-
* 1. Attaches access token to outgoing requests
20-
* 2. Handles 401 Unauthenticated errors by refreshing the token
21-
* 3. Retries the original request with the new token
22-
* 4. Redirects to login if refresh fails
23-
*/
24-
const authInterceptor: Interceptor = (next) => async (req) => {
25-
// Add access token to request if available
26-
const token = getAccessToken();
27-
if (token) {
28-
req.header.set("Authorization", `Bearer ${token}`);
29-
}
20+
const RETRY_HEADER = "X-Retry";
21+
const RETRY_HEADER_VALUE = "true";
3022

31-
try {
32-
return await next(req);
33-
} catch (error) {
34-
// Only handle ConnectError with Unauthenticated code
35-
if (error instanceof ConnectError && error.code === Code.Unauthenticated && !req.header.get("X-Retry")) {
36-
// Prevent concurrent refresh attempts
37-
if (!isRefreshing) {
38-
isRefreshing = true;
39-
refreshPromise = refreshAccessToken();
40-
}
23+
const ROUTE_CONFIG = {
24+
// Routes accessible without authentication (uses prefix matching)
25+
public: [
26+
ROUTES.AUTH, // Authentication pages
27+
ROUTES.EXPLORE, // Explore page
28+
"/u/", // User profile pages (dynamic)
29+
"/memos/", // Individual memo detail pages (dynamic)
30+
],
4131

42-
try {
43-
await refreshPromise;
44-
isRefreshing = false;
45-
refreshPromise = null;
46-
47-
// Retry with new token
48-
const newToken = getAccessToken();
49-
if (newToken) {
50-
req.header.set("Authorization", `Bearer ${newToken}`);
51-
req.header.set("X-Retry", "true");
52-
return await next(req);
53-
}
54-
} catch (refreshError) {
55-
isRefreshing = false;
56-
refreshPromise = null;
57-
// Refresh failed - redirect to login (only if not already there)
58-
if (!window.location.pathname.startsWith("/auth")) {
59-
window.location.href = "/auth";
60-
}
61-
throw refreshError;
62-
}
32+
// Routes that require authentication (uses exact matching)
33+
private: [ROUTES.ROOT, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.ARCHIVED, ROUTES.SETTING],
34+
} as const;
35+
36+
// ============================================================================
37+
// Token Refresh State Management
38+
// ============================================================================
39+
40+
class TokenRefreshManager {
41+
private isRefreshing = false;
42+
private refreshPromise: Promise<void> | null = null;
43+
44+
async refresh(refreshFn: () => Promise<void>): Promise<void> {
45+
if (this.isRefreshing && this.refreshPromise) {
46+
return this.refreshPromise;
6347
}
64-
throw error;
48+
49+
this.isRefreshing = true;
50+
this.refreshPromise = refreshFn().finally(() => {
51+
this.isRefreshing = false;
52+
this.refreshPromise = null;
53+
});
54+
55+
return this.refreshPromise;
6556
}
66-
};
6757

68-
/**
69-
* Custom fetch that includes credentials for cookie handling.
70-
* Required for HttpOnly refresh token cookie to be sent/received.
71-
*/
58+
isCurrentlyRefreshing(): boolean {
59+
return this.isRefreshing;
60+
}
61+
}
62+
63+
const tokenRefreshManager = new TokenRefreshManager();
64+
65+
// ============================================================================
66+
// Route Access Control
67+
// ============================================================================
68+
69+
function isPublicRoute(path: string): boolean {
70+
return ROUTE_CONFIG.public.some((route) => path.startsWith(route));
71+
}
72+
73+
function isPrivateRoute(path: string): boolean {
74+
return (ROUTE_CONFIG.private as readonly string[]).includes(path);
75+
}
76+
77+
function getAuthFailureRedirect(currentPath: string): string | null {
78+
if (isPublicRoute(currentPath)) {
79+
return null;
80+
}
81+
82+
if (instanceStore.state.memoRelatedSetting.disallowPublicVisibility) {
83+
return ROUTES.AUTH;
84+
}
85+
86+
if (isPrivateRoute(currentPath)) {
87+
return ROUTES.EXPLORE;
88+
}
89+
90+
return null;
91+
}
92+
93+
function performRedirect(redirectUrl: string | null): void {
94+
if (redirectUrl) {
95+
window.location.href = redirectUrl;
96+
}
97+
}
98+
99+
// ============================================================================
100+
// Token Refresh
101+
// ============================================================================
102+
72103
const fetchWithCredentials: typeof globalThis.fetch = (input, init) => {
73104
return globalThis.fetch(input, {
74105
...init,
75106
credentials: "include",
76107
});
77108
};
78109

79-
/**
80-
* Separate transport for refresh token operations.
81-
* Uses no auth interceptor to avoid circular dependency when the main
82-
* interceptor triggers a refresh.
83-
*/
110+
// Separate transport without auth interceptor to prevent recursion
84111
const refreshTransport = createConnectTransport({
85112
baseUrl: window.location.origin,
86113
useBinaryFormat: true,
87114
fetch: fetchWithCredentials,
88-
interceptors: [], // No interceptors to avoid recursion
115+
interceptors: [],
89116
});
90117

91-
// Dedicated auth client for refresh operations only
92118
const refreshAuthClient = createClient(AuthService, refreshTransport);
93119

94-
/**
95-
* Refreshes the access token using the HttpOnly refresh token cookie.
96-
* Called automatically by the auth interceptor when requests fail with 401.
97-
*/
98120
async function refreshAccessToken(): Promise<void> {
99121
const response = await refreshAuthClient.refreshToken({});
100-
setAccessToken(response.accessToken, response.expiresAt ? timestampDate(response.expiresAt) : undefined);
122+
123+
if (!response.accessToken) {
124+
throw new ConnectError("Refresh token response missing access token", Code.Internal);
125+
}
126+
127+
const expiresAt = response.expiresAt ? timestampDate(response.expiresAt) : undefined;
128+
setAccessToken(response.accessToken, expiresAt);
101129
}
102130

103-
/**
104-
* Main transport for all API requests.
105-
*/
131+
// ============================================================================
132+
// Authentication Interceptor
133+
// ============================================================================
134+
135+
const authInterceptor: Interceptor = (next) => async (req) => {
136+
const token = getAccessToken();
137+
if (token) {
138+
req.header.set("Authorization", `Bearer ${token}`);
139+
}
140+
141+
try {
142+
return await next(req);
143+
} catch (error) {
144+
if (!(error instanceof ConnectError)) {
145+
throw error;
146+
}
147+
148+
if (error.code !== Code.Unauthenticated) {
149+
throw error;
150+
}
151+
152+
if (req.header.get(RETRY_HEADER) === RETRY_HEADER_VALUE) {
153+
throw error;
154+
}
155+
156+
try {
157+
await tokenRefreshManager.refresh(refreshAccessToken);
158+
159+
const newToken = getAccessToken();
160+
if (!newToken) {
161+
throw new ConnectError("Token refresh succeeded but no token available", Code.Internal);
162+
}
163+
164+
req.header.set("Authorization", `Bearer ${newToken}`);
165+
req.header.set(RETRY_HEADER, RETRY_HEADER_VALUE);
166+
return await next(req);
167+
} catch (refreshError) {
168+
const redirectUrl = getAuthFailureRedirect(window.location.pathname);
169+
performRedirect(redirectUrl);
170+
throw refreshError;
171+
}
172+
}
173+
};
174+
175+
// ============================================================================
176+
// Transport & Service Clients
177+
// ============================================================================
178+
106179
const transport = createConnectTransport({
107180
baseUrl: window.location.origin,
108181
useBinaryFormat: true,

web/src/router/index.tsx

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,11 @@ const SignUp = lazy(() => import("@/pages/SignUp"));
2222
const UserProfile = lazy(() => import("@/pages/UserProfile"));
2323
const MemoDetailRedirect = lazy(() => import("./MemoDetailRedirect"));
2424

25-
export enum Routes {
26-
ROOT = "/",
27-
ATTACHMENTS = "/attachments",
28-
CALENDAR = "/calendar",
29-
INBOX = "/inbox",
30-
ARCHIVED = "/archived",
31-
SETTING = "/setting",
32-
EXPLORE = "/explore",
33-
AUTH = "/auth",
34-
}
25+
import { ROUTES } from "./routes";
26+
27+
// Backward compatibility alias
28+
export const Routes = ROUTES;
29+
export { ROUTES };
3530

3631
const router = createBrowserRouter([
3732
{

web/src/router/routes.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export const ROUTES = {
2+
ROOT: "/",
3+
ATTACHMENTS: "/attachments",
4+
CALENDAR: "/calendar",
5+
INBOX: "/inbox",
6+
ARCHIVED: "/archived",
7+
SETTING: "/setting",
8+
EXPLORE: "/explore",
9+
AUTH: "/auth",
10+
} as const;
11+
12+
export type RouteKey = keyof typeof ROUTES;
13+
export type RoutePath = (typeof ROUTES)[RouteKey];

0 commit comments

Comments
 (0)