Commit b0aeb06f authored by Steven's avatar Steven

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: 's avatarClaude Sonnet 4.5 <noreply@anthropic.com>
parent 50606a85
...@@ -2,6 +2,8 @@ import { timestampDate } from "@bufbuild/protobuf/wkt"; ...@@ -2,6 +2,8 @@ import { timestampDate } from "@bufbuild/protobuf/wkt";
import { Code, ConnectError, createClient, type Interceptor } from "@connectrpc/connect"; import { Code, ConnectError, createClient, type Interceptor } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web"; import { createConnectTransport } from "@connectrpc/connect-web";
import { getAccessToken, setAccessToken } from "./auth-state"; 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 { ActivityService } from "./types/proto/api/v1/activity_service_pb";
import { AttachmentService } from "./types/proto/api/v1/attachment_service_pb"; import { AttachmentService } from "./types/proto/api/v1/attachment_service_pb";
import { AuthService } from "./types/proto/api/v1/auth_service_pb"; import { AuthService } from "./types/proto/api/v1/auth_service_pb";
...@@ -11,64 +13,93 @@ import { MemoService } from "./types/proto/api/v1/memo_service_pb"; ...@@ -11,64 +13,93 @@ import { MemoService } from "./types/proto/api/v1/memo_service_pb";
import { ShortcutService } from "./types/proto/api/v1/shortcut_service_pb"; import { ShortcutService } from "./types/proto/api/v1/shortcut_service_pb";
import { UserService } from "./types/proto/api/v1/user_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";
* Authentication interceptor that: const RETRY_HEADER_VALUE = "true";
* 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}`);
}
try { const ROUTE_CONFIG = {
return await next(req); // Routes accessible without authentication (uses prefix matching)
} catch (error) { public: [
// Only handle ConnectError with Unauthenticated code ROUTES.AUTH, // Authentication pages
if (error instanceof ConnectError && error.code === Code.Unauthenticated && !req.header.get("X-Retry")) { ROUTES.EXPLORE, // Explore page
// Prevent concurrent refresh attempts "/u/", // User profile pages (dynamic)
if (!isRefreshing) { "/memos/", // Individual memo detail pages (dynamic)
isRefreshing = true; ],
refreshPromise = refreshAccessToken();
}
try { // Routes that require authentication (uses exact matching)
await refreshPromise; private: [ROUTES.ROOT, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.ARCHIVED, ROUTES.SETTING],
isRefreshing = false; } as const;
refreshPromise = null;
// ============================================================================
// Retry with new token // Token Refresh State Management
const newToken = getAccessToken(); // ============================================================================
if (newToken) {
req.header.set("Authorization", `Bearer ${newToken}`); class TokenRefreshManager {
req.header.set("X-Retry", "true"); private isRefreshing = false;
return await next(req); private refreshPromise: Promise<void> | null = null;
}
} catch (refreshError) { async refresh(refreshFn: () => Promise<void>): Promise<void> {
isRefreshing = false; if (this.isRefreshing && this.refreshPromise) {
refreshPromise = null; return this.refreshPromise;
// Refresh failed - redirect to login (only if not already there)
if (!window.location.pathname.startsWith("/auth")) {
window.location.href = "/auth";
}
throw refreshError;
}
} }
throw error;
this.isRefreshing = true;
this.refreshPromise = refreshFn().finally(() => {
this.isRefreshing = false;
this.refreshPromise = null;
});
return this.refreshPromise;
} }
};
/** isCurrentlyRefreshing(): boolean {
* Custom fetch that includes credentials for cookie handling. return this.isRefreshing;
* Required for HttpOnly refresh token cookie to be sent/received. }
*/ }
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) => { const fetchWithCredentials: typeof globalThis.fetch = (input, init) => {
return globalThis.fetch(input, { return globalThis.fetch(input, {
...init, ...init,
...@@ -76,33 +107,75 @@ const fetchWithCredentials: typeof globalThis.fetch = (input, init) => { ...@@ -76,33 +107,75 @@ const fetchWithCredentials: typeof globalThis.fetch = (input, init) => {
}); });
}; };
/** // Separate transport without auth interceptor to prevent recursion
* Separate transport for refresh token operations.
* Uses no auth interceptor to avoid circular dependency when the main
* interceptor triggers a refresh.
*/
const refreshTransport = createConnectTransport({ const refreshTransport = createConnectTransport({
baseUrl: window.location.origin, baseUrl: window.location.origin,
useBinaryFormat: true, useBinaryFormat: true,
fetch: fetchWithCredentials, fetch: fetchWithCredentials,
interceptors: [], // No interceptors to avoid recursion interceptors: [],
}); });
// Dedicated auth client for refresh operations only
const refreshAuthClient = createClient(AuthService, refreshTransport); 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> { async function refreshAccessToken(): Promise<void> {
const response = await refreshAuthClient.refreshToken({}); const response = await refreshAuthClient.refreshToken({});
setAccessToken(response.accessToken, response.expiresAt ? timestampDate(response.expiresAt) : undefined);
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);
} }
/** // ============================================================================
* Main transport for all API requests. // Authentication Interceptor
*/ // ============================================================================
const authInterceptor: Interceptor = (next) => async (req) => {
const token = getAccessToken();
if (token) {
req.header.set("Authorization", `Bearer ${token}`);
}
try {
return await next(req);
} catch (error) {
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;
}
}
};
// ============================================================================
// Transport & Service Clients
// ============================================================================
const transport = createConnectTransport({ const transport = createConnectTransport({
baseUrl: window.location.origin, baseUrl: window.location.origin,
useBinaryFormat: true, useBinaryFormat: true,
......
...@@ -22,16 +22,11 @@ const SignUp = lazy(() => import("@/pages/SignUp")); ...@@ -22,16 +22,11 @@ const SignUp = lazy(() => import("@/pages/SignUp"));
const UserProfile = lazy(() => import("@/pages/UserProfile")); const UserProfile = lazy(() => import("@/pages/UserProfile"));
const MemoDetailRedirect = lazy(() => import("./MemoDetailRedirect")); const MemoDetailRedirect = lazy(() => import("./MemoDetailRedirect"));
export enum Routes { import { ROUTES } from "./routes";
ROOT = "/",
ATTACHMENTS = "/attachments", // Backward compatibility alias
CALENDAR = "/calendar", export const Routes = ROUTES;
INBOX = "/inbox", export { ROUTES };
ARCHIVED = "/archived",
SETTING = "/setting",
EXPLORE = "/explore",
AUTH = "/auth",
}
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
......
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];
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment