diff --git a/lib/__tests__/lite-tokens.test.ts b/lib/__tests__/lite-tokens.test.ts index 5913319f2..1841eed35 100644 --- a/lib/__tests__/lite-tokens.test.ts +++ b/lib/__tests__/lite-tokens.test.ts @@ -7,9 +7,11 @@ import { liteRefreshTokens, liteTokenLogin, probeLiteTokenLogin, + readLiteAccessToken, readLiteBasicSession, readLiteRefreshToken, resetLiteProbeCache, + saveLiteAccessToken, saveLiteBasicSession, saveLiteRefreshToken, } from '@/lib/auth/lite-tokens'; @@ -70,8 +72,55 @@ describe('lite token storage', () => { it('ignores corrupt storage entries', () => { localStorage.setItem('bulwark-lite:refresh:0', '{not json'); sessionStorage.setItem('bulwark-lite:basic:0', JSON.stringify({ username: 'x' })); + localStorage.setItem('bulwark-lite:access:0', JSON.stringify({ accessToken: 'AT' })); expect(readLiteRefreshToken(0)).toBeNull(); expect(readLiteBasicSession(0)).toBeNull(); + expect(readLiteAccessToken(0)).toBeNull(); + }); +}); + +describe('lite access token cache (#552)', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + vi.useFakeTimers({ now: new Date('2026-09-29T12:00:00Z'), toFake: ['Date'] }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('keeps the access token where the session lives and reports its remaining life', () => { + saveLiteAccessToken(0, 'AT0', 1800, true); + saveLiteAccessToken(1, 'AT1', 600, false); + + expect(localStorage.getItem('bulwark-lite:access:1')).toBeNull(); + expect(JSON.parse(sessionStorage.getItem('bulwark-lite:access:1')!).accessToken).toBe('AT1'); + vi.advanceTimersByTime(300_000); + expect(readLiteAccessToken(0)).toEqual({ accessToken: 'AT0', expiresIn: 1500 }); + expect(readLiteAccessToken(1)).toEqual({ accessToken: 'AT1', expiresIn: 300 }); + expect(readLiteAccessToken(2)).toBeNull(); + }); + + it('stops serving a token in its last minute, when renewal is due anyway', () => { + saveLiteAccessToken(0, 'AT', 1800, true); + vi.advanceTimersByTime(1740_000); + expect(readLiteAccessToken(0)?.expiresIn).toBe(60); + vi.advanceTimersByTime(1_000); + expect(readLiteAccessToken(0)).toBeNull(); + }); + + it('goes with the refresh token when the slot is cleared', () => { + saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'a', refreshToken: 'r0' }, true); + saveLiteAccessToken(0, 'AT0', 1800, true); + saveLiteAccessToken(1, 'AT1', 1800, false); + + clearLiteRefreshToken(0); + expect(readLiteAccessToken(0)).toBeNull(); + expect(readLiteAccessToken(1)?.accessToken).toBe('AT1'); + + clearAllLiteSessions(); + expect(readLiteAccessToken(1)).toBeNull(); }); }); @@ -204,6 +253,8 @@ describe('liteRefreshTokens', () => { expect(tokens).toEqual({ accessToken: 'AT2', expiresIn: 900, refreshToken: 'RT2' }); expect(JSON.parse(localStorage.getItem('bulwark-lite:refresh:2')!).refreshToken).toBe('RT2'); expect(sessionStorage.getItem('bulwark-lite:refresh:2')).toBeNull(); + // The new access token is cached beside it for the next reload. + expect(JSON.parse(localStorage.getItem('bulwark-lite:access:2')!).accessToken).toBe('AT2'); }); it('keeps a tab-only token in sessionStorage after rotation', async () => { @@ -212,6 +263,8 @@ describe('liteRefreshTokens', () => { await liteRefreshTokens(0); expect(localStorage.getItem('bulwark-lite:refresh:0')).toBeNull(); expect(readLiteRefreshToken(0)?.refreshToken).toBe('RT2'); + expect(localStorage.getItem('bulwark-lite:access:0')).toBeNull(); + expect(readLiteAccessToken(0)?.accessToken).toBe('AT'); }); it('rejects immediately without a stored token', async () => { @@ -221,9 +274,11 @@ describe('liteRefreshTokens', () => { it('clears the slot when the server rejects the refresh token', async () => { saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'a', refreshToken: 'RT' }, true); + saveLiteAccessToken(0, 'AT', 1800, true); fetchMock.mockResolvedValue(jsonResponse({ error: 'invalid_grant' }, 400)); await expect(liteRefreshTokens(0)).rejects.toMatchObject({ code: 'refresh_rejected', status: 400 }); expect(readLiteRefreshToken(0)).toBeNull(); + expect(readLiteAccessToken(0)).toBeNull(); }); it('keeps the token on a 5xx so the session can resume after the outage', async () => { diff --git a/lib/auth/lite-tokens.ts b/lib/auth/lite-tokens.ts index b10e13946..57a1343e3 100644 --- a/lib/auth/lite-tokens.ts +++ b/lib/auth/lite-tokens.ts @@ -17,11 +17,12 @@ import { IS_LITE_STALWART, getLiteInjectedClientId } from '@/lib/lite'; * (or, for an account an external OpenID provider signs in, through that * provider's redirect flow: lib/auth/lite-oauth.ts) * - * and renew with `grant_type=refresh_token`. The refresh token is the only - * thing that persists: localStorage when the user ticked "remember me", - * sessionStorage (this tab only) otherwise. Servers without /api/auth (older - * Stalwart, other JMAP servers) fall back to Basic auth; those sessions can - * only survive a reload inside the same tab. + * and renew with `grant_type=refresh_token`. The tokens are what persists: + * localStorage when the user ticked "remember me", sessionStorage (this tab + * only) otherwise. The current access token is kept beside the refresh token + * so a reload resumes with it instead of renewing early. Servers without + * /api/auth (older Stalwart, other JMAP servers) fall back to Basic auth; + * those sessions can only survive a reload inside the same tab. * * Trade-off, documented in LITE-README.md: a refresh token in web storage is * readable by any script on the origin. That is the standard SPA position, @@ -81,6 +82,12 @@ interface StoredRefreshToken { revocationEndpoint?: string; } +interface StoredAccessToken { + accessToken: string; + /** Unix seconds. */ + expiresAt: number; +} + interface StoredBasicSession { serverUrl: string; username: string; @@ -88,8 +95,17 @@ interface StoredBasicSession { } const REFRESH_KEY_PREFIX = 'bulwark-lite:refresh:'; +const ACCESS_KEY_PREFIX = 'bulwark-lite:access:'; const BASIC_KEY_PREFIX = 'bulwark-lite:basic:'; +/** + * Seconds of life a cached access token needs left to be reused. Mirrors + * ACCESS_TOKEN_MIN_REMAINING_SECONDS in lib/oauth/tokens.ts, which cannot be + * imported here (server-only): inside this window the scheduled renewal is + * due anyway, and providers that gate refresh on `nbf` have opened by then. + */ +const ACCESS_TOKEN_MIN_REMAINING_SECONDS = 60; + function trimUrl(url: string): string { return url.replace(/\/+$/, ''); } @@ -168,10 +184,36 @@ export function nameLiteRefreshToken(slot: number, username: string): void { saveLiteRefreshToken(slot, { ...stored, username }, refreshTokenIsPersistent(slot)); } +/** Forgets the slot's refresh token and the access token cached beside it. */ export function clearLiteRefreshToken(slot: number): void { - const key = `${REFRESH_KEY_PREFIX}${slot}`; + for (const key of [`${REFRESH_KEY_PREFIX}${slot}`, `${ACCESS_KEY_PREFIX}${slot}`]) { + remove(storage('local'), key); + remove(storage('session'), key); + } +} + +/** + * Caches the slot's current access token, so a reload resumes with it rather + * than spending the refresh token. Providers that stamp refresh tokens with + * `nbf` (Rauthy: iat + access token lifetime - 60s) refuse an early renewal, + * and a refused renewal ends the session (#552). `persistent` follows the + * refresh token: a remembered session reopened in a new tab needs it too. + */ +export function saveLiteAccessToken(slot: number, accessToken: string, expiresIn: number, persistent: boolean): void { + const key = `${ACCESS_KEY_PREFIX}${slot}`; remove(storage('local'), key); remove(storage('session'), key); + const entry: StoredAccessToken = { accessToken, expiresAt: Math.floor(Date.now() / 1000) + expiresIn }; + writeJson(storage(persistent ? 'local' : 'session'), key, entry); +} + +/** The slot's cached access token, while it has more than a minute to live. */ +export function readLiteAccessToken(slot: number): { accessToken: string; expiresIn: number } | null { + const key = `${ACCESS_KEY_PREFIX}${slot}`; + const entry = readJson(storage('local'), key) ?? readJson(storage('session'), key); + if (!entry || typeof entry.accessToken !== 'string' || !entry.accessToken || !Number.isFinite(entry.expiresAt)) return null; + const expiresIn = entry.expiresAt - Math.floor(Date.now() / 1000); + return expiresIn >= ACCESS_TOKEN_MIN_REMAINING_SECONDS ? { accessToken: entry.accessToken, expiresIn } : null; } /** @@ -282,7 +324,7 @@ export function clearAllLiteSessions(): void { try { for (let i = 0; i < store.length; i++) { const key = store.key(i); - if (key && (key.startsWith(REFRESH_KEY_PREFIX) || key.startsWith(BASIC_KEY_PREFIX))) keys.push(key); + if (key && [REFRESH_KEY_PREFIX, ACCESS_KEY_PREFIX, BASIC_KEY_PREFIX].some((prefix) => key.startsWith(prefix))) keys.push(key); } } catch { continue; @@ -449,12 +491,15 @@ export async function liteRefreshTokens(slot: number, clientIdOverride?: string) clearLiteRefreshToken(slot); throw new LiteLoginError('refresh_rejected', response.status || 401, tokens.error); } + const persistent = refreshTokenIsPersistent(slot); if (tokens.refresh_token && tokens.refresh_token !== stored.refreshToken) { - saveLiteRefreshToken(slot, { ...stored, refreshToken: tokens.refresh_token }, refreshTokenIsPersistent(slot)); + saveLiteRefreshToken(slot, { ...stored, refreshToken: tokens.refresh_token }, persistent); } + const expiresIn = tokens.expires_in || 3600; + saveLiteAccessToken(slot, tokens.access_token, expiresIn, persistent); return { accessToken: tokens.access_token, - expiresIn: tokens.expires_in || 3600, + expiresIn, refreshToken: tokens.refresh_token ?? stored.refreshToken, }; } diff --git a/stores/__tests__/auth-store-lite.test.ts b/stores/__tests__/auth-store-lite.test.ts index 8341d5682..f3edff9ce 100644 --- a/stores/__tests__/auth-store-lite.test.ts +++ b/stores/__tests__/auth-store-lite.test.ts @@ -13,7 +13,14 @@ import { JMAPClient } from '@/lib/jmap/client'; import * as browserNavigation from '@/lib/browser-navigation'; import { useAuthStore } from '../auth-store'; import { useAccountStore } from '../account-store'; -import { readLiteRefreshToken, readLiteBasicSession, saveLiteRefreshToken, saveLiteBasicSession } from '@/lib/auth/lite-tokens'; +import { + readLiteAccessToken, + readLiteBasicSession, + readLiteRefreshToken, + saveLiteAccessToken, + saveLiteBasicSession, + saveLiteRefreshToken, +} from '@/lib/auth/lite-tokens'; import { readLiteOAuthFlow, saveLiteOAuthFlow } from '@/lib/auth/lite-oauth'; type FetchInput = Parameters[0]; @@ -25,6 +32,11 @@ function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); } +/** Rauthy's answer to a refresh token used before its `nbf` (#552). */ +function notYetValid(): Response { + return jsonResponse({ error: 'JwtToken', message: 'Token is not valid yet' }, 401); +} + /** * A fetch that serves Stalwart's login + token endpoints, the static * config/policy files, and fails on anything else - in particular on any @@ -137,7 +149,7 @@ describe('auth-store in the static Lite build', () => { vi.unstubAllGlobals(); }); - it('"remember me" logs in through Stalwart token login and keeps only a refresh token in localStorage', async () => { + it('"remember me" logs in through Stalwart token login and keeps the tokens, never the password, in localStorage', async () => { const { calls } = stalwartFetch(); const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw', undefined, true); @@ -151,6 +163,7 @@ describe('auth-store in the static Lite build', () => { // The client the token was issued to rides along, so refreshes present the same one. expect(readLiteRefreshToken(0)).toEqual({ serverUrl: SERVER, username: 'alice', refreshToken: 'RT-1', clientId: 'bulwark-webmail' }); expect(localStorage.getItem('bulwark-lite:refresh:0')).not.toBeNull(); + expect(localStorage.getItem('bulwark-lite:access:0')).toContain('"accessToken":"AT-1"'); // No password anywhere in web storage. const dump = JSON.stringify({ ...localStorage, ...sessionStorage }); expect(dump).not.toContain('"pw"'); @@ -271,7 +284,37 @@ describe('auth-store in the static Lite build', () => { expect(localStorage.getItem('bulwark-lite:basic:0')).toBeNull(); }); - it('restores a remembered token session on reload by refreshing against the mail server', async () => { + it('resumes a reload with the cached access token instead of renewing early (#552)', async () => { + saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'alice', refreshToken: 'RT-1' }, true); + saveLiteAccessToken(0, 'AT-cached', 1800, true); + const id = registerAccount('oauth'); + // A provider that gates refresh on `nbf` refuses a renewal this early. + const { calls } = stalwartFetch({ refreshAnswer: notYetValid }); + + await useAuthStore.getState().checkAuth(); + + expect(calls).toEqual([]); + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(true); + expect(state.client?.getAuthHeader()).toBe('Bearer AT-cached'); + expect(useAccountStore.getState().getAccountById(id)?.isConnected).toBe(true); + expect(readLiteRefreshToken(0)?.refreshToken).toBe('RT-1'); + }); + + it('renews on reload once the cached access token is in its last minute', async () => { + saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'alice', refreshToken: 'RT-1' }, true); + saveLiteAccessToken(0, 'AT-stale', 30, true); + registerAccount('oauth'); + const { calls } = stalwartFetch(); + + await useAuthStore.getState().checkAuth(); + + expect(calls).toEqual([`POST ${SERVER}/auth/token`]); + expect(useAuthStore.getState().client?.getAuthHeader()).toBe('Bearer AT-refreshed'); + expect(readLiteAccessToken(0)?.accessToken).toBe('AT-refreshed'); + }); + + it('restores a remembered session without a cached access token by refreshing against the mail server', async () => { saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'alice', refreshToken: 'RT-1' }, true); registerAccount('oauth'); const { calls } = stalwartFetch(); @@ -433,6 +476,8 @@ describe('auth-store in the static Lite build', () => { serverUrl: SERVER, username: 'alice@example.com', refreshToken: 'RT-sso', clientId: 'app-client', tokenEndpoint: PROVIDER_TOKEN, }); expect(localStorage.getItem('bulwark-lite:refresh:0')).not.toBeNull(); + // The reload that follows sign-in resumes with this token (#552). + expect(localStorage.getItem('bulwark-lite:access:0')).toContain('"accessToken":"AT-sso"'); // A code is good for one attempt. expect(readLiteOAuthFlow()).toBeNull(); @@ -449,6 +494,8 @@ describe('auth-store in the static Lite build', () => { expect(localStorage.getItem('bulwark-lite:refresh:0')).toBeNull(); expect(sessionStorage.getItem('bulwark-lite:refresh:0')).not.toBeNull(); + expect(localStorage.getItem('bulwark-lite:access:0')).toBeNull(); + expect(sessionStorage.getItem('bulwark-lite:access:0')).not.toBeNull(); }); it('refuses a callback it did not start', async () => { diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 9e6a88d36..f2aa30f9a 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -35,9 +35,11 @@ import { liteRefreshTokens, liteTokenLogin, nameLiteRefreshToken, + readLiteAccessToken, readLiteBasicSession, revokeAllLiteSessions, revokeLiteSlot, + saveLiteAccessToken, saveLiteBasicSession, saveLiteRefreshToken, } from '@/lib/auth/lite-tokens'; @@ -393,6 +395,7 @@ async function exchangePasswordForTokens(params: { } else { clearLiteRefreshToken(slot); } + saveLiteAccessToken(slot, tokens.accessToken, tokens.expiresIn, rememberMe); return jsonResponse({ access_token: tokens.accessToken, expires_in: tokens.expiresIn, has_refresh_token: !!tokens.refreshToken }); } catch (err) { if (err instanceof LiteLoginError) return jsonResponse({ error: err.code }, liteErrorStatus(err)); @@ -451,6 +454,7 @@ async function exchangeOAuthCode(params: { } else { clearLiteRefreshToken(slot); } + saveLiteAccessToken(slot, tokens.accessToken, tokens.expiresIn, flow.persistent); return jsonResponse({ access_token: tokens.accessToken, expires_in: tokens.expiresIn }); } catch (err) { if (err instanceof LiteLoginError) return jsonResponse({ error: err.code }, liteErrorStatus(err)); @@ -485,6 +489,12 @@ async function fetchSlotAccessToken(slot: number, opts: { force?: boolean } = {} if (!IS_LITE) { return trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/token?slot=${slot}${opts.force ? '&force=true' : ''}`, { method: 'PUT', signal })); } + // Like the route's cookie cache: a restore resumes with the token it had, + // since a renewal before the refresh token's `nbf` is refused (#552). + if (!opts.force) { + const cached = readLiteAccessToken(slot); + if (cached) return jsonResponse({ access_token: cached.accessToken, expires_in: cached.expiresIn }); + } return trackSessionWrite(slot, async () => { try { const tokens = await liteRefreshTokens(slot);