fix: stop signing out on launch when a redirect drops the auth header

When a proxy redirects the session request (an http to https upgrade, a
trailing slash, another port), iOS and OkHttp follow it without the
Authorization header. The server then answers 401 or, like Stalwart, an
empty session, which the client reported as invalid credentials, so a
restored account was evicted and its stored credentials deleted. The
retry that was meant to catch this keyed on `response.redirected`,
which React Native's fetch never sets.

Session discovery now also detects a redirect by comparing the final
`response.url` with the requested URL, and on a 401 or an empty session
refetches the final URL with the header. Credentials only follow within
the same host or its https upgrade, the rule `secureFetch` applies; a
redirect elsewhere fails as a discovery error, which `loadAccount`
turns into a `NetworkError`, so the stored account is kept.
This commit is contained in:
Linus Rath
2026-09-24 18:43:56 +02:00
parent c51a848dfb
commit 859770b814
2 changed files with 121 additions and 13 deletions
@@ -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<string, string> | 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<typeof vi.fn>).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();
+47 -13
View File
@@ -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<Response> => {
// 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}`);