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>
This commit is contained in:
Steven 2025-12-18 22:14:30 +08:00
parent 50606a850e
commit b0aeb06f85
3 changed files with 168 additions and 87 deletions

View File

@ -2,6 +2,8 @@ import { timestampDate } from "@bufbuild/protobuf/wkt";
import { Code, ConnectError, createClient, type Interceptor } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { getAccessToken, setAccessToken } from "./auth-state";
import { ROUTES } from "./router/routes";
import { instanceStore } from "./store";
import { ActivityService } from "./types/proto/api/v1/activity_service_pb";
import { AttachmentService } from "./types/proto/api/v1/attachment_service_pb";
import { AuthService } from "./types/proto/api/v1/auth_service_pb";
@ -11,18 +13,126 @@ import { MemoService } from "./types/proto/api/v1/memo_service_pb";
import { ShortcutService } from "./types/proto/api/v1/shortcut_service_pb";
import { UserService } from "./types/proto/api/v1/user_service_pb";
let isRefreshing = false;
let refreshPromise: Promise<void> | null = null;
// ============================================================================
// Constants
// ============================================================================
const RETRY_HEADER = "X-Retry";
const RETRY_HEADER_VALUE = "true";
const ROUTE_CONFIG = {
// Routes accessible without authentication (uses prefix matching)
public: [
ROUTES.AUTH, // Authentication pages
ROUTES.EXPLORE, // Explore page
"/u/", // User profile pages (dynamic)
"/memos/", // Individual memo detail pages (dynamic)
],
// Routes that require authentication (uses exact matching)
private: [ROUTES.ROOT, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.ARCHIVED, ROUTES.SETTING],
} as const;
// ============================================================================
// Token Refresh State Management
// ============================================================================
class TokenRefreshManager {
private isRefreshing = false;
private refreshPromise: Promise<void> | null = null;
async refresh(refreshFn: () => Promise<void>): Promise<void> {
if (this.isRefreshing && this.refreshPromise) {
return this.refreshPromise;
}
this.isRefreshing = true;
this.refreshPromise = refreshFn().finally(() => {
this.isRefreshing = false;
this.refreshPromise = null;
});
return this.refreshPromise;
}
isCurrentlyRefreshing(): boolean {
return this.isRefreshing;
}
}
const tokenRefreshManager = new TokenRefreshManager();
// ============================================================================
// Route Access Control
// ============================================================================
function isPublicRoute(path: string): boolean {
return ROUTE_CONFIG.public.some((route) => path.startsWith(route));
}
function isPrivateRoute(path: string): boolean {
return (ROUTE_CONFIG.private as readonly string[]).includes(path);
}
function getAuthFailureRedirect(currentPath: string): string | null {
if (isPublicRoute(currentPath)) {
return null;
}
if (instanceStore.state.memoRelatedSetting.disallowPublicVisibility) {
return ROUTES.AUTH;
}
if (isPrivateRoute(currentPath)) {
return ROUTES.EXPLORE;
}
return null;
}
function performRedirect(redirectUrl: string | null): void {
if (redirectUrl) {
window.location.href = redirectUrl;
}
}
// ============================================================================
// Token Refresh
// ============================================================================
const fetchWithCredentials: typeof globalThis.fetch = (input, init) => {
return globalThis.fetch(input, {
...init,
credentials: "include",
});
};
// Separate transport without auth interceptor to prevent recursion
const refreshTransport = createConnectTransport({
baseUrl: window.location.origin,
useBinaryFormat: true,
fetch: fetchWithCredentials,
interceptors: [],
});
const refreshAuthClient = createClient(AuthService, refreshTransport);
async function refreshAccessToken(): Promise<void> {
const response = await refreshAuthClient.refreshToken({});
if (!response.accessToken) {
throw new ConnectError("Refresh token response missing access token", Code.Internal);
}
const expiresAt = response.expiresAt ? timestampDate(response.expiresAt) : undefined;
setAccessToken(response.accessToken, expiresAt);
}
// ============================================================================
// Authentication Interceptor
// ============================================================================
/**
* Authentication interceptor that:
* 1. Attaches access token to outgoing requests
* 2. Handles 401 Unauthenticated errors by refreshing the token
* 3. Retries the original request with the new token
* 4. Redirects to login if refresh fails
*/
const authInterceptor: Interceptor = (next) => async (req) => {
// Add access token to request if available
const token = getAccessToken();
if (token) {
req.header.set("Authorization", `Bearer ${token}`);
@ -31,78 +141,41 @@ const authInterceptor: Interceptor = (next) => async (req) => {
try {
return await next(req);
} catch (error) {
// Only handle ConnectError with Unauthenticated code
if (error instanceof ConnectError && error.code === Code.Unauthenticated && !req.header.get("X-Retry")) {
// Prevent concurrent refresh attempts
if (!isRefreshing) {
isRefreshing = true;
refreshPromise = refreshAccessToken();
}
try {
await refreshPromise;
isRefreshing = false;
refreshPromise = null;
// Retry with new token
const newToken = getAccessToken();
if (newToken) {
req.header.set("Authorization", `Bearer ${newToken}`);
req.header.set("X-Retry", "true");
return await next(req);
}
} catch (refreshError) {
isRefreshing = false;
refreshPromise = null;
// Refresh failed - redirect to login (only if not already there)
if (!window.location.pathname.startsWith("/auth")) {
window.location.href = "/auth";
}
throw refreshError;
}
if (!(error instanceof ConnectError)) {
throw error;
}
if (error.code !== Code.Unauthenticated) {
throw error;
}
if (req.header.get(RETRY_HEADER) === RETRY_HEADER_VALUE) {
throw error;
}
try {
await tokenRefreshManager.refresh(refreshAccessToken);
const newToken = getAccessToken();
if (!newToken) {
throw new ConnectError("Token refresh succeeded but no token available", Code.Internal);
}
req.header.set("Authorization", `Bearer ${newToken}`);
req.header.set(RETRY_HEADER, RETRY_HEADER_VALUE);
return await next(req);
} catch (refreshError) {
const redirectUrl = getAuthFailureRedirect(window.location.pathname);
performRedirect(redirectUrl);
throw refreshError;
}
throw error;
}
};
/**
* Custom fetch that includes credentials for cookie handling.
* Required for HttpOnly refresh token cookie to be sent/received.
*/
const fetchWithCredentials: typeof globalThis.fetch = (input, init) => {
return globalThis.fetch(input, {
...init,
credentials: "include",
});
};
// ============================================================================
// Transport & Service Clients
// ============================================================================
/**
* Separate transport for refresh token operations.
* Uses no auth interceptor to avoid circular dependency when the main
* interceptor triggers a refresh.
*/
const refreshTransport = createConnectTransport({
baseUrl: window.location.origin,
useBinaryFormat: true,
fetch: fetchWithCredentials,
interceptors: [], // No interceptors to avoid recursion
});
// Dedicated auth client for refresh operations only
const refreshAuthClient = createClient(AuthService, refreshTransport);
/**
* Refreshes the access token using the HttpOnly refresh token cookie.
* Called automatically by the auth interceptor when requests fail with 401.
*/
async function refreshAccessToken(): Promise<void> {
const response = await refreshAuthClient.refreshToken({});
setAccessToken(response.accessToken, response.expiresAt ? timestampDate(response.expiresAt) : undefined);
}
/**
* Main transport for all API requests.
*/
const transport = createConnectTransport({
baseUrl: window.location.origin,
useBinaryFormat: true,

View File

@ -22,16 +22,11 @@ const SignUp = lazy(() => import("@/pages/SignUp"));
const UserProfile = lazy(() => import("@/pages/UserProfile"));
const MemoDetailRedirect = lazy(() => import("./MemoDetailRedirect"));
export enum Routes {
ROOT = "/",
ATTACHMENTS = "/attachments",
CALENDAR = "/calendar",
INBOX = "/inbox",
ARCHIVED = "/archived",
SETTING = "/setting",
EXPLORE = "/explore",
AUTH = "/auth",
}
import { ROUTES } from "./routes";
// Backward compatibility alias
export const Routes = ROUTES;
export { ROUTES };
const router = createBrowserRouter([
{

13
web/src/router/routes.ts Normal file
View File

@ -0,0 +1,13 @@
export const ROUTES = {
ROOT: "/",
ATTACHMENTS: "/attachments",
CALENDAR: "/calendar",
INBOX: "/inbox",
ARCHIVED: "/archived",
SETTING: "/setting",
EXPLORE: "/explore",
AUTH: "/auth",
} as const;
export type RouteKey = keyof typeof ROUTES;
export type RoutePath = (typeof ROUTES)[RouteKey];