Merge branch 'fx/auth3' into feat/stalwart-1.0

This commit is contained in:
Linus Rath
2026-10-29 03:43:39 +01:00
9 changed files with 354 additions and 17 deletions
+1
View File
@@ -6,6 +6,7 @@ const { mockSecureFetch } = vi.hoisted(() => ({
vi.mock('../client-cert', () => ({
secureFetch: mockSecureFetch,
fetchWithoutForeignRedirect: mockSecureFetch,
}));
import {
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as WebBrowser from 'expo-web-browser';
import { setManualFetchForTests } from '../client-cert';
import { HandoffError, TransientRefreshError, refreshOAuthAccessToken, resetRefreshRotations } from '../oauth';
import { discoverOAuthMetadata, loginWithPkce, revokeRefreshToken, type OAuthMetadata } from '../oauth-native';
// The refresh token, the code + verifier and the metadata that decides where
// they go must not follow a redirect to another host. One fake network sits
// behind both fetches: the one told `redirect: 'manual'` (Expo's on a device)
// gets the 3xx back; React Native's own follows a 307/308 anywhere with the
// method and body, as OkHttp does.
const SERVER = 'https://mail.example.com';
// An external identity provider: its token endpoint is on another host than
// the mail server, and that is fine.
const IDP = 'https://id.example.net';
const EVIL = 'https://evil.example';
interface Hit { url: string; body?: string }
let hits: Hit[];
let routes: Record<string, () => Response>;
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
}
function redirect(location: string, status = 307): Response {
return new Response(null, { status, headers: { Location: location } });
}
async function network(url: string, init?: RequestInit): Promise<Response> {
hits.push({ url, body: typeof init?.body === 'string' ? init.body : undefined });
const route = routes[url.split('?')[0]];
const response = route ? route() : json({ error: 'not found' }, 404);
const location = response.headers.get('location');
if (location && init?.redirect !== 'manual') return network(location, init);
return response;
}
const evilHits = () => hits.filter((h) => h.url.startsWith(EVIL));
beforeEach(() => {
hits = [];
routes = {};
resetRefreshRotations();
setManualFetchForTests(network);
vi.stubGlobal('fetch', vi.fn(network));
});
afterEach(() => {
setManualFetchForTests(undefined);
vi.unstubAllGlobals();
});
const tokens = {
accessToken: 'a1',
refreshToken: 'r1',
expiresAt: Date.now() + 5_000,
tokenEndpoint: `${IDP}/token`,
clientId: 'bulwark-webmail',
};
describe('token refresh', () => {
it('does not take the refresh token along a redirect to another host', async () => {
routes[`${IDP}/token`] = () => redirect(`${EVIL}/token`);
await expect(refreshOAuthAccessToken(tokens)).rejects.toBeInstanceOf(TransientRefreshError);
expect(evilHits()).toEqual([]);
});
it("follows a redirect within the token endpoint's own host, not the server's", async () => {
routes[`${IDP}/token`] = () => redirect(`${IDP}/oauth/token`, 308);
routes[`${IDP}/oauth/token`] = () => json({ access_token: 'a2', refresh_token: 'r2', expires_in: 3600 });
const next = await refreshOAuthAccessToken(tokens);
expect(next.accessToken).toBe('a2');
expect(hits.map((h) => h.url)).toEqual([`${IDP}/token`, `${IDP}/oauth/token`]);
});
});
describe('PKCE code exchange', () => {
it('does not take the code and verifier along a redirect to another host', async () => {
vi.mocked(WebBrowser.openAuthSessionAsync).mockImplementationOnce(async (authUrl: string) => ({
type: 'success',
url: `bulwarkmobile://auth/callback?code=c1&state=${new URL(authUrl).searchParams.get('state')}`,
}));
routes[`${IDP}/token`] = () => redirect(`${EVIL}/token`);
const metadata: OAuthMetadata = { authorization_endpoint: `${IDP}/authorize`, token_endpoint: `${IDP}/token` };
await expect(loginWithPkce(SERVER, metadata, { clientId: 'bulwark-webmail' })).rejects.toBeInstanceOf(HandoffError);
expect(hits.map((h) => h.url)).toEqual([`${IDP}/token`]);
expect(evilHits()).toEqual([]);
});
});
describe('revocation', () => {
it('reaches an identity provider on another host but not a redirect away from it', async () => {
routes[`${SERVER}/.well-known/oauth-authorization-server`] = () => json({
authorization_endpoint: `${IDP}/authorize`,
token_endpoint: `${IDP}/token`,
revocation_endpoint: `${IDP}/revoke`,
});
routes[`${IDP}/revoke`] = () => redirect(`${EVIL}/revoke`);
await revokeRefreshToken(SERVER, { ...tokens, source: 'native' });
expect(hits.map((h) => h.url)).toContain(`${IDP}/revoke`);
expect(evilHits()).toEqual([]);
});
});
describe('metadata discovery', () => {
it('ignores metadata reached through a redirect to another host', async () => {
routes[`${SERVER}/.well-known/oauth-authorization-server`] = () =>
redirect(`${EVIL}/.well-known/oauth-authorization-server`, 302);
routes[`${EVIL}/.well-known/oauth-authorization-server`] = () => json({
authorization_endpoint: `${EVIL}/authorize`,
token_endpoint: `${EVIL}/token`,
registration_endpoint: `${EVIL}/register`,
});
expect(await discoverOAuthMetadata(SERVER)).toBeNull();
expect(evilHits()).toEqual([]);
});
it('ignores it where the platform followed the redirect anyway', async () => {
// No fetch that takes `redirect: 'manual'`: the answer's url gives the
// redirect away.
setManualFetchForTests(null);
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
hits.push({ url });
const response = json({ authorization_endpoint: `${EVIL}/authorize`, token_endpoint: `${EVIL}/token` });
Object.defineProperty(response, 'url', { value: `${EVIL}/.well-known/oauth-authorization-server` });
return response;
}));
expect(await discoverOAuthMetadata(SERVER)).toBeNull();
});
it('takes metadata from the server itself', async () => {
routes[`${SERVER}/.well-known/oauth-authorization-server`] = () => json({
authorization_endpoint: `${IDP}/authorize`,
token_endpoint: `${IDP}/token`,
});
expect((await discoverOAuthMetadata(SERVER))?.token_endpoint).toBe(`${IDP}/token`);
});
});
+33
View File
@@ -6,6 +6,7 @@ const { mockSecureFetch } = vi.hoisted(() => ({
vi.mock('../client-cert', () => ({
secureFetch: mockSecureFetch,
fetchWithoutForeignRedirect: mockSecureFetch,
}));
import { forgetRefreshRotations, refreshOAuthAccessToken, resetRefreshRotations, type OAuthTokens } from '../oauth';
@@ -125,6 +126,38 @@ describe('oauth token refresh deduplication', () => {
expect(mockSecureFetch).not.toHaveBeenCalled();
});
it('does not bring back a chain forgotten while its refresh was in flight', async () => {
const tokens: OAuthTokens = {
accessToken: 'a1',
refreshToken: 'r1',
tokenEndpoint: 'https://auth.example.com/token',
clientId: 'bulwark-webmail',
};
let answer!: () => void;
mockSecureFetch.mockImplementationOnce(async () => {
await new Promise<void>((resolve) => { answer = resolve; });
return {
ok: true,
status: 200,
json: async () => ({ access_token: 'a2', refresh_token: 'r2', expires_in: 3600 }),
} as Response;
});
const inFlight = refreshOAuthAccessToken(tokens);
await vi.waitFor(() => expect(mockSecureFetch).toHaveBeenCalledTimes(1));
// The account signs out before the token endpoint answers.
forgetRefreshRotations('r1');
answer();
await inFlight;
// A stale copy refreshed later doesn't get the signed-out account's
// successor, nor one of the successor's.
mockSecureFetch.mockResolvedValue({ ok: false, status: 400 } as Response);
await expect(refreshOAuthAccessToken(tokens)).rejects.toThrow('400');
await expect(refreshOAuthAccessToken({ ...tokens, accessToken: 'a2', refreshToken: 'r2' })).rejects.toThrow('400');
expect(mockSecureFetch).toHaveBeenCalledTimes(3);
});
it('keeps refreshing with the same token on a server that does not rotate', async () => {
const tokens: OAuthTokens = {
accessToken: 'a1',
+21 -2
View File
@@ -5,7 +5,7 @@ const { mockSecureFetch, store } = vi.hoisted(() => ({
store: new Map<string, unknown>(),
}));
vi.mock('../client-cert', () => ({ secureFetch: mockSecureFetch }));
vi.mock('../client-cert', () => ({ secureFetch: mockSecureFetch, fetchWithoutForeignRedirect: mockSecureFetch }));
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
getStoredCredentials: vi.fn(async (id: string) => (store.has(id) ? structuredClone(store.get(id)) : null)),
@@ -14,7 +14,7 @@ vi.mock('../../api/jmap-client', () => ({
}));
import { jmapClient, type StoredCredentials } from '../../api/jmap-client';
import { resetRefreshRotations } from '../oauth';
import { forgetRefreshRotations, refreshOAuthAccessToken, resetRefreshRotations } from '../oauth';
import { freshStoredCredentials } from '../stored-token-refresh';
const ID = 'u@example.org@https://mail.example.com';
@@ -99,6 +99,25 @@ describe('freshStoredCredentials', () => {
expect(jmapClient.setStoredCredentials).not.toHaveBeenCalled();
});
it('hands out nothing when the account signs out while the refresh runs', async () => {
store.set(ID, bundle(1));
const rotate = mockSecureFetch.getMockImplementation()!;
mockSecureFetch.mockImplementationOnce(async (url: string, init: RequestInit) => {
// The sign-out, as the auth store does it, before the endpoint answers.
forgetRefreshRotations('r1');
store.delete(ID);
return rotate(url, init);
});
await expect(freshStoredCredentials(ID, bundle(1))).rejects.toThrow();
expect(store.has(ID)).toBe(false);
// Nor does a stale copy of the bundle get the successor out of memory.
await expect(
refreshOAuthAccessToken({ accessToken: 'a1', refreshToken: 'r1', tokenEndpoint: 'https://mail.example.com/auth/token', clientId: 'swc1.x' }),
).rejects.toThrow('400');
});
it('does not overwrite a newer bundle stored while the refresh ran', async () => {
store.set(ID, bundle(1));
// The iOS notification extension rotates the grant meanwhile (it can't
+12 -5
View File
@@ -11,7 +11,7 @@
// the hand-off produces, so `connectWithOAuth` and the refresh path apply.
import * as WebBrowser from 'expo-web-browser';
import { secureFetch } from './client-cert';
import { fetchWithoutForeignRedirect, followedOnlyAllowedRedirects, mayFollowRedirect, secureFetch } from './client-cert';
import { HANDOFF_REDIRECT_URI, HandoffError, HandoffCancelledError, type OAuthTokens } from './oauth';
import {
generateCodeChallenge,
@@ -51,7 +51,9 @@ function sameHost(a: string, b: string): boolean {
/**
* RFC 8414 / OIDC discovery on the mail server origin. Returns null when the
* server advertises no authorization server (plain Stalwart without OAuth,
* or a non-Stalwart JMAP server).
* or a non-Stalwart JMAP server). The document decides where registration,
* the sign-in page and the code exchange go, so one reached through a
* redirect to another host doesn't count.
*/
export async function discoverOAuthMetadata(serverUrl: string): Promise<OAuthMetadata | null> {
const base = serverUrl.replace(/\/+$/, '');
@@ -62,8 +64,11 @@ export async function discoverOAuthMetadata(serverUrl: string): Promise<OAuthMet
for (const url of urls) {
const t = withTimeout(DISCOVERY_TIMEOUT_MS);
try {
const res = await secureFetch(url, { headers: { Accept: 'application/json' }, signal: t.signal });
const res = await fetchWithoutForeignRedirect(url, { headers: { Accept: 'application/json' }, signal: t.signal });
if (!res.ok) continue;
// Where no fetch could be told not to follow (see client-cert), the
// answer's url says whether it came from elsewhere.
if (res.url && !followedOnlyAllowedRedirects(res) && !mayFollowRedirect(url, res.url)) continue;
const data = (await res.json()) as Partial<OAuthMetadata>;
if (
typeof data.authorization_endpoint === 'string' &&
@@ -239,9 +244,11 @@ export async function loginWithPkce(
redirect_uri: redirectUri,
code_verifier: verifier,
});
// The code and verifier follow a redirect only within the token endpoint's
// own host, which may be an identity provider's rather than the server's.
let response: Response;
try {
response = await secureFetch(metadata.token_endpoint, {
response = await fetchWithoutForeignRedirect(metadata.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: body.toString(),
@@ -284,7 +291,7 @@ export async function revokeRefreshToken(serverUrl: string, tokens: OAuthTokens)
token_type_hint: 'refresh_token',
client_id: tokens.clientId,
});
await secureFetch(endpoint, {
await fetchWithoutForeignRedirect(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
+29 -4
View File
@@ -1,5 +1,5 @@
import * as WebBrowser from 'expo-web-browser';
import { secureFetch } from './client-cert';
import { fetchWithoutForeignRedirect, secureFetch } from './client-cert';
import { randomHex } from './random';
// Webmail-mediated login. The app opens the webmail's normal login page with
@@ -547,9 +547,26 @@ const ROTATED_MAX = 64;
// A successor whose access token has less left than this is refreshed in
// turn rather than handed out.
const SUCCESSOR_MIN_LIFETIME_MS = 60_000;
// Refresh tokens whose chain was dropped on sign-out. A refresh that was in
// flight then returns afterwards and must not put the chain back.
const forgotten = new Set<string>();
const FORGOTTEN_MAX = 256;
function markForgotten(refreshToken: string): void {
forgotten.delete(refreshToken);
if (forgotten.size >= FORGOTTEN_MAX) {
const oldest = forgotten.values().next().value;
if (oldest !== undefined) forgotten.delete(oldest);
}
forgotten.add(refreshToken);
}
function rememberRotation(previous: string, next: OAuthTokens): void {
if (!next.refreshToken || next.refreshToken === previous) return;
if (forgotten.has(previous)) {
markForgotten(next.refreshToken);
return;
}
if (!rotatedTo.has(previous) && rotatedTo.size >= ROTATED_MAX) {
const oldest = rotatedTo.keys().next().value;
if (oldest !== undefined) rotatedTo.delete(oldest);
@@ -572,13 +589,15 @@ function latestRotation(refreshToken: string): OAuthTokens | null {
/** Test hook. */
export function resetRefreshRotations(): void {
rotatedTo.clear();
forgotten.clear();
}
/**
* Drop the rotation chain `refreshToken` is part of, its predecessors and
* successors both, when its account signs out. A stale copy of the bundle
* refreshed later would otherwise get the successor from here and could
* write the removed account's tokens back.
* write the removed account's tokens back. A refresh of the chain still in
* flight doesn't add it again when it returns.
*/
export function forgetRefreshRotations(refreshToken: string | undefined): void {
if (!refreshToken) return;
@@ -593,7 +612,10 @@ export function forgetRefreshRotations(refreshToken: string | undefined): void {
if (next.refreshToken && !chain.has(next.refreshToken)) { chain.add(next.refreshToken); grew = true; }
}
}
for (const token of chain) rotatedTo.delete(token);
for (const token of chain) {
rotatedTo.delete(token);
markForgotten(token);
}
}
// OAuth refresh — exchanges the refresh token at the original token endpoint
@@ -623,9 +645,12 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
refresh_token: tokens.refreshToken!,
client_id: tokens.clientId,
});
// The refresh token goes in the body: a redirect to another host than
// the token endpoint's own comes back unfollowed (a transient failure
// below) instead of taking the token along.
let response: Response;
try {
response = await secureFetch(tokens.tokenEndpoint, {
response = await fetchWithoutForeignRedirect(tokens.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
+15 -4
View File
@@ -16,7 +16,7 @@
// someone else wrote.
import { jmapClient, type StoredCredentials } from '../api/jmap-client';
import { refreshOAuthAccessToken } from './oauth';
import { forgetRefreshRotations, refreshOAuthAccessToken } from './oauth';
const TOKEN_REFRESH_LEEWAY_MS = 60_000;
@@ -29,7 +29,8 @@ function expiresSoon(expiresAt: number | undefined): boolean {
* stored when needed. Password credentials come back as they are; so does
* an account whose credentials are gone from storage (signed out). A failed
* refresh returns what there is, and the request that uses it fails on its
* own.
* own. When the account signs out while the refresh runs, its fresh tokens
* are dropped and this throws.
*/
export async function freshStoredCredentials(
accountId: string,
@@ -58,6 +59,7 @@ export async function freshStoredCredentials(
if (!expiresSoon(base.expiresAt)) return base;
}
let signedOut = false;
try {
const next = await refreshOAuthAccessToken({
accessToken: base.accessToken!,
@@ -75,11 +77,20 @@ export async function freshStoredCredentials(
clientId: next.clientId,
};
const current = await jmapClient.getStoredCredentials(accountId);
if (current && current.refreshToken === base.refreshToken && current.accessToken !== tokens.accessToken) {
if (!current) {
// Signed out meanwhile: the sign-out forgot the chain before this
// refresh returned. Nobody may use the tokens it bought.
forgetRefreshRotations(base.refreshToken);
forgetRefreshRotations(tokens.refreshToken);
signedOut = true;
throw new Error('Account signed out');
}
if (current.refreshToken === base.refreshToken && current.accessToken !== tokens.accessToken) {
await jmapClient.setStoredCredentials(accountId, { ...current, ...tokens });
}
return { ...base, ...tokens };
} catch {
} catch (err) {
if (signedOut) throw err;
return base;
}
}
+64 -2
View File
@@ -261,7 +261,7 @@ describe('what a sign-out leaves behind', () => {
feeds: useCalendarSubscriptionsStore.getState().subscriptions.map((s) => s.id),
});
it('keeps the recent searches and calendar feeds while an account is still signed in', async () => {
it("keeps the recent searches and the other accounts' calendar feeds while an account is still signed in", async () => {
await signedIn(A, B);
useSearchHistoryStore.setState({ recentSearches: ['invoice'] });
useCalendarSubscriptionsStore.setState({ subscriptions: [feed(A), feed(B)] });
@@ -269,7 +269,69 @@ describe('what a sign-out leaves behind', () => {
await useAuthStore.getState().logout();
await settle();
expect(leftovers()).toEqual({ searches: ['invoice'], feeds: [`sub-${A}`, `sub-${B}`] });
expect(leftovers()).toEqual({ searches: ['invoice'], feeds: [`sub-${B}`] });
});
it("drops a removed account's calendar feeds right away", async () => {
await signedIn(A, B, C);
useCalendarSubscriptionsStore.setState({ subscriptions: [feed(A), feed(B), feed(C)] });
await useAuthStore.getState().removeAccount(C);
expect(leftovers().feeds).toEqual([`sub-${A}`, `sub-${B}`]);
});
it('drops the feeds of an account whose sign-in is refused on the switch to it', async () => {
await signedIn(A, B);
useCalendarSubscriptionsStore.setState({ subscriptions: [feed(A), feed(B)] });
client.loadErrors = { [B]: new AuthenticationError('Invalid credentials') };
await useAuthStore.getState().switchAccount(B);
expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual([A]);
expect(leftovers().feeds).toEqual([`sub-${A}`]);
});
it('clears them when a switch evicts the last account', async () => {
// Signed out of the active account while the last one's server was
// away; it is refused when the user picks it.
await signedIn(A);
useAuthStore.setState({ isAuthenticated: false, session: null, activeAccountId: null, accountId: null });
client.served = null;
useSearchHistoryStore.setState({ recentSearches: ['invoice'] });
useCalendarSubscriptionsStore.setState({ subscriptions: [feed(A)] });
client.loadErrors = { [A]: new AuthenticationError('Invalid credentials') };
await useAuthStore.getState().switchAccount(A);
expect(useAccountStore.getState().accounts).toEqual([]);
expect(leftovers()).toEqual({ searches: [], feeds: [] });
});
it('clears them when the restore on launch evicts the last account', async () => {
await signedIn(A);
useAuthStore.setState({ isAuthenticated: false, session: null, activeAccountId: null, accountId: null });
useSearchHistoryStore.setState({ recentSearches: ['invoice'] });
useCalendarSubscriptionsStore.setState({ subscriptions: [feed(A)] });
client.loadErrors = { [A]: new AuthenticationError('Invalid credentials') };
expect(await useAuthStore.getState().restoreSession()).toBe(false);
expect(useAccountStore.getState().accounts).toEqual([]);
expect(leftovers()).toEqual({ searches: [], feeds: [] });
});
it('clears them when a session retry evicts the last account', async () => {
await signedIn(A);
useAuthStore.setState({ session: null });
useSearchHistoryStore.setState({ recentSearches: ['invoice'] });
useCalendarSubscriptionsStore.setState({ subscriptions: [feed(A)] });
client.loadErrors = { [A]: new AuthenticationError('Invalid credentials') };
expect(await useAuthStore.getState().retrySession()).toBe(false);
expect(useAccountStore.getState().accounts).toEqual([]);
expect(leftovers()).toEqual({ searches: [], feeds: [] });
});
it('clears them when the last account signs out', async () => {
+28
View File
@@ -147,6 +147,27 @@ function clearDeviceWideAccountData(): void {
useCalendarSubscriptionsStore.getState().clearAll();
}
// An account left the registry: signed out, or evicted because its
// credentials are gone or refused. Its own calendar feeds go now, since feed
// URLs are often secret; feeds made before they recorded their account stay
// until nobody is signed in. When it was the last account, the device-wide
// data goes too.
async function forgetRemovedAccount(accountId: string): Promise<void> {
try {
const feeds = useCalendarSubscriptionsStore;
// On a cold start the stored list may not have been read yet; written
// before that, the filtered empty default would replace it.
if (!feeds.persist.hasHydrated()) await feeds.persist.rehydrate();
const { subscriptions } = feeds.getState();
if (subscriptions.some((s) => s.localAccountId === accountId)) {
feeds.setState({ subscriptions: subscriptions.filter((s) => s.localAccountId !== accountId) });
}
} catch (err) {
console.warn('[auth-store] could not drop the calendar feeds of a removed account:', err);
}
if (useAccountStore.getState().accounts.length === 0) clearDeviceWideAccountData();
}
// Drop the named account from the email cache, then reset the (per-session,
// not yet per-account) contacts and calendar stores. Used by logout when
// signing one account out while others remain.
@@ -719,6 +740,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
await revokeStoredRefreshToken(currentId);
await jmapClient.clearAccountCredentials(currentId);
accountStore.removeAccount(currentId);
await forgetRemovedAccount(currentId);
} else {
await jmapClient.logout();
}
@@ -877,6 +899,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useEmailStore.getState().removeAccount(accountId);
restorePrevious();
set({ isLoading: false, error: 'Session expired for this account' });
await forgetRemovedAccount(accountId);
return;
}
} catch (err) {
@@ -888,6 +911,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useEmailStore.getState().removeAccount(accountId);
restorePrevious();
set({ isLoading: false, error: 'Session expired for this account' });
await forgetRemovedAccount(accountId);
return;
}
// NetworkError or anything else - keep the previous active account
@@ -943,6 +967,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useEmailStore.getState().removeAccount(accountId);
clearViewerCaches();
accountStore.removeAccount(accountId);
await forgetRemovedAccount(accountId);
},
restoreSession: async () => {
@@ -1004,6 +1029,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// No stored credentials (or corrupt) — genuine logout.
accountStore.removeAccount(target.id);
useEmailStore.getState().removeAccount(target.id);
await forgetRemovedAccount(target.id);
set({ isLoading: false, hasRestoredSession: true });
return false;
}
@@ -1038,6 +1064,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
await jmapClient.clearAccountCredentials(target.id).catch(() => undefined);
accountStore.removeAccount(target.id);
useEmailStore.getState().removeAccount(target.id);
await forgetRemovedAccount(target.id);
set({ isLoading: false, hasRestoredSession: true, error: 'Session expired' });
return false;
}
@@ -1107,6 +1134,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// Now we know the credentials are bad — fall back to logout flow.
await jmapClient.clearAccountCredentials(activeAccountId).catch(() => undefined);
accountStore.removeAccount(activeAccountId);
await forgetRemovedAccount(activeAccountId);
set({
isAuthenticated: false,
isLoading: false,