fix: reuse cached access token on lite session restore #552

This commit is contained in:
Linus Rath
2026-09-29 21:25:02 +02:00
parent a1914abfad
commit ddc0b03055
4 changed files with 169 additions and 12 deletions
+55
View File
@@ -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 () => {
+54 -9
View File
@@ -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<StoredAccessToken>(storage('local'), key) ?? readJson<StoredAccessToken>(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,
};
}
+50 -3
View File
@@ -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<typeof fetch>[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 () => {
+10
View File
@@ -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);