fix: keep the browser's sign-in dialog away from uploads after the access token expired #1396

This commit is contained in:
Linus Rath
2026-10-29 14:32:27 +01:00
parent c8bfc46091
commit 378b0e996c
2 changed files with 116 additions and 0 deletions
@@ -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<string, string> = {};
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']);
});
});
});
+23
View File
@@ -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<string | null>;
/** When the server last answered a request without 401 (epoch ms). */
private authAcceptedAt = 0;
private onTotpRequired?: () => Promise<string | null>;
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<void> {
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,