diff --git a/apps/api/src/api/controllers/auth.controller.ts b/apps/api/src/api/controllers/auth.controller.ts index 64e6f6c01..b3ab0de36 100644 --- a/apps/api/src/api/controllers/auth.controller.ts +++ b/apps/api/src/api/controllers/auth.controller.ts @@ -1,7 +1,7 @@ import { Request, Response } from "express"; import logger from "../../config/logger"; import User from "../../models/user.model"; -import { SupabaseAuthService } from "../services/auth"; +import { RefreshTokenError, SupabaseAuthService } from "../services/auth"; export class AuthController { /** @@ -124,9 +124,18 @@ export class AuthController { success: true }); } catch (error) { + // Only a confirmed-invalid refresh token yields a 401 (which the frontend treats as a + // definitive logout). Transient failures — and anything unexpected — return 503 so the + // frontend keeps the session and retries instead of forcing re-login. + if (error instanceof RefreshTokenError && !error.transient) { + return res.status(401).json({ + error: "Invalid refresh token" + }); + } + logger.error("Error in refreshToken:", error); - return res.status(401).json({ - error: "Invalid refresh token" + return res.status(503).json({ + error: "Auth service temporarily unavailable" }); } } diff --git a/apps/api/src/api/services/auth/index.ts b/apps/api/src/api/services/auth/index.ts index 5f6ff90ae..91db21e5e 100644 --- a/apps/api/src/api/services/auth/index.ts +++ b/apps/api/src/api/services/auth/index.ts @@ -1 +1 @@ -export { SupabaseAuthService } from "./supabase.service"; +export { RefreshTokenError, SupabaseAuthService } from "./supabase.service"; diff --git a/apps/api/src/api/services/auth/supabase.service.ts b/apps/api/src/api/services/auth/supabase.service.ts index 66e0d5710..747806ce1 100644 --- a/apps/api/src/api/services/auth/supabase.service.ts +++ b/apps/api/src/api/services/auth/supabase.service.ts @@ -1,7 +1,22 @@ -import type { User } from "@supabase/supabase-js"; +import { isAuthRetryableFetchError, type User } from "@supabase/supabase-js"; import logger from "../../../config/logger"; import { supabase, supabaseAdmin } from "../../../config/supabase"; +/** + * Thrown by `refreshToken` to distinguish a confirmed-invalid refresh token (the session is + * over) from a transient failure (Supabase unreachable / 5xx). Callers must only end the + * session on `transient === false`; transient failures are retryable. + */ +export class RefreshTokenError extends Error { + constructor( + message: string, + readonly transient: boolean + ) { + super(message); + this.name = this.constructor.name; + } +} + // Supported BCP 47 locale values and their canonical forms. // The Supabase email templates branch on `.Data.locale` using these values. const LOCALE_MAP: Record = { @@ -170,8 +185,17 @@ export class SupabaseAuthService { refresh_token: refreshToken }); - if (error || !data.session) { - throw new Error("Failed to refresh token"); + if (error) { + // Network/transport failures and upstream 5xx are transient: the refresh token may still + // be valid, so callers must retry rather than tear down the session. Only a definite 4xx + // auth error means the refresh token itself is invalid/revoked. + const status = error.status ?? 0; + const transient = isAuthRetryableFetchError(error) || status === 0 || status >= 500; + throw new RefreshTokenError(error.message, transient); + } + + if (!data.session) { + throw new RefreshTokenError("No session returned from refresh", false); } return { diff --git a/apps/frontend/src/config/supabase.ts b/apps/frontend/src/config/supabase.ts index 7bfa762c6..609691faf 100644 --- a/apps/frontend/src/config/supabase.ts +++ b/apps/frontend/src/config/supabase.ts @@ -9,8 +9,10 @@ if (!supabaseUrl || !supabaseAnonKey) { export const supabase = createClient(supabaseUrl, supabaseAnonKey, { auth: { - autoRefreshToken: true, - detectSessionInUrl: true, - persistSession: true + // The app owns token storage and refresh (backend /auth/refresh + a single scheduler). + // Keep the client passive so it doesn't run a competing background refresh. + autoRefreshToken: false, + detectSessionInUrl: false, + persistSession: false } }); diff --git a/apps/frontend/src/contexts/rampState.tsx b/apps/frontend/src/contexts/rampState.tsx index 871441332..e1f818737 100644 --- a/apps/frontend/src/contexts/rampState.tsx +++ b/apps/frontend/src/contexts/rampState.tsx @@ -12,11 +12,14 @@ import { SelectedAveniaData, SelectedMykoboData } from "../machines/types"; +import { AuthService } from "../services/auth"; import { RampExecutionInput } from "../types/phases"; const RAMP_STATE_STORAGE_KEY = "rampState"; const RAMP_EPHEMERALS_STORAGE_KEY = "rampEphemerals"; const MAX_RAMP_EPHEMERALS = 50; +const TOKEN_REFRESH_SKEW_MS = 60 * 1000; // refresh 60s before expiry +const TOKEN_REFRESH_RETRY_MS = 30 * 1000; // retry after a transient failure type RampEphemeralEntry = { substrateEphemeral: EphemeralAccount; @@ -136,10 +139,70 @@ const PersistenceEffect = () => { return null; }; +// Single app-wide token refresher: schedules a refresh just before the access token's real +// expiry (decoded from the JWT), reschedules off each new token, and retries transient +// failures without dropping the session. +const TokenRefreshEffect = () => { + const rampActor = useRampActor(); + const isAuthenticated = useSelector(rampActor, state => state?.context.isAuthenticated ?? false); + + useEffect(() => { + if (!isAuthenticated) { + return; + } + + let cancelled = false; + let timer: ReturnType | undefined; + + const scheduleNext = () => { + if (cancelled) return; + const expiryMs = AuthService.getAccessTokenExpiryMs(); + // If the expiry can't be decoded, don't kill the loop permanently — retry shortly so a + // later (decodable) token re-establishes the schedule. + const delay = expiryMs === null ? TOKEN_REFRESH_RETRY_MS : Math.max(expiryMs - Date.now() - TOKEN_REFRESH_SKEW_MS, 0); + if (expiryMs === null) { + timer = setTimeout(scheduleNext, delay); + return; + } + timer = setTimeout(async () => { + if (cancelled) return; + try { + const refreshed = await AuthService.refreshAccessToken(); + if (cancelled) return; + if (refreshed) { + // refreshAccessToken() has already persisted the new token, so this reads the fresh expiry. + scheduleNext(); + } else { + // Refresh token confirmed invalid: the session is over. + rampActor.send({ type: "LOGOUT" }); + } + } catch { + // Transient failure: retry soon without touching the session. + if (!cancelled) { + timer = setTimeout(scheduleNext, TOKEN_REFRESH_RETRY_MS); + } + } + }, delay); + }; + + scheduleNext(); + + return () => { + cancelled = true; + if (timer) { + clearTimeout(timer); + } + }; + }, [isAuthenticated, rampActor]); + + return null; +}; + export const PersistentRampStateProvider: React.FC = ({ children }) => { return ( + {children} ); diff --git a/apps/frontend/src/hooks/useAuthTokens.ts b/apps/frontend/src/hooks/useAuthTokens.ts index f09b8db23..17831fe66 100644 --- a/apps/frontend/src/hooks/useAuthTokens.ts +++ b/apps/frontend/src/hooks/useAuthTokens.ts @@ -49,12 +49,6 @@ export function useAuthTokens(actorRef: ActorRefFrom) { } }, [actorRef]); - // Setup auto-refresh on mount - useEffect(() => { - const cleanup = AuthService.setupAutoRefresh(); - return cleanup; - }, []); - // Restore session from localStorage on mount useEffect(() => { // Only restore once on initial mount to avoid infinite loops diff --git a/apps/frontend/src/machines/ramp.actors.ts b/apps/frontend/src/machines/ramp.actors.ts index e22c3e2a4..e50e6f601 100644 --- a/apps/frontend/src/machines/ramp.actors.ts +++ b/apps/frontend/src/machines/ramp.actors.ts @@ -78,12 +78,14 @@ export async function checkAndRefreshTokenActor() { if (refreshedTokens) { return { success: true, tokens: refreshedTokens }; } + // A null result means the refresh token was confirmed invalid; refreshAccessToken() + // has already cleared the stored session, so we just report failure here. + return { success: false, tokens: null }; } catch { - // If refreshing fails, continue to the shared cleanup path below. + // Transient refresh failure (network/5xx): keep the session rather than forcing a + // logout. Proceed with the current tokens; request-level 401 retry will recover later. + return { success: true, tokens }; } - - AuthService.clearTokens(); - return { success: false, tokens: null }; } export async function loadQuoteActor({ input }: { input: { quoteId: string } }) { diff --git a/apps/frontend/src/services/api/api-client.ts b/apps/frontend/src/services/api/api-client.ts index 66f9c66a1..2755074fa 100644 --- a/apps/frontend/src/services/api/api-client.ts +++ b/apps/frontend/src/services/api/api-client.ts @@ -1,5 +1,21 @@ import { SIGNING_SERVICE_URL } from "../../constants/constants"; -import { AuthService } from "../auth"; +import { AuthService, type AuthTokens } from "../auth"; + +// Single-flight token refresh: concurrent 401s share one refresh instead of each firing +// their own (which would race the refresh-token rotation and fail). +let refreshPromise: Promise | null = null; + +function refreshTokenOnce(): Promise { + if (!refreshPromise) { + refreshPromise = AuthService.refreshAccessToken() + // A transient refresh failure shouldn't reject every waiting request; treat as "no new token". + .catch(() => null) + .finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; +} export class ApiError extends Error { status: number; @@ -26,8 +42,6 @@ async function apiFetch( signal?: AbortSignal; } = {} ): Promise { - const tokens = AuthService.getTokens(); - const url = new URL(`${SIGNING_SERVICE_URL}/v1${path}`, window.location.origin); if (options.params) { for (const [key, value] of Object.entries(options.params)) { @@ -36,17 +50,29 @@ async function apiFetch( } const isFormData = options.data instanceof FormData; + const body = isFormData ? (options.data as FormData) : options.data !== undefined ? JSON.stringify(options.data) : undefined; - const response = await fetch(url.toString(), { - body: isFormData ? (options.data as FormData) : options.data !== undefined ? JSON.stringify(options.data) : undefined, - headers: { - ...(tokens?.accessToken ? { Authorization: `Bearer ${tokens.accessToken}` } : {}), - ...(!isFormData ? { "Content-Type": "application/json" } : {}), - ...options.headers - }, - method, - signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000) - }); + const doFetch = (accessToken: string | undefined) => + fetch(url.toString(), { + body, + headers: { + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + ...(!isFormData ? { "Content-Type": "application/json" } : {}), + ...options.headers + }, + method, + signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000) + }); + + const initialTokens = AuthService.getTokens(); + let response = await doFetch(initialTokens?.accessToken); + + if (response.status === 401 && initialTokens?.accessToken) { + const refreshed = await refreshTokenOnce(); + if (refreshed?.accessToken) { + response = await doFetch(refreshed.accessToken); + } + } if (!response.ok) { const errorData = (await response.json().catch(() => ({}))) as { error?: string; message?: string }; diff --git a/apps/frontend/src/services/auth.ts b/apps/frontend/src/services/auth.ts index cfd4f89fc..be56b6309 100644 --- a/apps/frontend/src/services/auth.ts +++ b/apps/frontend/src/services/auth.ts @@ -1,4 +1,5 @@ import { supabase } from "../config/supabase"; +import { SIGNING_SERVICE_URL } from "../constants/constants"; export interface AuthTokens { accessToken: string; @@ -60,7 +61,39 @@ export class AuthService { * Check if user is authenticated */ static isAuthenticated(): boolean { - return this.getTokens() !== null; + const tokens = this.getTokens(); + if (!tokens) { + return false; + } + const expiryMs = this.decodeJwtExpiryMs(tokens.accessToken); + return expiryMs === null || expiryMs > Date.now(); + } + + /** + * Returns the access token expiry as epoch milliseconds, or null if it can't be decoded. + */ + static getAccessTokenExpiryMs(): number | null { + const tokens = this.getTokens(); + if (!tokens) { + return null; + } + return this.decodeJwtExpiryMs(tokens.accessToken); + } + + private static decodeJwtExpiryMs(token: string): number | null { + try { + const payload = token.split(".")[1]; + if (!payload) { + return null; + } + // JWT segments are base64url and usually unpadded; convert to base64 and re-pad before decoding. + const base64 = payload.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); + const decoded = JSON.parse(atob(padded)) as { exp?: number }; + return typeof decoded.exp === "number" ? decoded.exp * 1000 : null; + } catch { + return null; + } } /** @@ -89,7 +122,12 @@ export class AuthService { } /** - * Refresh access token + * Refresh the access token via the backend `/auth/refresh` endpoint. + * + * Returns the new tokens on success, or `null` when the refresh token is confirmed + * invalid/revoked (a 401 from the backend) — in which case the session is cleared. + * Transient failures (network errors, timeouts, 5xx) throw so callers can retry + * without destroying a still-valid session. */ static async refreshAccessToken(): Promise { const tokens = this.getTokens(); @@ -97,45 +135,36 @@ export class AuthService { return null; } - try { - const { data, error } = await supabase.auth.refreshSession({ - refresh_token: tokens.refreshToken - }); - - if (error || !data.session || !data.user) { - this.clearTokens(); - return null; - } + const response = await fetch(`${SIGNING_SERVICE_URL}/v1/auth/refresh`, { + body: JSON.stringify({ refresh_token: tokens.refreshToken }), + headers: { "Content-Type": "application/json" }, + method: "POST", + signal: AbortSignal.timeout(30000) + }); - const newTokens: AuthTokens = { - accessToken: data.session.access_token, - refreshToken: data.session.refresh_token, - userEmail: data.user.email, - userId: data.user.id - }; - - this.storeTokens(newTokens); - return newTokens; - } catch (error) { - console.error("Token refresh failed:", error); + // A 401 means the refresh token itself is invalid/revoked: the session is dead. + if (response.status === 401) { this.clearTokens(); return null; } - } - /** - * Setup auto-refresh (refresh 5 minutes before expiry) - */ - static setupAutoRefresh(): () => void { - const REFRESH_INTERVAL = 55 * 60 * 1000; // 55 minutes + // Any other non-OK status is transient — keep the session and let the caller retry. + if (!response.ok) { + throw new Error(`Token refresh failed with status ${response.status}`); + } - const intervalId = setInterval(async () => { - if (this.isAuthenticated()) { - await this.refreshAccessToken(); - } - }, REFRESH_INTERVAL); + const data = (await response.json()) as { access_token: string; refresh_token: string }; + + // The refresh endpoint does not return identity; it is unchanged across a refresh. + const newTokens: AuthTokens = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + userEmail: tokens.userEmail, + userId: tokens.userId + }; - return () => clearInterval(intervalId); + this.storeTokens(newTokens); + return newTokens; } /** diff --git a/docs/security-spec/01-auth/supabase-otp.md b/docs/security-spec/01-auth/supabase-otp.md index 5a6a03752..62888870a 100644 --- a/docs/security-spec/01-auth/supabase-otp.md +++ b/docs/security-spec/01-auth/supabase-otp.md @@ -10,6 +10,7 @@ The flow: 3. Supabase verifies OTP and issues a JWT access token 4. Frontend includes JWT in `Authorization: Bearer ` header on API requests 5. API middleware (`supabaseAuth.ts`) verifies the JWT via `SupabaseAuthService.verifyToken()` and attaches `userId` to the request +6. Access tokens are short-lived. The frontend refreshes them via `POST /v1/auth/refresh` (`SupabaseAuthService.refreshToken()` → Supabase `refreshSession`), scheduled just before expiry and also triggered on a `401` (single-flight refresh + one retry). The frontend never calls Supabase `refreshSession` directly with the anon key. The endpoint returns `401` **only** when the refresh token is confirmed invalid/revoked; transient upstream failures (Supabase unreachable / 5xx) return `503` so the frontend keeps the session and retries. Two middleware variants exist: - **`requireAuth`** — Returns 401 if token is missing or invalid. Used on protected endpoints. @@ -25,6 +26,7 @@ Two middleware variants exist: 6. **Auth errors MUST NOT leak token content** — Error responses must use generic messages ("Invalid or expired token"). Tokens must be truncated in logs (as implemented: first 15 + last 4 chars). 7. **Supabase configuration MUST be present** — If `SUPABASE_URL`, `SUPABASE_ANON_KEY`, or `SUPABASE_SERVICE_KEY` are empty/missing, the auth system is non-functional. The service should fail to start rather than silently accept all tokens. 8. **JWT expiry MUST be enforced** — Supabase tokens have a configurable expiry. The verification MUST reject expired tokens, not just validate the signature. +9. **Session teardown MUST happen only on confirmed-invalid refresh** — The frontend clears the stored session (and forces re-login) only when `/v1/auth/refresh` returns `401` (refresh token invalid/revoked). Transient failures (network errors, 5xx, timeouts) MUST NOT clear the session; they are retried while the existing session is preserved. The backend enforces this contract: `/v1/auth/refresh` returns `401` only for a definite invalid-token error from Supabase and returns `503` for transient/transport failures (and any unexpected error), so a Supabase outage cannot masquerade as an invalid token and log users out. ## Threat Vectors & Mitigations @@ -48,4 +50,6 @@ Two middleware variants exist: - [x] `optionalAuth` truncates tokens in warning logs (first 15 + last 4 characters) — **PASS** - [x] `SUPABASE_URL`, `SUPABASE_ANON_KEY`, and `SUPABASE_SERVICE_KEY` are validated at startup — empty strings are treated as missing — **FAIL: All default to "" with no startup validation (F-019)** - [x] Token expiry is enforced by the verification call (not just signature validity) — **PASS** +- [x] Frontend refresh goes through `/v1/auth/refresh` (not the anon-key client) and clears the session only on a `401`, retrying transient failures — **PASS** +- [x] `/v1/auth/refresh` returns `401` only for a confirmed-invalid refresh token and `503` for transient/unexpected failures (so an outage cannot force logout) — **PASS** - [x] No endpoint that should require auth is using `optionalAuth` as a shortcut — **PARTIAL: BRLA KYC endpoints use optionalAuth but create user-specific resources**