diff --git a/src/lib/__tests__/oauth.test.ts b/src/lib/__tests__/oauth.test.ts index d31dd751..1bd42e92 100644 --- a/src/lib/__tests__/oauth.test.ts +++ b/src/lib/__tests__/oauth.test.ts @@ -8,11 +8,12 @@ vi.mock('../client-cert', () => ({ secureFetch: mockSecureFetch, })); -import { refreshOAuthAccessToken, type OAuthTokens } from '../oauth'; +import { refreshOAuthAccessToken, resetRefreshRotations, type OAuthTokens } from '../oauth'; describe('oauth token refresh deduplication', () => { beforeEach(() => { vi.clearAllMocks(); + resetRefreshRotations(); }); 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.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(); const res3 = await refreshOAuthAccessToken(tokens); - expect(mockSecureFetch).toHaveBeenCalledTimes(1); + expect(mockSecureFetch).not.toHaveBeenCalled(); 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 () => { diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index a31db07c..d99abfd7 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -535,6 +535,45 @@ export async function redeemPairingCode(webmailUrl: string, code: string): Promi const activeRefreshes = new Map>(); +// 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(); +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 // for a new access token. Returns the updated bundle so the caller can // persist it. @@ -543,6 +582,14 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise SUCCESSOR_MIN_LIFETIME_MS) { + return { ...successor, source: tokens.source }; + } + return refreshOAuthAccessToken({ ...successor, source: tokens.source }); + } + const cacheKey = tokens.refreshToken; let promise = activeRefreshes.get(cacheKey); @@ -599,7 +646,7 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise