fix: drop tokens refreshed for an account that signed out meanwhile

This commit is contained in:
Linus Rath
2026-10-29 03:39:15 +01:00
parent 06d68d73ee
commit cfd81737e3
4 changed files with 91 additions and 7 deletions
+32
View File
@@ -126,6 +126,38 @@ describe('oauth token refresh deduplication', () => {
expect(mockSecureFetch).not.toHaveBeenCalled(); expect(mockSecureFetch).not.toHaveBeenCalled();
}); });
it('does not bring back a chain forgotten while its refresh was in flight', async () => {
const tokens: OAuthTokens = {
accessToken: 'a1',
refreshToken: 'r1',
tokenEndpoint: 'https://auth.example.com/token',
clientId: 'bulwark-webmail',
};
let answer!: () => void;
mockSecureFetch.mockImplementationOnce(async () => {
await new Promise<void>((resolve) => { answer = resolve; });
return {
ok: true,
status: 200,
json: async () => ({ access_token: 'a2', refresh_token: 'r2', expires_in: 3600 }),
} as Response;
});
const inFlight = refreshOAuthAccessToken(tokens);
await vi.waitFor(() => expect(mockSecureFetch).toHaveBeenCalledTimes(1));
// The account signs out before the token endpoint answers.
forgetRefreshRotations('r1');
answer();
await inFlight;
// A stale copy refreshed later doesn't get the signed-out account's
// successor, nor one of the successor's.
mockSecureFetch.mockResolvedValue({ ok: false, status: 400 } as Response);
await expect(refreshOAuthAccessToken(tokens)).rejects.toThrow('400');
await expect(refreshOAuthAccessToken({ ...tokens, accessToken: 'a2', refreshToken: 'r2' })).rejects.toThrow('400');
expect(mockSecureFetch).toHaveBeenCalledTimes(3);
});
it('keeps refreshing with the same token on a server that does not rotate', async () => { it('keeps refreshing with the same token on a server that does not rotate', async () => {
const tokens: OAuthTokens = { const tokens: OAuthTokens = {
accessToken: 'a1', accessToken: 'a1',
+20 -1
View File
@@ -14,7 +14,7 @@ vi.mock('../../api/jmap-client', () => ({
})); }));
import { jmapClient, type StoredCredentials } from '../../api/jmap-client'; import { jmapClient, type StoredCredentials } from '../../api/jmap-client';
import { resetRefreshRotations } from '../oauth'; import { forgetRefreshRotations, refreshOAuthAccessToken, resetRefreshRotations } from '../oauth';
import { freshStoredCredentials } from '../stored-token-refresh'; import { freshStoredCredentials } from '../stored-token-refresh';
const ID = 'u@example.org@https://mail.example.com'; const ID = 'u@example.org@https://mail.example.com';
@@ -99,6 +99,25 @@ describe('freshStoredCredentials', () => {
expect(jmapClient.setStoredCredentials).not.toHaveBeenCalled(); expect(jmapClient.setStoredCredentials).not.toHaveBeenCalled();
}); });
it('hands out nothing when the account signs out while the refresh runs', async () => {
store.set(ID, bundle(1));
const rotate = mockSecureFetch.getMockImplementation()!;
mockSecureFetch.mockImplementationOnce(async (url: string, init: RequestInit) => {
// The sign-out, as the auth store does it, before the endpoint answers.
forgetRefreshRotations('r1');
store.delete(ID);
return rotate(url, init);
});
await expect(freshStoredCredentials(ID, bundle(1))).rejects.toThrow();
expect(store.has(ID)).toBe(false);
// Nor does a stale copy of the bundle get the successor out of memory.
await expect(
refreshOAuthAccessToken({ accessToken: 'a1', refreshToken: 'r1', tokenEndpoint: 'https://mail.example.com/auth/token', clientId: 'swc1.x' }),
).rejects.toThrow('400');
});
it('does not overwrite a newer bundle stored while the refresh ran', async () => { it('does not overwrite a newer bundle stored while the refresh ran', async () => {
store.set(ID, bundle(1)); store.set(ID, bundle(1));
// The iOS notification extension rotates the grant meanwhile (it can't // The iOS notification extension rotates the grant meanwhile (it can't
+24 -2
View File
@@ -547,9 +547,26 @@ const ROTATED_MAX = 64;
// A successor whose access token has less left than this is refreshed in // A successor whose access token has less left than this is refreshed in
// turn rather than handed out. // turn rather than handed out.
const SUCCESSOR_MIN_LIFETIME_MS = 60_000; const SUCCESSOR_MIN_LIFETIME_MS = 60_000;
// Refresh tokens whose chain was dropped on sign-out. A refresh that was in
// flight then returns afterwards and must not put the chain back.
const forgotten = new Set<string>();
const FORGOTTEN_MAX = 256;
function markForgotten(refreshToken: string): void {
forgotten.delete(refreshToken);
if (forgotten.size >= FORGOTTEN_MAX) {
const oldest = forgotten.values().next().value;
if (oldest !== undefined) forgotten.delete(oldest);
}
forgotten.add(refreshToken);
}
function rememberRotation(previous: string, next: OAuthTokens): void { function rememberRotation(previous: string, next: OAuthTokens): void {
if (!next.refreshToken || next.refreshToken === previous) return; if (!next.refreshToken || next.refreshToken === previous) return;
if (forgotten.has(previous)) {
markForgotten(next.refreshToken);
return;
}
if (!rotatedTo.has(previous) && rotatedTo.size >= ROTATED_MAX) { if (!rotatedTo.has(previous) && rotatedTo.size >= ROTATED_MAX) {
const oldest = rotatedTo.keys().next().value; const oldest = rotatedTo.keys().next().value;
if (oldest !== undefined) rotatedTo.delete(oldest); if (oldest !== undefined) rotatedTo.delete(oldest);
@@ -572,13 +589,15 @@ function latestRotation(refreshToken: string): OAuthTokens | null {
/** Test hook. */ /** Test hook. */
export function resetRefreshRotations(): void { export function resetRefreshRotations(): void {
rotatedTo.clear(); rotatedTo.clear();
forgotten.clear();
} }
/** /**
* Drop the rotation chain `refreshToken` is part of, its predecessors and * Drop the rotation chain `refreshToken` is part of, its predecessors and
* successors both, when its account signs out. A stale copy of the bundle * successors both, when its account signs out. A stale copy of the bundle
* refreshed later would otherwise get the successor from here and could * refreshed later would otherwise get the successor from here and could
* write the removed account's tokens back. * write the removed account's tokens back. A refresh of the chain still in
* flight doesn't add it again when it returns.
*/ */
export function forgetRefreshRotations(refreshToken: string | undefined): void { export function forgetRefreshRotations(refreshToken: string | undefined): void {
if (!refreshToken) return; if (!refreshToken) return;
@@ -593,7 +612,10 @@ export function forgetRefreshRotations(refreshToken: string | undefined): void {
if (next.refreshToken && !chain.has(next.refreshToken)) { chain.add(next.refreshToken); grew = true; } if (next.refreshToken && !chain.has(next.refreshToken)) { chain.add(next.refreshToken); grew = true; }
} }
} }
for (const token of chain) rotatedTo.delete(token); for (const token of chain) {
rotatedTo.delete(token);
markForgotten(token);
}
} }
// OAuth refresh — exchanges the refresh token at the original token endpoint // OAuth refresh — exchanges the refresh token at the original token endpoint
+15 -4
View File
@@ -16,7 +16,7 @@
// someone else wrote. // someone else wrote.
import { jmapClient, type StoredCredentials } from '../api/jmap-client'; import { jmapClient, type StoredCredentials } from '../api/jmap-client';
import { refreshOAuthAccessToken } from './oauth'; import { forgetRefreshRotations, refreshOAuthAccessToken } from './oauth';
const TOKEN_REFRESH_LEEWAY_MS = 60_000; const TOKEN_REFRESH_LEEWAY_MS = 60_000;
@@ -29,7 +29,8 @@ function expiresSoon(expiresAt: number | undefined): boolean {
* stored when needed. Password credentials come back as they are; so does * stored when needed. Password credentials come back as they are; so does
* an account whose credentials are gone from storage (signed out). A failed * an account whose credentials are gone from storage (signed out). A failed
* refresh returns what there is, and the request that uses it fails on its * refresh returns what there is, and the request that uses it fails on its
* own. * own. When the account signs out while the refresh runs, its fresh tokens
* are dropped and this throws.
*/ */
export async function freshStoredCredentials( export async function freshStoredCredentials(
accountId: string, accountId: string,
@@ -58,6 +59,7 @@ export async function freshStoredCredentials(
if (!expiresSoon(base.expiresAt)) return base; if (!expiresSoon(base.expiresAt)) return base;
} }
let signedOut = false;
try { try {
const next = await refreshOAuthAccessToken({ const next = await refreshOAuthAccessToken({
accessToken: base.accessToken!, accessToken: base.accessToken!,
@@ -75,11 +77,20 @@ export async function freshStoredCredentials(
clientId: next.clientId, clientId: next.clientId,
}; };
const current = await jmapClient.getStoredCredentials(accountId); const current = await jmapClient.getStoredCredentials(accountId);
if (current && current.refreshToken === base.refreshToken && current.accessToken !== tokens.accessToken) { if (!current) {
// Signed out meanwhile: the sign-out forgot the chain before this
// refresh returned. Nobody may use the tokens it bought.
forgetRefreshRotations(base.refreshToken);
forgetRefreshRotations(tokens.refreshToken);
signedOut = true;
throw new Error('Account signed out');
}
if (current.refreshToken === base.refreshToken && current.accessToken !== tokens.accessToken) {
await jmapClient.setStoredCredentials(accountId, { ...current, ...tokens }); await jmapClient.setStoredCredentials(accountId, { ...current, ...tokens });
} }
return { ...base, ...tokens }; return { ...base, ...tokens };
} catch { } catch (err) {
if (signedOut) throw err;
return base; return base;
} }
} }