mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: keep accounts signed in when Stalwart 1.0 rotates the refresh token
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
+50
-1
@@ -535,6 +535,45 @@ export async function redeemPairingCode(webmailUrl: string, code: string): Promi
|
||||
|
||||
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
|
||||
// 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<OAut
|
||||
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;
|
||||
let promise = activeRefreshes.get(cacheKey);
|
||||
|
||||
@@ -599,7 +646,7 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
|
||||
if (!data.access_token) {
|
||||
throw new HandoffError('Token refresh response missing access_token');
|
||||
}
|
||||
return {
|
||||
const next: OAuthTokens = {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? tokens.refreshToken!,
|
||||
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,
|
||||
source: tokens.source,
|
||||
};
|
||||
rememberRotation(cacheKey, next);
|
||||
return next;
|
||||
} finally {
|
||||
activeRefreshes.delete(cacheKey);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user