diff --git a/stores/__tests__/auth-store-switch-guard.test.ts b/stores/__tests__/auth-store-switch-guard.test.ts index edf8b0da..b2953f15 100644 --- a/stores/__tests__/auth-store-switch-guard.test.ts +++ b/stores/__tests__/auth-store-switch-guard.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { connectedAccountCandidates } from '../auth-store'; +import { connectedAccountCandidates, classifySessionMatch } from '../auth-store'; import type { Identity } from '@/lib/jmap/types'; // The account-switch guard force-re-auths only when the connected session @@ -66,3 +66,33 @@ describe('connectedAccountCandidates (account-switch guard)', () => { expect(out).toEqual([]); }); }); + +describe('classifySessionMatch (account-switch verdict)', () => { + const CANDIDATES = ['linus@rathblume.de@mail.example.com', 'admin@rbm.systems@mail.example.com']; + + it('accepts when a candidate equals the stored accountId (full-email login)', () => { + expect(classifySessionMatch(CANDIDATES, 'linus@rathblume.de@mail.example.com', [])).toBe('accept'); + }); + + it('accepts a short-username accountId via captured server identifiers', () => { + // accountId built from the short login `linus`, which the server canonicalizes + // to linus@rathblume.de — the id itself never appears among the candidates. + expect(classifySessionMatch(CANDIDATES, 'linus@mail.example.com', CANDIDATES)).toBe('accept'); + }); + + it('trusts a legacy account with no captured baseline (TOFU self-heal)', () => { + expect(classifySessionMatch(CANDIDATES, 'linus@mail.example.com', undefined)).toBe('trust'); + }); + + it('rejects a real desync once a baseline exists', () => { + // slot resolves to a genuinely different account; baseline was captured before. + expect( + classifySessionMatch(['stranger@mail.example.com'], 'linus@mail.example.com', CANDIDATES), + ).toBe('reject'); + }); + + it('accepts when nothing could be confirmed (empty candidates never bounce)', () => { + expect(classifySessionMatch([], 'linus@mail.example.com', undefined)).toBe('accept'); + expect(classifySessionMatch([], 'linus@mail.example.com', CANDIDATES)).toBe('accept'); + }); +}); diff --git a/stores/account-store.ts b/stores/account-store.ts index eb33ebe3..49835fe4 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -17,6 +17,15 @@ export interface AccountEntry { cookieSlot: number; /** Whether "Remember Me" was checked (basic auth only) */ rememberMe: boolean; + /** + * Server-confirmed account identifiers captured at login: the account-id form + * ({@link generateAccountId}) of the JMAP Session.username and the primary + * sending-identity email. The account-switch guard matches a reconnected + * session against these so a short login username (e.g. `linus`) that the + * server canonicalizes to a full address (`linus@example.com`) is still + * recognized as the same account. `undefined` = never captured (legacy entry). + */ + serverIdentifiers?: string[]; /** Cached display info */ displayName: string; email: string; diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 392216ae..1f54deaa 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -423,6 +423,17 @@ function scheduleRefresh(expiresIn: number, refreshFn: () => Promise(); + if (sessionUsername) ids.add(generateAccountId(sessionUsername, serverUrl)); + if (primaryEmail) ids.add(generateAccountId(primaryEmail, serverUrl)); + return [...ids]; +} + 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 @@ -434,16 +445,45 @@ export async function connectedAccountCandidates(client: JMAPClient, serverUrl: // 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(); + let sessionUser: string | undefined; try { - const sessionUser = client.getSessionUsername(); - if (sessionUser) ids.add(generateAccountId(sessionUser, serverUrl)); + sessionUser = client.getSessionUsername(); } catch { /* session unavailable */ } + let primaryEmail: string | undefined; try { const { primaryIdentity } = loadIdentities(await client.getIdentities(), client.getUsername()); - if (primaryIdentity?.email) ids.add(generateAccountId(primaryIdentity.email, serverUrl)); + primaryEmail = primaryIdentity?.email; } catch { /* identities unavailable */ } - return [...ids]; + return buildServerIdentifiers(sessionUser, primaryEmail, serverUrl); +} + +/** + * Decide whether a freshly connected session may be bound to `accountId`. + * + * `connectedCandidates` are the server-confirmed identifiers of the session we + * just connected ({@link connectedAccountCandidates}). We accept when they + * overlap either the stored `accountId` (full-email / OAuth logins, where the + * id already IS the canonical address) or `storedIdentifiers` — the identifiers + * captured when THIS account last logged in, which cover a short login username + * the server canonicalizes to a full address. + * + * - 'accept' — bind the session (matched, or nothing confirmable to check). + * - 'trust' — legacy account with no baseline yet: accept and backfill (TOFU), + * so short-username accounts created before this check self-heal + * instead of bouncing forever. + * - 'reject' — server identity contradicts a known baseline: a real desync; + * force a clean re-auth. + */ +export function classifySessionMatch( + connectedCandidates: string[], + accountId: string, + storedIdentifiers: string[] | undefined, +): 'accept' | 'trust' | 'reject' { + if (connectedCandidates.length === 0) return 'accept'; + const accepted = new Set([accountId, ...(storedIdentifiers ?? [])]); + if (connectedCandidates.some((c) => accepted.has(c))) return 'accept'; + if (storedIdentifiers === undefined) return 'trust'; + return 'reject'; } function clearRefreshTimer(accountId?: string): void { @@ -670,6 +710,14 @@ export const useAuthStore = create()( lastLoginAt: Date.now(), }); + // Capture the server-confirmed identity now so a later account switch + // recognizes this session even when `username` is a short login name + // the server canonicalizes to a different address (see the switch guard). + const serverIdentifiers = buildServerIdentifiers(client.getSessionUsername(), primaryIdentity?.email, serverUrl); + if (serverIdentifiers.length > 0) { + accountStore.updateAccount(accountId, { serverIdentifiers }); + } + set({ isAuthenticated: true, isLoading: false, @@ -1424,10 +1472,15 @@ export const useAuthStore = create()( // 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. + // mailbox. We accept the session when it matches the stored accountId + // OR the server identity captured at this account's login (so a short + // login username the server canonicalizes to a full address is still + // recognized). On a genuine 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)) { + const verdict = classifySessionMatch(connectedCandidates, accountId, targetAccount.serverIdentifiers); + if (verdict === 'reject') { debug.error(`switchAccount: slot ${targetAccount.cookieSlot} for ${accountId} resolved to [${connectedCandidates.join(", ")}] — forcing re-auth`); clients.delete(accountId); try { targetClient.disconnect(); } catch { /* noop */ } @@ -1438,6 +1491,11 @@ export const useAuthStore = create()( replaceWindowLocation(getLocaleLoginPath()); return; } + // Refresh (or, for a legacy 'trust' entry, establish) the identity + // baseline now that we've confirmed a good session. + if (connectedCandidates.length > 0) { + accountStore.updateAccount(accountId, { serverIdentifiers: connectedCandidates }); + } // Restore cached state or fetch fresh const restored = restoreAccount(accountId);