mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 00:06:53 +00:00
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:
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user