From 07b64dde0f38a637f6a9a614c11e7b20db306700 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:57:44 +0530 Subject: [PATCH 1/8] fix(auth): refresh expired access tokens and break the stale-session login loop The Auth0 session cookie outlives the access token and nothing ever refreshed it, so mid-session users were bounced to /login where the cached client user triggered an infinite login/gated-page redirect loop, and proxied API calls failed with 401. - /login now re-validates the session before trusting the cached user - proxied 401s trigger a session re-check so the client signs out cleanly when the server session is dead - getSession is wrapped server-side to renew expired access tokens with the session's refresh token via the oauth token endpoint Fixes DEPLOY-WEB-2C4 --- .../PasswordlessAuth.spec.tsx | 44 ++++- .../PasswordlessAuth/PasswordlessAuth.tsx | 18 ++- .../SessionExpirySync.spec.tsx | 68 ++++++++ .../SessionExpirySync/SessionExpirySync.tsx | 41 +++++ .../user/UserProviders/UserProviders.tsx | 2 + .../getSessionWithRefresh.spec.ts | 152 ++++++++++++++++++ .../getSessionWithRefresh.ts | 73 +++++++++ .../app-di-container/app-di-container.ts | 8 +- .../server-di-container.service.ts | 9 +- .../session-expiry-notifier.service.spec.ts | 77 +++++++++ .../session-expiry-notifier.service.ts | 33 ++++ .../services/session/session.service.spec.ts | 109 +++++++++++++ .../src/services/session/session.service.ts | 76 +++++++++ 13 files changed, 699 insertions(+), 11 deletions(-) create mode 100644 apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx create mode 100644 apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx create mode 100644 apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.spec.ts create mode 100644 apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.ts create mode 100644 apps/deploy-web/src/services/session-expiry-notifier/session-expiry-notifier.service.spec.ts create mode 100644 apps/deploy-web/src/services/session-expiry-notifier/session-expiry-notifier.service.ts diff --git a/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.spec.tsx b/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.spec.tsx index d9f21806f9..d672f8965b 100644 --- a/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.spec.tsx +++ b/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.spec.tsx @@ -1,4 +1,5 @@ import type { ReactNode, RefObject } from "react"; +import { useState } from "react"; import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; @@ -77,10 +78,30 @@ describe(PasswordlessAuth.name, () => { expect(checkSession).toHaveBeenCalled(); }); - it("navigates back once the user is authenticated", () => { + it("navigates back once the user is authenticated and the session is re-validated", async () => { const { navigateBack } = setup({ authenticated: true }); - expect(navigateBack).toHaveBeenCalled(); + await vi.waitFor(() => expect(navigateBack).toHaveBeenCalled()); + }); + + it("does not navigate back before the session re-validation settles", () => { + const { navigateBack } = setup({ authenticated: true, sessionRevalidation: "pending" }); + + expect(navigateBack).not.toHaveBeenCalled(); + }); + + it("renders the login form instead of navigating back when re-validation clears a stale user", async () => { + const EmailCodeStartMock = vi.fn(ComponentMock); + const { navigateBack, checkSession } = setup({ + authenticated: true, + sessionRevalidation: "expired", + dependencies: { EmailCodeStart: EmailCodeStartMock as never } + }); + + await vi.waitFor(() => expect(EmailCodeStartMock).toHaveBeenCalled()); + + expect(checkSession).toHaveBeenCalled(); + expect(navigateBack).not.toHaveBeenCalled(); }); it("renders the boot loader instead of the auth forms when authenticated", () => { @@ -145,24 +166,35 @@ describe(PasswordlessAuth.name, () => { initialEmail?: string; step?: string; authenticated?: boolean; + /** "valid" (default) keeps the user after re-validation, "expired" clears it, "pending" never settles. */ + sessionRevalidation?: "valid" | "expired" | "pending"; dependencies?: Partial; } = {} ) { const analyticsService = mock(); const onEmailChange = vi.fn(); const onFlowReset = vi.fn(); - const checkSession = vi.fn(async () => undefined); const navigateBack = vi.fn(); const push = vi.fn(); const replace = vi.fn(); const params = new URLSearchParams(); if (input.step) params.set("step", input.step); - const useUser: typeof DEPENDENCIES.useUser = () => - mock>({ + const initialUser = input.authenticated ? mock["user"]>>({ userId: "user-1" }) : undefined; + let clearUser: () => void = () => undefined; + const checkSession = vi.fn(async () => { + if (input.sessionRevalidation === "pending") return new Promise(() => undefined); + if (input.sessionRevalidation === "expired") clearUser(); + return undefined; + }); + const useUser: typeof DEPENDENCIES.useUser = () => { + const [user, setUser] = useState(initialUser); + clearUser = () => setUser(undefined); + return mock>({ checkSession, isLoading: false, - user: input.authenticated ? mock["user"]>>({ userId: "user-1" }) : undefined + user }); + }; const useReturnTo: typeof DEPENDENCIES.useReturnTo = () => mock>({ returnTo: "/", diff --git a/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.tsx b/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.tsx index fd101c03b1..d530e27e1b 100644 --- a/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.tsx +++ b/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.tsx @@ -43,6 +43,7 @@ export function PasswordlessAuth({ dependencies: d = DEPENDENCIES, ...props }: P const searchParams = d.useSearchParams(); const [email, setEmail] = useState(props.initialEmail); const [screenKey, setScreenKey] = useState(0); + const [isSessionRevalidated, setIsSessionRevalidated] = useState(false); const turnstileRef = useRef(null); const screen: "entry" | "verify" = searchParams.get("step") === "verify" ? "verify" : "entry"; @@ -92,11 +93,24 @@ export function PasswordlessAuth({ dependencies: d = DEPENDENCIES, ...props }: P [screen, email, goBackToEntry, user] ); + /** + * The Auth0 client context can hold a stale user whose server session has already expired (the + * session cookie outlives the access token). Trusting it here would `navigateBack()` to a gated + * page whose SSR guard bounces straight back to /login — an infinite loop on a boot spinner + * (DEPLOY-WEB-2C4). Re-fetching the profile clears a dead user before any navigation decision. + */ + useEffect( + function revalidateSessionOnMount() { + checkSession().finally(() => setIsSessionRevalidated(true)); + }, + [checkSession] + ); + useEffect( function leaveWhenAuthenticated() { - if (user) navigateBack(); + if (isSessionRevalidated && user) navigateBack(); }, - [user, navigateBack] + [isSessionRevalidated, user, navigateBack] ); const handleVerified = useCallback(async () => { diff --git a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx new file mode 100644 index 0000000000..bce520d061 --- /dev/null +++ b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import { SessionExpiryNotifier } from "@src/services/session-expiry-notifier/session-expiry-notifier.service"; +import type { DEPENDENCIES } from "./SessionExpirySync"; +import { SessionExpirySync } from "./SessionExpirySync"; + +import { act, render } from "@testing-library/react"; +import { TestContainerProvider } from "@tests/unit/TestContainerProvider"; + +describe(SessionExpirySync.name, () => { + it("re-checks the session when notified of an expiry", async () => { + const { notifier, checkSession } = setup(); + + await act(async () => notifier.notify()); + + expect(checkSession).toHaveBeenCalledTimes(1); + }); + + it("collapses a burst of notifications into a single in-flight re-check", async () => { + const { notifier, checkSession } = setup({ checkSessionDuration: "pending" }); + + await act(async () => { + notifier.notify(); + notifier.notify(); + notifier.notify(); + }); + + expect(checkSession).toHaveBeenCalledTimes(1); + }); + + it("re-checks again once the previous re-check has settled", async () => { + const { notifier, checkSession } = setup(); + + await act(async () => notifier.notify()); + await act(async () => notifier.notify()); + + expect(checkSession).toHaveBeenCalledTimes(2); + }); + + it("unsubscribes on unmount", async () => { + const { notifier, checkSession, unmount } = setup(); + + unmount(); + await act(async () => notifier.notify()); + + expect(checkSession).not.toHaveBeenCalled(); + }); + + function setup(input: { checkSessionDuration?: "settled" | "pending" } = {}) { + const notifier = new SessionExpiryNotifier(); + const checkSession = vi.fn(() => (input.checkSessionDuration === "pending" ? new Promise(() => undefined) : Promise.resolve())); + const useUser: typeof DEPENDENCIES.useUser = () => + mock>({ + checkSession, + isLoading: false, + user: undefined + }); + + const { unmount } = render( + notifier }}> + + + ); + + return { notifier, checkSession, unmount }; + } +}); diff --git a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx new file mode 100644 index 0000000000..e8248d9453 --- /dev/null +++ b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx @@ -0,0 +1,41 @@ +"use client"; +import { useEffect, useRef } from "react"; + +import { useServices } from "@src/context/ServicesProvider"; +import { useUser } from "@src/hooks/useUser"; + +export const DEPENDENCIES = { useUser }; + +interface Props { + dependencies?: typeof DEPENDENCIES; +} + +/** + * Converges client auth state with the server. A proxied API call that 401s means the server-side + * session is dead while the Auth0 client context may still hold a cached user; re-fetching the + * profile drops that user so `RequireAuth` routes to /login cleanly, instead of the app silently + * firing more requests with an expired token (DEPLOY-WEB-2C4). The in-flight guard collapses a + * burst of parallel 401s into a single re-check. + */ +export function SessionExpirySync({ dependencies: d = DEPENDENCIES }: Props = {}) { + const { checkSession } = d.useUser(); + const { sessionExpiryNotifier } = useServices(); + const isReCheckingRef = useRef(false); + + useEffect( + function reCheckSessionOnExpiryNotice() { + return sessionExpiryNotifier.subscribe(async () => { + if (isReCheckingRef.current) return; + isReCheckingRef.current = true; + try { + await checkSession(); + } finally { + isReCheckingRef.current = false; + } + }); + }, + [sessionExpiryNotifier, checkSession] + ); + + return null; +} diff --git a/apps/deploy-web/src/components/user/UserProviders/UserProviders.tsx b/apps/deploy-web/src/components/user/UserProviders/UserProviders.tsx index 2ad320f393..12ebd4085f 100644 --- a/apps/deploy-web/src/components/user/UserProviders/UserProviders.tsx +++ b/apps/deploy-web/src/components/user/UserProviders/UserProviders.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect } from "react"; import { UserProvider } from "@auth0/nextjs-auth0/client"; +import { SessionExpirySync } from "@src/components/user/SessionExpirySync/SessionExpirySync"; import { UserInitLoader } from "@src/components/user/UserInitLoader"; import { useServices } from "@src/context/ServicesProvider"; import { useUser } from "@src/hooks/useUser"; @@ -22,6 +23,7 @@ export const UserProviders: FCWithChildren = ({ children }) => { ); return ( + {children} diff --git a/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.spec.ts b/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.spec.ts new file mode 100644 index 0000000000..fd2113788b --- /dev/null +++ b/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.spec.ts @@ -0,0 +1,152 @@ +import type { LoggerService } from "@akashnetwork/logging"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { Err, Ok } from "ts-results"; +import { describe, expect, it, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { Session } from "@src/lib/auth0"; +import type { RefreshedTokens, SessionService } from "@src/services/session/session.service"; +import { createGetSessionWithRefresh } from "./getSessionWithRefresh"; + +const NOW_SECONDS = Math.floor(Date.now() / 1_000); + +describe(createGetSessionWithRefresh.name, () => { + it("returns the empty result when there is no session", async () => { + const { getSessionWithRefresh, sessionService, req, res } = setup({ session: null }); + + const result = await getSessionWithRefresh(req, res); + + expect(result).toBeNull(); + expect(sessionService.refreshAccessToken).not.toHaveBeenCalled(); + }); + + it("returns the session untouched when the access token is not expired", async () => { + const session = createSession({ accessTokenExpiresAt: NOW_SECONDS + 3_600 }); + const { getSessionWithRefresh, sessionService, setSession, req, res } = setup({ session }); + + const result = await getSessionWithRefresh(req, res); + + expect(result).toBe(session); + expect(result?.accessToken).toBe("expired-access-token"); + expect(sessionService.refreshAccessToken).not.toHaveBeenCalled(); + expect(setSession).not.toHaveBeenCalled(); + }); + + it("returns an expired session untouched when it has no refresh token", async () => { + const session = createSession({ accessTokenExpiresAt: NOW_SECONDS - 60, refreshToken: undefined }); + const { getSessionWithRefresh, sessionService, req, res } = setup({ session }); + + const result = await getSessionWithRefresh(req, res); + + expect(result).toBe(session); + expect(sessionService.refreshAccessToken).not.toHaveBeenCalled(); + }); + + it("refreshes, persists, and returns the session when the token is expired", async () => { + const session = createSession({ accessTokenExpiresAt: NOW_SECONDS - 60 }); + const { getSessionWithRefresh, sessionService, setSession, req, res } = setup({ session }); + sessionService.refreshAccessToken.mockResolvedValue(Ok(createRefreshedTokens())); + + const result = await getSessionWithRefresh(req, res); + + expect(sessionService.refreshAccessToken).toHaveBeenCalledWith("refresh-token"); + expect(setSession).toHaveBeenCalledWith(req, res, session); + expect(result?.accessToken).toBe("new-access-token"); + expect(result?.refreshToken).toBe("rotated-refresh-token"); + expect(result?.accessTokenExpiresAt).toBe(NOW_SECONDS + 3_600); + expect(result?.user).toEqual(session.user); + }); + + it("treats a missing accessTokenExpiresAt as expired", async () => { + const session = createSession({ accessTokenExpiresAt: undefined }); + const { getSessionWithRefresh, sessionService, req, res } = setup({ session }); + sessionService.refreshAccessToken.mockResolvedValue(Ok(createRefreshedTokens())); + + await getSessionWithRefresh(req, res); + + expect(sessionService.refreshAccessToken).toHaveBeenCalledWith("refresh-token"); + }); + + it("clears the session cookies and returns the expired session when the refresh fails", async () => { + const session = createSession({ accessTokenExpiresAt: NOW_SECONDS - 60 }); + const { getSessionWithRefresh, sessionService, setSession, req, res } = setup({ session }); + sessionService.refreshAccessToken.mockResolvedValue(Err({ code: "invalid_grant", message: "revoked", cause: {} })); + + const result = await getSessionWithRefresh(req, res); + + expect(result).toBe(session); + expect(result?.accessToken).toBe("expired-access-token"); + expect(setSession).not.toHaveBeenCalled(); + expect(res.setHeader).toHaveBeenCalledWith("Set-Cookie", expect.arrayContaining([expect.stringContaining("appSession=;")])); + }); + + it("still returns the refreshed session when persisting the cookie fails", async () => { + const session = createSession({ accessTokenExpiresAt: NOW_SECONDS - 60 }); + const { getSessionWithRefresh, sessionService, setSession, logger, req, res } = setup({ session }); + sessionService.refreshAccessToken.mockResolvedValue(Ok(createRefreshedTokens())); + setSession.mockRejectedValue(new Error("session cache not initialized")); + + const result = await getSessionWithRefresh(req, res); + + expect(result?.accessToken).toBe("new-access-token"); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ event: "ACCESS_TOKEN_REFRESH_PERSIST_FAILED" })); + }); + + it("shares one refresh call between concurrent requests carrying the same refresh token", async () => { + const { getSessionWithRefresh, sessionService, getSession, req, res } = setup({ session: null }); + getSession.mockImplementation(async () => createSession({ accessTokenExpiresAt: NOW_SECONDS - 60 })); + let resolveRefresh!: (value: Awaited>) => void; + sessionService.refreshAccessToken.mockReturnValue(new Promise(resolve => (resolveRefresh = resolve))); + + const first = getSessionWithRefresh(req, res); + const second = getSessionWithRefresh(req, res); + resolveRefresh(Ok(createRefreshedTokens())); + const results = await Promise.all([first, second]); + + expect(sessionService.refreshAccessToken).toHaveBeenCalledTimes(1); + expect(results[0]?.accessToken).toBe("new-access-token"); + expect(results[1]?.accessToken).toBe("new-access-token"); + }); + + it("performs a fresh refresh once the previous one has settled", async () => { + const { getSessionWithRefresh, sessionService, getSession, req, res } = setup({ session: null }); + getSession.mockImplementation(async () => createSession({ accessTokenExpiresAt: NOW_SECONDS - 60 })); + sessionService.refreshAccessToken.mockResolvedValue(Ok(createRefreshedTokens())); + + await getSessionWithRefresh(req, res); + await getSessionWithRefresh(req, res); + + expect(sessionService.refreshAccessToken).toHaveBeenCalledTimes(2); + }); + + function setup(input: { session: Session | null }) { + const getSession = vi.fn(async () => input.session); + const setSession = vi.fn(async () => undefined); + const sessionService = mock>(); + const logger = mock>(); + const req = mock({ cookies: { appSession: "encrypted" } }); + const res = mock({ getHeader: vi.fn(() => undefined) }); + const getSessionWithRefresh = createGetSessionWithRefresh({ getSession, setSession, sessionService, logger }); + + return { getSessionWithRefresh, getSession, setSession, sessionService, logger, req, res }; + } +}); + +function createSession(input: { accessTokenExpiresAt: number | undefined; refreshToken?: string | undefined }) { + return mock({ + accessToken: "expired-access-token", + accessTokenExpiresAt: input.accessTokenExpiresAt, + refreshToken: "refreshToken" in input ? input.refreshToken : "refresh-token", + user: { id: "user-1", email: "user@example.com" } + }); +} + +function createRefreshedTokens(): RefreshedTokens { + return { + accessToken: "new-access-token", + accessTokenScope: "openid profile email offline_access", + accessTokenExpiresAt: NOW_SECONDS + 3_600, + refreshToken: "rotated-refresh-token", + idToken: "new-id-token" + }; +} diff --git a/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.ts b/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.ts new file mode 100644 index 0000000000..28603595a4 --- /dev/null +++ b/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.ts @@ -0,0 +1,73 @@ +import type { LoggerService } from "@akashnetwork/logging"; +import type { IncomingMessage, ServerResponse } from "http"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import type { Session } from "@src/lib/auth0"; +import { clearSessionCookies } from "@src/lib/auth0/clearSessionCookies/clearSessionCookies"; +import type { setSession } from "@src/lib/auth0/setSession/setSession"; +import type { SessionService } from "@src/services/session/session.service"; + +export type SessionRequest = (IncomingMessage & { cookies: NextApiRequest["cookies"] }) | NextApiRequest; +export type SessionResponse = ServerResponse | NextApiResponse; +export type GetSession = (req: SessionRequest, res: SessionResponse) => Promise; + +export interface GetSessionWithRefreshDependencies { + getSession: GetSession; + setSession: typeof setSession; + sessionService: Pick; + logger: Pick; +} + +/** + * Wraps `getSession` so an expired access token is transparently renewed with the session's refresh + * token instead of being treated as "logged out" (DEPLOY-WEB-2C4: the session cookie outlives the + * access token, so users were bounced to /login mid-session and proxied API calls 401ed). Refresh + * only happens once the token is actually expired, and concurrent requests carrying the same + * refresh token share a single in-flight `/oauth/token` call — with Auth0 refresh-token rotation + * enabled, configure the tenant's rotation *reuse interval* (30–60s) so cross-instance races don't + * revoke the token family; a lost race degrades to today's unauthenticated behavior, never an error. + */ +export function createGetSessionWithRefresh(deps: GetSessionWithRefreshDependencies): GetSession { + const inFlightRefreshes = new Map>(); + + function refreshOncePerToken(refreshToken: string) { + const inFlight = inFlightRefreshes.get(refreshToken); + if (inFlight) return inFlight; + + const refresh = deps.sessionService.refreshAccessToken(refreshToken).finally(() => { + inFlightRefreshes.delete(refreshToken); + }); + inFlightRefreshes.set(refreshToken, refresh); + return refresh; + } + + return async function getSessionWithRefresh(req, res) { + const session = await deps.getSession(req, res); + if (!session || !isAccessTokenExpired(session) || !session.refreshToken) { + return session; + } + + const result = await refreshOncePerToken(session.refreshToken); + + if (!result.ok) { + deps.logger.warn({ event: "ACCESS_TOKEN_REFRESH_FAILED", code: result.val.code, error: result.val }); + clearSessionCookies(req as NextApiRequest, res as NextApiResponse); + return session; + } + + Object.assign(session, result.val); + try { + await deps.setSession(req as NextApiRequest, res as NextApiResponse, session); + } catch (error) { + deps.logger.warn({ event: "ACCESS_TOKEN_REFRESH_PERSIST_FAILED", error }); + } + deps.logger.info({ event: "ACCESS_TOKEN_REFRESHED", userId: session.user?.id }); + + return session; + }; +} + +/** Mirrors the expiry predicate of `pageGuards.isAuthenticated` and the auth0 profile handler. */ +function isAccessTokenExpired(session: Session): boolean { + return (session.accessTokenExpiresAt || 0) * 1_000 <= Date.now(); +} diff --git a/apps/deploy-web/src/services/app-di-container/app-di-container.ts b/apps/deploy-web/src/services/app-di-container/app-di-container.ts index 253870d222..187581fe57 100644 --- a/apps/deploy-web/src/services/app-di-container/app-di-container.ts +++ b/apps/deploy-web/src/services/app-di-container/app-di-container.ts @@ -28,6 +28,7 @@ import { withUserToken } from "../auth/auth/interceptors"; import { createContainer } from "../container/createContainer"; import { ErrorHandlerService } from "../error-handler/error-handler.service"; import { ProviderProxyService } from "../provider-proxy/provider-proxy.service"; +import { createSessionExpiryResponseInterceptor, SessionExpiryNotifier } from "../session-expiry-notifier/session-expiry-notifier.service"; import { StripeService } from "../stripe/stripe.service"; import { UserTracker } from "../user-tracker/user-tracker.service"; @@ -47,12 +48,15 @@ export const createAppRootContainer = (config: ServicesConfig) => { if (traceData?.baggage) config.headers.set("Baggage", traceData.baggage); return config; }; - return (axiosInstance, interceptors?) => - withInterceptors(axiosInstance, { + return (axiosInstance, interceptors?) => { + axiosInstance.interceptors.response.use(undefined, createSessionExpiryResponseInterceptor(container.sessionExpiryNotifier)); + return withInterceptors(axiosInstance, { request: [config.globalRequestMiddleware, otelInterceptor, ...(interceptors?.request || [])], response: [...(interceptors?.response || [])] }); + }; }, + sessionExpiryNotifier: () => new SessionExpiryNotifier(), stripe: () => new HttpStripeService( container.applyAxiosInterceptors(createHttpClient(apiConfig), { diff --git a/apps/deploy-web/src/services/app-di-container/server-di-container.service.ts b/apps/deploy-web/src/services/app-di-container/server-di-container.service.ts index 9e15009a71..10af0204c6 100644 --- a/apps/deploy-web/src/services/app-di-container/server-di-container.service.ts +++ b/apps/deploy-web/src/services/app-di-container/server-di-container.service.ts @@ -2,6 +2,7 @@ import * as unleashModule from "@unleash/nextjs"; import { serverEnvConfig } from "@src/config/server-env.config"; import { getSession } from "@src/lib/auth0"; +import { createGetSessionWithRefresh } from "@src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh"; import { setSession } from "@src/lib/auth0/setSession/setSession"; import { proxyRequest } from "@src/lib/nextjs/proxyRequest/proxyRequest"; import { createApiSdk } from "@src/services/api-sdk/createApiSdk"; @@ -23,7 +24,13 @@ const rootContainer = createAppRootContainer({ }); export const services = createChildContainer(rootContainer, { - getSession: () => getSession, + getSession: () => + createGetSessionWithRefresh({ + getSession, + setSession, + sessionService: services.sessionService, + logger: services.logger + }), setSession: () => setSession, proxyRequest: () => proxyRequest, featureFlagService: () => new FeatureFlagService(unleashModule, serverEnvConfig), diff --git a/apps/deploy-web/src/services/session-expiry-notifier/session-expiry-notifier.service.spec.ts b/apps/deploy-web/src/services/session-expiry-notifier/session-expiry-notifier.service.spec.ts new file mode 100644 index 0000000000..e66406a239 --- /dev/null +++ b/apps/deploy-web/src/services/session-expiry-notifier/session-expiry-notifier.service.spec.ts @@ -0,0 +1,77 @@ +import { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from "axios"; +import { describe, expect, it, vi } from "vitest"; + +import { createSessionExpiryResponseInterceptor, SessionExpiryNotifier } from "./session-expiry-notifier.service"; + +describe(SessionExpiryNotifier.name, () => { + it("notifies every subscribed listener", () => { + const notifier = new SessionExpiryNotifier(); + const first = vi.fn(); + const second = vi.fn(); + notifier.subscribe(first); + notifier.subscribe(second); + + notifier.notify(); + + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + + it("stops notifying a listener after unsubscribe", () => { + const notifier = new SessionExpiryNotifier(); + const listener = vi.fn(); + const unsubscribe = notifier.subscribe(listener); + + unsubscribe(); + notifier.notify(); + + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe(createSessionExpiryResponseInterceptor.name, () => { + it("notifies and rethrows on a 401 from the session proxy", async () => { + const { interceptor, notifier } = setup(); + const error = createAxiosError({ status: 401, baseURL: "/api/proxy" }); + + await expect(interceptor(error)).rejects.toBe(error); + expect(notifier.notify).toHaveBeenCalledTimes(1); + }); + + it("rethrows without notifying on a non-401 from the session proxy", async () => { + const { interceptor, notifier } = setup(); + const error = createAxiosError({ status: 500, baseURL: "/api/proxy" }); + + await expect(interceptor(error)).rejects.toBe(error); + expect(notifier.notify).not.toHaveBeenCalled(); + }); + + it("rethrows without notifying on a 401 from another origin", async () => { + const { interceptor, notifier } = setup(); + const error = createAxiosError({ status: 401, baseURL: "https://provider-proxy.example.com" }); + + await expect(interceptor(error)).rejects.toBe(error); + expect(notifier.notify).not.toHaveBeenCalled(); + }); + + it("rethrows without notifying on a non-axios error", async () => { + const { interceptor, notifier } = setup(); + const error = new Error("network down"); + + await expect(interceptor(error)).rejects.toBe(error); + expect(notifier.notify).not.toHaveBeenCalled(); + }); + + function setup() { + const notifier = new SessionExpiryNotifier(); + vi.spyOn(notifier, "notify"); + const interceptor = createSessionExpiryResponseInterceptor(notifier); + return { interceptor, notifier }; + } +}); + +function createAxiosError(input: { status: number; baseURL: string }) { + const config = { baseURL: input.baseURL, url: "v1/wallets" } as InternalAxiosRequestConfig; + const response = { status: input.status, config } as AxiosResponse; + return new AxiosError("Request failed", String(input.status), config, undefined, response); +} diff --git a/apps/deploy-web/src/services/session-expiry-notifier/session-expiry-notifier.service.ts b/apps/deploy-web/src/services/session-expiry-notifier/session-expiry-notifier.service.ts new file mode 100644 index 0000000000..e2008c69ba --- /dev/null +++ b/apps/deploy-web/src/services/session-expiry-notifier/session-expiry-notifier.service.ts @@ -0,0 +1,33 @@ +import { isHttpError } from "@akashnetwork/http-sdk"; + +/** + * Bridges the HTTP layer and the Auth0 client context: proxied API calls that fail with 401 signal + * that the server-side session is dead while the client may still hold a cached user. Subscribers + * (see `SessionExpirySync`) re-check the session so the client auth state converges with the server. + */ +export class SessionExpiryNotifier { + readonly #listeners = new Set<() => void>(); + + notify(): void { + this.#listeners.forEach(listener => listener()); + } + + subscribe(listener: () => void): () => void { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + } +} + +/** Matches `withUserToken`, which routes all session-authenticated API calls through this base URL. */ +const PROXIED_API_BASE_URL = "/api/proxy"; + +export function createSessionExpiryResponseInterceptor(notifier: SessionExpiryNotifier) { + return (error: unknown): Promise => { + if (isHttpError(error) && error.response?.status === 401 && error.config?.baseURL === PROXIED_API_BASE_URL) { + notifier.notify(); + } + return Promise.reject(error); + }; +} diff --git a/apps/deploy-web/src/services/session/session.service.spec.ts b/apps/deploy-web/src/services/session/session.service.spec.ts index a934d9a2db..b65dc7d597 100644 --- a/apps/deploy-web/src/services/session/session.service.spec.ts +++ b/apps/deploy-web/src/services/session/session.service.spec.ts @@ -499,6 +499,115 @@ describe(SessionService.name, () => { }); }); + describe("refreshAccessToken", () => { + it("exchanges the refresh token and returns the rotated tokens", async () => { + const { service, externalHttpClient, config } = setup(); + externalHttpClient.post.mockResolvedValueOnce({ + status: 200, + data: { + access_token: "new-access-token", + refresh_token: "rotated-refresh-token", + id_token: "new-id-token", + scope: "openid profile email offline_access", + expires_in: 3_600, + token_type: "Bearer" + } + }); + + const result = await service.refreshAccessToken("old-refresh-token"); + + expect(externalHttpClient.post).toHaveBeenCalledWith( + `${new URL(config.ISSUER_BASE_URL).origin}/oauth/token`, + { + grant_type: "refresh_token", + client_id: config.CLIENT_ID, + client_secret: config.CLIENT_SECRET, + refresh_token: "old-refresh-token" + }, + { validateStatus: expect.any(Function) } + ); + const tokens = expectOk(result); + expect(tokens.accessToken).toBe("new-access-token"); + expect(tokens.refreshToken).toBe("rotated-refresh-token"); + expect(tokens.idToken).toBe("new-id-token"); + expect(tokens.accessTokenExpiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000)); + }); + + it("keeps the input refresh token when Auth0 does not rotate", async () => { + const { service, externalHttpClient } = setup(); + externalHttpClient.post.mockResolvedValueOnce({ + status: 200, + data: { + access_token: "new-access-token", + expires_in: 3_600 + } + }); + + const result = await service.refreshAccessToken("stable-refresh-token"); + + expect(expectOk(result).refreshToken).toBe("stable-refresh-token"); + }); + + it("returns invalid_grant when the refresh token is revoked or reused", async () => { + const { service, externalHttpClient } = setup(); + externalHttpClient.post.mockResolvedValueOnce({ + status: 403, + data: { error: "invalid_grant", error_description: "Unknown or invalid refresh token." }, + config: {}, + headers: {} + }); + + const result = await service.refreshAccessToken("revoked-refresh-token"); + + const error = expectErr(result); + expect(error.code).toBe("invalid_grant"); + }); + + it("returns rate_limited with a retry delay on 429", async () => { + const { service, externalHttpClient } = setup(); + externalHttpClient.post.mockResolvedValueOnce({ + status: 429, + data: {}, + config: {}, + headers: { "x-ratelimit-reset": String(Math.floor(Date.now() / 1000) + 30) } + }); + + const result = await service.refreshAccessToken("refresh-token"); + + const error = expectErr(result) as { code: string; retryAfter: number }; + expect(error.code).toBe("rate_limited"); + expect(error.retryAfter).toBeGreaterThan(0); + }); + + it("returns unknown on other client errors", async () => { + const { service, externalHttpClient } = setup(); + externalHttpClient.post.mockResolvedValueOnce({ + status: 400, + data: { error: "invalid_request" }, + config: {}, + headers: {} + }); + + const result = await service.refreshAccessToken("refresh-token"); + + expect(expectErr(result).code).toBe("unknown"); + }); + + it("returns unknown when the response is missing token fields", async () => { + const { service, externalHttpClient } = setup(); + externalHttpClient.post.mockResolvedValueOnce({ + status: 200, + data: { token_type: "Bearer" }, + config: {}, + headers: {} + }); + + const result = await service.refreshAccessToken("refresh-token"); + + expect(expectErr(result).code).toBe("unknown"); + }); + }); + function setup(input?: { externalHttpClient?: MockProxy; consoleApiHttpClient?: MockProxy; config?: OauthConfig }) { const externalHttpClient = input?.externalHttpClient ?? createHttpClientMock(); const consoleApiHttpClient = input?.consoleApiHttpClient ?? createHttpClientMock(); diff --git a/apps/deploy-web/src/services/session/session.service.ts b/apps/deploy-web/src/services/session/session.service.ts index f023f8093a..a2c6cc3cd4 100644 --- a/apps/deploy-web/src/services/session/session.service.ts +++ b/apps/deploy-web/src/services/session/session.service.ts @@ -129,6 +129,73 @@ export class SessionService { }); } + async refreshAccessToken( + refreshToken: string + ): Promise< + Result< + RefreshedTokens, + | { code: "invalid_grant"; message: string; cause: unknown } + | { code: "rate_limited"; message: string; retryAfter: number; cause: unknown } + | { code: "unknown"; message: string; cause: unknown } + > + > { + const oauthIssuerUrl = new URL(this.#config.ISSUER_BASE_URL); + + const tokenResponse = await this.#externalHttpClient.post( + `${oauthIssuerUrl.origin}/oauth/token`, + { + grant_type: "refresh_token", + client_id: this.#config.CLIENT_ID, + client_secret: this.#config.CLIENT_SECRET, + refresh_token: refreshToken + }, + { + validateStatus: notServerError + } + ); + + if (tokenResponse.status === 429) { + return Err({ + code: "rate_limited", + message: "Too many attempts. Please try again later.", + retryAfter: retryAfterFromHeaders(tokenResponse.headers), + cause: extractResponseDetails(tokenResponse) + }); + } + + if (tokenResponse.status >= 400) { + if (tokenResponse.data?.error === "invalid_grant") { + return Err({ + code: "invalid_grant", + message: tokenResponse.data.error_description || "Refresh token is no longer valid.", + cause: extractResponseDetails(tokenResponse) + }); + } + return Err({ + code: "unknown", + message: tokenResponse.data?.error_description || "Token refresh failed.", + cause: extractResponseDetails(tokenResponse) + }); + } + + const { access_token, id_token, scope, expires_in, refresh_token } = tokenResponse.data; + if (!access_token || !Number.isFinite(Number(expires_in))) { + return Err({ + code: "unknown", + message: "Token refresh returned an incomplete response.", + cause: extractResponseDetails(tokenResponse) + }); + } + + return Ok({ + accessToken: access_token, + accessTokenScope: scope, + accessTokenExpiresAt: Math.floor(Date.now() / 1000) + Number(expires_in), + refreshToken: refresh_token ?? refreshToken, + idToken: id_token + }); + } + /** * This method calls idempotent API call to create a local user in the database. */ @@ -316,6 +383,15 @@ export class SessionService { } } +export interface RefreshedTokens { + accessToken: string; + accessTokenScope?: string; + accessTokenExpiresAt: number; + /** The rotated refresh token, or the input one when Auth0 rotation is disabled and none is returned. */ + refreshToken: string; + idToken?: string; +} + export interface OauthConfig { ISSUER_BASE_URL: string; CLIENT_ID: string; From b6ccd2eb7da49c2f5e486409f6677e4faf0caedd Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:18:02 +0530 Subject: [PATCH 2/8] fix(auth): harden token refresh and session re-check paths Return null (not the expired session) when refresh fails so callers treat the request as unauthenticated instead of proxying a dead access token. Merge only defined refreshed-token fields so Auth0 omitting id_token/scope on a refresh exchange no longer wipes the session's still-valid values. Catch a rejected checkSession in SessionExpirySync so it logs instead of leaking an unhandled rejection. Back the expiry notifier with EventTarget. --- .../SessionExpirySync.spec.tsx | 22 +++++++++++++++---- .../SessionExpirySync/SessionExpirySync.tsx | 6 +++-- .../getSessionWithRefresh.spec.ts | 19 +++++++++++++--- .../getSessionWithRefresh.ts | 15 ++++++++++--- .../session-expiry-notifier.service.ts | 9 ++++---- 5 files changed, 55 insertions(+), 16 deletions(-) diff --git a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx index bce520d061..7a33daf460 100644 --- a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx +++ b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx @@ -1,3 +1,4 @@ +import type { LoggerService } from "@akashnetwork/logging"; import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; @@ -47,9 +48,22 @@ describe(SessionExpirySync.name, () => { expect(checkSession).not.toHaveBeenCalled(); }); - function setup(input: { checkSessionDuration?: "settled" | "pending" } = {}) { + it("logs a failed re-check instead of leaking an unhandled rejection", async () => { + const { notifier, logger } = setup({ checkSessionDuration: "rejected" }); + + await act(async () => notifier.notify()); + + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "SESSION_RECHECK_FAILED" })); + }); + + function setup(input: { checkSessionDuration?: "settled" | "pending" | "rejected" } = {}) { const notifier = new SessionExpiryNotifier(); - const checkSession = vi.fn(() => (input.checkSessionDuration === "pending" ? new Promise(() => undefined) : Promise.resolve())); + const logger = mock(); + const checkSession = vi.fn(() => { + if (input.checkSessionDuration === "pending") return new Promise(() => undefined); + if (input.checkSessionDuration === "rejected") return Promise.reject(new Error("network down")); + return Promise.resolve(); + }); const useUser: typeof DEPENDENCIES.useUser = () => mock>({ checkSession, @@ -58,11 +72,11 @@ describe(SessionExpirySync.name, () => { }); const { unmount } = render( - notifier }}> + notifier, logger: () => logger }}> ); - return { notifier, checkSession, unmount }; + return { notifier, checkSession, logger, unmount }; } }); diff --git a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx index e8248d9453..e3c0a32eaf 100644 --- a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx +++ b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx @@ -19,7 +19,7 @@ interface Props { */ export function SessionExpirySync({ dependencies: d = DEPENDENCIES }: Props = {}) { const { checkSession } = d.useUser(); - const { sessionExpiryNotifier } = useServices(); + const { sessionExpiryNotifier, logger } = useServices(); const isReCheckingRef = useRef(false); useEffect( @@ -29,12 +29,14 @@ export function SessionExpirySync({ dependencies: d = DEPENDENCIES }: Props = {} isReCheckingRef.current = true; try { await checkSession(); + } catch (error) { + logger.error({ event: "SESSION_RECHECK_FAILED", error }); } finally { isReCheckingRef.current = false; } }); }, - [sessionExpiryNotifier, checkSession] + [sessionExpiryNotifier, checkSession, logger] ); return null; diff --git a/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.spec.ts b/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.spec.ts index fd2113788b..e03129305b 100644 --- a/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.spec.ts +++ b/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.spec.ts @@ -67,19 +67,32 @@ describe(createGetSessionWithRefresh.name, () => { expect(sessionService.refreshAccessToken).toHaveBeenCalledWith("refresh-token"); }); - it("clears the session cookies and returns the expired session when the refresh fails", async () => { + it("clears the session cookies and returns null when the refresh fails", async () => { const session = createSession({ accessTokenExpiresAt: NOW_SECONDS - 60 }); const { getSessionWithRefresh, sessionService, setSession, req, res } = setup({ session }); sessionService.refreshAccessToken.mockResolvedValue(Err({ code: "invalid_grant", message: "revoked", cause: {} })); const result = await getSessionWithRefresh(req, res); - expect(result).toBe(session); - expect(result?.accessToken).toBe("expired-access-token"); + expect(result).toBeNull(); expect(setSession).not.toHaveBeenCalled(); expect(res.setHeader).toHaveBeenCalledWith("Set-Cookie", expect.arrayContaining([expect.stringContaining("appSession=;")])); }); + it("keeps the existing idToken and scope when the refresh response omits them", async () => { + const session = createSession({ accessTokenExpiresAt: NOW_SECONDS - 60 }); + session.idToken = "existing-id-token"; + session.accessTokenScope = "openid profile email offline_access"; + const { getSessionWithRefresh, sessionService, req, res } = setup({ session }); + sessionService.refreshAccessToken.mockResolvedValue(Ok({ ...createRefreshedTokens(), idToken: undefined, accessTokenScope: undefined })); + + const result = await getSessionWithRefresh(req, res); + + expect(result?.accessToken).toBe("new-access-token"); + expect(result?.idToken).toBe("existing-id-token"); + expect(result?.accessTokenScope).toBe("openid profile email offline_access"); + }); + it("still returns the refreshed session when persisting the cookie fails", async () => { const session = createSession({ accessTokenExpiresAt: NOW_SECONDS - 60 }); const { getSessionWithRefresh, sessionService, setSession, logger, req, res } = setup({ session }); diff --git a/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.ts b/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.ts index 28603595a4..f581f0bf58 100644 --- a/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.ts +++ b/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.ts @@ -5,7 +5,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import type { Session } from "@src/lib/auth0"; import { clearSessionCookies } from "@src/lib/auth0/clearSessionCookies/clearSessionCookies"; import type { setSession } from "@src/lib/auth0/setSession/setSession"; -import type { SessionService } from "@src/services/session/session.service"; +import type { RefreshedTokens, SessionService } from "@src/services/session/session.service"; export type SessionRequest = (IncomingMessage & { cookies: NextApiRequest["cookies"] }) | NextApiRequest; export type SessionResponse = ServerResponse | NextApiResponse; @@ -52,10 +52,10 @@ export function createGetSessionWithRefresh(deps: GetSessionWithRefreshDependenc if (!result.ok) { deps.logger.warn({ event: "ACCESS_TOKEN_REFRESH_FAILED", code: result.val.code, error: result.val }); clearSessionCookies(req as NextApiRequest, res as NextApiResponse); - return session; + return null; } - Object.assign(session, result.val); + mergeRefreshedTokens(session, result.val); try { await deps.setSession(req as NextApiRequest, res as NextApiResponse, session); } catch (error) { @@ -71,3 +71,12 @@ export function createGetSessionWithRefresh(deps: GetSessionWithRefreshDependenc function isAccessTokenExpired(session: Session): boolean { return (session.accessTokenExpiresAt || 0) * 1_000 <= Date.now(); } + +/** + * Auth0 omits `id_token` (and sometimes `scope`) from a refresh-token exchange when the grant lacks + * the `openid` scope, so `RefreshedTokens` can carry `undefined` there. Merging those over the + * session would wipe still-valid values, so only defined fields are copied. + */ +function mergeRefreshedTokens(session: Session, tokens: RefreshedTokens): void { + Object.assign(session, Object.fromEntries(Object.entries(tokens).filter(([, value]) => value !== undefined))); +} diff --git a/apps/deploy-web/src/services/session-expiry-notifier/session-expiry-notifier.service.ts b/apps/deploy-web/src/services/session-expiry-notifier/session-expiry-notifier.service.ts index e2008c69ba..e7f7b70c4e 100644 --- a/apps/deploy-web/src/services/session-expiry-notifier/session-expiry-notifier.service.ts +++ b/apps/deploy-web/src/services/session-expiry-notifier/session-expiry-notifier.service.ts @@ -6,16 +6,17 @@ import { isHttpError } from "@akashnetwork/http-sdk"; * (see `SessionExpirySync`) re-check the session so the client auth state converges with the server. */ export class SessionExpiryNotifier { - readonly #listeners = new Set<() => void>(); + static readonly #EVENT_TYPE = "session-expiry"; + readonly #target = new EventTarget(); notify(): void { - this.#listeners.forEach(listener => listener()); + this.#target.dispatchEvent(new Event(SessionExpiryNotifier.#EVENT_TYPE)); } subscribe(listener: () => void): () => void { - this.#listeners.add(listener); + this.#target.addEventListener(SessionExpiryNotifier.#EVENT_TYPE, listener); return () => { - this.#listeners.delete(listener); + this.#target.removeEventListener(SessionExpiryNotifier.#EVENT_TYPE, listener); }; } } From 5a478a159ddef17c1c18c925149fedb4ce2db7bd Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:39:10 +0530 Subject: [PATCH 3/8] fix(auth): only clear cookies on invalid_grant and guard refresh throws A transient refresh failure (Auth0 429 or an unknown 4xx) was clearing the session cookies just like a real invalid_grant, force-logging-out a user whose refresh token is still valid; now only invalid_grant clears them so the next request can retry. refreshAccessToken rejects rather than returning Err on a 5xx or network error (validateStatus is <500), and the un-caught await turned a transient Auth0 blip into a 500 SSR page since requireAuth runs before the handler try/catch; wrap the refresh in try/catch and degrade to null. --- .../getSessionWithRefresh.spec.ts | 23 +++++++++++++++++++ .../getSessionWithRefresh.ts | 16 ++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.spec.ts b/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.spec.ts index e03129305b..c587ee2c3c 100644 --- a/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.spec.ts +++ b/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.spec.ts @@ -79,6 +79,29 @@ describe(createGetSessionWithRefresh.name, () => { expect(res.setHeader).toHaveBeenCalledWith("Set-Cookie", expect.arrayContaining([expect.stringContaining("appSession=;")])); }); + it("leaves the session cookie intact and returns null on a transient rate-limit failure", async () => { + const session = createSession({ accessTokenExpiresAt: NOW_SECONDS - 60 }); + const { getSessionWithRefresh, sessionService, req, res } = setup({ session }); + sessionService.refreshAccessToken.mockResolvedValue(Err({ code: "rate_limited", message: "slow down", retryAfter: 30, cause: {} })); + + const result = await getSessionWithRefresh(req, res); + + expect(result).toBeNull(); + expect(res.setHeader).not.toHaveBeenCalledWith("Set-Cookie", expect.anything()); + }); + + it("leaves the session cookie intact and returns null when the refresh call throws", async () => { + const session = createSession({ accessTokenExpiresAt: NOW_SECONDS - 60 }); + const { getSessionWithRefresh, sessionService, logger, req, res } = setup({ session }); + sessionService.refreshAccessToken.mockRejectedValue(new Error("network down")); + + const result = await getSessionWithRefresh(req, res); + + expect(result).toBeNull(); + expect(res.setHeader).not.toHaveBeenCalledWith("Set-Cookie", expect.anything()); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ event: "ACCESS_TOKEN_REFRESH_ERROR" })); + }); + it("keeps the existing idToken and scope when the refresh response omits them", async () => { const session = createSession({ accessTokenExpiresAt: NOW_SECONDS - 60 }); session.idToken = "existing-id-token"; diff --git a/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.ts b/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.ts index f581f0bf58..a3c0ad91c5 100644 --- a/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.ts +++ b/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.ts @@ -25,7 +25,9 @@ export interface GetSessionWithRefreshDependencies { * only happens once the token is actually expired, and concurrent requests carrying the same * refresh token share a single in-flight `/oauth/token` call — with Auth0 refresh-token rotation * enabled, configure the tenant's rotation *reuse interval* (30–60s) so cross-instance races don't - * revoke the token family; a lost race degrades to today's unauthenticated behavior, never an error. + * revoke the token family. Only a genuine `invalid_grant` clears the session cookies; a transient + * failure (rate limit, Auth0 5xx, network error) leaves them intact so a later request can retry, + * and it degrades to unauthenticated behavior rather than surfacing as an SSR error. */ export function createGetSessionWithRefresh(deps: GetSessionWithRefreshDependencies): GetSession { const inFlightRefreshes = new Map>(); @@ -47,11 +49,19 @@ export function createGetSessionWithRefresh(deps: GetSessionWithRefreshDependenc return session; } - const result = await refreshOncePerToken(session.refreshToken); + let result: Awaited>; + try { + result = await refreshOncePerToken(session.refreshToken); + } catch (error) { + deps.logger.warn({ event: "ACCESS_TOKEN_REFRESH_ERROR", error }); + return null; + } if (!result.ok) { deps.logger.warn({ event: "ACCESS_TOKEN_REFRESH_FAILED", code: result.val.code, error: result.val }); - clearSessionCookies(req as NextApiRequest, res as NextApiResponse); + if (result.val.code === "invalid_grant") { + clearSessionCookies(req as NextApiRequest, res as NextApiResponse); + } return null; } From 421afcbb8edc6b13cee87ffd43ee06a53faf20bd Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:59:24 +0530 Subject: [PATCH 4/8] fix(auth): gate login re-check on a cached user and a confirmed session revalidateSessionOnMount fired checkSession for every /login visitor, adding a redundant /api/auth/me for anonymous visitors the app already fetched on boot; skip it when no user was cached at mount. Auth0's checkSession keeps the cached user and only sets error when the profile re-fetch fails (network/5xx) rather than a clean 401, so a transient failure left a stale user in place and leaveWhenAuthenticated navigated back onto it, reproducing the redirect loop; gate navigation and the boot loader on the absence of that error so a transient failure falls back to the login form. Expose error from the useUser wrapper. --- .../PasswordlessAuth.spec.tsx | 33 +++++++++++++++++-- .../PasswordlessAuth/PasswordlessAuth.tsx | 21 +++++++++--- apps/deploy-web/src/hooks/useUser.ts | 4 ++- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.spec.tsx b/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.spec.tsx index d672f8965b..9f4d6903cb 100644 --- a/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.spec.tsx +++ b/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.spec.tsx @@ -104,6 +104,25 @@ describe(PasswordlessAuth.name, () => { expect(navigateBack).not.toHaveBeenCalled(); }); + it("does not re-check the session when there is no cached user to revalidate", () => { + const { checkSession } = setup(); + + expect(checkSession).not.toHaveBeenCalled(); + }); + + it("shows the login form without navigating back when re-validation fails transiently", async () => { + const EmailCodeStartMock = vi.fn(ComponentMock); + const { navigateBack } = setup({ + authenticated: true, + sessionRevalidation: "errored", + dependencies: { EmailCodeStart: EmailCodeStartMock as never } + }); + + await vi.waitFor(() => expect(EmailCodeStartMock).toHaveBeenCalled()); + + expect(navigateBack).not.toHaveBeenCalled(); + }); + it("renders the boot loader instead of the auth forms when authenticated", () => { const EmailCodeStartMock = vi.fn(ComponentMock); const EmailCodeVerifyMock = vi.fn(ComponentMock); @@ -166,8 +185,11 @@ describe(PasswordlessAuth.name, () => { initialEmail?: string; step?: string; authenticated?: boolean; - /** "valid" (default) keeps the user after re-validation, "expired" clears it, "pending" never settles. */ - sessionRevalidation?: "valid" | "expired" | "pending"; + /** + * "valid" (default) keeps the user after re-validation, "expired" clears it, "pending" never + * settles, "errored" keeps the stale user but surfaces an error (transient re-fetch failure). + */ + sessionRevalidation?: "valid" | "expired" | "pending" | "errored"; dependencies?: Partial; } = {} ) { @@ -181,18 +203,23 @@ describe(PasswordlessAuth.name, () => { if (input.step) params.set("step", input.step); const initialUser = input.authenticated ? mock["user"]>>({ userId: "user-1" }) : undefined; let clearUser: () => void = () => undefined; + let failRevalidation: () => void = () => undefined; const checkSession = vi.fn(async () => { if (input.sessionRevalidation === "pending") return new Promise(() => undefined); if (input.sessionRevalidation === "expired") clearUser(); + if (input.sessionRevalidation === "errored") failRevalidation(); return undefined; }); const useUser: typeof DEPENDENCIES.useUser = () => { const [user, setUser] = useState(initialUser); + const [error, setError] = useState(undefined); clearUser = () => setUser(undefined); + failRevalidation = () => setError(new Error("network down")); return mock>({ checkSession, isLoading: false, - user + user, + error }); }; const useReturnTo: typeof DEPENDENCIES.useReturnTo = () => diff --git a/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.tsx b/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.tsx index d530e27e1b..bee3c4d175 100644 --- a/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.tsx +++ b/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.tsx @@ -38,13 +38,14 @@ interface Props extends PassedFlowProps { export function PasswordlessAuth({ dependencies: d = DEPENDENCIES, ...props }: Props) { const { publicConfig, analyticsService } = useServices(); const { navigateBack } = d.useReturnTo({ defaultReturnTo: "/" }); - const { checkSession, user } = d.useUser(); + const { checkSession, user, error } = d.useUser(); const router = d.useRouter(); const searchParams = d.useSearchParams(); const [email, setEmail] = useState(props.initialEmail); const [screenKey, setScreenKey] = useState(0); const [isSessionRevalidated, setIsSessionRevalidated] = useState(false); const turnstileRef = useRef(null); + const hadCachedUserOnMountRef = useRef(!!user); const screen: "entry" | "verify" = searchParams.get("step") === "verify" ? "verify" : "entry"; @@ -97,20 +98,30 @@ export function PasswordlessAuth({ dependencies: d = DEPENDENCIES, ...props }: P * The Auth0 client context can hold a stale user whose server session has already expired (the * session cookie outlives the access token). Trusting it here would `navigateBack()` to a gated * page whose SSR guard bounces straight back to /login — an infinite loop on a boot spinner - * (DEPLOY-WEB-2C4). Re-fetching the profile clears a dead user before any navigation decision. + * (DEPLOY-WEB-2C4). When a user is cached, re-fetch the profile to clear a dead one before any + * navigation; a logged-out visitor has nothing to revalidate, so skip the redundant round trip. */ useEffect( function revalidateSessionOnMount() { + if (!hadCachedUserOnMountRef.current) { + setIsSessionRevalidated(true); + return; + } checkSession().finally(() => setIsSessionRevalidated(true)); }, [checkSession] ); + /** + * Auth0's `checkSession` keeps the cached user and only populates `error` when the profile + * re-fetch itself fails (network error or 5xx, as opposed to a clean 401 that clears the user). + * Gating on `!error` avoids navigating away on a stale user a transient failure couldn't confirm. + */ useEffect( function leaveWhenAuthenticated() { - if (isSessionRevalidated && user) navigateBack(); + if (isSessionRevalidated && user && !error) navigateBack(); }, - [isSessionRevalidated, user, navigateBack] + [isSessionRevalidated, user, error, navigateBack] ); const handleVerified = useCallback(async () => { @@ -122,7 +133,7 @@ export function PasswordlessAuth({ dependencies: d = DEPENDENCIES, ...props }: P setScreenKey(value => value + 1); }, []); - if (user) return ; + if (user && !error) return ; return ( <> diff --git a/apps/deploy-web/src/hooks/useUser.ts b/apps/deploy-web/src/hooks/useUser.ts index b17b309145..a5743a1b2b 100644 --- a/apps/deploy-web/src/hooks/useUser.ts +++ b/apps/deploy-web/src/hooks/useUser.ts @@ -6,15 +6,17 @@ import type { CustomUserProfile } from "@src/types/user"; export const useUser = (): { user: CustomUserProfile | undefined; isLoading: boolean; + error: Error | undefined; checkSession: () => Promise; } => { - const { user: registeredUser, isLoading: isLoadingRegisteredUser, checkSession } = useCustomUser(); + const { user: registeredUser, isLoading: isLoadingRegisteredUser, error, checkSession } = useCustomUser(); const user = useMemo(() => registeredUser, [registeredUser]); const isLoading = useMemo(() => isLoadingRegisteredUser, [isLoadingRegisteredUser]); return { user, isLoading, + error, checkSession }; }; From cd0767b46c105da39bf1984e610379feba94c522 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:14:42 +0530 Subject: [PATCH 5/8] refactor(auth): dedupe access-token-expiry predicate and proxy base URL Extract isAccessTokenExpired into one helper used by the page guards, the auth0 profile handler, and the refreshing session wrapper, so a rule change like clock-skew leeway lands in one place instead of four. Export PROXY_API_BASE_URL from the withUserToken interceptor and reuse it in the API SDK factory and the session-expiry interceptor's 401 check, so a route rename can't silently drift the copies apart. --- .../getSessionWithRefresh.ts | 6 +---- .../isAccessTokenExpired.spec.ts | 23 +++++++++++++++++++ .../isAccessTokenExpired.ts | 8 +++++++ .../src/lib/nextjs/pageGuards/pageGuards.ts | 9 +++----- .../src/pages/api/auth/[...auth0].ts | 4 ++-- .../app-di-container/browser-di-container.ts | 4 ++-- .../src/services/auth/auth/interceptors.ts | 5 +++- .../session-expiry-notifier.service.ts | 7 +++--- 8 files changed, 46 insertions(+), 20 deletions(-) create mode 100644 apps/deploy-web/src/lib/auth0/isAccessTokenExpired/isAccessTokenExpired.spec.ts create mode 100644 apps/deploy-web/src/lib/auth0/isAccessTokenExpired/isAccessTokenExpired.ts diff --git a/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.ts b/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.ts index a3c0ad91c5..d6fa7db343 100644 --- a/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.ts +++ b/apps/deploy-web/src/lib/auth0/getSessionWithRefresh/getSessionWithRefresh.ts @@ -4,6 +4,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import type { Session } from "@src/lib/auth0"; import { clearSessionCookies } from "@src/lib/auth0/clearSessionCookies/clearSessionCookies"; +import { isAccessTokenExpired } from "@src/lib/auth0/isAccessTokenExpired/isAccessTokenExpired"; import type { setSession } from "@src/lib/auth0/setSession/setSession"; import type { RefreshedTokens, SessionService } from "@src/services/session/session.service"; @@ -77,11 +78,6 @@ export function createGetSessionWithRefresh(deps: GetSessionWithRefreshDependenc }; } -/** Mirrors the expiry predicate of `pageGuards.isAuthenticated` and the auth0 profile handler. */ -function isAccessTokenExpired(session: Session): boolean { - return (session.accessTokenExpiresAt || 0) * 1_000 <= Date.now(); -} - /** * Auth0 omits `id_token` (and sometimes `scope`) from a refresh-token exchange when the grant lacks * the `openid` scope, so `RefreshedTokens` can carry `undefined` there. Merging those over the diff --git a/apps/deploy-web/src/lib/auth0/isAccessTokenExpired/isAccessTokenExpired.spec.ts b/apps/deploy-web/src/lib/auth0/isAccessTokenExpired/isAccessTokenExpired.spec.ts new file mode 100644 index 0000000000..5947747831 --- /dev/null +++ b/apps/deploy-web/src/lib/auth0/isAccessTokenExpired/isAccessTokenExpired.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { isAccessTokenExpired } from "./isAccessTokenExpired"; + +const NOW_SECONDS = Math.floor(Date.now() / 1_000); + +describe(isAccessTokenExpired.name, () => { + it("returns false when the access token expires in the future", () => { + expect(isAccessTokenExpired({ accessTokenExpiresAt: NOW_SECONDS + 3_600 })).toBe(false); + }); + + it("returns true when the access token has already expired", () => { + expect(isAccessTokenExpired({ accessTokenExpiresAt: NOW_SECONDS - 60 })).toBe(true); + }); + + it("treats a missing expiry as expired", () => { + expect(isAccessTokenExpired({})).toBe(true); + }); + + it("treats a null session as expired", () => { + expect(isAccessTokenExpired(null)).toBe(true); + }); +}); diff --git a/apps/deploy-web/src/lib/auth0/isAccessTokenExpired/isAccessTokenExpired.ts b/apps/deploy-web/src/lib/auth0/isAccessTokenExpired/isAccessTokenExpired.ts new file mode 100644 index 0000000000..546794511c --- /dev/null +++ b/apps/deploy-web/src/lib/auth0/isAccessTokenExpired/isAccessTokenExpired.ts @@ -0,0 +1,8 @@ +/** + * Single source of truth for "is this session's access token past its expiry". Shared by the SSR + * page guards, the auth0 profile handler, and the refreshing session wrapper so a future rule change + * (e.g. clock-skew leeway) lands in one place. A missing `accessTokenExpiresAt` counts as expired. + */ +export function isAccessTokenExpired(session: { accessTokenExpiresAt?: number } | null | undefined): boolean { + return (session?.accessTokenExpiresAt || 0) * 1_000 <= Date.now(); +} diff --git a/apps/deploy-web/src/lib/nextjs/pageGuards/pageGuards.ts b/apps/deploy-web/src/lib/nextjs/pageGuards/pageGuards.ts index b9e1be5c80..77b9fb03e6 100644 --- a/apps/deploy-web/src/lib/nextjs/pageGuards/pageGuards.ts +++ b/apps/deploy-web/src/lib/nextjs/pageGuards/pageGuards.ts @@ -1,5 +1,6 @@ import type { Redirect } from "next"; +import { isAccessTokenExpired } from "@src/lib/auth0/isAccessTokenExpired/isAccessTokenExpired"; import type { AppTypedContext } from "../defineServerSideProps/defineServerSideProps"; export async function isFeatureEnabled(featureName: string, context: AppTypedContext): Promise { @@ -11,10 +12,7 @@ export async function isAuthenticated(context: AppTypedContext): Promise { @@ -29,9 +27,8 @@ export async function requireAuth(context: AppTypedContext): Promise<{ redirect: export async function redirectIfAccessTokenExpired(context: AppTypedContext): Promise<{ redirect: Redirect } | true> { const session = await context.getCurrentSession(); - const accessTokenExpiry = new Date((session?.accessTokenExpiresAt || 0) * 1_000); - if (accessTokenExpiry <= new Date()) { + if (isAccessTokenExpired(session)) { context.services.logger.warn({ event: "AUTH0_ACCESS_TOKEN_EXPIRED", url: context.req.url, diff --git a/apps/deploy-web/src/pages/api/auth/[...auth0].ts b/apps/deploy-web/src/pages/api/auth/[...auth0].ts index cb980e37b7..e5fc5c0acd 100644 --- a/apps/deploy-web/src/pages/api/auth/[...auth0].ts +++ b/apps/deploy-web/src/pages/api/auth/[...auth0].ts @@ -9,6 +9,7 @@ import type { Session } from "@src/lib/auth0"; import { CallbackHandlerError, IdentityProviderError, MissingStateCookieError } from "@src/lib/auth0"; import { handleAuth, handleCallback, handleLogin, handleLogout } from "@src/lib/auth0"; import { clearSessionCookies } from "@src/lib/auth0/clearSessionCookies/clearSessionCookies"; +import { isAccessTokenExpired } from "@src/lib/auth0/isAccessTokenExpired/isAccessTokenExpired"; import { isInvalidSessionError } from "@src/lib/auth0/isInvalidSessionError/isInvalidSessionError"; import { defineApiHandler } from "@src/lib/nextjs/defineApiHandler/defineApiHandler"; import type { AppServices } from "@src/services/app-di-container/server-di-container.service"; @@ -89,8 +90,7 @@ const authHandler = once((services: AppServices) => return; } - const accessTokenExpiry = new Date((session.accessTokenExpiresAt || 0) * 1_000); - if (accessTokenExpiry <= new Date()) { + if (isAccessTokenExpired(session)) { services.logger.info({ event: "AUTH_PROFILE_REQUEST_ACCESS_TOKEN_EXPIRED", url: req.url }); res.status(401).json({ error: "Not authenticated" }); return; diff --git a/apps/deploy-web/src/services/app-di-container/browser-di-container.ts b/apps/deploy-web/src/services/app-di-container/browser-di-container.ts index 512564aab7..378e16eecd 100644 --- a/apps/deploy-web/src/services/app-di-container/browser-di-container.ts +++ b/apps/deploy-web/src/services/app-di-container/browser-di-container.ts @@ -5,7 +5,7 @@ import { createApiSdk } from "@src/services/api-sdk/createApiSdk"; import { ApiUrlService } from "@src/services/api-url/api-url.service"; import * as walletUtils from "@src/utils/walletUtils"; import { AuthService } from "../auth/auth/auth.service"; -import { withUserToken } from "../auth/auth/interceptors"; +import { PROXY_API_BASE_URL, withUserToken } from "../auth/auth/interceptors"; import { createChildContainer } from "../container/createContainer"; import { DeploymentStorageService } from "../deployment-storage/deployment-storage.service"; import { BitbucketService } from "../remote-deploy/bitbucket-http.service"; @@ -23,7 +23,7 @@ const rootContainer = createAppRootContainer({ }); export const services = createChildContainer(rootContainer, { - api: () => createProxy(createApiSdk({ baseUrl: "/api/proxy" })), + api: () => createProxy(createApiSdk({ baseUrl: PROXY_API_BASE_URL })), githubService: () => new GitHubService(services.internalApiHttpClient, services.createAxios, { githubAppInstallationUrl: services.publicConfig.NEXT_PUBLIC_GITHUB_APP_INSTALLATION_URL, diff --git a/apps/deploy-web/src/services/auth/auth/interceptors.ts b/apps/deploy-web/src/services/auth/auth/interceptors.ts index 798d3da7ef..1d92790001 100644 --- a/apps/deploy-web/src/services/auth/auth/interceptors.ts +++ b/apps/deploy-web/src/services/auth/auth/interceptors.ts @@ -1,6 +1,9 @@ import type { InternalAxiosRequestConfig } from "axios"; +/** Local Next.js route that proxies session-authenticated calls to the console API with the bearer token attached. */ +export const PROXY_API_BASE_URL = "/api/proxy"; + export function withUserToken(config: InternalAxiosRequestConfig) { - config.baseURL = "/api/proxy"; + config.baseURL = PROXY_API_BASE_URL; return config; } diff --git a/apps/deploy-web/src/services/session-expiry-notifier/session-expiry-notifier.service.ts b/apps/deploy-web/src/services/session-expiry-notifier/session-expiry-notifier.service.ts index e7f7b70c4e..32c88aa361 100644 --- a/apps/deploy-web/src/services/session-expiry-notifier/session-expiry-notifier.service.ts +++ b/apps/deploy-web/src/services/session-expiry-notifier/session-expiry-notifier.service.ts @@ -1,5 +1,7 @@ import { isHttpError } from "@akashnetwork/http-sdk"; +import { PROXY_API_BASE_URL } from "@src/services/auth/auth/interceptors"; + /** * Bridges the HTTP layer and the Auth0 client context: proxied API calls that fail with 401 signal * that the server-side session is dead while the client may still hold a cached user. Subscribers @@ -21,12 +23,9 @@ export class SessionExpiryNotifier { } } -/** Matches `withUserToken`, which routes all session-authenticated API calls through this base URL. */ -const PROXIED_API_BASE_URL = "/api/proxy"; - export function createSessionExpiryResponseInterceptor(notifier: SessionExpiryNotifier) { return (error: unknown): Promise => { - if (isHttpError(error) && error.response?.status === 401 && error.config?.baseURL === PROXIED_API_BASE_URL) { + if (isHttpError(error) && error.response?.status === 401 && error.config?.baseURL === PROXY_API_BASE_URL) { notifier.notify(); } return Promise.reject(error); From a14bf3afc3589dab3149d71c4c2470f16fff9cc2 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:49:43 +0530 Subject: [PATCH 6/8] fix(auth): log real re-check failures and redirect on a stale-user error Auth0's checkSession resolves and stores a failed profile fetch in the hook's error state rather than rejecting, so SessionExpirySync's catch never fired in production; observe error and log SESSION_RECHECK_FAILED from there while keeping the catch as a defensive guard. In PasswordlessAuth, gate the redirect-to-entry guard on !error to match leaveWhenAuthenticated and the boot loader, so a stale user whose re-check errors on a missing-email verify step still gets redirected instead of seeing a blank screen. --- .../PasswordlessAuth.spec.tsx | 6 ++++++ .../PasswordlessAuth/PasswordlessAuth.tsx | 14 +++++++------ .../SessionExpirySync.spec.tsx | 21 ++++++++++++++++--- .../SessionExpirySync/SessionExpirySync.tsx | 18 +++++++++++++--- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.spec.tsx b/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.spec.tsx index 9f4d6903cb..1f72cf0db3 100644 --- a/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.spec.tsx +++ b/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.spec.tsx @@ -149,6 +149,12 @@ describe(PasswordlessAuth.name, () => { expect(replace).not.toHaveBeenCalled(); }); + it("redirects to entry on a missing-email verify step when a stale user's re-check errors", async () => { + const { replace } = setup({ authenticated: true, sessionRevalidation: "errored", step: "verify", initialEmail: "" }); + + await vi.waitFor(() => expect(replace).toHaveBeenCalledWith(expect.not.stringContaining("step"), undefined, { shallow: true })); + }); + it("provides a captcha-token getter that resolves to the Turnstile token", async () => { const EmailCodeStartMock = vi.fn(ComponentMock); setup({ dependencies: { EmailCodeStart: EmailCodeStartMock as never } }); diff --git a/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.tsx b/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.tsx index bee3c4d175..f1f4897a50 100644 --- a/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.tsx +++ b/apps/deploy-web/src/components/auth/PasswordlessAuth/PasswordlessAuth.tsx @@ -79,19 +79,21 @@ export function PasswordlessAuth({ dependencies: d = DEPENDENCIES, ...props }: P /** * Sends a visitor who reached `?step=verify` without an in-flight email (a deep link, or a reload - * after the flow was cleared) back to the entry screen. Skipped once authenticated: a successful - * verification clears the persisted email and remounts this component (an ancestor provider swaps - * on the anon→authed transition) with an empty `email`; firing here would `router.replace` back to - * entry and clobber the post-verify `navigateBack()`. + * after the flow was cleared) back to the entry screen. Skipped only for a confirmed user (`!error`) + * about to leave via `leaveWhenAuthenticated`: a successful verification clears the persisted email + * and remounts this component (an ancestor provider swaps on the anon→authed transition) with an + * empty `email`; firing here would `router.replace` back to entry and clobber the post-verify + * `navigateBack()`. When a re-check errors and retains a stale user, none of the leave guards fire, + * so this must still redirect rather than leave a blank screen. */ useEffect( function redirectToEntryWhenEmailMissing() { - if (user) return; + if (user && !error) return; if (screen === "verify" && !email) { goBackToEntry(); } }, - [screen, email, goBackToEntry, user] + [screen, email, goBackToEntry, user, error] ); /** diff --git a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx index 7a33daf460..b12a8b93e9 100644 --- a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx +++ b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx @@ -48,7 +48,21 @@ describe(SessionExpirySync.name, () => { expect(checkSession).not.toHaveBeenCalled(); }); - it("logs a failed re-check instead of leaking an unhandled rejection", async () => { + it("logs when the re-check surfaces a session error", () => { + const { logger } = setup({ error: new Error("network down") }); + + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "SESSION_RECHECK_FAILED" })); + }); + + it("does not log when the re-check reports no error", async () => { + const { notifier, logger } = setup(); + + await act(async () => notifier.notify()); + + expect(logger.error).not.toHaveBeenCalled(); + }); + + it("swallows and logs an unexpected rejection from the re-check", async () => { const { notifier, logger } = setup({ checkSessionDuration: "rejected" }); await act(async () => notifier.notify()); @@ -56,7 +70,7 @@ describe(SessionExpirySync.name, () => { expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "SESSION_RECHECK_FAILED" })); }); - function setup(input: { checkSessionDuration?: "settled" | "pending" | "rejected" } = {}) { + function setup(input: { checkSessionDuration?: "settled" | "pending" | "rejected"; error?: Error } = {}) { const notifier = new SessionExpiryNotifier(); const logger = mock(); const checkSession = vi.fn(() => { @@ -68,7 +82,8 @@ describe(SessionExpirySync.name, () => { mock>({ checkSession, isLoading: false, - user: undefined + user: undefined, + error: input.error }); const { unmount } = render( diff --git a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx index e3c0a32eaf..ad1789c166 100644 --- a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx +++ b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx @@ -18,7 +18,7 @@ interface Props { * burst of parallel 401s into a single re-check. */ export function SessionExpirySync({ dependencies: d = DEPENDENCIES }: Props = {}) { - const { checkSession } = d.useUser(); + const { checkSession, error } = d.useUser(); const { sessionExpiryNotifier, logger } = useServices(); const isReCheckingRef = useRef(false); @@ -29,8 +29,8 @@ export function SessionExpirySync({ dependencies: d = DEPENDENCIES }: Props = {} isReCheckingRef.current = true; try { await checkSession(); - } catch (error) { - logger.error({ event: "SESSION_RECHECK_FAILED", error }); + } catch (thrown) { + logger.error({ event: "SESSION_RECHECK_FAILED", error: thrown }); } finally { isReCheckingRef.current = false; } @@ -39,5 +39,17 @@ export function SessionExpirySync({ dependencies: d = DEPENDENCIES }: Props = {} [sessionExpiryNotifier, checkSession, logger] ); + /** + * Auth0's `checkSession` swallows a failed profile fetch (network error or 5xx) into the hook's + * `error` state and resolves rather than rejecting, so the catch above never fires for that case. + * Observing `error` is what surfaces a real re-check failure to the logs. + */ + useEffect( + function logWhenReCheckSurfacesError() { + if (error) logger.error({ event: "SESSION_RECHECK_FAILED", error }); + }, + [error, logger] + ); + return null; } From 8a0893fb4c95f830290c6b028695a3fb19ed6bbd Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:15:41 +0530 Subject: [PATCH 7/8] fix(auth): scope SESSION_RECHECK_FAILED to a notifier-triggered re-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The error-observing effect logged on any transition of the shared useUser error to truthy, so a failure unrelated to a notifier-triggered re-check — the app-boot profile fetch or PasswordlessAuth's /login re-check — was mislabeled as a session-expiry re-check failure. Gate the log on a flag set only when this component initiates the re-check (isReCheckingRef resets in finally before the effect commits, so it can't serve as the gate) and clear it once the outcome is observed. --- .../SessionExpirySync.spec.tsx | 40 +++++++++++++------ .../SessionExpirySync/SessionExpirySync.tsx | 12 ++++-- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx index b12a8b93e9..0932722c08 100644 --- a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx +++ b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import type { LoggerService } from "@akashnetwork/logging"; import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; @@ -19,7 +20,7 @@ describe(SessionExpirySync.name, () => { }); it("collapses a burst of notifications into a single in-flight re-check", async () => { - const { notifier, checkSession } = setup({ checkSessionDuration: "pending" }); + const { notifier, checkSession } = setup({ checkSessionOutcome: "pending" }); await act(async () => { notifier.notify(); @@ -48,13 +49,21 @@ describe(SessionExpirySync.name, () => { expect(checkSession).not.toHaveBeenCalled(); }); - it("logs when the re-check surfaces a session error", () => { - const { logger } = setup({ error: new Error("network down") }); + it("logs when a triggered re-check surfaces a session error", async () => { + const { notifier, logger } = setup({ checkSessionOutcome: "error" }); + + await act(async () => notifier.notify()); expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "SESSION_RECHECK_FAILED" })); }); - it("does not log when the re-check reports no error", async () => { + it("does not log an auth error that no re-check triggered", () => { + const { logger } = setup({ initialError: new Error("boot profile fetch failed") }); + + expect(logger.error).not.toHaveBeenCalled(); + }); + + it("does not log when a triggered re-check reports no error", async () => { const { notifier, logger } = setup(); await act(async () => notifier.notify()); @@ -63,28 +72,33 @@ describe(SessionExpirySync.name, () => { }); it("swallows and logs an unexpected rejection from the re-check", async () => { - const { notifier, logger } = setup({ checkSessionDuration: "rejected" }); + const { notifier, logger } = setup({ checkSessionOutcome: "rejected" }); await act(async () => notifier.notify()); expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "SESSION_RECHECK_FAILED" })); }); - function setup(input: { checkSessionDuration?: "settled" | "pending" | "rejected"; error?: Error } = {}) { + function setup(input: { checkSessionOutcome?: "success" | "pending" | "rejected" | "error"; initialError?: Error } = {}) { const notifier = new SessionExpiryNotifier(); const logger = mock(); - const checkSession = vi.fn(() => { - if (input.checkSessionDuration === "pending") return new Promise(() => undefined); - if (input.checkSessionDuration === "rejected") return Promise.reject(new Error("network down")); - return Promise.resolve(); + let surfaceError: () => void = () => undefined; + const checkSession = vi.fn(async () => { + if (input.checkSessionOutcome === "pending") return new Promise(() => undefined); + if (input.checkSessionOutcome === "rejected") throw new Error("network down"); + if (input.checkSessionOutcome === "error") surfaceError(); + return undefined; }); - const useUser: typeof DEPENDENCIES.useUser = () => - mock>({ + const useUser: typeof DEPENDENCIES.useUser = () => { + const [error, setError] = useState(input.initialError); + surfaceError = () => setError(new Error("network down")); + return mock>({ checkSession, isLoading: false, user: undefined, - error: input.error + error }); + }; const { unmount } = render( notifier, logger: () => logger }}> diff --git a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx index ad1789c166..ea905e5203 100644 --- a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx +++ b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx @@ -21,15 +21,18 @@ export function SessionExpirySync({ dependencies: d = DEPENDENCIES }: Props = {} const { checkSession, error } = d.useUser(); const { sessionExpiryNotifier, logger } = useServices(); const isReCheckingRef = useRef(false); + const awaitingReCheckOutcomeRef = useRef(false); useEffect( function reCheckSessionOnExpiryNotice() { return sessionExpiryNotifier.subscribe(async () => { if (isReCheckingRef.current) return; isReCheckingRef.current = true; + awaitingReCheckOutcomeRef.current = true; try { await checkSession(); } catch (thrown) { + awaitingReCheckOutcomeRef.current = false; logger.error({ event: "SESSION_RECHECK_FAILED", error: thrown }); } finally { isReCheckingRef.current = false; @@ -40,12 +43,15 @@ export function SessionExpirySync({ dependencies: d = DEPENDENCIES }: Props = {} ); /** - * Auth0's `checkSession` swallows a failed profile fetch (network error or 5xx) into the hook's + * Auth0's `checkSession` swallows a failed profile fetch (network error or 5xx) into the shared * `error` state and resolves rather than rejecting, so the catch above never fires for that case. - * Observing `error` is what surfaces a real re-check failure to the logs. + * `awaitingReCheckOutcomeRef` scopes the log to a re-check this component actually triggered, so an + * unrelated auth error (the app-boot profile fetch, the /login re-check) isn't mislabeled here. */ useEffect( - function logWhenReCheckSurfacesError() { + function reportReCheckOutcome() { + if (!awaitingReCheckOutcomeRef.current) return; + awaitingReCheckOutcomeRef.current = false; if (error) logger.error({ event: "SESSION_RECHECK_FAILED", error }); }, [error, logger] From ae1bcbfb8c893997fa300c41207e938a8328ad04 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:45:31 +0530 Subject: [PATCH 8/8] fix(auth): clear the re-check outcome flag on every settle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior scoping gated the log on a ref cleared only inside the outcome effect, whose deps were [error, logger] — so a re-check that resolved without changing error (a clean refresh or 401) never re-ran the effect, left the flag set, and mislabeled the next unrelated error. Tick a settle counter in finally so the outcome effect runs after every re-check regardless of whether error changed, and report only when that counter advances past the last one seen. --- .../SessionExpirySync.spec.tsx | 11 ++++++++++- .../SessionExpirySync/SessionExpirySync.tsx | 19 ++++++++++--------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx index 0932722c08..97383fe7db 100644 --- a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx +++ b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.spec.tsx @@ -79,6 +79,15 @@ describe(SessionExpirySync.name, () => { expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "SESSION_RECHECK_FAILED" })); }); + it("does not log an unrelated error that lands after a clean re-check", async () => { + const { notifier, logger, surfaceError } = setup(); + + await act(async () => notifier.notify()); + await act(async () => surfaceError()); + + expect(logger.error).not.toHaveBeenCalled(); + }); + function setup(input: { checkSessionOutcome?: "success" | "pending" | "rejected" | "error"; initialError?: Error } = {}) { const notifier = new SessionExpiryNotifier(); const logger = mock(); @@ -106,6 +115,6 @@ describe(SessionExpirySync.name, () => { ); - return { notifier, checkSession, logger, unmount }; + return { notifier, checkSession, logger, unmount, surfaceError: () => surfaceError() }; } }); diff --git a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx index ea905e5203..862589dc0c 100644 --- a/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx +++ b/apps/deploy-web/src/components/user/SessionExpirySync/SessionExpirySync.tsx @@ -1,5 +1,5 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { useServices } from "@src/context/ServicesProvider"; import { useUser } from "@src/hooks/useUser"; @@ -21,21 +21,21 @@ export function SessionExpirySync({ dependencies: d = DEPENDENCIES }: Props = {} const { checkSession, error } = d.useUser(); const { sessionExpiryNotifier, logger } = useServices(); const isReCheckingRef = useRef(false); - const awaitingReCheckOutcomeRef = useRef(false); + const [reCheckSettleCount, setReCheckSettleCount] = useState(0); + const reportedSettleCountRef = useRef(0); useEffect( function reCheckSessionOnExpiryNotice() { return sessionExpiryNotifier.subscribe(async () => { if (isReCheckingRef.current) return; isReCheckingRef.current = true; - awaitingReCheckOutcomeRef.current = true; try { await checkSession(); } catch (thrown) { - awaitingReCheckOutcomeRef.current = false; logger.error({ event: "SESSION_RECHECK_FAILED", error: thrown }); } finally { isReCheckingRef.current = false; + setReCheckSettleCount(count => count + 1); } }); }, @@ -45,16 +45,17 @@ export function SessionExpirySync({ dependencies: d = DEPENDENCIES }: Props = {} /** * Auth0's `checkSession` swallows a failed profile fetch (network error or 5xx) into the shared * `error` state and resolves rather than rejecting, so the catch above never fires for that case. - * `awaitingReCheckOutcomeRef` scopes the log to a re-check this component actually triggered, so an - * unrelated auth error (the app-boot profile fetch, the /login re-check) isn't mislabeled here. + * `reCheckSettleCount` ticks once per re-check this component triggered; only acting when it moves + * past the last reported tick reports that re-check's error alone, so an unrelated auth error (the + * app-boot profile fetch, the /login re-check) that also lands in `error` isn't mislabeled here. */ useEffect( function reportReCheckOutcome() { - if (!awaitingReCheckOutcomeRef.current) return; - awaitingReCheckOutcomeRef.current = false; + if (reCheckSettleCount === reportedSettleCountRef.current) return; + reportedSettleCountRef.current = reCheckSettleCount; if (error) logger.error({ event: "SESSION_RECHECK_FAILED", error }); }, - [error, logger] + [reCheckSettleCount, error, logger] ); return null;