mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
fix: reuse cached access token on lite session restore #552
This commit is contained in:
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user