mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +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,
|
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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user