fix(jmap): request /jmap/session directly to survive iOS auth-drop on redirect

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).
This commit is contained in:
Ninad Chandorkar
2026-08-05 23:34:53 +05:30
parent 82b97fada4
commit da1ff62725
2 changed files with 32 additions and 8 deletions
+5 -3
View File
@@ -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');
});
});
});
+27 -5
View File
@@ -361,18 +361,30 @@ export class JMAPClient {
// ── Session Discovery ─────────────────────────────────
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
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 {