diff --git a/src/api/__tests__/jmap-client-hardening.test.ts b/src/api/__tests__/jmap-client-hardening.test.ts index abfb5bbf..e6b16cac 100644 --- a/src/api/__tests__/jmap-client-hardening.test.ts +++ b/src/api/__tests__/jmap-client-hardening.test.ts @@ -208,6 +208,80 @@ describe('JMAPClient hardening', () => { expect(client.accountId).toBe('acc-1'); }); + describe('redirects that drop the Authorization header (B15, webmail #892)', () => { + // RN's fetch leaves `redirected` false; only `url` shows the redirect. + const authOf = (call: unknown[]) => + ((call[1] as RequestInit | undefined)?.headers as Record | undefined)?.Authorization; + + it('refetches the final URL with credentials after a 401 behind an https upgrade', async () => { + const fetch = mockFetch([ + { status: 401, url: 'https://mail.example.com/jmap/session' }, + { status: 200, json: SESSION }, + ]); + const client = new JMAPClient(); + await client.connect('http://mail.example.com', 'user', 'pass'); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls[1][0]).toBe('https://mail.example.com/jmap/session'); + expect(authOf(fetch.mock.calls[1])).toMatch(/^Basic /); + expect(client.accountId).toBe('acc-1'); + }); + + it('refetches when the redirect only shows in response.url and the session came back empty', async () => { + const fetch = mockFetch([ + { status: 404, url: 'https://mail.example.com/jmap/session' }, + { status: 200, json: { apiUrl: '/jmap/', accounts: {}, primaryAccounts: {} }, 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.mock.calls.map((c) => c[0])).toEqual([ + 'https://mail.example.com/jmap/session', + 'https://mail.example.com/.well-known/jmap', + 'https://mail.example.com/jmap/session/', + ]); + expect(client.accountId).toBe('acc-1'); + }); + + it('does not treat a canonicalised final URL as a redirect', async () => { + const fetch = mockFetch([{ status: 401, url: 'https://mail.example.com/jmap/session' }]); + const client = new JMAPClient(); + const err = await client.connect('https://Mail.Example.com:443', 'user', 'bad').catch((e) => e); + expect(err).toBeInstanceOf(AuthenticationError); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('still reports bad credentials when the direct refetch is rejected too', async () => { + mockFetch([ + { status: 401, url: 'https://mail.example.com/jmap/session' }, + { status: 401, url: 'https://mail.example.com/jmap/session' }, + ]); + const client = new JMAPClient(); + const err = await client.connect('http://mail.example.com', 'user', 'bad').catch((e) => e); + expect(err).toBeInstanceOf(AuthenticationError); + }); + + it('keeps credentials off another host and fails without an auth error', async () => { + const fetch = mockFetch([{ status: 401, url: 'https://sso.example.net/jmap/session' }]); + const client = new JMAPClient(); + const err = await client.connect('https://mail.example.com', 'user', 'pass').catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(AuthenticationError); + expect(String(err.message)).toContain('redirected to https://sso.example.net'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('surfaces a redirect it cannot follow as NetworkError on restore, so the account is kept', async () => { + const SecureStore = await import('expo-secure-store'); + (SecureStore.getItemAsync as ReturnType).mockResolvedValueOnce( + JSON.stringify({ serverUrl: 'https://mail.example.com', username: 'user', password: 'pass' }), + ); + mockFetch([{ status: 200, json: { apiUrl: '/jmap/', accounts: {}, primaryAccounts: {} }, url: 'https://sso.example.net/session' }]); + const client = new JMAPClient(); + const err = await client.loadAccount('user@mail.example.com').catch((e) => e); + expect(err).toBeInstanceOf(NetworkError); + }); + }); + it('throws TotpRequiredError on a 402 MFA challenge', async () => { mockFetch([{ status: 402, json: { title: 'MFA code required' } }]); const client = new JMAPClient(); diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 89550c22..6177c1dc 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -7,7 +7,7 @@ import type { } from './types'; import { CAPABILITIES } from './types'; import { generateAccountId } from '../lib/account-utils'; -import { secureFetch } from '../lib/client-cert'; +import { secureFetch, mayFollowRedirect } from '../lib/client-cert'; import { refreshOAuthAccessToken, TransientRefreshError, @@ -112,6 +112,18 @@ function fetchSessionDescribes(session: unknown): session is JMAPSession { return typeof s.apiUrl === 'string'; } +/** + * `url` with the scheme and host lowercased and a default port dropped, so a + * response's final URL can be compared with the requested one. + */ +function canonicalUrl(url: string): string { + const m = /^(https?):\/\/([^/?#]+)(.*)$/i.exec(url); + if (!m) return url; + const scheme = m[1].toLowerCase(); + const host = m[2].toLowerCase().replace(scheme === 'https' ? /:443$/ : /:80$/, ''); + return `${scheme}://${host}${m[3]}`; +} + // Shared response helpers live in ./jmap-result (dependency-free) and are // re-exported here for convenience. export { JMAPMethodError, requireMethodResult, assertSetResult, batched } from './jmap-result'; @@ -699,14 +711,39 @@ export class JMAPClient { // servers that don't serve /jmap/session (#39). const primary = `${baseUrl}/jmap/session`; const fallback = `${baseUrl}/.well-known/jmap`; + let requested = primary; const fetchSessionDoc = async () => { const r = await this.authenticatedFetch(primary, { headers: { Accept: 'application/json' } }); - return r.status === 404 - ? this.authenticatedFetch(fallback, { headers: { Accept: 'application/json' } }) - : r; + if (r.status !== 404) return r; + requested = fallback; + return this.authenticatedFetch(fallback, { headers: { Accept: 'application/json' } }); }; let response = await fetchSessionDoc(); + // A redirect the platform followed may have dropped the Authorization + // header (iOS NSURLSession does; OkHttp does for another scheme, host or + // port). The server then answers 401, or - Stalwart - 200 with an empty + // session, although the credentials are fine. RN's fetch never sets + // `response.redirected`, so also compare the final URL with the one + // requested, and refetch it with the header (webmail #892). + const finalUrl = canonicalUrl((response as { url?: string }).url || requested); + const redirected = + Boolean((response as { redirected?: boolean }).redirected) || finalUrl !== canonicalUrl(requested); + let refetched = false; + const refetchRedirected = async (): Promise => { + // Credentials only follow a redirect within the same host (or its + // https upgrade), the rule secureFetch applies. Anything else fails as + // a discovery error, not an auth one, so a stored account survives it. + if (!mayFollowRedirect(canonicalUrl(requested), finalUrl)) { + throw new Error(`Session discovery failed: redirected to ${finalUrl}`); + } + refetched = true; + return this.authenticatedFetch(finalUrl, { headers: { Accept: 'application/json' } }); + }; + if (redirected && response.status === 401) { + response = await refetchRedirected(); + } + if (response.status === 401) { throw new AuthenticationError('Invalid credentials'); } @@ -730,18 +767,15 @@ export class JMAPClient { 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. + // Stalwart 307-redirects /.well-known/jmap to /jmap/session and answers + // the header-less redirected request with a 200 empty session. const redirectedEmpty = - (response as { redirected?: boolean }).redirected && + redirected && + !refetched && (!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 (redirectedEmpty) { + response = await refetchRedirected(); if (response.status === 401) throw new AuthenticationError('Invalid credentials'); if (!response.ok) { throw new Error(`Session discovery failed: ${response.status} ${response.statusText}`);