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:
parent
50606a850e
commit
b0aeb06f85
3 changed files with 168 additions and 87 deletions
|
|
@ -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,18 +13,126 @@ 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";
|
||||||
|
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) => {
|
const authInterceptor: Interceptor = (next) => async (req) => {
|
||||||
// Add access token to request if available
|
|
||||||
const token = getAccessToken();
|
const token = getAccessToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
req.header.set("Authorization", `Bearer ${token}`);
|
req.header.set("Authorization", `Bearer ${token}`);
|
||||||
|
|
@ -31,78 +141,41 @@ const authInterceptor: Interceptor = (next) => async (req) => {
|
||||||
try {
|
try {
|
||||||
return await next(req);
|
return await next(req);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Only handle ConnectError with Unauthenticated code
|
if (!(error instanceof ConnectError)) {
|
||||||
if (error instanceof ConnectError && error.code === Code.Unauthenticated && !req.header.get("X-Retry")) {
|
throw error;
|
||||||
// Prevent concurrent refresh attempts
|
}
|
||||||
if (!isRefreshing) {
|
|
||||||
isRefreshing = true;
|
if (error.code !== Code.Unauthenticated) {
|
||||||
refreshPromise = refreshAccessToken();
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (req.header.get(RETRY_HEADER) === RETRY_HEADER_VALUE) {
|
||||||
await refreshPromise;
|
throw error;
|
||||||
isRefreshing = false;
|
}
|
||||||
refreshPromise = null;
|
|
||||||
|
try {
|
||||||
// Retry with new token
|
await tokenRefreshManager.refresh(refreshAccessToken);
|
||||||
const newToken = getAccessToken();
|
|
||||||
if (newToken) {
|
const newToken = getAccessToken();
|
||||||
req.header.set("Authorization", `Bearer ${newToken}`);
|
if (!newToken) {
|
||||||
req.header.set("X-Retry", "true");
|
throw new ConnectError("Token refresh succeeded but no token available", Code.Internal);
|
||||||
return await next(req);
|
}
|
||||||
}
|
|
||||||
} catch (refreshError) {
|
req.header.set("Authorization", `Bearer ${newToken}`);
|
||||||
isRefreshing = false;
|
req.header.set(RETRY_HEADER, RETRY_HEADER_VALUE);
|
||||||
refreshPromise = null;
|
return await next(req);
|
||||||
// Refresh failed - redirect to login (only if not already there)
|
} catch (refreshError) {
|
||||||
if (!window.location.pathname.startsWith("/auth")) {
|
const redirectUrl = getAuthFailureRedirect(window.location.pathname);
|
||||||
window.location.href = "/auth";
|
performRedirect(redirectUrl);
|
||||||
}
|
throw refreshError;
|
||||||
throw refreshError;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// ============================================================================
|
||||||
* Custom fetch that includes credentials for cookie handling.
|
// Transport & Service Clients
|
||||||
* Required for HttpOnly refresh token cookie to be sent/received.
|
// ============================================================================
|
||||||
*/
|
|
||||||
const fetchWithCredentials: typeof globalThis.fetch = (input, init) => {
|
|
||||||
return globalThis.fetch(input, {
|
|
||||||
...init,
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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({
|
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"));
|
||||||
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([
|
||||||
{
|
{
|
||||||
|
|
|
||||||
13
web/src/router/routes.ts
Normal file
13
web/src/router/routes.ts
Normal 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];
|
||||||
Loading…
Reference in a new issue