diff --git a/lib/__tests__/jmap-lite-credentials.test.ts b/lib/__tests__/jmap-lite-credentials.test.ts index c14cc19ad..c4b8a880b 100644 --- a/lib/__tests__/jmap-lite-credentials.test.ts +++ b/lib/__tests__/jmap-lite-credentials.test.ts @@ -24,6 +24,35 @@ function json(status: number, body?: unknown): Response { }); } +/** + * XMLHttpRequest has no credentials mode, so the upload-with-progress path + * can't omit them: records the Authorization header each upload went out with. + */ +const xhrAuthHeaders: string[] = []; +class FakeXHR { + status = 0; + responseText = ''; + responseType = ''; + upload: { onprogress: ((ev: ProgressEvent) => void) | null } = { onprogress: null }; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + onabort: (() => void) | null = null; + private headers: Record = {}; + open() {} + abort() {} + setRequestHeader(name: string, value: string) { + this.headers[name] = value; + } + send() { + xhrAuthHeaders.push(this.headers.Authorization); + queueMicrotask(() => { + this.status = 201; + this.responseText = JSON.stringify({ blobId: 'blob-1', size: 3, type: 'text/plain' }); + this.onload?.(); + }); + } +} + type Client = typeof import('../jmap/client').JMAPClient; // The build target is read once at import, so each mode needs a fresh module. @@ -102,4 +131,68 @@ describe('JMAP request credentials mode', () => { expect((fetchSpy.mock.calls[0][1] as RequestInit).credentials).toBeUndefined(); }); + + describe('uploads with progress', () => { + const file = () => new File(['abc'], 'a.txt', { type: 'text/plain' }); + + beforeEach(() => { + xhrAuthHeaders.length = 0; + vi.stubGlobal('XMLHttpRequest', FakeXHR); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('refreshes an access token that may have expired before the XHR goes out 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(); + + // Woken from sleep: nothing has confirmed the token for a while. + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(Date.now() + 10 * 60_000); + fetchSpy + .mockResolvedValueOnce(json(401)) + .mockResolvedValueOnce(json(200, { methodResponses: [['Core/echo', {}, '0']] })); + await client.uploadBlob(file(), { onProgress: () => {} }); + client.disconnect(); + + expect(refresh).toHaveBeenCalledOnce(); + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect((fetchSpy.mock.calls[1][1] as RequestInit).credentials).toBe('omit'); + expect(xhrAuthHeaders).toEqual(['Bearer fresh-token']); + }); + + it('sends the XHR straight away when a request just confirmed the token', async () => { + const JMAPClient = clients.lite!; + fetchSpy.mockResolvedValueOnce(json(200, session)); + const client = JMAPClient.withBearer('https://mail.example.com', 'token', 'user@test.com', vi.fn()); + await client.connect(); + + await client.uploadBlob(file(), { onProgress: () => {} }); + client.disconnect(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(xhrAuthHeaders).toEqual(['Bearer token']); + }); + + it('sends the XHR straight away 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', vi.fn()); + await client.connect(); + + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(Date.now() + 10 * 60_000); + await client.uploadBlob(file(), { onProgress: () => {} }); + client.disconnect(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(xhrAuthHeaders).toEqual(['Bearer token']); + }); + }); }); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 1ba980a10..c8137fbc5 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1478,6 +1478,12 @@ export class JMAPClient implements IJMAPClient { * attachment on a slow uplink legitimately outlives the normal deadline. */ private static readonly TRANSFER_TIMEOUT_MS = 300_000; + /** + * How long an access token counts as good after the server last accepted + * it. The keep-alive ping confirms it every 30s while the page is awake, + * and the refresh timer renews it a minute before it expires. + */ + private static readonly AUTH_ACCEPTED_FRESH_MS = 30_000; private serverUrl: string; private username: string; @@ -1486,6 +1492,8 @@ export class JMAPClient implements IJMAPClient { private authHeader: string; private authMode: 'basic' | 'bearer' = 'basic'; private onTokenRefresh?: () => Promise; + /** When the server last answered a request without 401 (epoch ms). */ + private authAcceptedAt = 0; private onTotpRequired?: () => Promise; private apiUrl: string = ""; private accountId: string = ""; @@ -1792,6 +1800,7 @@ export class JMAPClient implements IJMAPClient { } } + if (response.status !== 401) this.authAcceptedAt = Date.now(); return response; } @@ -5720,6 +5729,19 @@ export class JMAPClient implements IJMAPClient { } } + /** + * XMLHttpRequest has no credentials mode, so in Lite on Stalwart an upload + * with an expired access token still lets the browser act on the Basic + * challenge and show its login dialog (#1396). When nothing has confirmed + * the token lately (the machine slept, say), check it with a fetch first: + * that one omits credentials, and an expired token is refreshed there. + */ + private async confirmTokenBeforeXhr(): Promise { + if (JMAP_FETCH_CREDENTIALS !== 'omit' || this.authMode !== 'bearer' || !this.onTokenRefresh) return; + if (Date.now() - this.authAcceptedAt < JMAPClient.AUTH_ACCEPTED_FRESH_MS) return; + await this.request([['Core/echo', {}, '0']]); + } + private xhrUpload( url: string, file: File, @@ -5803,6 +5825,7 @@ export class JMAPClient implements IJMAPClient { // existing 401/retry behaviour through authenticatedFetch(). let responseText: string; if (opts.onProgress || opts.signal) { + await this.confirmTokenBeforeXhr(); responseText = await this.xhrUpload( finalUploadUrl, file,