From 068ae5e53ef01e344c51ae9ae4d5118258eb6778 Mon Sep 17 00:00:00 2001 From: Yogesh Chaudhary Date: Mon, 17 Aug 2026 22:52:19 +0530 Subject: [PATCH 1/4] fix: restore SPA session on page refresh using refresh tokens and localstorage cache --- src/client/Auth0Provider.tsx | 15 ++++++++++++++- src/vite-env.d.ts | 2 ++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/client/Auth0Provider.tsx b/src/client/Auth0Provider.tsx index 02a5a22..2282ef2 100644 --- a/src/client/Auth0Provider.tsx +++ b/src/client/Auth0Provider.tsx @@ -120,6 +120,8 @@ function SpaAuth0Provider({ children }: Auth0ProviderProps) { new Auth0Client({ domain: import.meta.env.VITE_AUTH0_DOMAIN as string, clientId: import.meta.env.VITE_AUTH0_CLIENT_ID as string, + useRefreshTokens: import.meta.env.VITE_AUTH0_USE_REFRESH_TOKENS !== 'false', + cacheLocation: (import.meta.env.VITE_AUTH0_CACHE_LOCATION as 'memory' | 'localstorage') ?? 'localstorage', authorizationParams: { redirect_uri: import.meta.env.VITE_AUTH0_REDIRECT_URI ?? @@ -164,7 +166,18 @@ function SpaAuth0Provider({ children }: Auth0ProviderProps) { navigate(appState.returnTo, { replace: true }); } } - const u = await client.getUser(); + let u = await client.getUser(); + if (!u) { + // On page refresh the in-memory cache is empty. getTokenSilently() + // uses the stored refresh token (localstorage) or Auth0's SSO session + // to silently re-authenticate and repopulate the cache. + try { + await client.getTokenSilently(); + u = await client.getUser(); + } catch { + u = undefined; + } + } setUser(u ?? null); } catch { setUser(null); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 13612e0..4550781 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -6,6 +6,8 @@ interface ImportMetaEnv { readonly VITE_AUTH0_REDIRECT_URI?: string; readonly VITE_AUTH0_AUDIENCE?: string; readonly VITE_AUTH0_SCOPE?: string; + readonly VITE_AUTH0_USE_REFRESH_TOKENS?: string; + readonly VITE_AUTH0_CACHE_LOCATION?: string; } interface ImportMeta { From eb5ebc09bd3d834af78df13b49edfe3a522288e7 Mon Sep 17 00:00:00 2001 From: Yogesh Chaudhary Date: Tue, 18 Aug 2026 18:19:02 +0530 Subject: [PATCH 2/4] feat: add useRefreshTokensFallback support for SPA mode --- src/client/Auth0Provider.tsx | 6 ++++-- src/vite-env.d.ts | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/client/Auth0Provider.tsx b/src/client/Auth0Provider.tsx index 2282ef2..0ca9bdf 100644 --- a/src/client/Auth0Provider.tsx +++ b/src/client/Auth0Provider.tsx @@ -121,6 +121,7 @@ function SpaAuth0Provider({ children }: Auth0ProviderProps) { domain: import.meta.env.VITE_AUTH0_DOMAIN as string, clientId: import.meta.env.VITE_AUTH0_CLIENT_ID as string, useRefreshTokens: import.meta.env.VITE_AUTH0_USE_REFRESH_TOKENS !== 'false', + useRefreshTokensFallback: import.meta.env.VITE_AUTH0_USE_REFRESH_TOKENS_FALLBACK === 'true', cacheLocation: (import.meta.env.VITE_AUTH0_CACHE_LOCATION as 'memory' | 'localstorage') ?? 'localstorage', authorizationParams: { redirect_uri: @@ -169,8 +170,9 @@ function SpaAuth0Provider({ children }: Auth0ProviderProps) { let u = await client.getUser(); if (!u) { // On page refresh the in-memory cache is empty. getTokenSilently() - // uses the stored refresh token (localstorage) or Auth0's SSO session - // to silently re-authenticate and repopulate the cache. + // uses the stored refresh token to silently re-authenticate and + // repopulate the cache. If useRefreshTokensFallback is enabled it + // will also fall back to Auth0's SSO session (silent iframe). try { await client.getTokenSilently(); u = await client.getUser(); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 4550781..dd9c745 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -7,6 +7,7 @@ interface ImportMetaEnv { readonly VITE_AUTH0_AUDIENCE?: string; readonly VITE_AUTH0_SCOPE?: string; readonly VITE_AUTH0_USE_REFRESH_TOKENS?: string; + readonly VITE_AUTH0_USE_REFRESH_TOKENS_FALLBACK?: string; readonly VITE_AUTH0_CACHE_LOCATION?: string; } From 975929da35294f06a2ef709ee3cd9c94111fa270 Mon Sep 17 00:00:00 2001 From: Yogesh Chaudhary Date: Tue, 18 Aug 2026 18:52:15 +0530 Subject: [PATCH 3/4] fix: default cacheLocation to memory for SPA mode --- src/client/Auth0Provider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/Auth0Provider.tsx b/src/client/Auth0Provider.tsx index 0ca9bdf..be476c0 100644 --- a/src/client/Auth0Provider.tsx +++ b/src/client/Auth0Provider.tsx @@ -122,7 +122,7 @@ function SpaAuth0Provider({ children }: Auth0ProviderProps) { clientId: import.meta.env.VITE_AUTH0_CLIENT_ID as string, useRefreshTokens: import.meta.env.VITE_AUTH0_USE_REFRESH_TOKENS !== 'false', useRefreshTokensFallback: import.meta.env.VITE_AUTH0_USE_REFRESH_TOKENS_FALLBACK === 'true', - cacheLocation: (import.meta.env.VITE_AUTH0_CACHE_LOCATION as 'memory' | 'localstorage') ?? 'localstorage', + cacheLocation: (import.meta.env.VITE_AUTH0_CACHE_LOCATION as 'memory' | 'localstorage') ?? 'memory', authorizationParams: { redirect_uri: import.meta.env.VITE_AUTH0_REDIRECT_URI ?? From 0421c5baefa95c5302f6dd0f3a55b68651466d85 Mon Sep 17 00:00:00 2001 From: Yogesh Chaudhary Date: Tue, 18 Aug 2026 18:52:34 +0530 Subject: [PATCH 4/4] test: add session restoration and Auth0Client config tests --- __tests__/client/spa-provider.test.tsx | 75 ++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/__tests__/client/spa-provider.test.tsx b/__tests__/client/spa-provider.test.tsx index db54092..b4d5099 100644 --- a/__tests__/client/spa-provider.test.tsx +++ b/__tests__/client/spa-provider.test.tsx @@ -1,6 +1,8 @@ // @vitest-environment happy-dom import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Mock } from 'vitest'; import { render, screen, act, cleanup } from '@testing-library/react'; +import { Auth0Client } from '@auth0/auth0-spa-js'; import { Auth0Provider } from '../../src/client/Auth0Provider.js'; import { useAuth0 } from '../../src/client/use-auth0.js'; @@ -238,3 +240,76 @@ describe('Auth0Provider — SPA mode', () => { expect(mockGetTokenSilently).toHaveBeenCalledTimes(1); }); }); + +// ─── Session restoration ────────────────────────────────────────────────────── + +describe('Auth0Provider — SPA mode — session restoration', () => { + it('calls getTokenSilently and retries getUser when getUser returns undefined on init', async () => { + mockGetUser + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ sub: 'auth0|restored', name: 'Restored' }); + + const { getCtx } = await renderSpaProvider(); + + expect(mockGetTokenSilently).toHaveBeenCalledTimes(1); + expect(getCtx().user).toEqual({ sub: 'auth0|restored', name: 'Restored' }); + expect(getCtx().isAuthenticated).toBe(true); + }); + + it('sets user to null when getTokenSilently throws during restoration', async () => { + mockGetUser.mockResolvedValue(undefined); + mockGetTokenSilently.mockRejectedValue(new Error('login_required')); + + const { getCtx } = await renderSpaProvider(); + + expect(mockGetTokenSilently).toHaveBeenCalledTimes(1); + expect(getCtx().user).toBeNull(); + expect(getCtx().isAuthenticated).toBe(false); + }); + + it('does not call getTokenSilently when getUser returns a user on init', async () => { + await renderSpaProvider(); + expect(mockGetTokenSilently).not.toHaveBeenCalled(); + }); +}); + +// ─── Auth0Client config ─────────────────────────────────────────────────────── + +describe('Auth0Provider — SPA mode — Auth0Client config', () => { + function getConstructorOpts() { + return (Auth0Client as unknown as Mock).mock.calls[0][0]; + } + + it('passes useRefreshTokens: true by default', async () => { + await renderSpaProvider(); + expect(getConstructorOpts().useRefreshTokens).toBe(true); + }); + + it('passes useRefreshTokensFallback: false by default', async () => { + await renderSpaProvider(); + expect(getConstructorOpts().useRefreshTokensFallback).toBe(false); + }); + + it('passes cacheLocation: memory by default', async () => { + await renderSpaProvider(); + expect(getConstructorOpts().cacheLocation).toBe('memory'); + }); + + it('respects VITE_AUTH0_USE_REFRESH_TOKENS=false', async () => { + vi.stubEnv('VITE_AUTH0_USE_REFRESH_TOKENS', 'false'); + await renderSpaProvider(); + expect(getConstructorOpts().useRefreshTokens).toBe(false); + }); + + it('respects VITE_AUTH0_USE_REFRESH_TOKENS_FALLBACK=true', async () => { + vi.stubEnv('VITE_AUTH0_USE_REFRESH_TOKENS_FALLBACK', 'true'); + await renderSpaProvider(); + expect(getConstructorOpts().useRefreshTokensFallback).toBe(true); + }); + + it('respects VITE_AUTH0_CACHE_LOCATION=localstorage', async () => { + vi.stubEnv('VITE_AUTH0_CACHE_LOCATION', 'localstorage'); + await renderSpaProvider(); + expect(getConstructorOpts().cacheLocation).toBe('localstorage'); + }); +});