Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions lib/jmap/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
}
Expand Down
68 changes: 68 additions & 0 deletions stores/__tests__/auth-store-switch-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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([]);
});
});
50 changes: 50 additions & 0 deletions stores/auth-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,36 @@ function scheduleRefresh(expiresIn: number, refreshFn: () => Promise<string | nu
}
}

/**
* Derive the accountId a *connected* client actually belongs to, using the
* same canonicalisation as login (primary-identity email for OAuth, else the
* JMAP session username). Lets a caller detect a slot->token 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<string[]> {
// 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<string>();
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);
Expand Down Expand Up @@ -1207,6 +1237,26 @@ export const useAuthStore = create<AuthState>()(
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);
Expand Down