From da1ff6272527e33526b8350a53cd566d86cee909 Mon Sep 17 00:00:00 2001 From: Ninad Chandorkar Date: Wed, 5 Aug 2026 23:34:53 +0530 Subject: [PATCH] fix(jmap): request /jmap/session directly to survive iOS auth-drop on redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signing in from the iOS build against a Stalwart server fails with "No account found in JMAP session". Stalwart's discovery endpoint /.well-known/jmap answers with a 307 to /jmap/session. iOS NSURLSession strips the Authorization header when it auto-follows that redirect, so the app gets an unauthenticated session back — HTTP 200, empty accounts — and resolveAccountId throws. Browsers keep the auth header on same-origin redirects, which is why the webmail never saw this; it only bites the native app on iOS. Request /jmap/session directly so the header stays attached, falling back to /.well-known/jmap on 404 for servers that don't expose the Stalwart path. Also treat a 200 with no accounts as an auth failure. Stalwart answers that way for missing or invalid credentials instead of sending a 401, so a user who fat-fingers their password currently gets "No account found in JMAP session" rather than "Invalid credentials". Tests updated for both behaviours; full suite passes (the pre-existing auth-store.test.ts parse failure on main is unrelated). --- src/api/__tests__/jmap-client.test.ts | 8 ++++--- src/api/jmap-client.ts | 32 ++++++++++++++++++++++----- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/api/__tests__/jmap-client.test.ts b/src/api/__tests__/jmap-client.test.ts index c1784b9..fac0d7b 100644 --- a/src/api/__tests__/jmap-client.test.ts +++ b/src/api/__tests__/jmap-client.test.ts @@ -90,7 +90,7 @@ describe('JMAPClient', () => { await client.connect('https://mail.example.com///', 'user', 'pass'); expect(global.fetch).toHaveBeenCalledWith( - 'https://mail.example.com/.well-known/jmap', + 'https://mail.example.com/jmap/session', expect.any(Object), ); }); @@ -281,7 +281,9 @@ describe('JMAPClient', () => { expect(client.accountId).toBe('only-acc'); }); - it('should throw if no accounts at all', async () => { + it('should throw on an empty (unauthenticated) session', async () => { + // Stalwart returns 200 with empty accounts for invalid credentials; + // the client surfaces that as an auth failure. const session = { ...MOCK_SESSION, primaryAccounts: {}, @@ -290,7 +292,7 @@ describe('JMAPClient', () => { global.fetch = mockFetch([{ status: 200, json: session }]) as any; await expect(client.connect('https://mail.example.com', 'user', 'pass')) - .rejects.toThrow('No account found'); + .rejects.toThrow('Invalid credentials'); }); }); }); diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 8ee0133..2b8cd4f 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -361,18 +361,30 @@ export class JMAPClient { // ── Session Discovery ───────────────────────────────── private async fetchSession(baseUrl: string): Promise { - const url = `${baseUrl}/.well-known/jmap`; await this.ensureFreshToken(); - const doFetch = () => + // Stalwart's discovery endpoint /.well-known/jmap 307-redirects to + // /jmap/session. On iOS, NSURLSession drops the Authorization header when it + // auto-follows that redirect, so the app receives an unauthenticated, + // empty-accounts session and fails with "No account found in JMAP session". + // Request the session endpoint directly so the auth header stays attached; + // fall back to the standard well-known path for non-Stalwart servers. + const doFetch = (url: string) => secureFetch(url, { headers: { Authorization: this.authHeader, Accept: 'application/json', }, }); - let response = await doFetch(); + const primary = `${baseUrl}/jmap/session`; + const fallback = `${baseUrl}/.well-known/jmap`; + const run = async () => { + const r = await doFetch(primary); + return r.status === 404 ? doFetch(fallback) : r; + }; + + let response = await run(); if (response.status === 401 && (await this.forceRefreshToken())) { - response = await doFetch(); + response = await run(); } if (response.status === 401) { @@ -382,7 +394,17 @@ export class JMAPClient { throw new Error(`Session discovery failed: ${response.status} ${response.statusText}`); } - return response.json(); + const session: JMAPSession = await response.json(); + // Stalwart returns HTTP 200 with empty accounts for missing/invalid + // credentials (rather than 401). Treat an empty session as an auth failure + // so the user sees "Invalid credentials" instead of "No account found". + const hasAccount = + Object.keys(session.primaryAccounts ?? {}).length > 0 || + Object.keys(session.accounts ?? {}).length > 0; + if (!hasAccount) { + throw new AuthenticationError('Invalid credentials'); + } + return session; } private resolveAccountId(session: JMAPSession): string {