From 40e38d1679a011fd576e997f98ec032cd7d69d9c Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Mon, 7 Sep 2026 22:34:47 +0200 Subject: [PATCH] fix: retry the JMAP session fetch when a redirect drops the auth header #892 --- .../jmap-session-redirect-401.test.ts | 83 +++++++++++++++++++ lib/jmap/client.ts | 9 ++ 2 files changed, 92 insertions(+) create mode 100644 lib/__tests__/jmap-session-redirect-401.test.ts diff --git a/lib/__tests__/jmap-session-redirect-401.test.ts b/lib/__tests__/jmap-session-redirect-401.test.ts new file mode 100644 index 000000000..4d4cd3c39 --- /dev/null +++ b/lib/__tests__/jmap-session-redirect-401.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { JMAPClient } from '../jmap/client'; + +const WELL_KNOWN = 'https://mail.example.com/.well-known/jmap'; +const SESSION_URL = 'https://proxy/jmap/session'; + +function makeSession() { + return { + capabilities: { 'urn:ietf:params:jmap:core': {} }, + accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' }, + username: 'test@example.com', + apiUrl: 'https://mail.example.com/jmap/api', + downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}', + uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/', + eventSourceUrl: 'https://mail.example.com/jmap/eventsource', + }; +} + +function response(status: number, body: unknown, extra?: { redirected?: boolean; url?: string }): Response { + const res = new Response(body === null ? null : JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + Object.defineProperty(res, 'redirected', { value: extra?.redirected ?? false }); + Object.defineProperty(res, 'url', { value: extra?.url ?? '' }); + return res; +} + +describe('JMAP session fetch when a redirect drops the Authorization header (#892)', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('re-fetches the redirected session URL with credentials after a 401', async () => { + fetchSpy.mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url === WELL_KNOWN) { + // The proxy rejected the header-less redirected request. + return response(401, { title: 'Unauthorized' }, { redirected: true, url: SESSION_URL }); + } + if (url === SESSION_URL) { + return response(200, makeSession()); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + const client = new JMAPClient('https://mail.example.com', 'test@example.com', 'secret'); + await expect(client.connect()).resolves.toBeUndefined(); + + const sessionCall = fetchSpy.mock.calls.find(([input]: unknown[]) => String(input) === SESSION_URL); + expect(sessionCall).toBeDefined(); + const headers = (sessionCall![1] as RequestInit).headers as Record; + expect(headers.Authorization).toMatch(/^Basic /); + }); + + it('still fails when the direct request is rejected as well', async () => { + fetchSpy.mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url === WELL_KNOWN) { + return response(401, { title: 'Unauthorized' }, { redirected: true, url: SESSION_URL }); + } + return response(401, { title: 'Unauthorized' }); + }); + + const client = new JMAPClient('https://mail.example.com', 'test@example.com', 'wrong'); + await expect(client.connect()).rejects.toThrow(/Invalid username or password/); + }); + + it('does not retry a plain 401 that was not redirected', async () => { + fetchSpy.mockImplementation(async () => response(401, { title: 'Unauthorized' })); + + const client = new JMAPClient('https://mail.example.com', 'test@example.com', 'wrong'); + await expect(client.connect()).rejects.toThrow(/Invalid username or password/); + expect(fetchSpy.mock.calls.every(([input]: unknown[]) => String(input) === WELL_KNOWN)).toBe(true); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index ccb8d4b35..17937d02f 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -949,6 +949,15 @@ export class JMAPClient implements IJMAPClient { private async fetchSessionResponse(): Promise { const discoveryUrl = `${this.serverUrl}/.well-known/jmap`; const response = await this.authenticatedFetch(discoveryUrl, { method: 'GET' }); + // A proxy in front of the session URL answers the header-less redirected + // request with 401 instead of Stalwart's silent 200 - the credentials are + // fine, Safari just dropped them. Retry the final URL directly. (#892) + if (response.redirected && response.status === 401) { + return fetch(response.url, { + method: 'GET', + headers: { 'Authorization': this.authHeader }, + }); + } if (!response.ok || !response.redirected) return response; const peek = await response.clone().json().catch(() => null);