fix: retry the JMAP session fetch when a redirect drops the auth header #892

This commit is contained in:
Linus Rath
2026-09-07 22:34:47 +02:00
parent faed1591ef
commit 40e38d1679
2 changed files with 92 additions and 0 deletions
@@ -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<typeof vi.spyOn>;
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<string, string>;
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);
});
});
+9
View File
@@ -949,6 +949,15 @@ export class JMAPClient implements IJMAPClient {
private async fetchSessionResponse(): Promise<Response> {
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);