mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: harden the JMAP client with request deadlines, retries, rate-limit state and method-error helpers (#702, #780, #907)
This commit is contained in:
@@ -0,0 +1,375 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('expo-secure-store', () => ({
|
||||||
|
setItemAsync: vi.fn(),
|
||||||
|
getItemAsync: vi.fn(),
|
||||||
|
deleteItemAsync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockRefresh } = vi.hoisted(() => ({ mockRefresh: vi.fn() }));
|
||||||
|
vi.mock('../../lib/oauth', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../../lib/oauth')>();
|
||||||
|
return { ...actual, refreshOAuthAccessToken: mockRefresh };
|
||||||
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
JMAPClient,
|
||||||
|
AuthenticationError,
|
||||||
|
NetworkError,
|
||||||
|
RateLimitError,
|
||||||
|
RequestTimeoutError,
|
||||||
|
TotpRequiredError,
|
||||||
|
JMAPMethodError,
|
||||||
|
requireMethodResult,
|
||||||
|
assertSetResult,
|
||||||
|
rewriteSessionUrl,
|
||||||
|
parseRetryAfter,
|
||||||
|
isConcurrentRequestRefusal,
|
||||||
|
batched,
|
||||||
|
} from '../jmap-client';
|
||||||
|
import { TransientRefreshError } from '../../lib/oauth';
|
||||||
|
import { FirstTouchGate, gateKeysFor } from '../first-touch-gate';
|
||||||
|
import type { JMAPSession } from '../types';
|
||||||
|
|
||||||
|
const SESSION: JMAPSession = {
|
||||||
|
apiUrl: 'https://mail.example.com/jmap/',
|
||||||
|
downloadUrl: 'https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}',
|
||||||
|
uploadUrl: 'https://mail.example.com/upload/{accountId}/',
|
||||||
|
eventSourceUrl: 'https://mail.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}',
|
||||||
|
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acc-1' },
|
||||||
|
accounts: {
|
||||||
|
'acc-1': {
|
||||||
|
name: 'user@example.com',
|
||||||
|
isPersonal: true,
|
||||||
|
isReadOnly: false,
|
||||||
|
accountCapabilities: { 'urn:stalwart:jmap': {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
'urn:ietf:params:jmap:core': { maxObjectsInSet: 50, maxSizeUpload: 1024 },
|
||||||
|
'urn:ietf:params:jmap:mail': {},
|
||||||
|
},
|
||||||
|
state: 's1',
|
||||||
|
username: 'user@example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
type Resp = {
|
||||||
|
status: number;
|
||||||
|
json?: unknown;
|
||||||
|
text?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
redirected?: boolean;
|
||||||
|
url?: string;
|
||||||
|
delayMs?: number;
|
||||||
|
throw?: Error;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeResponse(resp: Resp) {
|
||||||
|
const text = resp.text ?? (resp.json === undefined ? '' : JSON.stringify(resp.json));
|
||||||
|
return {
|
||||||
|
ok: resp.status >= 200 && resp.status < 300,
|
||||||
|
status: resp.status,
|
||||||
|
statusText: 'x',
|
||||||
|
redirected: resp.redirected ?? false,
|
||||||
|
url: resp.url ?? '',
|
||||||
|
headers: { get: (n: string) => resp.headers?.[n] ?? null },
|
||||||
|
json: async () => (resp.json !== undefined ? resp.json : JSON.parse(text)),
|
||||||
|
text: async () => text,
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFetch(responses: Resp[]) {
|
||||||
|
let i = 0;
|
||||||
|
const fn = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||||
|
const resp = responses[i++] ?? responses[responses.length - 1];
|
||||||
|
if (resp.throw) throw resp.throw;
|
||||||
|
if (resp.delayMs) {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const t = setTimeout(resolve, resp.delayMs);
|
||||||
|
init?.signal?.addEventListener('abort', () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
const e = new Error('Aborted');
|
||||||
|
e.name = 'AbortError';
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return makeResponse(resp);
|
||||||
|
});
|
||||||
|
global.fetch = fn as unknown as typeof fetch;
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connected(): Promise<JMAPClient> {
|
||||||
|
mockFetch([{ status: 200, json: SESSION }]);
|
||||||
|
const client = new JMAPClient();
|
||||||
|
await client.connect('https://mail.example.com', 'user', 'pass');
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('pure helpers', () => {
|
||||||
|
it('rewriteSessionUrl rebases relative and foreign-origin URLs, keeps templates', () => {
|
||||||
|
const origin = 'https://mail.example.com';
|
||||||
|
expect(rewriteSessionUrl('/jmap/', origin)).toBe('https://mail.example.com/jmap/');
|
||||||
|
expect(rewriteSessionUrl('jmap/', origin)).toBe('https://mail.example.com/jmap/');
|
||||||
|
expect(rewriteSessionUrl('http://localhost:8080/download/{accountId}/{blobId}/{name}?type={type}', origin))
|
||||||
|
.toBe('https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}');
|
||||||
|
expect(rewriteSessionUrl('https://mail.example.com/x', origin)).toBe('https://mail.example.com/x');
|
||||||
|
expect(rewriteSessionUrl(undefined, origin)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseRetryAfter handles seconds, HTTP-date, garbage', () => {
|
||||||
|
expect(parseRetryAfter('10')).toBe(10_000);
|
||||||
|
expect(parseRetryAfter('9999')).toBe(300_000);
|
||||||
|
const future = new Date(Date.now() + 30_000).toUTCString();
|
||||||
|
const ms = parseRetryAfter(future);
|
||||||
|
expect(ms).toBeGreaterThan(25_000);
|
||||||
|
expect(ms).toBeLessThanOrEqual(31_000);
|
||||||
|
expect(parseRetryAfter('bogus')).toBe(60_000);
|
||||||
|
expect(parseRetryAfter(null)).toBe(60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isConcurrentRequestRefusal matches only the limit error', () => {
|
||||||
|
expect(isConcurrentRequestRefusal(400, JSON.stringify({ type: 'urn:ietf:params:jmap:error:limit', limit: 'maxConcurrentRequests' }))).toBe(true);
|
||||||
|
expect(isConcurrentRequestRefusal(400, JSON.stringify({ type: 'urn:ietf:params:jmap:error:limit', limit: 'maxSizeRequest' }))).toBe(false);
|
||||||
|
expect(isConcurrentRequestRefusal(500, 'nope')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requireMethodResult throws JMAPMethodError on the error envelope', () => {
|
||||||
|
const res = { methodResponses: [['error', { type: 'forbidden', description: 'no' }, '0']] as Array<[string, Record<string, any>, string]> };
|
||||||
|
expect(() => requireMethodResult(res, '0')).toThrow(JMAPMethodError);
|
||||||
|
expect(() => requireMethodResult(res)).toThrow('no');
|
||||||
|
const ok = { methodResponses: [['Mailbox/get', { list: [1] }, 'a']] as Array<[string, Record<string, any>, string]> };
|
||||||
|
expect(requireMethodResult(ok, 'a', 'Mailbox/get')).toEqual({ list: [1] });
|
||||||
|
expect(() => requireMethodResult(ok, 'a', 'Email/get')).toThrow('Expected Email/get');
|
||||||
|
expect(() => requireMethodResult(ok, 'zz')).toThrow('Missing JMAP response');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assertSetResult throws for notUpdated / notDestroyed / notCreated', () => {
|
||||||
|
expect(() => assertSetResult({ updated: { a: null } }, ['a'])).not.toThrow();
|
||||||
|
expect(() => assertSetResult({ notUpdated: { a: { type: 'forbidden' } } }, ['a'])).toThrow('update');
|
||||||
|
expect(() => assertSetResult({ notUpdated: { b: { type: 'forbidden' } } }, ['a'])).not.toThrow();
|
||||||
|
expect(() => assertSetResult({ notDestroyed: { a: { type: 'notFound', description: 'gone' } } })).toThrow('gone');
|
||||||
|
expect(() => assertSetResult({ notCreated: { x: { type: 'invalidProperties', properties: ['uid'] } } }, undefined, 'contact')).toThrow('properties=[uid]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('batched splits lists', () => {
|
||||||
|
expect(batched([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
|
||||||
|
expect(batched([], 2)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FirstTouchGate', () => {
|
||||||
|
it('serialises the first calendar/contacts request per account', async () => {
|
||||||
|
const gate = new FirstTouchGate();
|
||||||
|
const order: string[] = [];
|
||||||
|
let release!: () => void;
|
||||||
|
const first = gate.run([['Calendar/get', { accountId: 'a' }, '0']], () => new Promise<string>((resolve) => {
|
||||||
|
release = () => { order.push('first'); resolve('first'); };
|
||||||
|
}));
|
||||||
|
const second = gate.run([['CalendarEvent/query', { accountId: 'a' }, '0']], async () => { order.push('second'); return 'second'; });
|
||||||
|
const other = gate.run([['Mailbox/get', { accountId: 'a' }, '0']], async () => { order.push('mail'); return 'mail'; });
|
||||||
|
await other;
|
||||||
|
expect(order).toEqual(['mail']);
|
||||||
|
release();
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
expect(order).toEqual(['mail', 'first', 'second']);
|
||||||
|
expect(gateKeysFor([['AddressBook/get', { accountId: 'b' }, '0'], ['Email/get', { accountId: 'b' }, '1']])).toEqual(['contacts:b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JMAPClient hardening', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
mockRefresh.mockReset();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves relative session URLs against the login origin', async () => {
|
||||||
|
mockFetch([{ status: 200, json: { ...SESSION, apiUrl: '/jmap/', downloadUrl: '/download/{accountId}/{blobId}/{name}?type={type}' } }]);
|
||||||
|
const client = new JMAPClient();
|
||||||
|
const session = await client.connect('https://mail.example.com', 'user', 'pass');
|
||||||
|
expect(session.apiUrl).toBe('https://mail.example.com/jmap/');
|
||||||
|
expect(client.getBlobDownloadUrl('b1', 'n')).toBe('https://mail.example.com/download/acc-1/b1/n?type=application%2Foctet-stream');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refetches the final URL when a redirect dropped the Authorization header', async () => {
|
||||||
|
const fetch = mockFetch([
|
||||||
|
{ status: 200, json: { apiUrl: '/jmap/', accounts: {}, primaryAccounts: {} }, redirected: true, url: 'https://mail.example.com/jmap/session' },
|
||||||
|
{ status: 200, json: SESSION },
|
||||||
|
]);
|
||||||
|
const client = new JMAPClient();
|
||||||
|
await client.connect('https://mail.example.com', 'user', 'pass');
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fetch.mock.calls[1][0]).toBe('https://mail.example.com/jmap/session');
|
||||||
|
expect(client.accountId).toBe('acc-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws TotpRequiredError on a 402 MFA challenge', async () => {
|
||||||
|
mockFetch([{ status: 402, json: { title: 'MFA code required' } }]);
|
||||||
|
const client = new JMAPClient();
|
||||||
|
const err = await client.connect('https://mail.example.com', 'user', 'pass').catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(TotpRequiredError);
|
||||||
|
expect(err).toBeInstanceOf(AuthenticationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('times out a stalled request with RequestTimeoutError and does not retry it', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const client = await connected();
|
||||||
|
const fetch = mockFetch([{ status: 200, json: { methodResponses: [] }, delayMs: 60_000 }]);
|
||||||
|
const p = client.request([['EmailSubmission/set', { accountId: 'acc-1' }, '0']]);
|
||||||
|
const settled = p.catch((e) => e);
|
||||||
|
await vi.advanceTimersByTimeAsync(31_000);
|
||||||
|
const err = await settled;
|
||||||
|
expect(err).toBeInstanceOf(RequestTimeoutError);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries an idempotent request once after a transient network error', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const client = await connected();
|
||||||
|
const fetch = mockFetch([
|
||||||
|
{ status: 200, throw: new TypeError('Network request failed') },
|
||||||
|
{ status: 200, json: { methodResponses: [['Mailbox/get', { list: [] }, '0']] } },
|
||||||
|
]);
|
||||||
|
const p = client.request([['Mailbox/get', { accountId: 'acc-1' }, '0']]);
|
||||||
|
await vi.advanceTimersByTimeAsync(1100);
|
||||||
|
const res = await p;
|
||||||
|
expect(res.methodResponses[0][0]).toBe('Mailbox/get');
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not retry a submission after a network error and wraps it as NetworkError', async () => {
|
||||||
|
const client = await connected();
|
||||||
|
const fetch = mockFetch([{ status: 200, throw: new TypeError('Network request failed') }]);
|
||||||
|
const err = await client.request([['EmailSubmission/set', { accountId: 'acc-1' }, '0']]).catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(NetworkError);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replays a maxConcurrentRequests refusal with back-off', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const client = await connected();
|
||||||
|
const refusal = { type: 'urn:ietf:params:jmap:error:limit', limit: 'maxConcurrentRequests' };
|
||||||
|
const fetch = mockFetch([
|
||||||
|
{ status: 400, json: refusal },
|
||||||
|
{ status: 400, json: refusal },
|
||||||
|
{ status: 200, json: { methodResponses: [['Email/set', { updated: {} }, '0']] } },
|
||||||
|
]);
|
||||||
|
const p = client.request([['Email/set', { accountId: 'acc-1' }, '0']]);
|
||||||
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
|
await p;
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the response body on a failed request and guards JSON parsing', async () => {
|
||||||
|
const client = await connected();
|
||||||
|
mockFetch([{ status: 500, text: 'boom happened' }]);
|
||||||
|
await expect(client.request([['Mailbox/get', {}, '0']])).rejects.toThrow('JMAP request failed: 500 - boom happened');
|
||||||
|
mockFetch([{ status: 200, text: '<html>not json' }]);
|
||||||
|
await expect(client.request([['Mailbox/get', {}, '0']])).rejects.toThrow('Invalid JSON');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enters a rate-limit window on 429 (HTTP-date) and short-circuits until it ends', async () => {
|
||||||
|
const client = await connected();
|
||||||
|
const onRateLimit = vi.fn();
|
||||||
|
client.onRateLimit(onRateLimit);
|
||||||
|
const fetch = mockFetch([{ status: 429, headers: { 'Retry-After': new Date(Date.now() + 20_000).toUTCString() } }]);
|
||||||
|
const err = await client.request([['Mailbox/get', {}, '0']]).catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(RateLimitError);
|
||||||
|
expect(Number.isFinite((err as RateLimitError).retryAfterMs)).toBe(true);
|
||||||
|
expect((err as RateLimitError).retryAfterMs).toBeGreaterThan(10_000);
|
||||||
|
expect(onRateLimit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(client.isRateLimited()).toBe(true);
|
||||||
|
const err2 = await client.request([['Mailbox/get', {}, '0']]).catch((e) => e);
|
||||||
|
expect(err2).toBeInstanceOf(RateLimitError);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies auth-failure listeners on a 401 during a request', async () => {
|
||||||
|
const client = await connected();
|
||||||
|
const onAuthFailure = vi.fn();
|
||||||
|
client.onAuthFailure(onAuthFailure);
|
||||||
|
mockFetch([{ status: 401 }]);
|
||||||
|
await expect(client.request([['Mailbox/get', {}, '0']])).rejects.toThrow(AuthenticationError);
|
||||||
|
expect(onAuthFailure).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the account when the token endpoint is transiently down', async () => {
|
||||||
|
const client = new JMAPClient();
|
||||||
|
mockFetch([{ status: 200, json: SESSION }]);
|
||||||
|
await client.connectWithOAuth('https://mail.example.com', {
|
||||||
|
accessToken: 'old',
|
||||||
|
refreshToken: 'r',
|
||||||
|
expiresAt: Date.now() + 3_600_000,
|
||||||
|
tokenEndpoint: 'https://mail.example.com/auth/token',
|
||||||
|
clientId: 'c',
|
||||||
|
});
|
||||||
|
mockRefresh.mockRejectedValue(new TransientRefreshError('503'));
|
||||||
|
mockFetch([{ status: 401 }]);
|
||||||
|
const err = await client.request([['Mailbox/get', {}, '0']]).catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(NetworkError);
|
||||||
|
expect(err).not.toBeInstanceOf(AuthenticationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires token-refresh listeners and retries with the new bearer after a 401', async () => {
|
||||||
|
const client = new JMAPClient();
|
||||||
|
mockFetch([{ status: 200, json: SESSION }]);
|
||||||
|
await client.connectWithOAuth('https://mail.example.com', {
|
||||||
|
accessToken: 'old',
|
||||||
|
refreshToken: 'r',
|
||||||
|
expiresAt: Date.now() + 3_600_000,
|
||||||
|
tokenEndpoint: 'https://mail.example.com/auth/token',
|
||||||
|
clientId: 'c',
|
||||||
|
});
|
||||||
|
const onRefresh = vi.fn();
|
||||||
|
client.onTokenRefresh(onRefresh);
|
||||||
|
mockRefresh.mockResolvedValue({
|
||||||
|
accessToken: 'new',
|
||||||
|
refreshToken: 'r2',
|
||||||
|
expiresAt: Date.now() + 3_600_000,
|
||||||
|
tokenEndpoint: 'https://mail.example.com/auth/token',
|
||||||
|
clientId: 'c',
|
||||||
|
});
|
||||||
|
const fetch = mockFetch([
|
||||||
|
{ status: 401 },
|
||||||
|
{ status: 200, json: { methodResponses: [['Mailbox/get', {}, '0']] } },
|
||||||
|
]);
|
||||||
|
await client.request([['Mailbox/get', {}, '0']]);
|
||||||
|
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||||
|
expect((fetch.mock.calls[1][1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer new' });
|
||||||
|
expect(client.authHeader).toBe('Bearer new');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updatePassword rewrites the stored credential', async () => {
|
||||||
|
const SecureStore = await import('expo-secure-store');
|
||||||
|
const client = await connected();
|
||||||
|
(SecureStore.setItemAsync as ReturnType<typeof vi.fn>).mockClear();
|
||||||
|
await client.updatePassword('new-pass');
|
||||||
|
expect(client.authHeader).toBe(`Basic ${btoa('user:new-pass')}`);
|
||||||
|
expect(SecureStore.setItemAsync).toHaveBeenCalledTimes(1);
|
||||||
|
const saved = JSON.parse((SecureStore.setItemAsync as ReturnType<typeof vi.fn>).mock.calls[0][1] as string);
|
||||||
|
expect(saved.password).toBe('new-pass');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes account-level capabilities and set/upload limits', async () => {
|
||||||
|
const client = await connected();
|
||||||
|
expect(client.hasAccountCapability('urn:stalwart:jmap')).toBe(true);
|
||||||
|
expect(client.hasCapability('urn:stalwart:jmap')).toBe(false);
|
||||||
|
expect(client.getMaxObjectsInSet()).toBe(50);
|
||||||
|
expect(client.getMaxSizeUpload()).toBe(1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('snapshot/restoreSnapshot round-trips the live connection', async () => {
|
||||||
|
const client = await connected();
|
||||||
|
const snap = client.snapshot();
|
||||||
|
client.reset();
|
||||||
|
expect(client.isConnected).toBe(false);
|
||||||
|
client.restoreSnapshot(snap);
|
||||||
|
expect(client.isConnected).toBe(true);
|
||||||
|
expect(client.accountId).toBe('acc-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,6 +42,7 @@ function mockFetch(responses: Array<{ status: number; json?: any; headers?: Reco
|
|||||||
get: (name: string) => resp.headers?.[name] ?? null,
|
get: (name: string) => resp.headers?.[name] ?? null,
|
||||||
},
|
},
|
||||||
json: async () => resp.json,
|
json: async () => resp.json,
|
||||||
|
text: async () => (resp.json === undefined ? '' : JSON.stringify(resp.json)),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Serialises the FIRST request that touches a lazily-initialised server
|
||||||
|
* collection, per account. Port of the webmail's lib/jmap/first-touch-gate.ts.
|
||||||
|
*
|
||||||
|
* Stalwart creates an account's default calendar (and default address book) on
|
||||||
|
* the first access to an account that has none. On a single node that creation
|
||||||
|
* is guarded by the node's in-memory resource cache, but in clustered / multi-
|
||||||
|
* node deployments each node builds its own cache, so concurrent first-touch
|
||||||
|
* requests that land on different nodes each see "no calendars" and each create
|
||||||
|
* a default - leaving the user with two identical "Personal" calendars, one of
|
||||||
|
* which is flagged `isDefault` and can't be removed from the UI (#907).
|
||||||
|
*
|
||||||
|
* The app's login fans out `Calendar/get`, `CalendarEvent/query` (and
|
||||||
|
* `AddressBook/get`, `ContactCard/query`) within a few milliseconds of each
|
||||||
|
* other, which makes that race trivially winnable. This gate holds every later
|
||||||
|
* request on a (collection, account) pair until the first one has settled -
|
||||||
|
* success or failure - after which requests flow with no further coordination.
|
||||||
|
* Method calls outside the gated collections are never delayed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type MethodCall = [string, Record<string, unknown>, string];
|
||||||
|
|
||||||
|
export type GatedCollection = 'calendar' | 'contacts';
|
||||||
|
|
||||||
|
const COLLECTION_PREFIXES: ReadonlyArray<readonly [GatedCollection, string]> = [
|
||||||
|
['calendar', 'Calendar/'],
|
||||||
|
['calendar', 'CalendarEvent/'],
|
||||||
|
['calendar', 'CalendarEventNotification/'],
|
||||||
|
['contacts', 'AddressBook/'],
|
||||||
|
['contacts', 'ContactCard/'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function collectionOf(method: string): GatedCollection | null {
|
||||||
|
for (const [collection, prefix] of COLLECTION_PREFIXES) {
|
||||||
|
if (method.startsWith(prefix)) return collection;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `collection:accountId` keys a request touches. Calls without a string
|
||||||
|
* accountId are skipped - they'd be rejected by the server anyway.
|
||||||
|
*/
|
||||||
|
export function gateKeysFor(methodCalls: ReadonlyArray<MethodCall>): string[] {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const call of methodCalls) {
|
||||||
|
const collection = collectionOf(call[0]);
|
||||||
|
const accountId = call[1]?.accountId;
|
||||||
|
if (collection && typeof accountId === 'string' && accountId) {
|
||||||
|
keys.add(`${collection}:${accountId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FirstTouchGate {
|
||||||
|
private settled = new Set<string>();
|
||||||
|
private inFlight = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `send` for `methodCalls`, delaying it while another request is the
|
||||||
|
* first to touch one of the collections it uses. Resolves/rejects with
|
||||||
|
* whatever `send` does.
|
||||||
|
*/
|
||||||
|
async run<T>(methodCalls: ReadonlyArray<MethodCall>, send: () => Promise<T>): Promise<T> {
|
||||||
|
const pending = gateKeysFor(methodCalls).filter((key) => !this.settled.has(key));
|
||||||
|
if (pending.length === 0) return send();
|
||||||
|
|
||||||
|
const waits = pending
|
||||||
|
.map((key) => this.inFlight.get(key))
|
||||||
|
.filter((p): p is Promise<void> => p !== undefined);
|
||||||
|
if (waits.length > 0) {
|
||||||
|
await Promise.all(waits);
|
||||||
|
// Those keys are settled now; anything still pending belongs to another
|
||||||
|
// collection/account this request happens to be first on.
|
||||||
|
return this.run(methodCalls, send);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First touch for every pending key: run it and let the others queue.
|
||||||
|
const result = send();
|
||||||
|
const done = result.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
for (const key of pending) this.inFlight.set(key, done);
|
||||||
|
try {
|
||||||
|
return await result;
|
||||||
|
} finally {
|
||||||
|
for (const key of pending) {
|
||||||
|
this.settled.add(key);
|
||||||
|
this.inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget everything - e.g. after reconnecting as a different account. */
|
||||||
|
reset(): void {
|
||||||
|
this.settled.clear();
|
||||||
|
this.inFlight.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
+653
-77
@@ -8,12 +8,32 @@ import type {
|
|||||||
import { CAPABILITIES } from './types';
|
import { CAPABILITIES } from './types';
|
||||||
import { generateAccountId } from '../lib/account-utils';
|
import { generateAccountId } from '../lib/account-utils';
|
||||||
import { secureFetch } from '../lib/client-cert';
|
import { secureFetch } from '../lib/client-cert';
|
||||||
import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth';
|
import {
|
||||||
|
refreshOAuthAccessToken,
|
||||||
|
TransientRefreshError,
|
||||||
|
type OAuthTokens,
|
||||||
|
} from '../lib/oauth';
|
||||||
|
import { FirstTouchGate } from './first-touch-gate';
|
||||||
|
|
||||||
// Refresh OAuth access tokens this many ms before they actually expire so
|
// Refresh OAuth access tokens this many ms before they actually expire so
|
||||||
// in-flight requests don't race the expiry window.
|
// in-flight requests don't race the expiry window.
|
||||||
const TOKEN_REFRESH_LEEWAY_MS = 60_000;
|
const TOKEN_REFRESH_LEEWAY_MS = 60_000;
|
||||||
|
|
||||||
|
// Deadline on response headers for an ordinary JMAP call, and for blob
|
||||||
|
// transfers (uploads/downloads can legitimately take minutes on cellular).
|
||||||
|
// Mirrors the webmail's REQUEST_TIMEOUT_MS / blob timeout (#702): iOS suspends
|
||||||
|
// sockets in the background and hands back dead pooled connections on resume;
|
||||||
|
// without a deadline a send or save hangs forever.
|
||||||
|
export const REQUEST_TIMEOUT_MS = 30_000;
|
||||||
|
export const BLOB_TIMEOUT_MS = 300_000;
|
||||||
|
|
||||||
|
// Base back-off per attempt when the server refuses a request for being one
|
||||||
|
// too many in parallel (maxConcurrentRequests, #780).
|
||||||
|
const CONCURRENT_REQUEST_RETRY_DELAYS_MS = [200, 400, 800];
|
||||||
|
|
||||||
|
// Delay before the single transient-network retry of an idempotent request.
|
||||||
|
const TRANSIENT_RETRY_DELAY_MS = 1000;
|
||||||
|
|
||||||
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
|
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
|
||||||
const CREDENTIALS_PREFIX = 'jmap_credentials__';
|
const CREDENTIALS_PREFIX = 'jmap_credentials__';
|
||||||
|
|
||||||
@@ -36,10 +56,152 @@ export interface StoredCredentials {
|
|||||||
clientId?: string;
|
clientId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 8620 §3.6.1 limit error naming the server's parallel-request ceiling.
|
||||||
|
* Stalwart refuses the surplus of a burst with 400 jmap:error:limit /
|
||||||
|
* maxConcurrentRequests BEFORE any method runs, so the request is safe to
|
||||||
|
* replay - even a /set.
|
||||||
|
*/
|
||||||
|
export function isConcurrentRequestRefusal(status: number, body: string): boolean {
|
||||||
|
if (status !== 400) return false;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body) as { type?: unknown; limit?: unknown } | null;
|
||||||
|
return (
|
||||||
|
parsed?.type === 'urn:ietf:params:jmap:error:limit' &&
|
||||||
|
parsed?.limit === 'maxConcurrentRequests'
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seconds or HTTP-date `Retry-After`, capped at 5 minutes, defaulting to 60 s
|
||||||
|
* when missing or unparseable (an HTTP-date used to turn into `NaN` ms).
|
||||||
|
*/
|
||||||
|
export function parseRetryAfter(header: string | null): number {
|
||||||
|
if (!header) return 60_000;
|
||||||
|
const seconds = Number(header);
|
||||||
|
if (!Number.isNaN(seconds) && seconds > 0) {
|
||||||
|
return Math.min(seconds * 1000, 300_000);
|
||||||
|
}
|
||||||
|
const date = Date.parse(header);
|
||||||
|
if (!Number.isNaN(date)) {
|
||||||
|
const ms = date - Date.now();
|
||||||
|
return ms > 0 ? Math.min(ms, 300_000) : 60_000;
|
||||||
|
}
|
||||||
|
return 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods whose bodies must never be replayed automatically: a retried
|
||||||
|
// EmailSubmission/set would send the mail twice.
|
||||||
|
function hasNonIdempotentMethod(methodCalls: ReadonlyArray<JMAPMethodCall>): boolean {
|
||||||
|
return methodCalls.some(
|
||||||
|
([name]) =>
|
||||||
|
name === 'EmailSubmission/set' ||
|
||||||
|
name === 'Email/import' ||
|
||||||
|
name === 'Blob/upload',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchSessionDescribes(session: unknown): session is JMAPSession {
|
||||||
|
if (!session || typeof session !== 'object') return false;
|
||||||
|
const s = session as Partial<JMAPSession>;
|
||||||
|
return typeof s.apiUrl === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared response helpers ──────────────────────────────
|
||||||
|
// Most RN api functions used to index `methodResponses[0][1]` blindly, so a
|
||||||
|
// method-level `['error', {...}]` reply produced a TypeError on reads and was
|
||||||
|
// silently treated as success on writes. Route every response through these.
|
||||||
|
|
||||||
|
type MethodResponse = [string, Record<string, any>, string];
|
||||||
|
|
||||||
|
export class JMAPMethodError extends Error {
|
||||||
|
type: string;
|
||||||
|
constructor(type: string, description?: string) {
|
||||||
|
super(description || type || 'JMAP method error');
|
||||||
|
this.name = 'JMAPMethodError';
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the response body for `callId` (or the first response when omitted)
|
||||||
|
* and throw a `JMAPMethodError` when the server answered with the error
|
||||||
|
* envelope. Also verifies the method name when `expectMethod` is given.
|
||||||
|
*/
|
||||||
|
export function requireMethodResult<T = Record<string, any>>(
|
||||||
|
res: JMAPResponseBody,
|
||||||
|
callId?: string,
|
||||||
|
expectMethod?: string,
|
||||||
|
): T {
|
||||||
|
const responses = (res.methodResponses ?? []) as MethodResponse[];
|
||||||
|
const match = callId === undefined
|
||||||
|
? responses[0]
|
||||||
|
: responses.find((r) => r[2] === callId);
|
||||||
|
if (!match) {
|
||||||
|
throw new JMAPMethodError('missingResponse', `Missing JMAP response${callId !== undefined ? ` for call ${callId}` : ''}`);
|
||||||
|
}
|
||||||
|
if (match[0] === 'error') {
|
||||||
|
const err = match[1] as { type?: string; description?: string };
|
||||||
|
throw new JMAPMethodError(err.type ?? 'serverFail', err.description);
|
||||||
|
}
|
||||||
|
if (expectMethod && match[0] !== expectMethod) {
|
||||||
|
throw new JMAPMethodError('unexpectedMethod', `Expected ${expectMethod}, got ${match[0]}`);
|
||||||
|
}
|
||||||
|
return match[1] as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw when a /set response reports the given ids (or any id when `ids` is
|
||||||
|
* omitted) under `notCreated` / `notUpdated` / `notDestroyed`.
|
||||||
|
*/
|
||||||
|
export function assertSetResult(
|
||||||
|
body: Record<string, any>,
|
||||||
|
ids?: string[],
|
||||||
|
what = 'object',
|
||||||
|
): void {
|
||||||
|
const buckets = ['notCreated', 'notUpdated', 'notDestroyed'] as const;
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
const failures = body?.[bucket] as
|
||||||
|
| Record<string, { type?: string; description?: string; properties?: string[] }>
|
||||||
|
| undefined;
|
||||||
|
if (!failures) continue;
|
||||||
|
const entries = Object.entries(failures).filter(([id]) => !ids || ids.includes(id));
|
||||||
|
if (entries.length === 0) continue;
|
||||||
|
const [id, err] = entries[0];
|
||||||
|
const verb = bucket === 'notCreated' ? 'create' : bucket === 'notUpdated' ? 'update' : 'destroy';
|
||||||
|
const detail = [err?.type ?? 'unknown'];
|
||||||
|
if (err?.properties?.length) detail.push(`properties=[${err.properties.join(', ')}]`);
|
||||||
|
if (err?.description) detail.push(err.description);
|
||||||
|
const more = entries.length > 1 ? ` (+${entries.length - 1} more)` : '';
|
||||||
|
throw new JMAPMethodError(
|
||||||
|
err?.type ?? 'setError',
|
||||||
|
`Failed to ${verb} ${what} ${id}${more}: ${detail.join(' – ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split `items` into consecutive batches of at most `size` entries. */
|
||||||
|
export function batched<T>(items: T[], size: number): T[][] {
|
||||||
|
const step = Math.max(1, Math.floor(size));
|
||||||
|
const result: T[][] = [];
|
||||||
|
for (let i = 0; i < items.length; i += step) {
|
||||||
|
result.push(items.slice(i, i + step));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export class JMAPClient {
|
export class JMAPClient {
|
||||||
private session: JMAPSession | null = null;
|
private session: JMAPSession | null = null;
|
||||||
private credentials: StoredCredentials | null = null;
|
private credentials: StoredCredentials | null = null;
|
||||||
private _accountId: string | null = null;
|
private _accountId: string | null = null;
|
||||||
|
private firstTouchGate = new FirstTouchGate();
|
||||||
|
private rateLimitedUntil = 0;
|
||||||
|
private authFailureListeners = new Set<(err: AuthenticationError) => void>();
|
||||||
|
private rateLimitListeners = new Set<(retryAfterMs: number) => void>();
|
||||||
|
private tokenRefreshListeners = new Set<() => void>();
|
||||||
|
|
||||||
get accountId(): string {
|
get accountId(): string {
|
||||||
if (!this._accountId) {
|
if (!this._accountId) {
|
||||||
@@ -71,6 +233,48 @@ export class JMAPClient {
|
|||||||
return this.session !== null && this._accountId !== null;
|
return this.session !== null && this._accountId !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle hooks ───────────────────────────────────
|
||||||
|
// The auth store subscribes to auth failures so a revoked token/password
|
||||||
|
// leads back to the login screen instead of a dead session; the offline
|
||||||
|
// banner subscribes to rate-limit windows; the live-update layer recreates
|
||||||
|
// the SSE stream whenever the bearer token rotates.
|
||||||
|
|
||||||
|
onAuthFailure(listener: (err: AuthenticationError) => void): () => void {
|
||||||
|
this.authFailureListeners.add(listener);
|
||||||
|
return () => this.authFailureListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
onRateLimit(listener: (retryAfterMs: number) => void): () => void {
|
||||||
|
this.rateLimitListeners.add(listener);
|
||||||
|
return () => this.rateLimitListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
onTokenRefresh(listener: () => void): () => void {
|
||||||
|
this.tokenRefreshListeners.add(listener);
|
||||||
|
return () => this.tokenRefreshListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyAuthFailure(err: AuthenticationError): void {
|
||||||
|
for (const l of this.authFailureListeners) {
|
||||||
|
try { l(err); } catch { /* listener errors must not mask the request */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isRateLimited(): boolean {
|
||||||
|
return this.rateLimitedUntil > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
rateLimitRemainingMs(): number {
|
||||||
|
return Math.max(0, this.rateLimitedUntil - Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
private setRateLimited(retryAfterMs: number): void {
|
||||||
|
this.rateLimitedUntil = Date.now() + retryAfterMs;
|
||||||
|
for (const l of this.rateLimitListeners) {
|
||||||
|
try { l(retryAfterMs); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Authentication ────────────────────────────────────
|
// ── Authentication ────────────────────────────────────
|
||||||
|
|
||||||
// Public so blob upload/download helpers (which can't go through the JMAP
|
// Public so blob upload/download helpers (which can't go through the JMAP
|
||||||
@@ -85,13 +289,28 @@ export class JMAPClient {
|
|||||||
return `Basic ${encoded}`;
|
return `Basic ${encoded}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(serverUrl: string, username: string, password: string): Promise<JMAPSession> {
|
/**
|
||||||
// Normalise URL
|
* Password login. When the server answers 402 "MFA code required" and no
|
||||||
|
* `totp` was supplied, throws `TotpRequiredError` so the UI can ask for a
|
||||||
|
* code. With a code, Stalwart 0.16+ no longer accepts the legacy
|
||||||
|
* `password$totp` Basic convention, so the structured login endpoint is used
|
||||||
|
* to obtain OAuth tokens (see `connectWithTotp`).
|
||||||
|
*/
|
||||||
|
async connect(
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
totp?: string,
|
||||||
|
): Promise<JMAPSession> {
|
||||||
const baseUrl = serverUrl.replace(/\/+$/, '');
|
const baseUrl = serverUrl.replace(/\/+$/, '');
|
||||||
|
if (totp) {
|
||||||
|
return this.connectWithTotp(baseUrl, username, password, totp);
|
||||||
|
}
|
||||||
this.credentials = { serverUrl: baseUrl, username, password };
|
this.credentials = { serverUrl: baseUrl, username, password };
|
||||||
|
|
||||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||||
this._accountId = this.resolveAccountId(this.session);
|
this._accountId = this.resolveAccountId(this.session);
|
||||||
|
this.firstTouchGate.reset();
|
||||||
|
|
||||||
const accountId = generateAccountId(username, baseUrl);
|
const accountId = generateAccountId(username, baseUrl);
|
||||||
await SecureStore.setItemAsync(
|
await SecureStore.setItemAsync(
|
||||||
@@ -102,12 +321,41 @@ export class JMAPClient {
|
|||||||
return this.session;
|
return this.session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOTP login through Stalwart's structured auth endpoint (`/api/auth` with a
|
||||||
|
* separate `mfaToken`, then `/auth/token` with PKCE). The resulting bundle is
|
||||||
|
* persisted as an OAuth account so the normal refresh path keeps it alive.
|
||||||
|
* Falls back to the legacy `password$totp` Basic convention when the server
|
||||||
|
* predates the structured endpoint (404).
|
||||||
|
*/
|
||||||
|
private async connectWithTotp(
|
||||||
|
baseUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
totp: string,
|
||||||
|
): Promise<JMAPSession> {
|
||||||
|
const { exchangePasswordForTokens, LoginEndpointMissingError } = await import('../lib/totp-login');
|
||||||
|
let tokens: OAuthTokens;
|
||||||
|
try {
|
||||||
|
tokens = await exchangePasswordForTokens(baseUrl, username, password, totp);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof LoginEndpointMissingError) {
|
||||||
|
// Pre-0.16 Stalwart: TOTP rides along in the Basic password.
|
||||||
|
return this.connect(baseUrl, username, `${password}$${totp}`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const { session } = await this.connectWithOAuth(baseUrl, tokens, username);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
async connectWithToken(serverUrl: string, accessToken: string): Promise<JMAPSession> {
|
async connectWithToken(serverUrl: string, accessToken: string): Promise<JMAPSession> {
|
||||||
const baseUrl = serverUrl.replace(/\/+$/, '');
|
const baseUrl = serverUrl.replace(/\/+$/, '');
|
||||||
this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken };
|
this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken };
|
||||||
|
|
||||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||||
this._accountId = this.resolveAccountId(this.session);
|
this._accountId = this.resolveAccountId(this.session);
|
||||||
|
this.firstTouchGate.reset();
|
||||||
|
|
||||||
return this.session;
|
return this.session;
|
||||||
}
|
}
|
||||||
@@ -115,9 +363,12 @@ export class JMAPClient {
|
|||||||
// OAuth login via webmail handoff. The webmail did the OAuth dance against
|
// OAuth login via webmail handoff. The webmail did the OAuth dance against
|
||||||
// Stalwart and handed us a complete token bundle. We persist the bundle
|
// Stalwart and handed us a complete token bundle. We persist the bundle
|
||||||
// so subsequent launches can refresh without prompting the user again.
|
// so subsequent launches can refresh without prompting the user again.
|
||||||
|
// `preferredUsername` (the address the user typed) wins over the session's
|
||||||
|
// `username`, which for OIDC logins may be a bare `preferred_username`.
|
||||||
async connectWithOAuth(
|
async connectWithOAuth(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
tokens: OAuthTokens,
|
tokens: OAuthTokens,
|
||||||
|
preferredUsername?: string,
|
||||||
): Promise<{ session: JMAPSession; username: string; accountId: string }> {
|
): Promise<{ session: JMAPSession; username: string; accountId: string }> {
|
||||||
const baseUrl = serverUrl.replace(/\/+$/, '');
|
const baseUrl = serverUrl.replace(/\/+$/, '');
|
||||||
this.credentials = {
|
this.credentials = {
|
||||||
@@ -133,11 +384,13 @@ export class JMAPClient {
|
|||||||
|
|
||||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||||
this._accountId = this.resolveAccountId(this.session);
|
this._accountId = this.resolveAccountId(this.session);
|
||||||
|
this.firstTouchGate.reset();
|
||||||
|
|
||||||
// The JMAP session document carries the authenticated user's identifier
|
// The JMAP session document carries the authenticated user's identifier
|
||||||
// — use it as the username so per-account storage keys are stable across
|
// — use it as the username so per-account storage keys are stable across
|
||||||
// restarts and OAuth re-logins.
|
// restarts and OAuth re-logins.
|
||||||
const username = this.session.username || tokens.accessToken.slice(0, 8);
|
const username =
|
||||||
|
preferredUsername || this.session.username || tokens.accessToken.slice(0, 8);
|
||||||
this.credentials.username = username;
|
this.credentials.username = username;
|
||||||
|
|
||||||
const accountId = generateAccountId(username, baseUrl);
|
const accountId = generateAccountId(username, baseUrl);
|
||||||
@@ -170,6 +423,9 @@ export class JMAPClient {
|
|||||||
credentialsKey(accountId),
|
credentialsKey(accountId),
|
||||||
JSON.stringify(this.credentials),
|
JSON.stringify(this.credentials),
|
||||||
);
|
);
|
||||||
|
for (const l of this.tokenRefreshListeners) {
|
||||||
|
try { l(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private currentOAuthTokens(): OAuthTokens | null {
|
private currentOAuthTokens(): OAuthTokens | null {
|
||||||
@@ -191,8 +447,10 @@ export class JMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Proactive refresh: when the access token is about to expire, swap it for
|
// Proactive refresh: when the access token is about to expire, swap it for
|
||||||
// a fresh one. Quiet no-op for password credentials.
|
// a fresh one. Quiet no-op for password credentials. Public so long-lived
|
||||||
private async ensureFreshToken(): Promise<void> {
|
// consumers of `authHeader` (SSE stream, file downloads) can make sure the
|
||||||
|
// header they capture is not about to expire.
|
||||||
|
async ensureFreshToken(): Promise<void> {
|
||||||
const tokens = this.currentOAuthTokens();
|
const tokens = this.currentOAuthTokens();
|
||||||
if (!tokens) return;
|
if (!tokens) return;
|
||||||
if (tokens.expiresAt == null) return;
|
if (tokens.expiresAt == null) return;
|
||||||
@@ -207,19 +465,43 @@ export class JMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reactive refresh after a 401. Returns true if a fresh token was obtained
|
// Reactive refresh after a 401. Returns true if a fresh token was obtained
|
||||||
// so the caller can retry the original request.
|
// so the caller can retry the original request. A transient token-endpoint
|
||||||
private async forceRefreshToken(): Promise<boolean> {
|
// failure (offline, 5xx, 429) is rethrown as a NetworkError so callers keep
|
||||||
|
// the account instead of evicting it (webmail 1.7.6 "keep the session when
|
||||||
|
// the auth server is briefly unreachable").
|
||||||
|
async forceRefreshToken(): Promise<boolean> {
|
||||||
const tokens = this.currentOAuthTokens();
|
const tokens = this.currentOAuthTokens();
|
||||||
if (!tokens) return false;
|
if (!tokens) return false;
|
||||||
try {
|
try {
|
||||||
const next = await refreshOAuthAccessToken(tokens);
|
const next = await refreshOAuthAccessToken(tokens);
|
||||||
await this.persistRefreshedTokens(next);
|
await this.persistRefreshedTokens(next);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (err instanceof TransientRefreshError) {
|
||||||
|
throw new NetworkError(err.message);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a successful password change the stored credential must follow,
|
||||||
|
* otherwise the next request gets a 401 and the next launch evicts the
|
||||||
|
* account. No-op for bearer sessions.
|
||||||
|
*/
|
||||||
|
async updatePassword(newPassword: string): Promise<void> {
|
||||||
|
if (!this.credentials || this.credentials.accessToken) return;
|
||||||
|
this.credentials = { ...this.credentials, password: newPassword };
|
||||||
|
const accountId = generateAccountId(
|
||||||
|
this.credentials.username,
|
||||||
|
this.credentials.serverUrl,
|
||||||
|
);
|
||||||
|
await SecureStore.setItemAsync(
|
||||||
|
credentialsKey(accountId),
|
||||||
|
JSON.stringify(this.credentials),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Legacy single-slot restore - kept for backward-compat tests. New code
|
// Legacy single-slot restore - kept for backward-compat tests. New code
|
||||||
// should use loadAccount(accountId) driven by the account registry.
|
// should use loadAccount(accountId) driven by the account registry.
|
||||||
async restoreSession(): Promise<boolean> {
|
async restoreSession(): Promise<boolean> {
|
||||||
@@ -234,6 +516,7 @@ export class JMAPClient {
|
|||||||
creds.serverUrl,
|
creds.serverUrl,
|
||||||
);
|
);
|
||||||
this._accountId = this.resolveAccountId(this.session);
|
this._accountId = this.resolveAccountId(this.session);
|
||||||
|
this.firstTouchGate.reset();
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
await this.logout();
|
await this.logout();
|
||||||
@@ -257,6 +540,7 @@ export class JMAPClient {
|
|||||||
// unrecoverable (AuthenticationError) from transient (NetworkError) and
|
// unrecoverable (AuthenticationError) from transient (NetworkError) and
|
||||||
// avoid clearing credentials when the server is just unreachable.
|
// avoid clearing credentials when the server is just unreachable.
|
||||||
this.credentials = creds;
|
this.credentials = creds;
|
||||||
|
this.firstTouchGate.reset();
|
||||||
try {
|
try {
|
||||||
this.session = this.rewriteSessionUrls(
|
this.session = this.rewriteSessionUrls(
|
||||||
await this.fetchSession(creds.serverUrl),
|
await this.fetchSession(creds.serverUrl),
|
||||||
@@ -269,6 +553,7 @@ export class JMAPClient {
|
|||||||
this.session = null;
|
this.session = null;
|
||||||
this._accountId = null;
|
this._accountId = null;
|
||||||
if (err instanceof AuthenticationError) throw err;
|
if (err instanceof AuthenticationError) throw err;
|
||||||
|
if (err instanceof NetworkError) throw err;
|
||||||
// Anything else is treated as transport-level. Wrapping rather than
|
// Anything else is treated as transport-level. Wrapping rather than
|
||||||
// rethrowing the raw fetch error gives callers a single check.
|
// rethrowing the raw fetch error gives callers a single check.
|
||||||
throw new NetworkError(
|
throw new NetworkError(
|
||||||
@@ -279,6 +564,25 @@ export class JMAPClient {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of the live connection so a failed "add account" / switch can put
|
||||||
|
* the previous account back without a network round-trip.
|
||||||
|
*/
|
||||||
|
snapshot(): ClientSnapshot {
|
||||||
|
return {
|
||||||
|
session: this.session,
|
||||||
|
credentials: this.credentials,
|
||||||
|
accountId: this._accountId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreSnapshot(snap: ClientSnapshot): void {
|
||||||
|
this.session = snap.session;
|
||||||
|
this.credentials = snap.credentials;
|
||||||
|
this._accountId = snap.accountId;
|
||||||
|
this.firstTouchGate.reset();
|
||||||
|
}
|
||||||
|
|
||||||
async clearAccountCredentials(accountId: string): Promise<void> {
|
async clearAccountCredentials(accountId: string): Promise<void> {
|
||||||
await SecureStore.deleteItemAsync(credentialsKey(accountId));
|
await SecureStore.deleteItemAsync(credentialsKey(accountId));
|
||||||
}
|
}
|
||||||
@@ -311,20 +615,17 @@ export class JMAPClient {
|
|||||||
// Rewrite session URLs to share the origin the client connected with.
|
// Rewrite session URLs to share the origin the client connected with.
|
||||||
// JMAP servers often self-report localhost/container-internal hostnames in
|
// JMAP servers often self-report localhost/container-internal hostnames in
|
||||||
// apiUrl/downloadUrl/etc. that are unreachable from mobile clients (e.g.
|
// apiUrl/downloadUrl/etc. that are unreachable from mobile clients (e.g.
|
||||||
// the Android emulator can't resolve the host's "localhost").
|
// the Android emulator can't resolve the host's "localhost"), and some
|
||||||
|
// deployments advertise relative URLs (`/jmap/`) which `fetch` can't use.
|
||||||
//
|
//
|
||||||
// Splits via plain string indexing rather than `new URL()` because RN's
|
// Splits via plain string indexing rather than `new URL()` because RN's
|
||||||
// URL polyfill mutates inputs (appends trailing slashes, normalises
|
// URL polyfill mutates inputs (appends trailing slashes, normalises
|
||||||
// characters) and would corrupt the RFC 6570 templates {accountId}/{blobId}
|
// characters) and would corrupt the RFC 6570 templates {accountId}/{blobId}
|
||||||
// before the caller has a chance to substitute values into them.
|
// before the caller has a chance to substitute values into them.
|
||||||
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
|
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
|
||||||
const serverOrigin = this.extractOrigin(serverUrl);
|
const serverOrigin = extractOrigin(serverUrl);
|
||||||
const rewrite = (url: string | undefined): string | undefined => {
|
const rewrite = (url: string | undefined): string | undefined =>
|
||||||
if (!url) return url;
|
rewriteSessionUrl(url, serverOrigin);
|
||||||
const origin = this.extractOrigin(url);
|
|
||||||
if (!origin || !serverOrigin || origin === serverOrigin) return url;
|
|
||||||
return serverOrigin + url.slice(origin.length);
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
...session,
|
...session,
|
||||||
apiUrl: rewrite(session.apiUrl) ?? session.apiUrl,
|
apiUrl: rewrite(session.apiUrl) ?? session.apiUrl,
|
||||||
@@ -334,17 +635,14 @@ export class JMAPClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractOrigin(url: string): string | null {
|
|
||||||
const m = url.match(/^(https?:\/\/[^/?#]+)/i);
|
|
||||||
return m ? m[1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset in-memory state without touching persisted credentials (used on
|
// Reset in-memory state without touching persisted credentials (used on
|
||||||
// account switch so the active account's stored creds remain available).
|
// account switch so the active account's stored creds remain available).
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.session = null;
|
this.session = null;
|
||||||
this.credentials = null;
|
this.credentials = null;
|
||||||
this._accountId = null;
|
this._accountId = null;
|
||||||
|
this.firstTouchGate.reset();
|
||||||
|
this.rateLimitedUntil = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
@@ -358,31 +656,153 @@ export class JMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Transport ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `secureFetch` with a deadline on the response headers. The abort reason is
|
||||||
|
* tracked separately so a caller-supplied signal (cancelled upload) can be
|
||||||
|
* told apart from "we gave up".
|
||||||
|
*/
|
||||||
|
private async timedFetch(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit | undefined,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<Response> {
|
||||||
|
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||||
|
const external = init?.signal ?? undefined;
|
||||||
|
if (controller && external) {
|
||||||
|
if (external.aborted) controller.abort();
|
||||||
|
else external.addEventListener('abort', () => controller.abort(), { once: true });
|
||||||
|
}
|
||||||
|
let timedOut = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
controller?.abort();
|
||||||
|
}, timeoutMs);
|
||||||
|
try {
|
||||||
|
return await secureFetch(url, controller ? { ...init, signal: controller.signal } : init);
|
||||||
|
} catch (error) {
|
||||||
|
if (timedOut) throw new RequestTimeoutError(timeoutMs);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated fetch shared by the API, session and blob paths: proactive
|
||||||
|
* token refresh, deadline, one transient retry for idempotent requests,
|
||||||
|
* reactive refresh on 401, rate-limit bookkeeping on 429. Returns the
|
||||||
|
* response for the caller to interpret; throws `NetworkError` /
|
||||||
|
* `RequestTimeoutError` / `RateLimitError`.
|
||||||
|
*/
|
||||||
|
async authenticatedFetch(
|
||||||
|
url: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
opts?: { timeoutMs?: number; idempotent?: boolean },
|
||||||
|
): Promise<Response> {
|
||||||
|
if (this.isRateLimited()) {
|
||||||
|
throw new RateLimitError(this.rateLimitRemainingMs());
|
||||||
|
}
|
||||||
|
const timeoutMs = opts?.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
||||||
|
const idempotent = opts?.idempotent ?? true;
|
||||||
|
|
||||||
|
await this.ensureFreshToken();
|
||||||
|
const withAuth = (): RequestInit => ({
|
||||||
|
...init,
|
||||||
|
headers: { ...(init?.headers as Record<string, string> | undefined), Authorization: this.authHeader },
|
||||||
|
});
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await this.timedFetch(url, withAuth(), timeoutMs);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RequestTimeoutError) throw error;
|
||||||
|
if (!idempotent || (error as Error)?.name === 'AbortError') {
|
||||||
|
throw toNetworkError(error);
|
||||||
|
}
|
||||||
|
// Transient proxy/connection blip: one retry after a short pause.
|
||||||
|
await new Promise((r) => setTimeout(r, TRANSIENT_RETRY_DELAY_MS));
|
||||||
|
try {
|
||||||
|
response = await this.timedFetch(url, withAuth(), timeoutMs);
|
||||||
|
} catch (retryError) {
|
||||||
|
if (retryError instanceof RequestTimeoutError) throw retryError;
|
||||||
|
throw toNetworkError(retryError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401 && (await this.forceRefreshToken())) {
|
||||||
|
try {
|
||||||
|
response = await this.timedFetch(url, withAuth(), timeoutMs);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RequestTimeoutError) throw error;
|
||||||
|
throw toNetworkError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'));
|
||||||
|
this.setRateLimited(retryAfterMs);
|
||||||
|
throw new RateLimitError(retryAfterMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Session Discovery ─────────────────────────────────
|
// ── Session Discovery ─────────────────────────────────
|
||||||
|
|
||||||
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
|
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
|
||||||
const url = `${baseUrl}/.well-known/jmap`;
|
const url = `${baseUrl}/.well-known/jmap`;
|
||||||
await this.ensureFreshToken();
|
let response = await this.authenticatedFetch(url, {
|
||||||
const doFetch = () =>
|
headers: { Accept: 'application/json' },
|
||||||
secureFetch(url, {
|
});
|
||||||
headers: {
|
|
||||||
Authorization: this.authHeader,
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
let response = await doFetch();
|
|
||||||
if (response.status === 401 && (await this.forceRefreshToken())) {
|
|
||||||
response = await doFetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
throw new AuthenticationError('Invalid credentials');
|
throw new AuthenticationError('Invalid credentials');
|
||||||
}
|
}
|
||||||
|
if (response.status === 402) {
|
||||||
|
// Stalwart answers 402 with a problem+json whose title names the
|
||||||
|
// missing factor ("TOTP code required" / "MFA code required").
|
||||||
|
let title = '';
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { title?: string; detail?: string };
|
||||||
|
title = `${body?.title ?? ''} ${body?.detail ?? ''}`.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
// fall through - treat any 402 as an MFA challenge
|
||||||
|
}
|
||||||
|
if (!title || title.includes('totp') || title.includes('mfa') || title.includes('factor')) {
|
||||||
|
throw new TotpRequiredError();
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Session discovery failed: ${response.status} ${response.statusText}`);
|
throw new Error(`Session discovery failed: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
let session = (await response.json()) as JMAPSession;
|
||||||
|
|
||||||
|
// Stalwart 307-redirects /.well-known/jmap to /jmap/session. iOS
|
||||||
|
// NSURLSession (and some auth proxies) drop the Authorization header on
|
||||||
|
// the redirect, which yields a 200 with an empty session. Refetch the
|
||||||
|
// final URL with the header explicitly.
|
||||||
|
const redirectedEmpty =
|
||||||
|
(response as { redirected?: boolean }).redirected &&
|
||||||
|
(!session?.accounts || Object.keys(session.accounts).length === 0) &&
|
||||||
|
!session?.username;
|
||||||
|
if (redirectedEmpty && (response as { url?: string }).url) {
|
||||||
|
response = await this.authenticatedFetch((response as { url: string }).url, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (response.status === 401) throw new AuthenticationError('Invalid credentials');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Session discovery failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
session = (await response.json()) as JMAPSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fetchSessionDescribes(session)) {
|
||||||
|
throw new Error('Session discovery failed: response is not a JMAP session');
|
||||||
|
}
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveAccountId(session: JMAPSession): string {
|
private resolveAccountId(session: JMAPSession): string {
|
||||||
@@ -400,6 +820,34 @@ export class JMAPClient {
|
|||||||
throw new Error('No account found in JMAP session');
|
throw new Error('No account found in JMAP session');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read the session document (capabilities, shared accounts,
|
||||||
|
* eventSourceUrl) without disturbing credentials. Used by the keep-alive
|
||||||
|
* and after a basic-auth 401 that may just be a stale session.
|
||||||
|
*/
|
||||||
|
async refreshSession(): Promise<JMAPSession> {
|
||||||
|
if (!this.credentials) throw new Error('No credentials');
|
||||||
|
const baseUrl = this.credentials.serverUrl;
|
||||||
|
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||||
|
this._accountId = this.resolveAccountId(this.session);
|
||||||
|
return this.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheap liveness probe (`Core/echo`). Resolves true when the server answered,
|
||||||
|
* false on transport failure. Used by the foreground keep-alive.
|
||||||
|
*/
|
||||||
|
async ping(): Promise<boolean> {
|
||||||
|
if (!this.session) return false;
|
||||||
|
try {
|
||||||
|
await this.request([['Core/echo', { ping: Date.now() }, 'ping']], [CAPABILITIES.CORE]);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AuthenticationError) throw err;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── API Request ───────────────────────────────────────
|
// ── API Request ───────────────────────────────────────
|
||||||
|
|
||||||
async request(
|
async request(
|
||||||
@@ -412,38 +860,54 @@ export class JMAPClient {
|
|||||||
using: using ?? [CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
using: using ?? [CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||||
methodCalls,
|
methodCalls,
|
||||||
};
|
};
|
||||||
|
const serialized = JSON.stringify(body);
|
||||||
|
const apiUrl = this.session.apiUrl;
|
||||||
|
const idempotent = !hasNonIdempotentMethod(methodCalls);
|
||||||
|
|
||||||
await this.ensureFreshToken();
|
for (let attempt = 0; ; attempt++) {
|
||||||
const doFetch = () =>
|
const response = await this.firstTouchGate.run(methodCalls, () =>
|
||||||
secureFetch(this.session!.apiUrl, {
|
this.authenticatedFetch(
|
||||||
method: 'POST',
|
apiUrl,
|
||||||
headers: {
|
{
|
||||||
'Content-Type': 'application/json',
|
method: 'POST',
|
||||||
Authorization: this.authHeader,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
},
|
body: serialized,
|
||||||
body: JSON.stringify(body),
|
},
|
||||||
});
|
{ idempotent },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
let response = await doFetch();
|
if (response.status === 401) {
|
||||||
if (response.status === 401 && (await this.forceRefreshToken())) {
|
const err = new AuthenticationError('Session expired');
|
||||||
response = await doFetch();
|
this.notifyAuthFailure(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseText = await readBodyText(response);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Stalwart caps parallel API requests per user (maxConcurrentRequests,
|
||||||
|
// default 4). One push event fans out into several refreshes at once,
|
||||||
|
// so the ceiling is reached in ordinary use. The refusal comes before
|
||||||
|
// any method runs, so back off briefly and replay (#780).
|
||||||
|
if (
|
||||||
|
attempt < CONCURRENT_REQUEST_RETRY_DELAYS_MS.length &&
|
||||||
|
isConcurrentRequestRefusal(response.status, responseText)
|
||||||
|
) {
|
||||||
|
const base = CONCURRENT_REQUEST_RETRY_DELAYS_MS[attempt];
|
||||||
|
await new Promise((r) => setTimeout(r, base + Math.random() * base));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const detail = responseText ? ` - ${responseText.substring(0, 200)}` : '';
|
||||||
|
throw new Error(`JMAP request failed: ${response.status}${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(responseText) as JMAPResponseBody;
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid JSON response from server');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 401) {
|
|
||||||
throw new AuthenticationError('Session expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 429) {
|
|
||||||
const retryAfter = response.headers.get('Retry-After');
|
|
||||||
const ms = retryAfter ? parseInt(retryAfter, 10) * 1000 : 5000;
|
|
||||||
throw new RateLimitError(ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`JMAP request failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Capability Check ──────────────────────────────────
|
// ── Capability Check ──────────────────────────────────
|
||||||
@@ -452,18 +916,53 @@ export class JMAPClient {
|
|||||||
return Boolean(this.session?.capabilities?.[urn]);
|
return Boolean(this.session?.capabilities?.[urn]);
|
||||||
}
|
}
|
||||||
|
|
||||||
getMaxObjectsInGet(): number {
|
/**
|
||||||
const core = this.session?.capabilities?.[CAPABILITIES.CORE] as
|
* True when `urn` is advertised for the account (RFC 8620 §2
|
||||||
| { maxObjectsInGet?: number }
|
* `accountCapabilities`) or, as a fallback, at session level. Stalwart puts
|
||||||
|
* its extension capabilities (`urn:stalwart:jmap`, filenode, …) only under
|
||||||
|
* the account, so a session-level check alone is wrong for those.
|
||||||
|
*/
|
||||||
|
hasAccountCapability(urn: string, accountId?: string): boolean {
|
||||||
|
const id = accountId ?? this._accountId;
|
||||||
|
if (!id) return this.hasCapability(urn);
|
||||||
|
const account = this.session?.accounts?.[id];
|
||||||
|
if (account?.accountCapabilities && urn in account.accountCapabilities) return true;
|
||||||
|
return this.hasCapability(urn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get coreCapability():
|
||||||
|
| { maxObjectsInGet?: number; maxObjectsInSet?: number; maxCallsInRequest?: number; maxSizeUpload?: number; maxSizeRequest?: number }
|
||||||
|
| undefined {
|
||||||
|
return this.session?.capabilities?.[CAPABILITIES.CORE] as
|
||||||
|
| { maxObjectsInGet?: number; maxObjectsInSet?: number; maxCallsInRequest?: number; maxSizeUpload?: number; maxSizeRequest?: number }
|
||||||
| undefined;
|
| undefined;
|
||||||
return core?.maxObjectsInGet || 500;
|
}
|
||||||
|
|
||||||
|
getMaxObjectsInGet(): number {
|
||||||
|
return this.coreCapability?.maxObjectsInGet || 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMaxObjectsInSet(): number {
|
||||||
|
return this.coreCapability?.maxObjectsInSet || 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
getMaxCallsInRequest(): number {
|
getMaxCallsInRequest(): number {
|
||||||
const core = this.session?.capabilities?.[CAPABILITIES.CORE] as
|
return this.coreCapability?.maxCallsInRequest || 16;
|
||||||
| { maxCallsInRequest?: number }
|
}
|
||||||
|
|
||||||
|
/** Server-advertised upload ceiling in bytes (0 = unknown / unlimited). */
|
||||||
|
getMaxSizeUpload(): number {
|
||||||
|
const max = this.coreCapability?.maxSizeUpload;
|
||||||
|
return typeof max === 'number' && max > 0 ? max : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-message attachment total ceiling in bytes (0 = unknown). */
|
||||||
|
getMaxSizeAttachmentsPerEmail(): number {
|
||||||
|
const mail = this.session?.capabilities?.[CAPABILITIES.MAIL] as
|
||||||
|
| { maxSizeAttachmentsPerEmail?: number }
|
||||||
| undefined;
|
| undefined;
|
||||||
return core?.maxCallsInRequest || 16;
|
const max = mail?.maxSizeAttachmentsPerEmail;
|
||||||
|
return typeof max === 'number' && max > 0 ? max : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shared / group accounts ───────────────────────────
|
// ── Shared / group accounts ───────────────────────────
|
||||||
@@ -496,6 +995,16 @@ export class JMAPClient {
|
|||||||
return this.session?.accounts?.[accountId]?.name;
|
return this.session?.accounts?.[accountId]?.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary account for a capability (RFC 8620 `primaryAccounts`), falling
|
||||||
|
* back to the mail primary. Contacts/calendars can live in a different
|
||||||
|
* account than mail on some servers.
|
||||||
|
*/
|
||||||
|
getPrimaryAccountId(capability: string): string {
|
||||||
|
const id = this.session?.primaryAccounts?.[capability];
|
||||||
|
return id || this.accountId;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Scheduled send (FUTURERELEASE) ────────────────────
|
// ── Scheduled send (FUTURERELEASE) ────────────────────
|
||||||
// The JMAP submission capability advertises `maxDelayedSend` (max hold in
|
// The JMAP submission capability advertises `maxDelayedSend` (max hold in
|
||||||
// seconds) and a `submissionExtensions` map; FUTURERELEASE support is what
|
// seconds) and a `submissionExtensions` map; FUTURERELEASE support is what
|
||||||
@@ -572,18 +1081,68 @@ export class JMAPClient {
|
|||||||
accountId?: string,
|
accountId?: string,
|
||||||
): Promise<ArrayBuffer> {
|
): Promise<ArrayBuffer> {
|
||||||
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
|
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
|
||||||
await this.ensureFreshToken();
|
const response = await this.authenticatedFetch(url, undefined, {
|
||||||
const doFetch = () => secureFetch(url, { headers: { Authorization: this.authHeader } });
|
timeoutMs: BLOB_TIMEOUT_MS,
|
||||||
let response = await doFetch();
|
});
|
||||||
if (response.status === 401 && (await this.forceRefreshToken())) {
|
if (response.status === 401) {
|
||||||
response = await doFetch();
|
const err = new AuthenticationError('Session expired');
|
||||||
|
this.notifyAuthFailure(err);
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
if (response.status === 401) throw new AuthenticationError('Session expired');
|
|
||||||
if (!response.ok) throw new Error(`Failed to fetch blob: ${response.status}`);
|
if (!response.ok) throw new Error(`Failed to fetch blob: ${response.status}`);
|
||||||
return response.arrayBuffer();
|
return response.arrayBuffer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClientSnapshot {
|
||||||
|
session: JMAPSession | null;
|
||||||
|
credentials: StoredCredentials | null;
|
||||||
|
accountId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── URL helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export function extractOrigin(url: string): string | null {
|
||||||
|
const m = url.match(/^(https?:\/\/[^/?#]+)/i);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebase a session URL onto the origin the client connected with. Relative
|
||||||
|
* URLs (`/jmap/`) get the origin prefixed; absolute ones have their origin
|
||||||
|
* swapped. Pure string work so `{accountId}`/`{blobId}` templates survive.
|
||||||
|
*/
|
||||||
|
export function rewriteSessionUrl(
|
||||||
|
url: string | undefined,
|
||||||
|
serverOrigin: string | null,
|
||||||
|
): string | undefined {
|
||||||
|
if (!url || !serverOrigin) return url;
|
||||||
|
const origin = extractOrigin(url);
|
||||||
|
if (!origin) {
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return url; // non-http scheme: leave it
|
||||||
|
return serverOrigin + (url.startsWith('/') ? url : '/' + url);
|
||||||
|
}
|
||||||
|
if (origin === serverOrigin) return url;
|
||||||
|
return serverOrigin + url.slice(origin.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBodyText(response: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
if (typeof response.text === 'function') return await response.text();
|
||||||
|
if (typeof response.json === 'function') return JSON.stringify(await response.json());
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNetworkError(error: unknown): NetworkError {
|
||||||
|
if (error instanceof NetworkError) return error;
|
||||||
|
return new NetworkError(
|
||||||
|
error instanceof Error && error.message ? error.message : 'Network request failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Error Classes ─────────────────────────────────────────
|
// ── Error Classes ─────────────────────────────────────────
|
||||||
|
|
||||||
export class AuthenticationError extends Error {
|
export class AuthenticationError extends Error {
|
||||||
@@ -593,6 +1152,14 @@ export class AuthenticationError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The server wants a second factor before it will hand out a session.
|
||||||
|
export class TotpRequiredError extends AuthenticationError {
|
||||||
|
constructor() {
|
||||||
|
super('TOTP_REQUIRED');
|
||||||
|
this.name = 'TotpRequiredError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Server unreachable / transient transport failure. Callers should keep
|
// Server unreachable / transient transport failure. Callers should keep
|
||||||
// stored credentials and surface an offline state rather than logging out.
|
// stored credentials and surface an offline state rather than logging out.
|
||||||
export class NetworkError extends Error {
|
export class NetworkError extends Error {
|
||||||
@@ -602,6 +1169,15 @@ export class NetworkError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No response headers within the deadline. The request may have reached the
|
||||||
|
// server (a send may have gone out), so callers must not blindly retry.
|
||||||
|
export class RequestTimeoutError extends Error {
|
||||||
|
constructor(timeoutMs: number) {
|
||||||
|
super(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
|
||||||
|
this.name = 'RequestTimeoutError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class RateLimitError extends Error {
|
export class RateLimitError extends Error {
|
||||||
retryAfterMs: number;
|
retryAfterMs: number;
|
||||||
constructor(retryAfterMs: number) {
|
constructor(retryAfterMs: number) {
|
||||||
|
|||||||
+32
-11
@@ -1,19 +1,30 @@
|
|||||||
// Classify an error thrown by a JMAP call as a *transient* connectivity
|
// Classify an error thrown by a JMAP call as a *transient* connectivity
|
||||||
// failure (server unreachable, offline, session not yet up) versus a
|
// failure (server unreachable, offline, timed out, rate limited) versus a
|
||||||
// *permanent* one (the server received the request and rejected it, e.g. a
|
// *permanent* one (the server received the request and rejected it, e.g. a
|
||||||
// 4xx). The offline outbox uses this to decide whether to keep an operation
|
// method error or a 4xx). The offline outbox uses this to decide whether to
|
||||||
// queued for a later retry or to give up on it.
|
// keep an operation queued for a later retry or to give up on it.
|
||||||
//
|
//
|
||||||
// We err on the side of "transient" only for signals we recognise as
|
// Classification is by error class first. A bare `TypeError` used to count as
|
||||||
// connectivity-related; anything else (including bare 4xx/5xx responses) is
|
// transient because RN's fetch throws `TypeError("Network request failed")`,
|
||||||
// treated as permanent so a poison operation can't wedge the queue forever.
|
// but the JMAP client now wraps transport failures in `NetworkError`; a
|
||||||
|
// TypeError reaching here is far more likely a programming error (e.g. an
|
||||||
|
// unchecked method-error response) and must not wedge the queue forever.
|
||||||
|
// `AuthenticationError` is terminal: the credentials are gone, retrying won't
|
||||||
|
// help until the user signs in again.
|
||||||
|
|
||||||
import { useNetworkStore } from '../stores/network-store';
|
import { useNetworkStore } from '../stores/network-store';
|
||||||
|
|
||||||
const TRANSIENT_NAMES = new Set([
|
const TRANSIENT_NAMES = new Set([
|
||||||
'NetworkError', // jmap-client transport wrapper
|
'NetworkError', // jmap-client transport wrapper
|
||||||
'AbortError', // request aborted (e.g. app backgrounded)
|
'RequestTimeoutError', // no response headers within the deadline
|
||||||
'TypeError', // RN fetch throws TypeError("Network request failed")
|
'RateLimitError', // 429 - retry after the window
|
||||||
|
'AbortError', // request aborted (e.g. app backgrounded)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TERMINAL_NAMES = new Set([
|
||||||
|
'AuthenticationError',
|
||||||
|
'TotpRequiredError',
|
||||||
|
'JMAPMethodError',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const TRANSIENT_MESSAGE_HINTS = [
|
const TRANSIENT_MESSAGE_HINTS = [
|
||||||
@@ -21,19 +32,29 @@ const TRANSIENT_MESSAGE_HINTS = [
|
|||||||
'network error',
|
'network error',
|
||||||
'failed to fetch',
|
'failed to fetch',
|
||||||
'not connected',
|
'not connected',
|
||||||
'session expired', // token refresh will happen on the next live request
|
|
||||||
'timeout',
|
'timeout',
|
||||||
'timed out',
|
'timed out',
|
||||||
'connection',
|
'connection',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function isTransientNetworkError(err: unknown): boolean {
|
export function isTransientNetworkError(err: unknown): boolean {
|
||||||
// If the device itself reports offline, treat any failure as transient.
|
if (err instanceof Error && TERMINAL_NAMES.has(err.name)) return false;
|
||||||
|
|
||||||
|
// If the device itself reports offline, treat any other failure as transient.
|
||||||
if (!useNetworkStore.getState().online) return true;
|
if (!useNetworkStore.getState().online) return true;
|
||||||
|
|
||||||
if (!(err instanceof Error)) return false;
|
if (!(err instanceof Error)) return false;
|
||||||
if (TRANSIENT_NAMES.has(err.name)) return true;
|
if (TRANSIENT_NAMES.has(err.name)) return true;
|
||||||
|
|
||||||
|
// A raw fetch TypeError only counts when its message says "network".
|
||||||
const msg = err.message?.toLowerCase() ?? '';
|
const msg = err.message?.toLowerCase() ?? '';
|
||||||
|
if (err.name === 'TypeError') {
|
||||||
|
return msg.includes('network') || msg.includes('failed to fetch') || msg.includes('securefetch failed');
|
||||||
|
}
|
||||||
return TRANSIENT_MESSAGE_HINTS.some((hint) => msg.includes(hint));
|
return TRANSIENT_MESSAGE_HINTS.some((hint) => msg.includes(hint));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when the failure means the session/credentials are unusable. */
|
||||||
|
export function isAuthError(err: unknown): boolean {
|
||||||
|
return err instanceof Error && (err.name === 'AuthenticationError' || err.name === 'TotpRequiredError');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// Password + TOTP login against Stalwart's structured auth endpoint.
|
||||||
|
//
|
||||||
|
// Stalwart 0.16+ no longer accepts the legacy `password$totp` convention over
|
||||||
|
// HTTP Basic auth: its Basic decoder hardcodes `mfa_token: None`, so a TOTP
|
||||||
|
// appended to the password is verified verbatim against the password hash and
|
||||||
|
// fails. The MFA token must be supplied as a distinct field through
|
||||||
|
// `POST {server}/api/auth`, which answers with a short-lived authorization
|
||||||
|
// code that `POST {server}/auth/token` exchanges (with PKCE) for OAuth
|
||||||
|
// tokens. The webmail drives the same two steps server-side in
|
||||||
|
// app/api/auth/totp-token-exchange/route.ts; a native client has no CORS
|
||||||
|
// constraint and talks to the mail server directly.
|
||||||
|
//
|
||||||
|
// Token auth also survives TOTP rotation, unlike Basic auth, which would have
|
||||||
|
// to embed the ~30 s code in every request.
|
||||||
|
|
||||||
|
import { secureFetch } from './client-cert';
|
||||||
|
import { sha256Hex } from './sha256';
|
||||||
|
import { base64UrlEncode, randomBytes } from './random';
|
||||||
|
import { HANDOFF_REDIRECT_URI, type OAuthTokens } from './oauth';
|
||||||
|
import { NetworkError } from '../api/jmap-client';
|
||||||
|
|
||||||
|
// Stalwart's built-in OAuth accepts the webmail's public client id; using the
|
||||||
|
// same one keeps refresh behaviour identical to the browser-handoff bundle.
|
||||||
|
export const DEFAULT_CLIENT_ID = 'bulwark-webmail';
|
||||||
|
|
||||||
|
export class LoginEndpointMissingError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Structured login endpoint not available');
|
||||||
|
this.name = 'LoginEndpointMissingError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TotpLoginError extends Error {
|
||||||
|
code: 'invalid_credentials' | 'totp_required' | 'login_failed' | 'token_exchange_failed';
|
||||||
|
constructor(code: TotpLoginError['code'], message?: string) {
|
||||||
|
super(message ?? code);
|
||||||
|
this.name = 'TotpLoginError';
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToBytes(hex: string): Uint8Array {
|
||||||
|
const out = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateCodeVerifier(): string {
|
||||||
|
// 32 random bytes → 43 base64url chars, inside RFC 7636's 43..128 window.
|
||||||
|
return base64UrlEncode(randomBytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateCodeChallenge(verifier: string): string {
|
||||||
|
const digest = sha256Hex(new TextEncoder().encode(verifier));
|
||||||
|
return base64UrlEncode(hexToBytes(digest));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginResult {
|
||||||
|
type?: string;
|
||||||
|
// The response keeps snake_case: only the LoginResponse variant *tags* are
|
||||||
|
// camelCased server-side, not the struct fields (the request fields are).
|
||||||
|
client_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange username + password (+ TOTP) for an OAuth token bundle. Throws
|
||||||
|
* `LoginEndpointMissingError` when the server predates the structured
|
||||||
|
* endpoint (404) so the caller can fall back to legacy Basic auth,
|
||||||
|
* `TotpLoginError` for rejected credentials, `NetworkError` when unreachable.
|
||||||
|
*/
|
||||||
|
export async function exchangePasswordForTokens(
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
totp?: string,
|
||||||
|
opts?: { clientId?: string; redirectUri?: string },
|
||||||
|
): Promise<OAuthTokens> {
|
||||||
|
const base = serverUrl.replace(/\/+$/, '');
|
||||||
|
const clientId = opts?.clientId ?? DEFAULT_CLIENT_ID;
|
||||||
|
const redirectUri = opts?.redirectUri ?? HANDOFF_REDIRECT_URI;
|
||||||
|
const verifier = generateCodeVerifier();
|
||||||
|
const challenge = generateCodeChallenge(verifier);
|
||||||
|
|
||||||
|
let loginResponse: Response;
|
||||||
|
try {
|
||||||
|
loginResponse = await secureFetch(`${base}/api/auth`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'authCode',
|
||||||
|
accountName: username,
|
||||||
|
accountSecret: password,
|
||||||
|
...(totp ? { mfaToken: totp } : {}),
|
||||||
|
clientId,
|
||||||
|
redirectUri,
|
||||||
|
codeChallenge: challenge,
|
||||||
|
codeChallengeMethod: 'S256',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginResponse.status === 404) throw new LoginEndpointMissingError();
|
||||||
|
if (!loginResponse.ok) {
|
||||||
|
throw new TotpLoginError('login_failed', `Login failed: ${loginResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let login: LoginResult;
|
||||||
|
try {
|
||||||
|
login = (await loginResponse.json()) as LoginResult;
|
||||||
|
} catch {
|
||||||
|
throw new TotpLoginError('login_failed', 'Login response was not JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (login.type) {
|
||||||
|
case 'authenticated':
|
||||||
|
break;
|
||||||
|
case 'mfaRequired':
|
||||||
|
throw new TotpLoginError('totp_required', 'TOTP_REQUIRED');
|
||||||
|
case 'failure':
|
||||||
|
default:
|
||||||
|
throw new TotpLoginError('invalid_credentials', 'Invalid username, password or code');
|
||||||
|
}
|
||||||
|
if (!login.client_code) {
|
||||||
|
throw new TotpLoginError('login_failed', 'Login response missing authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenParams = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: login.client_code,
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_verifier: verifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
let tokenResponse: Response;
|
||||||
|
try {
|
||||||
|
tokenResponse = await secureFetch(`${base}/auth/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||||
|
body: tokenParams.toString(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
|
||||||
|
}
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
throw new TotpLoginError('token_exchange_failed', `Token exchange failed: ${tokenResponse.status}`);
|
||||||
|
}
|
||||||
|
const tokens = (await tokenResponse.json()) as {
|
||||||
|
access_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
refresh_token?: string;
|
||||||
|
};
|
||||||
|
if (!tokens.access_token) {
|
||||||
|
throw new TotpLoginError('token_exchange_failed', 'Token response missing access_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: tokens.refresh_token,
|
||||||
|
expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
|
||||||
|
tokenEndpoint: `${base}/auth/token`,
|
||||||
|
clientId,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user