Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions apps/api/src/api/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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 {
/**
Expand Down Expand Up @@ -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"
});
}
}
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/api/services/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { SupabaseAuthService } from "./supabase.service";
export { RefreshTokenError, SupabaseAuthService } from "./supabase.service";
30 changes: 27 additions & 3 deletions apps/api/src/api/services/auth/supabase.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 5 additions & 3 deletions apps/frontend/src/config/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
});
63 changes: 63 additions & 0 deletions apps/frontend/src/contexts/rampState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<typeof setTimeout> | 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;
}
Comment thread
Copilot marked this conversation as resolved.
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<PropsWithChildren> = ({ children }) => {
return (
<RampStateContext.Provider>
<PersistenceEffect />
<TokenRefreshEffect />
{children}
</RampStateContext.Provider>
);
Expand Down
6 changes: 0 additions & 6 deletions apps/frontend/src/hooks/useAuthTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,6 @@ export function useAuthTokens(actorRef: ActorRefFrom<typeof rampMachine>) {
}
}, [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
Expand Down
10 changes: 6 additions & 4 deletions apps/frontend/src/machines/ramp.actors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }) {
Expand Down
52 changes: 39 additions & 13 deletions apps/frontend/src/services/api/api-client.ts
Original file line number Diff line number Diff line change
@@ -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<AuthTokens | null> | null = null;

function refreshTokenOnce(): Promise<AuthTokens | null> {
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;
Expand All @@ -26,8 +42,6 @@ async function apiFetch<T>(
signal?: AbortSignal;
} = {}
): Promise<T> {
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)) {
Expand All @@ -36,17 +50,29 @@ async function apiFetch<T>(
}

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 };
Expand Down
Loading
Loading