import { timestampDate } from "@bufbuild/protobuf/wkt"; import { Code, ConnectError, createClient, type Interceptor } from "@connectrpc/connect"; import { createConnectTransport } from "@connectrpc/connect-web"; import { getAccessToken, hasStoredToken, isTokenExpired, REQUEST_TOKEN_EXPIRY_BUFFER_MS, setAccessToken } from "./auth-state"; import { AttachmentService } from "./types/proto/api/v1/attachment_service_pb"; import { AuthService } from "./types/proto/api/v1/auth_service_pb"; import { IdentityProviderService } from "./types/proto/api/v1/idp_service_pb"; import { InstanceService } from "./types/proto/api/v1/instance_service_pb"; 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"; import { redirectOnAuthFailure } from "./utils/auth-redirect"; interface RequestWithHeader { header: Headers; } // ============================================================================ // Constants // ============================================================================ const RETRY_HEADER = "X-Retry"; const RETRY_HEADER_VALUE = "true"; // ============================================================================ // Token Refresh State Management // ============================================================================ const createTokenRefreshManager = () => { let isRefreshing = false; let refreshPromise: Promise | null = null; return { async refresh(refreshFn: () => Promise): Promise { if (isRefreshing && refreshPromise) { return refreshPromise; } isRefreshing = true; refreshPromise = refreshFn().finally(() => { isRefreshing = false; refreshPromise = null; }); return refreshPromise; }, }; }; const tokenRefreshManager = createTokenRefreshManager(); // ============================================================================ // 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 doRefreshAccessToken(): Promise { 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); } // All callers go through the manager to deduplicate concurrent refresh requests. // This prevents race conditions between useTokenRefreshOnFocus (proactive refresh // on tab focus) and the auth interceptor (reactive refresh on 401), which could // otherwise send duplicate requests that conflict with server-side token rotation. export async function refreshAccessToken(): Promise { return tokenRefreshManager.refresh(doRefreshAccessToken); } // ============================================================================ // Authentication Interceptor Helpers // ============================================================================ function setAuthorizationHeader(req: RequestWithHeader, token: string | null) { if (!token) return; req.header.set("Authorization", `Bearer ${token}`); } function shouldHandleUnauthenticatedRetry(error: unknown, isRetryAttempt: boolean): boolean { if (!(error instanceof ConnectError)) { return false; } if (error.code !== Code.Unauthenticated) { return false; } if (isRetryAttempt) { return false; } return true; } async function refreshAndGetAccessToken(): Promise { await refreshAccessToken(); const token = getAccessToken(); if (!token) { throw new ConnectError("Token refresh succeeded but no token available", Code.Internal); } return token; } async function getRequestToken(): Promise { let token = getAccessToken(); if (!token) { if (!hasStoredToken()) return null; try { token = await refreshAndGetAccessToken(); } catch { return null; } return token; } // Preflight refresh: avoid sending requests with expired access tokens. // This is especially important for public endpoints (e.g. ListMemos), where // an expired token could otherwise be treated as anonymous and return // guest-scoped data before the reactive 401 refresh path runs. if (isTokenExpired(REQUEST_TOKEN_EXPIRY_BUFFER_MS)) { try { token = await refreshAndGetAccessToken(); } catch { // Keep existing reactive 401 flow as fallback. // Protected methods still trigger refresh/redirect in the catch block below. } } return token; } // ============================================================================ // Authentication Interceptor // ============================================================================ const authInterceptor: Interceptor = (next) => async (req) => { const isRetryAttempt = req.header.get(RETRY_HEADER) === RETRY_HEADER_VALUE; const token = await getRequestToken(); setAuthorizationHeader(req, token); try { return await next(req); } catch (error) { if (!shouldHandleUnauthenticatedRetry(error, isRetryAttempt)) { throw error; } try { const newToken = await refreshAndGetAccessToken(); setAuthorizationHeader(req, newToken); req.header.set(RETRY_HEADER, RETRY_HEADER_VALUE); return await next(req); } catch (refreshError) { redirectOnAuthFailure(); throw refreshError; } } }; // ============================================================================ // Transport & Service Clients // ============================================================================ const transport = createConnectTransport({ baseUrl: window.location.origin, useBinaryFormat: true, fetch: fetchWithCredentials, interceptors: [authInterceptor], }); // Core service clients export const instanceServiceClient = createClient(InstanceService, transport); export const authServiceClient = createClient(AuthService, transport); export const userServiceClient = createClient(UserService, transport); // Content service clients export const memoServiceClient = createClient(MemoService, transport); export const attachmentServiceClient = createClient(AttachmentService, transport); export const shortcutServiceClient = createClient(ShortcutService, transport); // Configuration service clients export const identityProviderServiceClient = createClient(IdentityProviderService, transport);