diff --git a/lib/__tests__/jmap-lite-credentials.test.ts b/lib/__tests__/jmap-lite-credentials.test.ts new file mode 100644 index 000000000..c14cc19ad --- /dev/null +++ b/lib/__tests__/jmap-lite-credentials.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; + +/** + * #1396: in the Lite bundle served by Stalwart, JMAP requests share the page's + * origin, so a 401 with Stalwart's `WWW-Authenticate: Basic` challenge made + * Safari show its login dialog unless credentials are omitted. The regular + * build must keep sending cookies (cookie-gated proxies in front of JMAP). + */ + +const session = { + capabilities: { 'urn:ietf:params:jmap:core': {} }, + accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' }, + 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?types={types}&closeafter={closeafter}&ping={ping}', +}; + +function json(status: number, body?: unknown): Response { + return new Response(body ? JSON.stringify(body) : null, { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +type Client = typeof import('../jmap/client').JMAPClient; + +// The build target is read once at import, so each mode needs a fresh module. +// A cold import of the client takes a few seconds, hence the hook timeout. +const IMPORT_TIMEOUT = 30_000; +const clients: Partial> = {}; + +async function loadClient(liteTarget: 'stalwart' | null): Promise { + vi.resetModules(); + vi.stubEnv('NEXT_PUBLIC_BULWARK_LITE', liteTarget ? '1' : ''); + vi.stubEnv('NEXT_PUBLIC_LITE_TARGET', liteTarget ?? ''); + const { JMAPClient } = await import('../jmap/client'); + vi.unstubAllEnvs(); + return JMAPClient; +} + +describe('JMAP request credentials mode', () => { + let fetchSpy: ReturnType; + + beforeAll(async () => { + clients.lite = await loadClient('stalwart'); + clients.regular = await loadClient(null); + }, IMPORT_TIMEOUT); + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((url) => + Promise.reject(new Error(`Unmocked fetch: ${String(url)}`))); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('omits credentials on the session fetch, the request and its refresh retry in Lite on Stalwart', async () => { + const JMAPClient = clients.lite!; + const refresh = vi.fn().mockResolvedValue('fresh-token'); + fetchSpy.mockResolvedValueOnce(json(200, session)); + const client = JMAPClient.withBearer('https://mail.example.com', 'expired', 'user@test.com', refresh); + await client.connect(); + + fetchSpy + .mockResolvedValueOnce(json(401)) + .mockResolvedValueOnce(json(200, { methodResponses: [['Core/echo', {}, '0']] })); + await client.ping(); + client.disconnect(); + + expect(refresh).toHaveBeenCalledOnce(); + expect(fetchSpy).toHaveBeenCalledTimes(3); + for (const [, init] of fetchSpy.mock.calls) { + expect((init as RequestInit).credentials).toBe('omit'); + } + }); + + it('keeps an explicit credentials mode chosen by the caller', async () => { + const JMAPClient = clients.lite!; + fetchSpy.mockResolvedValueOnce(json(200, session)); + const client = JMAPClient.withBearer('https://mail.example.com', 'token', 'user@test.com'); + await client.connect(); + + fetchSpy.mockResolvedValueOnce(json(200, {})); + // authenticatedFetch is private; reach it the way the class's own callers do. + await (client as unknown as { + authenticatedFetch(url: string, init: RequestInit): Promise; + }).authenticatedFetch('https://mail.example.com/jmap/api', { credentials: 'include' }); + client.disconnect(); + + expect((fetchSpy.mock.calls[1][1] as RequestInit).credentials).toBe('include'); + }); + + it('leaves the browser default in the regular build', async () => { + const JMAPClient = clients.regular!; + fetchSpy.mockResolvedValueOnce(json(200, session)); + const client = JMAPClient.withBearer('https://mail.example.com', 'token', 'user@test.com'); + await client.connect(); + client.disconnect(); + + expect((fetchSpy.mock.calls[0][1] as RequestInit).credentials).toBeUndefined(); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 43aaaf67c..1ba980a10 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -40,6 +40,7 @@ import type { AccountInfo } from "./types"; import { stalwartChangeId } from "./stalwart-state"; import { downloadNameSegment } from "./download-name"; import { toInertBlob } from "@/lib/file-preview"; +import { IS_LITE_STALWART } from "@/lib/lite"; import { toStorageQuota, type JMAPQuota, type StorageQuota } from "@/lib/quota"; import { messagePriorityHeaders, type MessagePriority } from "@/lib/message-priority"; @@ -212,6 +213,25 @@ export class RequestTimeoutError extends Error { } } +/** + * Credentials mode for the client's own JMAP requests, which always carry + * their own Authorization header. + * + * In the Lite bundle served by Stalwart the page and JMAP share an origin, so + * the default (`same-origin`) lets the browser act on Stalwart's + * `WWW-Authenticate: Basic` challenge: an expired access token makes Safari + * show its native login dialog before the 401 reaches the refresh-and-retry + * code. With credentials omitted the Fetch standard forbids that prompt + * (#1396). The regular build keeps the default: a same-origin JMAP server + * behind a cookie-gated proxy (Pangolin, Authelia) needs the cookie sent. + */ +const JMAP_FETCH_CREDENTIALS: RequestCredentials | undefined = IS_LITE_STALWART ? 'omit' : undefined; + +function withJmapCredentials(init: RequestInit | undefined): RequestInit { + if (!JMAP_FETCH_CREDENTIALS || init?.credentials) return { ...init }; + return { ...init, credentials: JMAP_FETCH_CREDENTIALS }; +} + const READ_ONLY_METHOD = /\/(?:get|query|changes|queryChanges|parse)$|^Core\/echo$/; /** @@ -1684,7 +1704,7 @@ export class JMAPClient implements IJMAPClient { }, timeoutMs); try { - return await fetch(url, { ...init, headers, signal: controller.signal }); + return await fetch(url, { ...withJmapCredentials(init), headers, signal: controller.signal }); } catch (error) { if (timedOut) throw new RequestTimeoutError(timeoutMs); throw error; @@ -1797,10 +1817,10 @@ export class JMAPClient implements IJMAPClient { // 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, { + return fetch(response.url, withJmapCredentials({ method: 'GET', headers: { 'Authorization': this.authHeader }, - }); + })); } if (!response.ok || !response.redirected) return response; @@ -1809,10 +1829,10 @@ export class JMAPClient implements IJMAPClient { const hasUsername = typeof peek?.username === 'string' && peek.username.length > 0; if (hasAccounts || hasUsername) return response; - return fetch(response.url, { + return fetch(response.url, withJmapCredentials({ method: 'GET', headers: { 'Authorization': this.authHeader }, - }); + })); } private async refreshSession(): Promise {