mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
fix: stop Safari's login dialog in Lite on Stalwart when the access token expired #1396
This commit is contained in:
@@ -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<Record<'lite' | 'regular', Client>> = {};
|
||||
|
||||
async function loadClient(liteTarget: 'stalwart' | null): Promise<Client> {
|
||||
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<typeof vi.spyOn>;
|
||||
|
||||
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<Response>;
|
||||
}).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();
|
||||
});
|
||||
});
|
||||
+25
-5
@@ -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<void> {
|
||||
|
||||
Reference in New Issue
Block a user