fix: keep accounts signed in when Stalwart 1.0 rotates the refresh token

This commit is contained in:
Linus Rath
2026-10-29 02:07:46 +01:00
parent 6ffd0473e5
commit 1456e15d5c
2 changed files with 101 additions and 4 deletions
+51 -3
View File
@@ -8,11 +8,12 @@ vi.mock('../client-cert', () => ({
secureFetch: mockSecureFetch, secureFetch: mockSecureFetch,
})); }));
import { refreshOAuthAccessToken, type OAuthTokens } from '../oauth'; import { refreshOAuthAccessToken, resetRefreshRotations, type OAuthTokens } from '../oauth';
describe('oauth token refresh deduplication', () => { describe('oauth token refresh deduplication', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
resetRefreshRotations();
}); });
it('should only send a single HTTP request when refreshOAuthAccessToken is called concurrently with the same refresh token', async () => { it('should only send a single HTTP request when refreshOAuthAccessToken is called concurrently with the same refresh token', async () => {
@@ -53,11 +54,58 @@ describe('oauth token refresh deduplication', () => {
expect(res2.accessToken).toBe('new-access-token'); expect(res2.accessToken).toBe('new-access-token');
expect(res2.refreshToken).toBe('rotated-refresh-token'); expect(res2.refreshToken).toBe('rotated-refresh-token');
// Fire another refresh after the first completes (now the map should be empty for this token) // A holder that still has the rotated-away token later gets its
// successor: presenting it again would revoke the grant on Stalwart 1.0.
mockSecureFetch.mockClear(); mockSecureFetch.mockClear();
const res3 = await refreshOAuthAccessToken(tokens); const res3 = await refreshOAuthAccessToken(tokens);
expect(mockSecureFetch).toHaveBeenCalledTimes(1); expect(mockSecureFetch).not.toHaveBeenCalled();
expect(res3.accessToken).toBe('new-access-token'); expect(res3.accessToken).toBe('new-access-token');
expect(res3.refreshToken).toBe('rotated-refresh-token');
});
it('refreshes again with the successor once its access token runs out, never with the old token', async () => {
const tokens: OAuthTokens = {
accessToken: 'a1',
refreshToken: 'r1',
tokenEndpoint: 'https://auth.example.com/token',
clientId: 'swc1.x',
};
let generation = 1;
mockSecureFetch.mockImplementation(async () => {
generation += 1;
return {
ok: true,
status: 200,
json: async () => ({ access_token: `a${generation}`, refresh_token: `r${generation}`, expires_in: 30 }),
} as Response;
});
await refreshOAuthAccessToken(tokens);
// 30 s left on the successor: not enough to hand out, so it is renewed.
const res = await refreshOAuthAccessToken(tokens);
const presented = mockSecureFetch.mock.calls.map(([, init]) => new URLSearchParams(String(init.body)).get('refresh_token'));
expect(presented).toEqual(['r1', 'r2']);
expect(res.refreshToken).toBe('r3');
});
it('keeps refreshing with the same token on a server that does not rotate', async () => {
const tokens: OAuthTokens = {
accessToken: 'a1',
refreshToken: 'stable',
tokenEndpoint: 'https://auth.example.com/token',
clientId: 'bulwark-webmail',
};
mockSecureFetch.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({ access_token: 'a2', expires_in: 3600 }),
}) as Response);
await refreshOAuthAccessToken(tokens);
await refreshOAuthAccessToken(tokens);
expect(mockSecureFetch).toHaveBeenCalledTimes(2);
}); });
it('should propagate errors to all waiters if the HTTP request fails', async () => { it('should propagate errors to all waiters if the HTTP request fails', async () => {
+50 -1
View File
@@ -535,6 +535,45 @@ export async function redeemPairingCode(webmailUrl: string, code: string): Promi
const activeRefreshes = new Map<string, Promise<OAuthTokens>>(); const activeRefreshes = new Map<string, Promise<OAuthTokens>>();
// Stalwart 1.0 rotates refresh tokens, and a superseded one presented after
// its successor was used revokes the whole grant (access token 401, current
// refresh token invalid_grant; checked on 1.0.0). Several holders refresh the
// same account from their own copy of the bundle: the live client, the
// unified inbox, the background push task. One that still holds the old
// token after another rotated it gets the successor here instead of spending
// the dead token and signing the account out.
const rotatedTo = new Map<string, OAuthTokens>();
const ROTATED_MAX = 64;
// A successor whose access token has less left than this is refreshed in
// turn rather than handed out.
const SUCCESSOR_MIN_LIFETIME_MS = 60_000;
function rememberRotation(previous: string, next: OAuthTokens): void {
if (!next.refreshToken || next.refreshToken === previous) return;
if (!rotatedTo.has(previous) && rotatedTo.size >= ROTATED_MAX) {
const oldest = rotatedTo.keys().next().value;
if (oldest !== undefined) rotatedTo.delete(oldest);
}
rotatedTo.set(previous, next);
}
function latestRotation(refreshToken: string): OAuthTokens | null {
let latest: OAuthTokens | null = null;
let key = refreshToken;
for (let hop = 0; hop < ROTATED_MAX; hop++) {
const next = rotatedTo.get(key);
if (!next?.refreshToken) break;
latest = next;
key = next.refreshToken;
}
return latest;
}
/** Test hook. */
export function resetRefreshRotations(): void {
rotatedTo.clear();
}
// OAuth refresh — exchanges the refresh token at the original token endpoint // OAuth refresh — exchanges the refresh token at the original token endpoint
// for a new access token. Returns the updated bundle so the caller can // for a new access token. Returns the updated bundle so the caller can
// persist it. // persist it.
@@ -543,6 +582,14 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
throw new HandoffError('No refresh token available'); throw new HandoffError('No refresh token available');
} }
const successor = latestRotation(tokens.refreshToken);
if (successor) {
if (successor.expiresAt == null || successor.expiresAt - Date.now() > SUCCESSOR_MIN_LIFETIME_MS) {
return { ...successor, source: tokens.source };
}
return refreshOAuthAccessToken({ ...successor, source: tokens.source });
}
const cacheKey = tokens.refreshToken; const cacheKey = tokens.refreshToken;
let promise = activeRefreshes.get(cacheKey); let promise = activeRefreshes.get(cacheKey);
@@ -599,7 +646,7 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
if (!data.access_token) { if (!data.access_token) {
throw new HandoffError('Token refresh response missing access_token'); throw new HandoffError('Token refresh response missing access_token');
} }
return { const next: OAuthTokens = {
accessToken: data.access_token, accessToken: data.access_token,
refreshToken: data.refresh_token ?? tokens.refreshToken!, refreshToken: data.refresh_token ?? tokens.refreshToken!,
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined, expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
@@ -607,6 +654,8 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
clientId: tokens.clientId, clientId: tokens.clientId,
source: tokens.source, source: tokens.source,
}; };
rememberRotation(cacheKey, next);
return next;
} finally { } finally {
activeRefreshes.delete(cacheKey); activeRefreshes.delete(cacheKey);
} }