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.
This commit is contained in:
Linus Rath
2026-09-26 15:13:41 +02:00
parent 928633d087
commit 49e8d08c23
3 changed files with 31 additions and 0 deletions
+5
View File
@@ -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) {
+22
View File
@@ -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);
+4
View File
@@ -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) {