diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 0f3ff10e..489788d0 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -26,6 +26,9 @@ export class RateLimitError extends Error { // JMAP protocol types - these are intentionally flexible due to server variations interface JMAPSession { + // The authenticated login (JMAP spec Session.username) — server-confirmed, + // unlike the client-side constructor username or the sending identity. + username?: string; apiUrl: string; downloadUrl: string; uploadUrl?: string; @@ -3412,6 +3415,14 @@ export class JMAPClient implements IJMAPClient { return this.username || this.session?.accounts?.[this.accountId]?.name || ''; } + // Server-confirmed authenticated login from the JMAP Session object. Use + // this (not getUsername(), which echoes the constructor arg, nor the + // sending identity) to verify a slot's token resolved to the expected + // account. + getSessionUsername(): string | undefined { + return this.session?.username; + } + supportsEmailSubmission(): boolean { return this.hasCapability("urn:ietf:params:jmap:submission"); } diff --git a/stores/__tests__/auth-store-switch-guard.test.ts b/stores/__tests__/auth-store-switch-guard.test.ts new file mode 100644 index 00000000..edf8b0da --- /dev/null +++ b/stores/__tests__/auth-store-switch-guard.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { connectedAccountCandidates } from '../auth-store'; +import type { Identity } from '@/lib/jmap/types'; + +// The account-switch guard force-re-auths only when the connected session +// matches NONE of its server-confirmed identifiers. accountId is generated +// from the primary-identity email (OAuth) OR the login username (basic), so +// the candidate set must cover both: the JMAP Session.username (authenticated +// login) and the primary sending-identity email. + +const SERVER = 'https://mail.example.com'; + +const fakeClient = (opts: { + sessionUsername?: string; + constructorUsername?: string; + identities?: Identity[] | Error; +}) => + ({ + getSessionUsername: () => opts.sessionUsername, + getUsername: () => opts.constructorUsername ?? '', + getIdentities: async () => { + if (opts.identities instanceof Error) throw opts.identities; + return opts.identities ?? []; + }, + }) as never; + +const id = (over: Partial = {}): Identity => ({ + id: 'id-1', name: 'Real User', email: 'real@example.com', mayDelete: true, ...over, +}); + +describe('connectedAccountCandidates (account-switch guard)', () => { + it('includes the primary-identity email (OAuth registers by email)', async () => { + const out = await connectedAccountCandidates( + fakeClient({ sessionUsername: 'preferred_user', identities: [id()] }), SERVER, + ); + expect(out).toContain('real@example.com@mail.example.com'); + }); + + it('includes the session login username (basic auth registers by login)', async () => { + // support@ case: login is the email, but the primary sending identity is a + // different address. The login must still be accepted. + const out = await connectedAccountCandidates( + fakeClient({ sessionUsername: 'support@linux-hosting.co.il', identities: [id({ email: 'alias@elsewhere.com' })] }), + SERVER, + ); + expect(out).toContain('support@linux-hosting.co.il@mail.example.com'); + expect(out).toContain('alias@elsewhere.com@mail.example.com'); + }); + + it('excludes the constructor username (cannot mask a desync)', async () => { + // A desynced slot: client built for support@ but the token resolves to + // shuki@. Candidates come only from the server (session + identities), + // never the constructor echo, so support@ is NOT among them. + const out = await connectedAccountCandidates( + fakeClient({ sessionUsername: 'shuki@linux-hosting.co.il', constructorUsername: 'support@linux-hosting.co.il', identities: [id({ email: 'shuki@linux-hosting.co.il' })] }), + SERVER, + ); + expect(out).not.toContain('support@linux-hosting.co.il@mail.example.com'); + expect(out).toContain('shuki@linux-hosting.co.il@mail.example.com'); + }); + + it('returns empty when nothing can be confirmed (caller must not bounce)', async () => { + const out = await connectedAccountCandidates( + fakeClient({ sessionUsername: undefined, identities: new Error('no idents') }), SERVER, + ); + expect(out).toEqual([]); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 4aa67f60..6e1ee7b4 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -296,6 +296,36 @@ function scheduleRefresh(expiresIn: number, refreshFn: () => Promisetoken mapping that + * resolves to the wrong account before it surfaces as the wrong mailbox. + * Returns null when it can't determine the identity (treated as "don't block"). + */ +export async function connectedAccountCandidates(client: JMAPClient, serverUrl: string): Promise { + // accountId is generated differently per auth mode: OAuth/SSO registers from + // the primary-identity EMAIL, basic auth from the typed login username. A + // single derivation can't match both, so collect every server-confirmed + // identifier the connected session exposes — the JMAP Session.username + // (authenticated login) and the primary sending-identity email — and let the + // caller accept the session if the target accountId matches ANY of them. + // Deliberately excludes client.getUsername(), which echoes the constructor + // username (always the target) and would defeat the desync check. An empty + // result means nothing could be confirmed → the caller should NOT force a + // re-auth. + const ids = new Set(); + try { + const sessionUser = client.getSessionUsername(); + if (sessionUser) ids.add(generateAccountId(sessionUser, serverUrl)); + } catch { /* session unavailable */ } + try { + const { primaryIdentity } = loadIdentities(await client.getIdentities(), client.getUsername()); + if (primaryIdentity?.email) ids.add(generateAccountId(primaryIdentity.email, serverUrl)); + } catch { /* identities unavailable */ } + return [...ids]; +} + function clearRefreshTimer(accountId?: string): void { if (accountId) { const timer = refreshTimers.get(accountId); @@ -1207,6 +1237,26 @@ export const useAuthStore = create()( return; } + // GUARD: verify the connected session actually belongs to the target + // account before we bind it. A corrupted slot->token mapping (e.g. + // persisted client state left over from an older build, or any future + // slot desync) can hand back a *different* account's token; the + // connection then succeeds and we would silently show the wrong + // mailbox. On mismatch, drop the poisoned cookies for this slot and + // force a clean re-auth instead of surfacing someone else's mail. + const connectedCandidates = await connectedAccountCandidates(targetClient, targetAccount.serverUrl); + if (connectedCandidates.length > 0 && !connectedCandidates.includes(accountId)) { + debug.error(`switchAccount: slot ${targetAccount.cookieSlot} for ${accountId} resolved to [${connectedCandidates.join(", ")}] — forcing re-auth`); + clients.delete(accountId); + try { targetClient.disconnect(); } catch { /* noop */ } + apiFetch(`/api/auth/token?slot=${targetAccount.cookieSlot}`, { method: 'DELETE' }).catch(() => {}); + apiFetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`, { method: 'DELETE' }).catch(() => {}); + accountStore.updateAccount(accountId, { isConnected: false, hasError: true, errorMessage: 'session_mismatch' }); + set({ isLoading: false, error: 'connection_failed', activeAccountId: state.activeAccountId }); + replaceWindowLocation(getLocaleLoginPath()); + return; + } + // Restore cached state or fetch fresh const restored = restoreAccount(accountId); accountStore.setActiveAccount(accountId);