mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
fix: keep the browser's sign-in dialog away from uploads after the access token expired #1396
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user