From 49e8d08c2389efa30e9b5d0d0868f3096ae1aa75 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:13:41 +0200 Subject: [PATCH] fix: never follow redirects on OAuth token and revocation requests The code exchange, token refresh and revocation POSTs carry the authorization code, refresh token and client secret, and used a plain `fetch` that follows redirects. The endpoints come from a validated discovery document, but a redirect from them (or from an endpoint that changed after discovery) re-sent the body to a host nobody validated. These requests now fail on any redirect. A failed refresh still keeps the refresh token, as for any other outage. --- app/api/auth/token/route.ts | 5 +++++ lib/__tests__/oauth-token-route.test.ts | 22 ++++++++++++++++++++++ lib/oauth/token-exchange.ts | 4 ++++ 3 files changed, 31 insertions(+) diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index 0a55ef72f..239739a54 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -52,11 +52,14 @@ async function revokeRefreshToken(token: string, serverId: string | null, metada }, serverId, { fallbackClientId: DEFAULT_CLIENT_ID }); try { + // Never follow a redirect with the refresh token and client secret in + // the body: the endpoint was validated, its redirect target was not. const revocationResponse = await fetch(metadata.revocation_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString(), signal: AbortSignal.timeout(REVOCATION_TIMEOUT_MS), + redirect: 'error', }); if (!revocationResponse.ok) { logger.warn('Token revocation returned error', { status: revocationResponse.status }); @@ -205,6 +208,8 @@ export async function PUT(request: NextRequest) { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString(), + // See revokeRefreshToken: the body holds the refresh token. + redirect: 'error', }); if (!tokenResponse.ok) { diff --git a/lib/__tests__/oauth-token-route.test.ts b/lib/__tests__/oauth-token-route.test.ts index 384702840..116d63ccd 100644 --- a/lib/__tests__/oauth-token-route.test.ts +++ b/lib/__tests__/oauth-token-route.test.ts @@ -145,6 +145,28 @@ describe('oauth token route - access token cache (#552)', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it('never follows a redirect with the refresh token in the body', async () => { + cookieStore.set('jmap_rt', 'refresh-token'); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ access_token: 'fresh-token', expires_in: 1800 }), + }); + + await callPut(); + + expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: 'POST', redirect: 'error' }); + }); + + it('keeps the refresh token when the token endpoint redirects', async () => { + cookieStore.set('jmap_rt', 'refresh-token'); + fetchMock.mockRejectedValue(new TypeError('fetch failed: unexpected redirect')); + + const { status } = await callPut(); + + expect(status).toBe(500); + expect(cookieStore.get('jmap_rt')).toBeTruthy(); + }); + it('ignores an expired cached token', async () => { cookieStore.set('jmap_rt', 'refresh-token'); seedCachedToken('long-gone', -600); diff --git a/lib/oauth/token-exchange.ts b/lib/oauth/token-exchange.ts index 5e84d3106..2c91f1911 100644 --- a/lib/oauth/token-exchange.ts +++ b/lib/oauth/token-exchange.ts @@ -174,10 +174,14 @@ export async function exchangeCodeForTokens( code_verifier: codeVerifier, }, serverId); + // The body carries the code, the PKCE verifier and the client secret. The + // endpoint was validated; a redirect would re-send all of it to a host + // that was not, so none is followed. const tokenResponse = await fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString(), + redirect: 'error', }); if (!tokenResponse.ok) {