mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
Merge branch 'cu-accounts' into catch-up-2026-11
This commit is contained in:
@@ -82,10 +82,12 @@ import { signOutWidgets, startWidgetSync } from './src/widgets/sync';
|
|||||||
import { draftContextFromEmail } from './src/lib/draft-context';
|
import { draftContextFromEmail } from './src/lib/draft-context';
|
||||||
import {
|
import {
|
||||||
acceptSignInLink,
|
acceptSignInLink,
|
||||||
|
enterLinkAccount,
|
||||||
handleDeepLink,
|
handleDeepLink,
|
||||||
parseDeepLink,
|
parseDeepLink,
|
||||||
routeParkedSignInLink,
|
routeParkedSignInLink,
|
||||||
shareToDeepLink,
|
shareToDeepLink,
|
||||||
|
type AccountSwitchResult,
|
||||||
type DeepLink,
|
type DeepLink,
|
||||||
} from './src/navigation/linking';
|
} from './src/navigation/linking';
|
||||||
import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link';
|
import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link';
|
||||||
@@ -107,20 +109,26 @@ const navigationRef = createNavigationContainerRef<RootStackParamList>();
|
|||||||
// on every getInitialURL(), and a sign-in link must not run twice.
|
// on every getInitialURL(), and a sign-in link must not run twice.
|
||||||
let initialSignInLinkRead = false;
|
let initialSignInLinkRead = false;
|
||||||
|
|
||||||
|
// Move to the account a notification, reminder or link names. An account
|
||||||
|
// that was signed out meanwhile has nothing safe to open.
|
||||||
|
async function switchToAccount(accountId: string): Promise<AccountSwitchResult> {
|
||||||
|
const auth = useAuthStore.getState();
|
||||||
|
if (auth.activeAccountId === accountId) return true;
|
||||||
|
if (!useAccountStore.getState().getAccountById(accountId)) return false;
|
||||||
|
await auth.switchAccount(accountId);
|
||||||
|
return useAuthStore.getState().activeAccountId === accountId ? 'switched' : false;
|
||||||
|
}
|
||||||
|
|
||||||
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
|
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
|
||||||
if (!navigationRef.isReady()) return;
|
if (!navigationRef.isReady()) return;
|
||||||
|
|
||||||
// The notification carries the account it was generated for. If the user
|
// The notification carries the account it was generated for. If the user
|
||||||
// has since switched to a different account (or had a different one active
|
// has since switched to a different account (or had a different one active
|
||||||
// when the notification arrived), opening EmailThread under the active
|
// when the notification arrived), opening EmailThread under the active
|
||||||
// account would fetch the email from the wrong server and fail.
|
// account would fetch the email from the wrong server and fail. After a
|
||||||
const auth = useAuthStore.getState();
|
// switch the stack starts over on that account's inbox, so Back from the
|
||||||
if (payload.accountId && payload.accountId !== auth.activeAccountId) {
|
// message lands there instead of on the old account's screens.
|
||||||
const account = useAccountStore.getState().getAccountById(payload.accountId);
|
if (!(await enterLinkAccount(navigationRef, payload.accountId, switchToAccount))) return;
|
||||||
if (!account) return; // account was logged out — nothing safe to open.
|
|
||||||
await auth.switchAccount(payload.accountId);
|
|
||||||
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
prefetchMessage({ id: payload.emailId, threadId: payload.threadId });
|
prefetchMessage({ id: payload.emailId, threadId: payload.threadId });
|
||||||
navigationRef.navigate('EmailThread', {
|
navigationRef.navigate('EmailThread', {
|
||||||
@@ -138,12 +146,7 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi
|
|||||||
// under the account it was scheduled for. The target is parked for
|
// under the account it was scheduled for. The target is parked for
|
||||||
// CalendarScreen, which also picks it up when it mounts after a cold start.
|
// CalendarScreen, which also picks it up when it mounts after a cold start.
|
||||||
async function openCalendarReminder(target: CalendarReminderTarget): Promise<void> {
|
async function openCalendarReminder(target: CalendarReminderTarget): Promise<void> {
|
||||||
const auth = useAuthStore.getState();
|
if (!(await enterLinkAccount(navigationRef, target.appAccountId, switchToAccount))) return;
|
||||||
if (target.appAccountId && target.appAccountId !== auth.activeAccountId) {
|
|
||||||
if (!useAccountStore.getState().getAccountById(target.appAccountId)) return;
|
|
||||||
await auth.switchAccount(target.appAccountId);
|
|
||||||
if (useAuthStore.getState().activeAccountId !== target.appAccountId) return;
|
|
||||||
}
|
|
||||||
setPendingCalendarOpen(target);
|
setPendingCalendarOpen(target);
|
||||||
// On a cold start the navigator mounts right after the auth gate flips.
|
// On a cold start the navigator mounts right after the auth gate flips.
|
||||||
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
|
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
|
||||||
@@ -178,13 +181,7 @@ async function openDeepLink(link: DeepLink): Promise<void> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
switchAccount: async (accountId) => {
|
switchAccount: switchToAccount,
|
||||||
const auth = useAuthStore.getState();
|
|
||||||
if (auth.activeAccountId === accountId) return true;
|
|
||||||
if (!useAccountStore.getState().getAccountById(accountId)) return false;
|
|
||||||
await auth.switchAccount(accountId);
|
|
||||||
return useAuthStore.getState().activeAccountId === accountId;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
// A widget or a link elsewhere brought the app up for this; say it failed
|
// A widget or a link elsewhere brought the app up for this; say it failed
|
||||||
// rather than leave the user wondering why the app just opened.
|
// rather than leave the user wondering why the app just opened.
|
||||||
@@ -643,6 +640,24 @@ export default function App() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [addAccountForLink]);
|
}, [addAccountForLink]);
|
||||||
|
|
||||||
|
// The server refused the stored sign-in of an account (auth store
|
||||||
|
// keepForSignIn): open the sign-in to it, filled in, over what is cached.
|
||||||
|
const reauthTarget = useAuthStore((s) => s.reauth);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isAuthenticated || !reauthTarget) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
if (cancelled) return;
|
||||||
|
}
|
||||||
|
if (cancelled || !navigationRef.isReady()) return;
|
||||||
|
if (navigationRef.getCurrentRoute()?.name === 'AddAccount') return;
|
||||||
|
navigationRef.navigate('AddAccount');
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [isAuthenticated, reauthTarget]);
|
||||||
|
|
||||||
// Re-register the device with the configured relay once authenticated,
|
// Re-register the device with the configured relay once authenticated,
|
||||||
// and whenever the FCM token rotates. Honours the user's notification
|
// and whenever the FCM token rotates. Honours the user's notification
|
||||||
// preference - flipping it off tears down THIS account's subscription so
|
// preference - flipping it off tears down THIS account's subscription so
|
||||||
@@ -654,7 +669,10 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isAuthenticated || !client) return;
|
// The setup asks the server through the client: it waits for the
|
||||||
|
// session, and runs again once one comes up (an account added or
|
||||||
|
// switched to, a cold start that began offline).
|
||||||
|
if (!isAuthenticated || !client || !haveLiveSession) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const doSetup = async () => {
|
const doSetup = async () => {
|
||||||
@@ -700,7 +718,7 @@ export default function App() {
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
unsubscribeUp();
|
unsubscribeUp();
|
||||||
};
|
};
|
||||||
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
|
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId, haveLiveSession]);
|
||||||
|
|
||||||
// Muting a folder or account changes the server-side push filter (#78),
|
// Muting a folder or account changes the server-side push filter (#78),
|
||||||
// which the setup above would only send at the next launch. Send it now,
|
// which the setup above would only send at the next launch. Send it now,
|
||||||
|
|||||||
+8
-2
@@ -567,10 +567,10 @@
|
|||||||
"export_failed": "Export failed",
|
"export_failed": "Export failed",
|
||||||
"forwarded": "Forwarded",
|
"forwarded": "Forwarded",
|
||||||
"load_failed": "Failed to load email",
|
"load_failed": "Failed to load email",
|
||||||
"next": "Next",
|
|
||||||
"not_spam_short": "Not spam",
|
|
||||||
"new_tag": "New tag",
|
"new_tag": "New tag",
|
||||||
"new_tag_placeholder": "Tag name",
|
"new_tag_placeholder": "Tag name",
|
||||||
|
"next": "Next",
|
||||||
|
"not_spam_short": "Not spam",
|
||||||
"pause_animation": "Pause animation",
|
"pause_animation": "Pause animation",
|
||||||
"play_animation": "Play animation",
|
"play_animation": "Play animation",
|
||||||
"preview": {
|
"preview": {
|
||||||
@@ -862,6 +862,8 @@
|
|||||||
"qr_hint": "Settings → Security → Link Mobile App shows a code. Point the camera at it.",
|
"qr_hint": "Settings → Security → Link Mobile App shows a code. Point the camera at it.",
|
||||||
"qr_hint_title": "Open Bulwark on your computer",
|
"qr_hint_title": "Open Bulwark on your computer",
|
||||||
"qr_title": "Sign-in code",
|
"qr_title": "Sign-in code",
|
||||||
|
"reauth_detail": "The server no longer accepts the saved sign-in for {account}. Sign in again to keep using this account.",
|
||||||
|
"reauth_title": "Sign in again",
|
||||||
"scan_desc": "The fastest way in if you're signed in on the web",
|
"scan_desc": "The fastest way in if you're signed in on the web",
|
||||||
"scan_title": "Scan a sign-in code",
|
"scan_title": "Scan a sign-in code",
|
||||||
"server_cert_active": "Signing in with the client certificate \"{alias}\". Tap to choose another.",
|
"server_cert_active": "Signing in with the client certificate \"{alias}\". Tap to choose another.",
|
||||||
@@ -1350,6 +1352,7 @@
|
|||||||
"accounts_desc_mobile": "Background notifications of each signed-in account on this device. Switch to an account to turn them on or off for it.",
|
"accounts_desc_mobile": "Background notifications of each signed-in account on this device. Switch to an account to turn them on or off for it.",
|
||||||
"confirm_disable_message_mobile": "This device will stop receiving new-mail alerts for this account.",
|
"confirm_disable_message_mobile": "This device will stop receiving new-mail alerts for this account.",
|
||||||
"description_mobile": "Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
|
"description_mobile": "Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
|
||||||
|
"err_account_changed": "The account changed while push was being set up. Try again.",
|
||||||
"err_android_only": "Push notifications are only available on Android.",
|
"err_android_only": "Push notifications are only available on Android.",
|
||||||
"err_apns_empty": "iOS returned an empty push token.",
|
"err_apns_empty": "iOS returned an empty push token.",
|
||||||
"err_apns_token": "iOS could not issue a push token ({detail}).",
|
"err_apns_token": "iOS could not issue a push token ({detail}).",
|
||||||
@@ -1496,6 +1499,8 @@
|
|||||||
"description": "Change the password for this account.",
|
"description": "Change the password for this account.",
|
||||||
"error": "Failed to change password.",
|
"error": "Failed to change password.",
|
||||||
"mismatch": "Passwords do not match.",
|
"mismatch": "Passwords do not match.",
|
||||||
|
"session_ended": "Changing the password ended this session. Sign in again with your new password.",
|
||||||
|
"session_ended_title": "Sign in again",
|
||||||
"toggle_visibility": "Show or hide password",
|
"toggle_visibility": "Show or hide password",
|
||||||
"too_short": "New password must be at least 8 characters."
|
"too_short": "New password must be at least 8 characters."
|
||||||
},
|
},
|
||||||
@@ -1661,6 +1666,7 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"account_error": "Needs attention",
|
"account_error": "Needs attention",
|
||||||
|
"account_sign_in_again": "Sign in again",
|
||||||
"remove_account_hint": "Long-press to remove this account",
|
"remove_account_hint": "Long-press to remove this account",
|
||||||
"remove_account_message": "Sign out of {account} on this device. Your mail stays on the server.",
|
"remove_account_message": "Sign out of {account} on this device. Your mail stays on the server.",
|
||||||
"remove_account_title": "Remove account?",
|
"remove_account_title": "Remove account?",
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Live check of ending the provider's session at sign-out (OIDC
|
||||||
|
* RP-Initiated Logout) against a real Stalwart. Opt-in like the other live
|
||||||
|
* tests:
|
||||||
|
*
|
||||||
|
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
|
||||||
|
* BULWARK_LIVE_USER=e2e-perf@example.org BULWARK_LIVE_PASS=… \
|
||||||
|
* npx vitest run src/api/__tests__/live-stalwart-end-session.test.ts
|
||||||
|
*
|
||||||
|
* 1.0 announces an end_session_endpoint and hands out an id token with the
|
||||||
|
* tokens; 0.16 has neither, and sign-out opens nothing there. The grant the
|
||||||
|
* run makes is revoked again.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('expo-secure-store', () => ({
|
||||||
|
setItemAsync: vi.fn(async () => undefined),
|
||||||
|
getItemAsync: vi.fn(async () => null),
|
||||||
|
deleteItemAsync: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { buildEndSessionUrl, discoverOAuthMetadata, revokeRefreshToken } from '../../lib/oauth-native';
|
||||||
|
import { exchangePasswordForTokens } from '../../lib/totp-login';
|
||||||
|
|
||||||
|
const SERVER = (process.env.BULWARK_LIVE_JMAP ?? '').replace(/\/+$/, '');
|
||||||
|
const USER = process.env.BULWARK_LIVE_USER ?? '';
|
||||||
|
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
|
||||||
|
const live = SERVER && USER && PASS ? describe : describe.skip;
|
||||||
|
|
||||||
|
live('ending the provider session at sign-out (live)', () => {
|
||||||
|
it('logs out with the id token where the server announces an end_session_endpoint', async () => {
|
||||||
|
const metadata = await discoverOAuthMetadata(SERVER);
|
||||||
|
expect(metadata).not.toBeNull();
|
||||||
|
const tokens = await exchangePasswordForTokens(SERVER, USER, PASS);
|
||||||
|
try {
|
||||||
|
if (!metadata!.end_session_endpoint) {
|
||||||
|
// 0.16: nothing to open.
|
||||||
|
expect(buildEndSessionUrl(SERVER, metadata!, tokens)).toBeNull();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 1.0: the token answer carries an id token (a JWT), kept for the hint.
|
||||||
|
expect(tokens.idToken?.split('.')).toHaveLength(3);
|
||||||
|
const target = buildEndSessionUrl(SERVER, metadata!, tokens);
|
||||||
|
expect(target).not.toBeNull();
|
||||||
|
const url = new URL(target!.url);
|
||||||
|
expect(url.searchParams.get('id_token_hint')).toBe(tokens.idToken);
|
||||||
|
const response = await fetch(target!.url, { redirect: 'manual' });
|
||||||
|
// Signed out: a page saying so, or back to where the app asked.
|
||||||
|
expect([200, 302, 303]).toContain(response.status);
|
||||||
|
if (response.status !== 200) {
|
||||||
|
expect(response.headers.get('location')).toBe(url.searchParams.get('post_logout_redirect_uri'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await revokeRefreshToken(SERVER, tokens);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -59,6 +59,9 @@ export interface StoredCredentials {
|
|||||||
tokenEndpoint?: string;
|
tokenEndpoint?: string;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
tokenSource?: OAuthTokenSource;
|
tokenSource?: OAuthTokenSource;
|
||||||
|
// The OIDC id token of a browser sign-in, for ending the provider's
|
||||||
|
// session at sign-out (lib/oauth-native endProviderSession).
|
||||||
|
idToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -404,6 +407,7 @@ export class JMAPClient {
|
|||||||
tokenEndpoint: tokens.tokenEndpoint,
|
tokenEndpoint: tokens.tokenEndpoint,
|
||||||
clientId: tokens.clientId,
|
clientId: tokens.clientId,
|
||||||
tokenSource: tokens.source,
|
tokenSource: tokens.source,
|
||||||
|
...(tokens.idToken ? { idToken: tokens.idToken } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetched = await this.fetchSession(baseUrl);
|
const fetched = await this.fetchSession(baseUrl);
|
||||||
@@ -460,6 +464,7 @@ export class JMAPClient {
|
|||||||
expiresAt: next.expiresAt,
|
expiresAt: next.expiresAt,
|
||||||
tokenEndpoint: next.tokenEndpoint,
|
tokenEndpoint: next.tokenEndpoint,
|
||||||
clientId: next.clientId,
|
clientId: next.clientId,
|
||||||
|
idToken: next.idToken ?? base.idToken,
|
||||||
};
|
};
|
||||||
if (inUse) this.credentials = updated;
|
if (inUse) this.credentials = updated;
|
||||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(updated));
|
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(updated));
|
||||||
@@ -529,6 +534,7 @@ export class JMAPClient {
|
|||||||
tokenEndpoint: creds.tokenEndpoint,
|
tokenEndpoint: creds.tokenEndpoint,
|
||||||
clientId: creds.clientId,
|
clientId: creds.clientId,
|
||||||
source: creds.tokenSource,
|
source: creds.tokenSource,
|
||||||
|
...(creds.idToken ? { idToken: creds.idToken } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { useAccountStore } from '../stores/account-store';
|
|||||||
import { useSettingsStore } from '../stores/settings-store';
|
import { useSettingsStore } from '../stores/settings-store';
|
||||||
import { useKeywordsStore, keywordToken } from '../stores/keywords-store';
|
import { useKeywordsStore, keywordToken } from '../stores/keywords-store';
|
||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import { MAX_ACCOUNTS, accountRowText } from '../lib/account-utils';
|
import { MAX_ACCOUNTS, accountRowText, serverHostLabel } from '../lib/account-utils';
|
||||||
import { toUnicodeEmail } from '../lib/idn';
|
import { toUnicodeEmail } from '../lib/idn';
|
||||||
import {
|
import {
|
||||||
buildMailboxTree, flattenVisible, mailboxSubtreeIds, ownMailboxes, type MailboxNode,
|
buildMailboxTree, flattenVisible, mailboxSubtreeIds, ownMailboxes, type MailboxNode,
|
||||||
@@ -339,6 +339,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
|||||||
const logout = useAuthStore((s) => s.logout);
|
const logout = useAuthStore((s) => s.logout);
|
||||||
const logoutAll = useAuthStore((s) => s.logoutAll);
|
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||||
|
const requestReauth = useAuthStore((s) => s.requestReauth);
|
||||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
const accounts = useAccountStore((s) => s.accounts);
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
|
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
|
||||||
@@ -788,6 +789,9 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
|||||||
if (!serverUrl) return '';
|
if (!serverUrl) return '';
|
||||||
try { return new URL(serverUrl).hostname; } catch { return serverUrl; }
|
try { return new URL(serverUrl).hostname; } catch { return serverUrl; }
|
||||||
}, [serverUrl]);
|
}, [serverUrl]);
|
||||||
|
// The header names the server with its port when that isn't the default:
|
||||||
|
// two servers on one host are two accounts with the same address.
|
||||||
|
const hostLabel = React.useMemo(() => (serverUrl ? serverHostLabel(serverUrl) : ''), [serverUrl]);
|
||||||
const accountEmailFull = React.useMemo(() => {
|
const accountEmailFull = React.useMemo(() => {
|
||||||
if (!username) return hostname;
|
if (!username) return hostname;
|
||||||
if (username.includes('@')) return username;
|
if (username.includes('@')) return username;
|
||||||
@@ -836,8 +840,8 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.accountInfo}>
|
<View style={styles.accountInfo}>
|
||||||
<Text style={styles.accountName} numberOfLines={1}>{username || t('settings.account.title', 'Account')}</Text>
|
<Text style={styles.accountName} numberOfLines={1}>{username || t('settings.account.title', 'Account')}</Text>
|
||||||
{hostname ? (
|
{hostLabel ? (
|
||||||
<Text style={styles.accountEmail} numberOfLines={1}>{hostname}</Text>
|
<Text style={styles.accountEmail} numberOfLines={1}>{hostLabel}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
{busy ? (
|
{busy ? (
|
||||||
@@ -857,8 +861,8 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
|||||||
<ScrollView style={styles.accountMenuList}>
|
<ScrollView style={styles.accountMenuList}>
|
||||||
{accounts.map((acc) => {
|
{accounts.map((acc) => {
|
||||||
const isActive = acc.id === activeAccountId;
|
const isActive = acc.id === activeAccountId;
|
||||||
let accHost = '';
|
// With the port when it isn't the default (see hostLabel).
|
||||||
try { accHost = new URL(acc.serverUrl).hostname; } catch { accHost = acc.serverUrl; }
|
const accHost = serverHostLabel(acc.serverUrl);
|
||||||
// The address leads when another account has the same name
|
// The address leads when another account has the same name
|
||||||
// (webmail 3bb9f7df8).
|
// (webmail 3bb9f7df8).
|
||||||
const rowText = accountRowText(acc, accounts);
|
const rowText = accountRowText(acc, accounts);
|
||||||
@@ -866,6 +870,13 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
|||||||
<Pressable
|
<Pressable
|
||||||
key={acc.id}
|
key={acc.id}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
// The server refused its stored sign-in: sign in again.
|
||||||
|
if (acc.needsSignIn) {
|
||||||
|
setAccountMenuOpen(false);
|
||||||
|
onClose();
|
||||||
|
void requestReauth(acc.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isActive) return;
|
if (isActive) return;
|
||||||
setAccountMenuOpen(false);
|
setAccountMenuOpen(false);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -909,7 +920,9 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
|||||||
<>
|
<>
|
||||||
<AlertTriangle size={10} color={c.error} />
|
<AlertTriangle size={10} color={c.error} />
|
||||||
<Text style={[styles.accountMenuStatusText, { color: c.error }]} numberOfLines={1}>
|
<Text style={[styles.accountMenuStatusText, { color: c.error }]} numberOfLines={1}>
|
||||||
{acc.errorMessage || t('sidebar.account_error', 'Needs attention')}
|
{acc.needsSignIn
|
||||||
|
? t('sidebar.account_sign_in_again', 'Sign in again')
|
||||||
|
: acc.errorMessage || t('sidebar.account_error', 'Needs attention')}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
pickClientCertAlias,
|
pickClientCertAlias,
|
||||||
} from '../../lib/client-cert';
|
} from '../../lib/client-cert';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { useAuthStore } from '../../stores/auth-store';
|
||||||
|
import { passwordChangeOutcome } from './password-change';
|
||||||
import {
|
import {
|
||||||
AccountSecurityForbiddenError,
|
AccountSecurityForbiddenError,
|
||||||
isLimitedStalwartSignIn,
|
isLimitedStalwartSignIn,
|
||||||
@@ -155,21 +157,45 @@ function PasswordChangeSection({ otpEnabled }: { otpEnabled: boolean }) {
|
|||||||
if (newPwd.length < 8) { setError(t('settings.security.password.too_short', "New password must be at least 8 characters.")); return; }
|
if (newPwd.length < 8) { setError(t('settings.security.password.too_short', "New password must be at least 8 characters.")); return; }
|
||||||
if (newPwd !== confirmPwd) { setError(t('settings.security.password.mismatch', "Passwords do not match.")); return; }
|
if (newPwd !== confirmPwd) { setError(t('settings.security.password.mismatch', "Passwords do not match.")); return; }
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
const tokenSignIn = jmapClient.usesBearerAuth;
|
||||||
|
let failure: unknown = null;
|
||||||
try {
|
try {
|
||||||
const tokenSignIn = jmapClient.usesBearerAuth;
|
|
||||||
await changePassword(currentPwd, newPwd, otpEnabled ? otpCode : undefined);
|
await changePassword(currentPwd, newPwd, otpEnabled ? otpCode : undefined);
|
||||||
setCurrentPwd(''); setOtpCode(''); setNewPwd(''); setConfirmPwd('');
|
|
||||||
Alert.alert(
|
|
||||||
t('settings.security.password.changed_title', "Password changed"),
|
|
||||||
tokenSignIn
|
|
||||||
? t('settings.security.password.changed_sign_in_again', "Your account password was updated. Sign in again with the new password.")
|
|
||||||
: t('settings.security.password.changed', "Your account password was updated."),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t('settings.security.password.error', "Failed to change password."));
|
failure = err ?? new Error('Failed to change password');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
const outcome = passwordChangeOutcome(failure, tokenSignIn);
|
||||||
|
if (outcome.kind === 'error') {
|
||||||
|
setError(outcome.message || t('settings.security.password.error', "Failed to change password."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentPwd(''); setOtpCode(''); setNewPwd(''); setConfirmPwd('');
|
||||||
|
if (outcome.kind === 'changed') {
|
||||||
|
Alert.alert(
|
||||||
|
t('settings.security.password.changed_title', "Password changed"),
|
||||||
|
t('settings.security.password.changed', "Your account password was updated."),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The change ended the session (see password-change): say so, then open
|
||||||
|
// the sign-in to this account, filled in, instead of letting the next
|
||||||
|
// request fail with a generic error.
|
||||||
|
const accountId = useAuthStore.getState().activeAccountId;
|
||||||
|
Alert.alert(
|
||||||
|
outcome.changed
|
||||||
|
? t('settings.security.password.changed_title', "Password changed")
|
||||||
|
: t('settings.security.password.session_ended_title', "Sign in again"),
|
||||||
|
outcome.changed
|
||||||
|
? t('settings.security.password.changed_sign_in_again', "Your account password was updated. Sign in again with the new password.")
|
||||||
|
: t('settings.security.password.session_ended', "Changing the password ended this session. Sign in again with your new password."),
|
||||||
|
[{
|
||||||
|
text: t('login.mobile.continue', 'Continue'),
|
||||||
|
onPress: () => { if (accountId) void useAuthStore.getState().requestReauth(accountId); },
|
||||||
|
}],
|
||||||
|
{ cancelable: false },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { setPendingSettingsTab } from '../../navigation/pending-settings-tab';
|
|||||||
import { sharedAccountSettingsTabs } from '../../lib/capabilities';
|
import { sharedAccountSettingsTabs } from '../../lib/capabilities';
|
||||||
import { jmapClient } from '../../api/jmap-client';
|
import { jmapClient } from '../../api/jmap-client';
|
||||||
import { fetchMailQuota, type MailQuota } from '../../api/quota';
|
import { fetchMailQuota, type MailQuota } from '../../api/quota';
|
||||||
import { MAX_ACCOUNTS, accountRowText } from '../../lib/account-utils';
|
import { MAX_ACCOUNTS, accountRowText, nonDefaultPort, serverHostLabel } from '../../lib/account-utils';
|
||||||
import { toUnicodeEmail } from '../../lib/idn';
|
import { toUnicodeEmail } from '../../lib/idn';
|
||||||
import type { RootStackParamList } from '../../navigation/types';
|
import type { RootStackParamList } from '../../navigation/types';
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@ export function AccountSettings() {
|
|||||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
const session = useAuthStore((s) => s.session);
|
const session = useAuthStore((s) => s.session);
|
||||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||||
|
const requestReauth = useAuthStore((s) => s.requestReauth);
|
||||||
const removeAccountAuth = useAuthStore((s) => s.removeAccount);
|
const removeAccountAuth = useAuthStore((s) => s.removeAccount);
|
||||||
const accounts = useAccountStore((s) => s.accounts);
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
const defaultAccountId = useAccountStore((s) => s.defaultAccountId);
|
const defaultAccountId = useAccountStore((s) => s.defaultAccountId);
|
||||||
@@ -178,7 +179,11 @@ export function AccountSettings() {
|
|||||||
</View>
|
</View>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={{ flex: 1, minWidth: 0 }}
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
onPress={() => { if (!isActive) void switchAccount(a.id); }}
|
onPress={() => {
|
||||||
|
// The server refused its stored sign-in: sign in again.
|
||||||
|
if (a.needsSignIn) void requestReauth(a.id);
|
||||||
|
else if (!isActive) void switchAccount(a.id);
|
||||||
|
}}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={isActive
|
accessibilityLabel={isActive
|
||||||
? t('settings.account.accounts.active', 'Currently active account')
|
? t('settings.account.accounts.active', 'Currently active account')
|
||||||
@@ -189,12 +194,16 @@ export function AccountSettings() {
|
|||||||
{isActive && <Check size={14} color={c.success} />}
|
{isActive && <Check size={14} color={c.success} />}
|
||||||
</View>
|
</View>
|
||||||
{rowText.subtitle ? <Text style={styles.rowSub} numberOfLines={1}>{toUnicodeEmail(rowText.subtitle)}</Text> : null}
|
{rowText.subtitle ? <Text style={styles.rowSub} numberOfLines={1}>{toUnicodeEmail(rowText.subtitle)}</Text> : null}
|
||||||
|
{/* Two servers on one host are two accounts with the same address. */}
|
||||||
|
{nonDefaultPort(a.serverUrl) ? <Text style={styles.rowSub} numberOfLines={1}>{serverHostLabel(a.serverUrl)}</Text> : null}
|
||||||
{isDefault && (
|
{isDefault && (
|
||||||
<Text style={styles.defaultBadge}>
|
<Text style={styles.defaultBadge}>
|
||||||
{t('settings.account.accounts.default_badge', 'Default account')}
|
{t('settings.account.accounts.default_badge', 'Default account')}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{a.hasError && a.errorMessage ? (
|
{a.needsSignIn ? (
|
||||||
|
<Text style={styles.rowError} numberOfLines={1}>{t('sidebar.account_sign_in_again', 'Sign in again')}</Text>
|
||||||
|
) : a.hasError && a.errorMessage ? (
|
||||||
<Text style={styles.rowError} numberOfLines={1}>{a.errorMessage}</Text>
|
<Text style={styles.rowError} numberOfLines={1}>{a.errorMessage}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { AuthenticationError } from '../../../api/jmap-client';
|
||||||
|
import { passwordChangeOutcome } from '../password-change';
|
||||||
|
|
||||||
|
// A password change on Stalwart revokes the session's tokens (0.16 with
|
||||||
|
// TOTP: the sign-in runs on OAuth tokens). The page says "sign in again"
|
||||||
|
// instead of showing the 401 as a failed change.
|
||||||
|
|
||||||
|
describe('passwordChangeOutcome', () => {
|
||||||
|
it('asks a token session to sign in again after the change', () => {
|
||||||
|
expect(passwordChangeOutcome(null, true)).toEqual({ kind: 'sign-in-again', changed: true });
|
||||||
|
expect(passwordChangeOutcome(null, false)).toEqual({ kind: 'changed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes a 401 on the change for the session it ended, not a failure', () => {
|
||||||
|
expect(passwordChangeOutcome(new AuthenticationError('Session expired'), true))
|
||||||
|
.toEqual({ kind: 'sign-in-again', changed: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows what the server refused', () => {
|
||||||
|
expect(passwordChangeOutcome(new Error('Current secret is incorrect.'), true))
|
||||||
|
.toEqual({ kind: 'error', message: 'Current secret is incorrect.' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* What the Security page says after a password change. Stalwart revokes the
|
||||||
|
* account's tokens with the old password, refresh token included (webmail
|
||||||
|
* #1432, checked on 0.16.27), so a session on tokens (a password plus TOTP
|
||||||
|
* sign-in, OAuth) ends with the change. That is told as "sign in again with
|
||||||
|
* the new password", and the app opens the sign-in to the account. A 401 on
|
||||||
|
* the change itself is the same ending seen from the other side: the server
|
||||||
|
* dropped the session as it changed the password. Anything else is an error
|
||||||
|
* the form shows.
|
||||||
|
*/
|
||||||
|
export type PasswordChangeOutcome =
|
||||||
|
| { kind: 'changed' }
|
||||||
|
| { kind: 'sign-in-again'; changed: boolean }
|
||||||
|
| { kind: 'error'; message: string | null };
|
||||||
|
|
||||||
|
export function passwordChangeOutcome(error: unknown, tokenSignIn: boolean): PasswordChangeOutcome {
|
||||||
|
if (error === null || error === undefined) {
|
||||||
|
return tokenSignIn ? { kind: 'sign-in-again', changed: true } : { kind: 'changed' };
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.name === 'AuthenticationError') {
|
||||||
|
return { kind: 'sign-in-again', changed: false };
|
||||||
|
}
|
||||||
|
return { kind: 'error', message: error instanceof Error ? error.message : null };
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { generateAccountId, nonDefaultPort, serverHostLabel, setKnownAccounts } from '../account-utils';
|
||||||
|
import { useAccountStore } from '../../stores/account-store';
|
||||||
|
|
||||||
|
// The same address on two servers on one host was one account: ids were
|
||||||
|
// username@host, so signing in on http://10.10.10.62:8016 replaced the
|
||||||
|
// account of http://10.10.10.62:8120. The port is part of the id now, while
|
||||||
|
// accounts already signed in keep the id they have.
|
||||||
|
|
||||||
|
const entry = (username: string, serverUrl: string) => ({
|
||||||
|
serverUrl,
|
||||||
|
username,
|
||||||
|
displayName: username,
|
||||||
|
email: username,
|
||||||
|
lastLoginAt: 1,
|
||||||
|
isConnected: true,
|
||||||
|
hasError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null });
|
||||||
|
setKnownAccounts([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateAccountId', () => {
|
||||||
|
it('keeps the ids of accounts on the default ports', () => {
|
||||||
|
expect(generateAccountId('u@example.org', 'https://mail.example.org')).toBe('u@example.org@mail.example.org');
|
||||||
|
expect(generateAccountId('u@example.org', 'https://mail.example.org:443/')).toBe('u@example.org@mail.example.org');
|
||||||
|
expect(generateAccountId('u@example.org', 'http://mail.example.org:80/jmap')).toBe('u@example.org@mail.example.org');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells two servers on one host apart by port', () => {
|
||||||
|
const a = generateAccountId('usera@example.org', 'http://10.10.10.62:8120');
|
||||||
|
const b = generateAccountId('usera@example.org', 'http://10.10.10.62:8016');
|
||||||
|
expect(a).toBe('usera@example.org@10.10.10.62:8120');
|
||||||
|
expect(b).toBe('usera@example.org@10.10.10.62:8016');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the host-only id of an account signed in before ports counted', () => {
|
||||||
|
setKnownAccounts([{ id: 'usera@example.org@10.10.10.62', username: 'usera@example.org', serverUrl: 'http://10.10.10.62:8120' }]);
|
||||||
|
expect(generateAccountId('usera@example.org', 'http://10.10.10.62:8120')).toBe('usera@example.org@10.10.10.62');
|
||||||
|
expect(generateAccountId('usera@example.org', 'http://10.10.10.62:8120/')).toBe('usera@example.org@10.10.10.62');
|
||||||
|
// Another server on the same host is another account.
|
||||||
|
expect(generateAccountId('usera@example.org', 'http://10.10.10.62:8016')).toBe('usera@example.org@10.10.10.62:8016');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the account registry', () => {
|
||||||
|
it('adds the same address on another port of the same host as a second account', () => {
|
||||||
|
const store = useAccountStore.getState();
|
||||||
|
const a = store.addAccount(entry('usera@example.org', 'http://10.10.10.62:8120'));
|
||||||
|
const b = useAccountStore.getState().addAccount(entry('usera@example.org', 'http://10.10.10.62:8016'));
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
const { accounts } = useAccountStore.getState();
|
||||||
|
expect(accounts.map((x) => x.serverUrl)).toEqual(['http://10.10.10.62:8120', 'http://10.10.10.62:8016']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an existing account under its old id when another port is added', () => {
|
||||||
|
// As persisted by an earlier version.
|
||||||
|
useAccountStore.setState({
|
||||||
|
accounts: [{
|
||||||
|
...entry('usera@example.org', 'http://10.10.10.62:8120'),
|
||||||
|
id: 'usera@example.org@10.10.10.62',
|
||||||
|
avatarColor: '#000',
|
||||||
|
isDefault: true,
|
||||||
|
}],
|
||||||
|
activeAccountId: 'usera@example.org@10.10.10.62',
|
||||||
|
defaultAccountId: 'usera@example.org@10.10.10.62',
|
||||||
|
});
|
||||||
|
// Signing in on the old server again updates that account.
|
||||||
|
expect(useAccountStore.getState().addAccount(entry('usera@example.org', 'http://10.10.10.62:8120')))
|
||||||
|
.toBe('usera@example.org@10.10.10.62');
|
||||||
|
const b = useAccountStore.getState().addAccount(entry('usera@example.org', 'http://10.10.10.62:8016'));
|
||||||
|
expect(b).toBe('usera@example.org@10.10.10.62:8016');
|
||||||
|
expect(useAccountStore.getState().accounts.map((x) => [x.id, x.serverUrl])).toEqual([
|
||||||
|
['usera@example.org@10.10.10.62', 'http://10.10.10.62:8120'],
|
||||||
|
['usera@example.org@10.10.10.62:8016', 'http://10.10.10.62:8016'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('serverHostLabel', () => {
|
||||||
|
it('shows the port only when it is not the default', () => {
|
||||||
|
expect(serverHostLabel('https://mail.example.org')).toBe('mail.example.org');
|
||||||
|
expect(serverHostLabel('https://mail.example.org:443')).toBe('mail.example.org');
|
||||||
|
expect(serverHostLabel('http://10.10.10.62:8016/')).toBe('10.10.10.62:8016');
|
||||||
|
expect(serverHostLabel('https://mail.example.org:8443/jmap')).toBe('mail.example.org:8443');
|
||||||
|
expect(nonDefaultPort('http://mail.example.org:80')).toBeNull();
|
||||||
|
expect(nonDefaultPort('https://mail.example.org:80')).toBe('80');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
|
||||||
|
// Sign-out ends the identity provider's own session too (OIDC RP-Initiated
|
||||||
|
// Logout, webmail #905): revoking the refresh token left the browser signed
|
||||||
|
// in at the provider, and the next browser sign-in went through unasked.
|
||||||
|
|
||||||
|
vi.mock('../client-cert', () => {
|
||||||
|
const fetchFn = vi.fn();
|
||||||
|
return {
|
||||||
|
secureFetch: fetchFn,
|
||||||
|
fetchWithoutForeignRedirect: fetchFn,
|
||||||
|
followedOnlyAllowedRedirects: () => true,
|
||||||
|
mayFollowRedirect: () => true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { fetchWithoutForeignRedirect } from '../client-cert';
|
||||||
|
import { buildEndSessionUrl, endProviderSession, loginWithPkce, type OAuthMetadata } from '../oauth-native';
|
||||||
|
import { refreshOAuthAccessToken, resetRefreshRotations, type OAuthTokens } from '../oauth';
|
||||||
|
import { DEFAULT_CLIENT_ID, MOBILE_CLIENT_ID, REGISTERED_LOGOUT_REDIRECT_URI } from '../totp-login';
|
||||||
|
|
||||||
|
const fetchMock = fetchWithoutForeignRedirect as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const browser = WebBrowser.openAuthSessionAsync as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const SERVER = 'https://mail.example.org';
|
||||||
|
const DISCOVERY = {
|
||||||
|
issuer: SERVER,
|
||||||
|
authorization_endpoint: `${SERVER}/login`,
|
||||||
|
token_endpoint: `${SERVER}/auth/token`,
|
||||||
|
end_session_endpoint: `${SERVER}/auth/logout`,
|
||||||
|
};
|
||||||
|
const json = (body: unknown) => ({ ok: true, status: 200, url: '', json: async () => body, text: async () => JSON.stringify(body) });
|
||||||
|
|
||||||
|
function tokens(over: Partial<OAuthTokens> = {}): OAuthTokens {
|
||||||
|
return {
|
||||||
|
accessToken: 'at', refreshToken: 'rt', tokenEndpoint: `${SERVER}/auth/token`,
|
||||||
|
clientId: 'registered-client', source: 'native', idToken: 'id.token.sig', ...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
resetRefreshRotations();
|
||||||
|
browser.mockResolvedValue({ type: 'dismiss' });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildEndSessionUrl', () => {
|
||||||
|
it('names the client, hints the id token and comes back to the app for a client the app registered', () => {
|
||||||
|
const target = buildEndSessionUrl(SERVER, DISCOVERY, tokens());
|
||||||
|
const url = new URL(target!.url);
|
||||||
|
expect(url.origin + url.pathname).toBe(`${SERVER}/auth/logout`);
|
||||||
|
expect(url.searchParams.get('client_id')).toBe('registered-client');
|
||||||
|
expect(url.searchParams.get('id_token_hint')).toBe('id.token.sig');
|
||||||
|
expect(url.searchParams.get('post_logout_redirect_uri')).toBe(REGISTERED_LOGOUT_REDIRECT_URI);
|
||||||
|
expect(target!.returnUrl).toBe(REGISTERED_LOGOUT_REDIRECT_URI);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends no return address for a client someone else registered', () => {
|
||||||
|
for (const clientId of [DEFAULT_CLIENT_ID, MOBILE_CLIENT_ID]) {
|
||||||
|
const url = new URL(buildEndSessionUrl(SERVER, DISCOVERY, tokens({ clientId }))!.url);
|
||||||
|
expect(url.searchParams.get('post_logout_redirect_uri')).toBeNull();
|
||||||
|
expect(url.searchParams.get('client_id')).toBe(clientId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a provider without an end_session_endpoint, or one on another host in the clear', () => {
|
||||||
|
expect(buildEndSessionUrl(SERVER, { end_session_endpoint: undefined }, tokens())).toBeNull();
|
||||||
|
expect(buildEndSessionUrl(SERVER, { end_session_endpoint: 'http://idp.example.net/logout' }, tokens())).toBeNull();
|
||||||
|
// The server's own host over http is what the user signed in to.
|
||||||
|
expect(buildEndSessionUrl('http://10.10.10.62:8120', { end_session_endpoint: 'http://10.10.10.62:8120/auth/logout' }, tokens()))
|
||||||
|
.not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('endProviderSession', () => {
|
||||||
|
it("opens the provider's logout page after a browser sign-in", async () => {
|
||||||
|
fetchMock.mockResolvedValue(json(DISCOVERY));
|
||||||
|
await endProviderSession(SERVER, tokens());
|
||||||
|
expect(browser).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, returnUrl] = browser.mock.calls[0] as [string, string];
|
||||||
|
expect(new URL(url).searchParams.get('id_token_hint')).toBe('id.token.sig');
|
||||||
|
expect(returnUrl).toBe(REGISTERED_LOGOUT_REDIRECT_URI);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens nothing for sign-ins that left no browser session, or a provider without logout', async () => {
|
||||||
|
fetchMock.mockResolvedValue(json(DISCOVERY));
|
||||||
|
await endProviderSession(SERVER, tokens({ source: 'totp' }));
|
||||||
|
await endProviderSession(SERVER, tokens({ source: 'pairing' }));
|
||||||
|
fetchMock.mockResolvedValue(json({ ...DISCOVERY, end_session_endpoint: undefined }));
|
||||||
|
await endProviderSession(SERVER, tokens());
|
||||||
|
expect(browser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never fails the sign-out', async () => {
|
||||||
|
fetchMock.mockRejectedValue(new Error('offline'));
|
||||||
|
await expect(endProviderSession(SERVER, tokens())).resolves.toBeUndefined();
|
||||||
|
fetchMock.mockResolvedValue(json(DISCOVERY));
|
||||||
|
browser.mockRejectedValueOnce(new Error('another session is open'));
|
||||||
|
await expect(endProviderSession(SERVER, tokens())).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the id token', () => {
|
||||||
|
it('is kept from the browser sign-in', async () => {
|
||||||
|
const meta: OAuthMetadata = { authorization_endpoint: `${SERVER}/authorize`, token_endpoint: `${SERVER}/auth/token` };
|
||||||
|
browser.mockImplementationOnce(async (authUrl: string) => {
|
||||||
|
const state = new URL(authUrl).searchParams.get('state');
|
||||||
|
return { type: 'success', url: `bulwarkmobile://auth/callback?code=c&state=${state}` };
|
||||||
|
});
|
||||||
|
fetchMock.mockResolvedValue(json({ access_token: 'at', refresh_token: 'rt', expires_in: 300, id_token: 'id.token.sig' }));
|
||||||
|
const got = await loginWithPkce(SERVER, meta, { clientId: 'c1' });
|
||||||
|
expect(got.idToken).toBe('id.token.sig');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('outlives a refresh that hands out none', async () => {
|
||||||
|
fetchMock.mockResolvedValue(json({ access_token: 'at2', refresh_token: 'rt2', expires_in: 300 }));
|
||||||
|
const next = await refreshOAuthAccessToken(tokens());
|
||||||
|
expect(next.idToken).toBe('id.token.sig');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -218,6 +218,39 @@ describe('pushBackgroundTask notifications', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #85: Delete only moves to Trash, so with a Trash it needs no unlock and
|
||||||
|
// the shade stays open; without one the unlock stays.
|
||||||
|
it.each([
|
||||||
|
['without unlocking when the account has a Trash', [{ id: 'tr', role: 'trash' }], false],
|
||||||
|
['behind the unlock when the account has no Trash', [{ id: 'in', role: 'inbox' }], true],
|
||||||
|
])('offers Delete %s', async (_label, mailboxes, needsUnlock) => {
|
||||||
|
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, init?: { body?: string }) => {
|
||||||
|
if (url.endsWith('/.well-known/jmap')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
apiUrl: 'https://mail.example.com/jmap/',
|
||||||
|
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
|
||||||
|
accounts: { 'jmap-primary': {} },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const calls = JSON.parse(init?.body ?? '{}').methodCalls as Array<[string, Record<string, unknown>, string]>;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
methodResponses: calls.map(([name, , id]) => (name === 'Mailbox/get'
|
||||||
|
? [name, { list: mailboxes }, id]
|
||||||
|
: [name, { list: [{ id: 'm1', threadId: 't1', keywords: {}, subject: 'Hi', from: [{ email: 'bob@example.com' }] }] }, id])),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await pushBackgroundTask({ kind: 'jmap-email-push', accountLabel: 'alice', accountId: 'jmap-primary', emailIds: '["m1"]' });
|
||||||
|
|
||||||
|
expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({ emailId: 'm1', deleteNeedsUnlock: needsUnlock }));
|
||||||
|
});
|
||||||
|
|
||||||
it('shows a generic notification when the lookup fails, not none (#1041)', async () => {
|
it('shows a generic notification when the lookup fails, not none (#1041)', async () => {
|
||||||
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => {
|
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => {
|
||||||
if (url.endsWith('/.well-known/jmap')) return { ok: false, status: 503 };
|
if (url.endsWith('/.well-known/jmap')) return { ok: false, status: 503 };
|
||||||
@@ -693,14 +726,14 @@ describe('pushBackgroundTask keeps the push subscription alive', () => {
|
|||||||
await recorded(2 * DAY);
|
await recorded(2 * DAY);
|
||||||
await push();
|
await push();
|
||||||
expect(showNotification).toHaveBeenCalled();
|
expect(showNotification).toHaveBeenCalled();
|
||||||
expect(server.calls).toEqual(['Email/get', 'PushSubscription/get', 'PushSubscription/set', 'PushSubscription/get']);
|
expect(server.calls).toEqual(['Email/get', 'Mailbox/get', 'PushSubscription/get', 'PushSubscription/set', 'PushSubscription/get']);
|
||||||
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${LOCAL}`)).toBe(server.expires);
|
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${LOCAL}`)).toBe(server.expires);
|
||||||
|
|
||||||
// Recorded now: the next pushes leave it alone for a day.
|
// Recorded now: the next pushes leave it alone for a day.
|
||||||
server.calls = [];
|
server.calls = [];
|
||||||
await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]);
|
await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]);
|
||||||
await push();
|
await push();
|
||||||
expect(server.calls).toEqual(['Email/get']);
|
expect(server.calls).toEqual(['Email/get', 'Mailbox/get']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('extends it also when the push brings nothing new to show', async () => {
|
it('extends it also when the push brings nothing new to show', async () => {
|
||||||
@@ -721,7 +754,7 @@ describe('pushBackgroundTask keeps the push subscription alive', () => {
|
|||||||
await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]);
|
await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]);
|
||||||
await setup();
|
await setup();
|
||||||
await push();
|
await push();
|
||||||
expect(server.calls).toEqual(['Email/get', 'PushSubscription/get', 'PushSubscription/set', 'PushSubscription/get']);
|
expect(server.calls).toEqual(['Email/get', 'Mailbox/get', 'PushSubscription/get', 'PushSubscription/set', 'PushSubscription/get']);
|
||||||
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${LOCAL}`)).toBe(server.expires);
|
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${LOCAL}`)).toBe(server.expires);
|
||||||
|
|
||||||
// The server's copy has a week left: recorded, not written.
|
// The server's copy has a week left: recorded, not written.
|
||||||
@@ -730,7 +763,7 @@ describe('pushBackgroundTask keeps the push subscription alive', () => {
|
|||||||
await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]);
|
await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]);
|
||||||
await setup();
|
await setup();
|
||||||
await push();
|
await push();
|
||||||
expect(server.calls).toEqual(['Email/get', 'PushSubscription/get']);
|
expect(server.calls).toEqual(['Email/get', 'Mailbox/get', 'PushSubscription/get']);
|
||||||
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${LOCAL}`)).toBe(serverExpires);
|
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${LOCAL}`)).toBe(serverExpires);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -748,7 +781,7 @@ describe('pushBackgroundTask keeps the push subscription alive', () => {
|
|||||||
await AsyncStorage.removeItem(`push:optedOut:v1:${LOCAL}`);
|
await AsyncStorage.removeItem(`push:optedOut:v1:${LOCAL}`);
|
||||||
await setup();
|
await setup();
|
||||||
await push();
|
await push();
|
||||||
expect(server.calls).toEqual(['Email/get']);
|
expect(server.calls).toEqual(['Email/get', 'Mailbox/get']);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -756,7 +789,7 @@ describe('pushBackgroundTask keeps the push subscription alive', () => {
|
|||||||
await recorded(2 * DAY);
|
await recorded(2 * DAY);
|
||||||
server.deviceClientId = 'dcid-someone-else';
|
server.deviceClientId = 'dcid-someone-else';
|
||||||
await push();
|
await push();
|
||||||
expect(server.calls).toEqual(['Email/get', 'PushSubscription/get']);
|
expect(server.calls).toEqual(['Email/get', 'Mailbox/get', 'PushSubscription/get']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still notifies when the server cannot be asked about the subscription', async () => {
|
it('still notifies when the server cannot be asked about the subscription', async () => {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ vi.mock('../../api/jmap-client', () => ({
|
|||||||
jmapClient: {
|
jmapClient: {
|
||||||
username: 'user@example.com',
|
username: 'user@example.com',
|
||||||
serverUrl: 'https://mail.example.com',
|
serverUrl: 'https://mail.example.com',
|
||||||
|
isConnected: true,
|
||||||
accountId: 'jmap-primary',
|
accountId: 'jmap-primary',
|
||||||
currentSession: { capabilities: { 'urn:ietf:params:jmap:core': {} } },
|
currentSession: { capabilities: { 'urn:ietf:params:jmap:core': {} } },
|
||||||
},
|
},
|
||||||
@@ -1582,6 +1583,45 @@ describe('resyncPushNotifications', () => {
|
|||||||
expect(createMock).not.toHaveBeenCalled();
|
expect(createMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('waits for the session instead of asking the server without one', async () => {
|
||||||
|
// An account being added: the client holds its credentials, no session yet.
|
||||||
|
const client = jmapClient as unknown as { isConnected: boolean };
|
||||||
|
client.isConnected = false;
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||||
|
await AsyncStorage.setItem(EXPIRES_KEY, inDays(5));
|
||||||
|
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(listMock).not.toHaveBeenCalled();
|
||||||
|
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull();
|
||||||
|
} finally {
|
||||||
|
client.isConnected = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps push on when the client moved to another account during the revocation check', async () => {
|
||||||
|
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||||
|
await AsyncStorage.setItem(EXPIRES_KEY, inDays(5));
|
||||||
|
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID]));
|
||||||
|
const client = jmapClient as unknown as { serverUrl: string };
|
||||||
|
// The listing answers for the account added meanwhile, which has none of ours.
|
||||||
|
listMock.mockImplementation(async () => {
|
||||||
|
client.serverUrl = 'https://mail.example.com:8443';
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull();
|
||||||
|
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||||||
|
expect(await readPushAccountIds()).toEqual([ACCOUNT_ID]);
|
||||||
|
} finally {
|
||||||
|
client.serverUrl = 'https://mail.example.com';
|
||||||
|
listMock.mockReset();
|
||||||
|
listMock.mockResolvedValue([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('does not re-register a subscription another device revoked', async () => {
|
it('does not re-register a subscription another device revoked', async () => {
|
||||||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||||
await AsyncStorage.setItem(EXPIRES_KEY, inDays(5));
|
await AsyncStorage.setItem(EXPIRES_KEY, inDays(5));
|
||||||
|
|||||||
@@ -13,7 +13,34 @@ export class AccountLimitError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateAccountId(username: string, serverUrl: string): string {
|
/**
|
||||||
|
* The port of `serverUrl` when it is not the scheme's default (443 for
|
||||||
|
* https, 80 for http), else null. Read from the text, since React Native's
|
||||||
|
* URL keeps a default port where the WHATWG one drops it.
|
||||||
|
*/
|
||||||
|
export function nonDefaultPort(serverUrl: string): string | null {
|
||||||
|
const m = /^([a-z][a-z0-9+.-]*):\/\/(?:[^@/?#]*@)?(?:\[[^\]]*\]|[^:/?#]*):(\d+)(?=[/?#]|$)/i.exec(serverUrl.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const scheme = m[1].toLowerCase();
|
||||||
|
const port = String(Number(m[2]));
|
||||||
|
if ((scheme === 'https' && port === '443') || (scheme === 'http' && port === '80')) return null;
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `serverUrl`'s host, with its port when that is not the default. */
|
||||||
|
export function serverHostLabel(serverUrl: string): string {
|
||||||
|
let host: string;
|
||||||
|
try {
|
||||||
|
host = new URL(serverUrl).hostname;
|
||||||
|
} catch {
|
||||||
|
return serverUrl.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
||||||
|
}
|
||||||
|
const port = nonDefaultPort(serverUrl);
|
||||||
|
return port ? `${host}:${port}` : host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The id an account got before ports were part of it: username@host. */
|
||||||
|
function hostOnlyAccountId(username: string, serverUrl: string): string {
|
||||||
let host = serverUrl;
|
let host = serverUrl;
|
||||||
try {
|
try {
|
||||||
host = new URL(serverUrl).hostname;
|
host = new URL(serverUrl).hostname;
|
||||||
@@ -23,6 +50,47 @@ export function generateAccountId(username: string, serverUrl: string): string {
|
|||||||
return `${username}@${host}`;
|
return `${username}@${host}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the registry tells generateAccountId (structural: no AccountEntry import). */
|
||||||
|
export interface KnownAccount {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
serverUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let knownAccounts: readonly KnownAccount[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The registry's accounts, so generateAccountId keeps the ids accounts were
|
||||||
|
* signed in under. The account store feeds this whenever it changes; the
|
||||||
|
* headless push task, which reads the registry from storage, feeds it too.
|
||||||
|
*/
|
||||||
|
export function setKnownAccounts(accounts: readonly KnownAccount[]): void {
|
||||||
|
knownAccounts = accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local id of the account `username` has on `serverUrl`:
|
||||||
|
* username@host, plus `:port` when the server is not on its scheme's
|
||||||
|
* default port. Two servers on one host are two accounts.
|
||||||
|
*
|
||||||
|
* Accounts were once keyed by username@host only. One signed in that way on
|
||||||
|
* a server with a port keeps that id, and with it its credentials, caches,
|
||||||
|
* outbox, push registration and settings: when the registry holds the
|
||||||
|
* host-only id for the same server, that is the answer. A second server on
|
||||||
|
* the same host then gets the id with its port, and the two stay apart.
|
||||||
|
* Accounts on the default ports have the same id either way.
|
||||||
|
*/
|
||||||
|
export function generateAccountId(username: string, serverUrl: string): string {
|
||||||
|
const legacy = hostOnlyAccountId(username, serverUrl);
|
||||||
|
const host = serverHostLabel(serverUrl);
|
||||||
|
const id = `${username}@${host}`;
|
||||||
|
if (id === legacy) return id;
|
||||||
|
const kept = knownAccounts.find((a) => a.id === legacy
|
||||||
|
&& a.username === username
|
||||||
|
&& `${a.username}@${serverHostLabel(a.serverUrl)}` === id);
|
||||||
|
return kept ? kept.id : id;
|
||||||
|
}
|
||||||
|
|
||||||
/** What naming an account in a list needs (structural: no AccountEntry import). */
|
/** What naming an account in a list needs (structural: no AccountEntry import). */
|
||||||
export interface NamedAccount {
|
export interface NamedAccount {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import {
|
|||||||
generateCodeVerifier,
|
generateCodeVerifier,
|
||||||
resolveLoginClient,
|
resolveLoginClient,
|
||||||
DEFAULT_CLIENT_ID,
|
DEFAULT_CLIENT_ID,
|
||||||
|
MOBILE_CLIENT_ID,
|
||||||
|
REGISTERED_LOGOUT_REDIRECT_URI,
|
||||||
|
REGISTERED_REDIRECT_URI,
|
||||||
} from './totp-login';
|
} from './totp-login';
|
||||||
import { randomHex } from './random';
|
import { randomHex } from './random';
|
||||||
import { NetworkError } from '../api/jmap-client';
|
import { NetworkError } from '../api/jmap-client';
|
||||||
@@ -27,6 +30,7 @@ export interface OAuthMetadata {
|
|||||||
authorization_endpoint: string;
|
authorization_endpoint: string;
|
||||||
token_endpoint: string;
|
token_endpoint: string;
|
||||||
revocation_endpoint?: string;
|
revocation_endpoint?: string;
|
||||||
|
end_session_endpoint?: string;
|
||||||
registration_endpoint?: string;
|
registration_endpoint?: string;
|
||||||
scopes_supported?: string[];
|
scopes_supported?: string[];
|
||||||
prompt_values_supported?: string[];
|
prompt_values_supported?: string[];
|
||||||
@@ -264,6 +268,7 @@ export async function loginWithPkce(
|
|||||||
access_token?: string;
|
access_token?: string;
|
||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
expires_in?: number;
|
expires_in?: number;
|
||||||
|
id_token?: string;
|
||||||
};
|
};
|
||||||
if (!data.access_token) throw new HandoffError('Token response missing access_token');
|
if (!data.access_token) throw new HandoffError('Token response missing access_token');
|
||||||
return {
|
return {
|
||||||
@@ -273,9 +278,70 @@ export async function loginWithPkce(
|
|||||||
tokenEndpoint: metadata.token_endpoint,
|
tokenEndpoint: metadata.token_endpoint,
|
||||||
clientId,
|
clientId,
|
||||||
source: 'native',
|
source: 'native',
|
||||||
|
// Kept for ending the provider's session at sign-out (endProviderSession).
|
||||||
|
...(typeof data.id_token === 'string' && data.id_token ? { idToken: data.id_token } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider's logout URL for a sign-out (OIDC RP-Initiated Logout 1.0;
|
||||||
|
* webmail lib/oauth/end-session.ts buildEndSessionUrl), or null when the
|
||||||
|
* provider advertises no end_session_endpoint or an unusable one.
|
||||||
|
*
|
||||||
|
* `client_id` always goes along, `id_token_hint` when the sign-in's id token
|
||||||
|
* was kept. The browser carries the id token there, so the endpoint must be
|
||||||
|
* https, or on the mail server's own host, which the app already talks to in
|
||||||
|
* the clear when the user chose that. `post_logout_redirect_uri` is sent only
|
||||||
|
* for a client the app registered itself, with that URI on file: a provider
|
||||||
|
* refuses the whole logout for one it does not have.
|
||||||
|
*/
|
||||||
|
export function buildEndSessionUrl(
|
||||||
|
serverUrl: string,
|
||||||
|
metadata: Pick<OAuthMetadata, 'end_session_endpoint'>,
|
||||||
|
tokens: Pick<OAuthTokens, 'clientId' | 'idToken'>,
|
||||||
|
): { url: string; returnUrl: string } | null {
|
||||||
|
const endpoint = metadata.end_session_endpoint;
|
||||||
|
if (typeof endpoint !== 'string' || !/^https?:\/\//i.test(endpoint)) return null;
|
||||||
|
if (!/^https:\/\//i.test(endpoint) && !sameHost(endpoint, serverUrl)) return null;
|
||||||
|
const registered = tokens.clientId !== DEFAULT_CLIENT_ID && tokens.clientId !== MOBILE_CLIENT_ID;
|
||||||
|
const params = new URLSearchParams({ client_id: tokens.clientId });
|
||||||
|
if (tokens.idToken) params.set('id_token_hint', tokens.idToken);
|
||||||
|
if (registered) params.set('post_logout_redirect_uri', REGISTERED_LOGOUT_REDIRECT_URI);
|
||||||
|
return {
|
||||||
|
url: `${endpoint}${endpoint.includes('?') ? '&' : '?'}${params.toString()}`,
|
||||||
|
// The browser session closes when the provider sends it back to the app.
|
||||||
|
returnUrl: registered ? REGISTERED_LOGOUT_REDIRECT_URI : REGISTERED_REDIRECT_URI,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let endSessionQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the identity provider's own login session at sign-out, best effort.
|
||||||
|
* Revoking the refresh token ends the app's grant, but the session the
|
||||||
|
* provider keeps in the browser stays, and the next browser sign-in would go
|
||||||
|
* through without asking (webmail #905). Only a browser sign-in left one
|
||||||
|
* (source 'native'); the password and TOTP sign-ins and the webmail's
|
||||||
|
* hand-off and pairing did not, or own it. Opens the logout page in the
|
||||||
|
* auth browser session, one at a time. Never throws and is not awaited by
|
||||||
|
* the sign-out.
|
||||||
|
*/
|
||||||
|
export function endProviderSession(serverUrl: string, tokens: OAuthTokens): Promise<void> {
|
||||||
|
if (tokens.source !== 'native') return Promise.resolve();
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const metadata = await discoverOAuthMetadata(serverUrl);
|
||||||
|
const target = metadata ? buildEndSessionUrl(serverUrl, metadata, tokens) : null;
|
||||||
|
if (!target) return;
|
||||||
|
await WebBrowser.openAuthSessionAsync(target.url, target.returnUrl);
|
||||||
|
} catch {
|
||||||
|
// never block or fail a sign-out
|
||||||
|
}
|
||||||
|
};
|
||||||
|
endSessionQueue = endSessionQueue.then(run, run);
|
||||||
|
return endSessionQueue;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Best-effort RFC 7009 revocation of a refresh token on sign-out. Never
|
* Best-effort RFC 7009 revocation of a refresh token on sign-out. Never
|
||||||
* throws; the caller is already dropping the credentials.
|
* throws; the caller is already dropping the credentials.
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export interface OAuthTokens {
|
|||||||
// the desktop's token, so sign-out leaves `pairing` bundles alone instead
|
// the desktop's token, so sign-out leaves `pairing` bundles alone instead
|
||||||
// of revoking them at the mail server; every other source is revoked.
|
// of revoking them at the mail server; every other source is revoked.
|
||||||
source?: OAuthTokenSource;
|
source?: OAuthTokenSource;
|
||||||
|
// The OIDC id token of the sign-in, kept for ending the provider's
|
||||||
|
// session at sign-out (id_token_hint). A refresh may hand out a new one.
|
||||||
|
idToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HandoffResult =
|
export type HandoffResult =
|
||||||
@@ -681,6 +684,7 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
|
|||||||
access_token?: string;
|
access_token?: string;
|
||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
expires_in?: number;
|
expires_in?: number;
|
||||||
|
id_token?: string;
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
data = (await response.json()) as typeof data;
|
data = (await response.json()) as typeof data;
|
||||||
@@ -702,6 +706,7 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
|
|||||||
tokenEndpoint: tokens.tokenEndpoint,
|
tokenEndpoint: tokens.tokenEndpoint,
|
||||||
clientId: tokens.clientId,
|
clientId: tokens.clientId,
|
||||||
source: tokens.source,
|
source: tokens.source,
|
||||||
|
idToken: typeof data.id_token === 'string' && data.id_token ? data.id_token : tokens.idToken,
|
||||||
};
|
};
|
||||||
rememberRotation(cacheKey, next);
|
rememberRotation(cacheKey, next);
|
||||||
return next;
|
return next;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CAPABILITIES } from '../api/types';
|
|||||||
import type { Email, JMAPMethodCall, JMAPSession, Mailbox } from '../api/types';
|
import type { Email, JMAPMethodCall, JMAPSession, Mailbox } from '../api/types';
|
||||||
import { detectDeviceLocale, isSupportedLocale, translate, type LocaleCode } from '../i18n';
|
import { detectDeviceLocale, isSupportedLocale, translate, type LocaleCode } from '../i18n';
|
||||||
import { secureFetch } from './client-cert';
|
import { secureFetch } from './client-cert';
|
||||||
|
import { setKnownAccounts, type KnownAccount } from './account-utils';
|
||||||
import { freshStoredCredentials } from './stored-token-refresh';
|
import { freshStoredCredentials } from './stored-token-refresh';
|
||||||
import {
|
import {
|
||||||
generateEmailAvatarColor,
|
generateEmailAvatarColor,
|
||||||
@@ -127,6 +128,11 @@ interface ShowNotificationOptions {
|
|||||||
groupTitle: string;
|
groupTitle: string;
|
||||||
markReadLabel?: string;
|
markReadLabel?: string;
|
||||||
deleteLabel?: string;
|
deleteLabel?: string;
|
||||||
|
// Whether Delete asks to unlock the phone first (#85). Delete only ever
|
||||||
|
// moves the message to Trash; with a Trash to move it to, nothing is lost
|
||||||
|
// and the action runs from the lock screen and the open shade. Without
|
||||||
|
// one, or when that is not known, the unlock stays.
|
||||||
|
deleteNeedsUnlock?: boolean;
|
||||||
replyLabel?: string;
|
replyLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +200,7 @@ export function parseRelayPushData(data: unknown): RelayPushData {
|
|||||||
interface RegistryAccount {
|
interface RegistryAccount {
|
||||||
id: string;
|
id: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
serverUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readAccountRegistry(): Promise<RegistryAccount[]> {
|
async function readAccountRegistry(): Promise<RegistryAccount[]> {
|
||||||
@@ -372,16 +379,29 @@ async function jmapPost(
|
|||||||
return body.methodResponses ?? [];
|
return body.methodResponses ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The messages to notify, and whether their account has a Trash for the
|
||||||
|
* notification's Delete to move them to (asked in the same request). An
|
||||||
|
* answer without the folders counts as no Trash.
|
||||||
|
*/
|
||||||
async function detachedGetEmails(
|
async function detachedGetEmails(
|
||||||
session: DetachedSession,
|
session: DetachedSession,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
ids: string[],
|
ids: string[],
|
||||||
): Promise<Email[]> {
|
): Promise<{ emails: Email[]; hasTrash: boolean }> {
|
||||||
const responses = await jmapPost(session, [
|
const responses = await jmapPost(session, [
|
||||||
['Email/get', { accountId, ids, properties: EMAIL_PROPERTIES }, '0'],
|
['Email/get', { accountId, ids, properties: EMAIL_PROPERTIES }, '0'],
|
||||||
|
['Mailbox/get', { accountId, properties: ['id', 'role'] }, '1'],
|
||||||
]);
|
]);
|
||||||
const body = methodResult(responses, 'Email/get');
|
const body = methodResult(responses, 'Email/get');
|
||||||
return (body.list as Email[]) ?? [];
|
return { emails: (body.list as Email[]) ?? [], hasTrash: answerHasTrash(responses) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the Mailbox/get answer (call id '1') lists a Trash. */
|
||||||
|
export function answerHasTrash(responses: Array<[string, Record<string, any>, string]>): boolean {
|
||||||
|
const answer = responses.find(([name, , id]) => name === 'Mailbox/get' && id === '1');
|
||||||
|
const list = answer?.[1]?.list;
|
||||||
|
return Array.isArray(list) && list.some((m: Mailbox) => m?.role === 'trash');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detachedNewestUnreadInboxIds(
|
async function detachedNewestUnreadInboxIds(
|
||||||
@@ -452,6 +472,9 @@ export async function pushBackgroundTask(data: unknown): Promise<void> {
|
|||||||
if (accountIds.length === 0) return;
|
if (accountIds.length === 0) return;
|
||||||
|
|
||||||
const registry = await readAccountRegistry();
|
const registry = await readAccountRegistry();
|
||||||
|
// Without the account store loaded, account ids are still the ones the
|
||||||
|
// registry holds (see generateAccountId).
|
||||||
|
setKnownAccounts(registry.filter((a): a is KnownAccount => !!a.username && !!a.serverUrl));
|
||||||
const jmapAccountIds = await readPushJmapAccountIds();
|
const jmapAccountIds = await readPushJmapAccountIds();
|
||||||
const match = matchAccountsForPush(payload, accountIds, jmapAccountIds, registry);
|
const match = matchAccountsForPush(payload, accountIds, jmapAccountIds, registry);
|
||||||
if (match.ambiguous) await showPlainNotification(payload);
|
if (match.ambiguous) await showPlainNotification(payload);
|
||||||
@@ -538,6 +561,7 @@ async function processAccountForPush(accountId: string, payload: RelayPushData,
|
|||||||
async function notifyForPush(accountId: string, session: DetachedSession, payload: RelayPushData): Promise<void> {
|
async function notifyForPush(accountId: string, session: DetachedSession, payload: RelayPushData): Promise<void> {
|
||||||
const alreadyNotified = await readNotifiedIds(accountId);
|
const alreadyNotified = await readNotifiedIds(accountId);
|
||||||
let candidates: Email[];
|
let candidates: Email[];
|
||||||
|
let hasTrash = false;
|
||||||
let emailAccountId = session.jmapAccountId;
|
let emailAccountId = session.jmapAccountId;
|
||||||
|
|
||||||
if (payload.emailIds.length > 0) {
|
if (payload.emailIds.length > 0) {
|
||||||
@@ -547,7 +571,7 @@ async function notifyForPush(accountId: string, session: DetachedSession, payloa
|
|||||||
const fresh = payload.emailIds.filter((id) => !alreadyNotified.includes(id));
|
const fresh = payload.emailIds.filter((id) => !alreadyNotified.includes(id));
|
||||||
if (fresh.length === 0) return;
|
if (fresh.length === 0) return;
|
||||||
emailAccountId = payload.jmapAccountId ?? session.jmapAccountId;
|
emailAccountId = payload.jmapAccountId ?? session.jmapAccountId;
|
||||||
candidates = await detachedGetEmails(session, emailAccountId, fresh);
|
({ emails: candidates, hasTrash } = await detachedGetEmails(session, emailAccountId, fresh));
|
||||||
} else {
|
} else {
|
||||||
// Legacy `jmap-state-change` payload without ids: look at the newest
|
// Legacy `jmap-state-change` payload without ids: look at the newest
|
||||||
// unread inbox messages and notify the ones not shown before. The ring
|
// unread inbox messages and notify the ones not shown before. The ring
|
||||||
@@ -556,7 +580,7 @@ async function notifyForPush(accountId: string, session: DetachedSession, payloa
|
|||||||
const ids = await detachedNewestUnreadInboxIds(session, LEGACY_QUERY_LIMIT);
|
const ids = await detachedNewestUnreadInboxIds(session, LEGACY_QUERY_LIMIT);
|
||||||
const fresh = ids.filter((id) => !alreadyNotified.includes(id));
|
const fresh = ids.filter((id) => !alreadyNotified.includes(id));
|
||||||
if (fresh.length === 0) return;
|
if (fresh.length === 0) return;
|
||||||
candidates = await detachedGetEmails(session, session.jmapAccountId, fresh);
|
({ emails: candidates, hasTrash } = await detachedGetEmails(session, session.jmapAccountId, fresh));
|
||||||
}
|
}
|
||||||
|
|
||||||
const toNotify = selectNotifiableEmails(candidates, alreadyNotified);
|
const toNotify = selectNotifiableEmails(candidates, alreadyNotified);
|
||||||
@@ -608,6 +632,7 @@ async function notifyForPush(accountId: string, session: DetachedSession, payloa
|
|||||||
groupTitle,
|
groupTitle,
|
||||||
markReadLabel,
|
markReadLabel,
|
||||||
deleteLabel,
|
deleteLabel,
|
||||||
|
deleteNeedsUnlock: !hasTrash,
|
||||||
replyLabel,
|
replyLabel,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1224,6 +1224,28 @@ async function pollVerificationCode(
|
|||||||
// PushVerification"). Callers share the first in-flight run instead.
|
// PushVerification"). Callers share the first in-flight run instead.
|
||||||
const inFlightSetups = new Map<string, Promise<PushSetupResult>>();
|
const inFlightSetups = new Map<string, Promise<PushSetupResult>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local account the singleton client serves with a live session; null
|
||||||
|
* while it has none, such as during an account change, when it holds the
|
||||||
|
* credentials of the account being signed in to before its session is up.
|
||||||
|
* The setup asks the server through the client, so it must know the client
|
||||||
|
* still serves the account it is setting up: a listing of another account's
|
||||||
|
* subscriptions took the account's own for revoked and turned its push off.
|
||||||
|
*/
|
||||||
|
function servedAccountId(): string | null {
|
||||||
|
const username = jmapClient.username;
|
||||||
|
const serverUrl = jmapClient.serverUrl;
|
||||||
|
if (!jmapClient.isConnected || !username || !serverUrl) return null;
|
||||||
|
return generateAccountId(username, serverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountChangedError(): PushSetupError {
|
||||||
|
return new PushSetupError(
|
||||||
|
'account',
|
||||||
|
t('settings.notifications.push.err_account_changed', 'The account changed while push was being set up. Try again.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full setup flow: ask permission, fetch the device's FCM token, register
|
* Full setup flow: ask permission, fetch the device's FCM token, register
|
||||||
* with the relay, create a JMAP PushSubscription, poll for the verification
|
* with the relay, create a JMAP PushSubscription, poll for the verification
|
||||||
@@ -1300,12 +1322,10 @@ async function setupPushNotificationsInner(
|
|||||||
|
|
||||||
// setupPushNotifications operates on the currently-loaded jmapClient. We
|
// setupPushNotifications operates on the currently-loaded jmapClient. We
|
||||||
// need its username/serverUrl up-front so we can key per-account state.
|
// need its username/serverUrl up-front so we can key per-account state.
|
||||||
const username = jmapClient.username;
|
const accountId = servedAccountId();
|
||||||
const serverUrl = jmapClient.serverUrl;
|
if (!accountId) {
|
||||||
if (!username || !serverUrl) {
|
|
||||||
throw new PushSetupError('account', t('settings.notifications.push.err_no_account', 'No account loaded - cannot set up push.'));
|
throw new PushSetupError('account', t('settings.notifications.push.err_no_account', 'No account loaded - cannot set up push.'));
|
||||||
}
|
}
|
||||||
const accountId = generateAccountId(username, serverUrl);
|
|
||||||
|
|
||||||
await migrateLegacyPushKeys();
|
await migrateLegacyPushKeys();
|
||||||
|
|
||||||
@@ -1381,6 +1401,8 @@ async function setupPushNotificationsInner(
|
|||||||
// A failed listing ends the run here: taken for "the server has no
|
// A failed listing ends the run here: taken for "the server has no
|
||||||
// subscription", it would drop the key of a working registration.
|
// subscription", it would drop the key of a working registration.
|
||||||
logPhase('jmap');
|
logPhase('jmap');
|
||||||
|
// From here on the server is asked through the client (see servedAccountId).
|
||||||
|
if (servedAccountId() !== accountId) throw accountChangedError();
|
||||||
let existingSubs: PushSubscription[];
|
let existingSubs: PushSubscription[];
|
||||||
try {
|
try {
|
||||||
existingSubs = await listPushSubscriptions();
|
existingSubs = await listPushSubscriptions();
|
||||||
@@ -1868,12 +1890,15 @@ function isOwnSubscription(
|
|||||||
export async function resyncPushNotifications(
|
export async function resyncPushNotifications(
|
||||||
params: PushSetupParams,
|
params: PushSetupParams,
|
||||||
): Promise<PushSetupResult | null> {
|
): Promise<PushSetupResult | null> {
|
||||||
const username = jmapClient.username;
|
// Without a live session (an account change under way) there is nothing
|
||||||
const serverUrl = jmapClient.serverUrl;
|
// to ask; the caller runs this again once the session is up.
|
||||||
if (!username || !serverUrl) return null;
|
const accountId = servedAccountId();
|
||||||
const accountId = generateAccountId(username, serverUrl);
|
if (!accountId) return null;
|
||||||
if (await AsyncStorage.getItem(optedOutKey(accountId))) return null;
|
if (await AsyncStorage.getItem(optedOutKey(accountId))) return null;
|
||||||
if (await wasRevokedOnServer(accountId)) {
|
const revoked = await wasRevokedOnServer(accountId);
|
||||||
|
// The listing went to whatever account the client served meanwhile.
|
||||||
|
if (servedAccountId() !== accountId) return null;
|
||||||
|
if (revoked) {
|
||||||
logPhase('revoked', 'subscription gone before it was due to expire; leaving push off');
|
logPhase('revoked', 'subscription gone before it was due to expire; leaving push off');
|
||||||
await disablePushForAccount(accountId);
|
await disablePushForAccount(accountId);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const client = vi.hoisted(() => ({ id: null as string | null }));
|
||||||
|
vi.mock('../../../api/jmap-client', () => ({
|
||||||
|
jmapClient: {
|
||||||
|
get accountId(): string {
|
||||||
|
if (!client.id) throw new Error('Not authenticated - call connect() first');
|
||||||
|
return client.id;
|
||||||
|
},
|
||||||
|
get connectedAccountId(): string | null {
|
||||||
|
return client.id;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('../../../api/archived-items', () => ({
|
||||||
|
advertisesArchivedItems: vi.fn(() => true),
|
||||||
|
queryArchivedItemIds: vi.fn(async () => ({ ids: [], total: 0 })),
|
||||||
|
hasArchivedEmail: vi.fn(async () => false),
|
||||||
|
}));
|
||||||
|
const auth = vi.hoisted(() => ({ state: { session: {} as unknown, activeAccountId: 'a@host' } }));
|
||||||
|
vi.mock('../../../stores/auth-store', () => ({
|
||||||
|
useAuthStore: (selector: (s: typeof auth.state) => unknown) => selector(auth.state),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { renderHook } from '../../../components/email/structured-mail/__tests__/render-native';
|
||||||
|
import { useDeletedItemsRecovery } from '../use-deleted-items-recovery';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
client.id = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useDeletedItemsRecovery', () => {
|
||||||
|
it('renders without a connected client while an account switch reconnects', () => {
|
||||||
|
// The auth store still holds a session (the account being left, or the
|
||||||
|
// target one restored), but the client has no account id yet.
|
||||||
|
client.id = null;
|
||||||
|
const hook = renderHook(() => useDeletedItemsRecovery());
|
||||||
|
expect(hook.current).toEqual({ available: false, accountId: null, key: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the connected account once the client is in', () => {
|
||||||
|
client.id = 'u1';
|
||||||
|
const hook = renderHook(() => useDeletedItemsRecovery());
|
||||||
|
expect(hook.current.accountId).toBe('u1');
|
||||||
|
expect(hook.current.key).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,7 +38,9 @@ export interface DeletedItemsRecovery {
|
|||||||
export function useDeletedItemsRecovery(): DeletedItemsRecovery {
|
export function useDeletedItemsRecovery(): DeletedItemsRecovery {
|
||||||
const session = useAuthStore((s) => s.session);
|
const session = useAuthStore((s) => s.session);
|
||||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
const accountId = session ? jmapClient.accountId || null : null;
|
// connectedAccountId, not accountId: the client is between sessions while an
|
||||||
|
// account switch reconnects, and accountId throws then (a crash in render).
|
||||||
|
const accountId = session ? jmapClient.connectedAccountId : null;
|
||||||
const off = recoveryPolicy().hidden;
|
const off = recoveryPolicy().hidden;
|
||||||
const key = accountId ? recoveryKey(activeAccountId, accountId) : null;
|
const key = accountId ? recoveryKey(activeAccountId, accountId) : null;
|
||||||
const check = useRecoveryStore((s) => (key ? s.checks[key] : undefined));
|
const check = useRecoveryStore((s) => (key ? s.checks[key] : undefined));
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ export const DEFAULT_CLIENT_ID = 'bulwark-webmail';
|
|||||||
*/
|
*/
|
||||||
export const REGISTERED_REDIRECT_URI = 'org.bulwarkmail.mobile:/auth/callback';
|
export const REGISTERED_REDIRECT_URI = 'org.bulwarkmail.mobile:/auth/callback';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the identity provider sends the browser back after sign-out ended
|
||||||
|
* its session (OIDC RP-Initiated Logout), registered with the client the app
|
||||||
|
* registers. Same scheme as the sign-in redirect, which the app handles.
|
||||||
|
*/
|
||||||
|
export const REGISTERED_LOGOUT_REDIRECT_URI = 'org.bulwarkmail.mobile:/auth/logout';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The app's own client, which the webmail's admin OAuth setup registers on
|
* The app's own client, which the webmail's admin OAuth setup registers on
|
||||||
* Stalwart 1.0 (webmail #1459): public and first-party, so its tokens reach
|
* Stalwart 1.0 (webmail #1459): public and first-party, so its tokens reach
|
||||||
@@ -174,6 +181,7 @@ async function registerPublicClient(
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
client_name: 'Bulwark Mobile',
|
client_name: 'Bulwark Mobile',
|
||||||
redirect_uris: [REGISTERED_REDIRECT_URI],
|
redirect_uris: [REGISTERED_REDIRECT_URI],
|
||||||
|
post_logout_redirect_uris: [REGISTERED_LOGOUT_REDIRECT_URI],
|
||||||
token_endpoint_auth_method: 'none',
|
token_endpoint_auth_method: 'none',
|
||||||
grant_types: ['authorization_code', 'refresh_token'],
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
response_types: ['code'],
|
response_types: ['code'],
|
||||||
@@ -552,6 +560,7 @@ export async function exchangePasswordForTokens(
|
|||||||
access_token?: string;
|
access_token?: string;
|
||||||
expires_in?: number;
|
expires_in?: number;
|
||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
|
id_token?: string;
|
||||||
};
|
};
|
||||||
if (!tokens.access_token) {
|
if (!tokens.access_token) {
|
||||||
throw new TotpLoginError('token_exchange_failed', 'Token response missing access_token');
|
throw new TotpLoginError('token_exchange_failed', 'Token response missing access_token');
|
||||||
@@ -564,5 +573,6 @@ export async function exchangePasswordForTokens(
|
|||||||
tokenEndpoint: tokenUrl,
|
tokenEndpoint: tokenUrl,
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
source: 'totp',
|
source: 'totp',
|
||||||
|
...(typeof tokens.id_token === 'string' && tokens.id_token ? { idToken: tokens.id_token } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { acceptSignInLink, handleDeepLink, parseDeepLink, parseSignInLink, shareToDeepLink } from '../linking';
|
import { acceptSignInLink, enterLinkAccount, handleDeepLink, parseDeepLink, parseSignInLink, shareToDeepLink } from '../linking';
|
||||||
|
import { usePendingMailFolder } from '../pending-mail-folder';
|
||||||
import { usePendingSignInLinkStore } from '../pending-sign-in-link';
|
import { usePendingSignInLinkStore } from '../pending-sign-in-link';
|
||||||
import { usePendingSettingsTab } from '../pending-settings-tab';
|
import { usePendingSettingsTab } from '../pending-settings-tab';
|
||||||
import { usePendingCalendarOpen } from '../pending-calendar-open';
|
import { usePendingCalendarOpen } from '../pending-calendar-open';
|
||||||
@@ -168,6 +169,40 @@ describe('handleDeepLink', () => {
|
|||||||
})).toBe(false);
|
})).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('starts the stack over on the inbox of the account a link switched to', async () => {
|
||||||
|
const calls: unknown[][] = [];
|
||||||
|
const navigation = {
|
||||||
|
isReady: () => true,
|
||||||
|
navigate: vi.fn((...args: unknown[]) => { calls.push(['navigate', ...args]); }),
|
||||||
|
reset: vi.fn((state: unknown) => { calls.push(['reset', state]); }),
|
||||||
|
};
|
||||||
|
const ok = await handleDeepLink(
|
||||||
|
{ kind: 'message', emailId: 'M1', threadId: 'T1', accountId: 'b@example.org@mail' },
|
||||||
|
{ navigation: navigation as never, resolveThreadId: async () => null, switchAccount: async () => 'switched' },
|
||||||
|
);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
// The old account's screens go, the message opens over the new inbox.
|
||||||
|
expect(calls).toEqual([
|
||||||
|
['reset', { index: 0, routes: [{ name: 'MainTabs', params: { screen: 'Mail' } }] }],
|
||||||
|
['navigate', 'EmailThread', { emailId: 'M1', threadId: 'T1' }],
|
||||||
|
]);
|
||||||
|
expect(usePendingMailFolder.getState().consume()).toBe('inbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the stack alone when the link names the active account', async () => {
|
||||||
|
const navigation = { isReady: () => true, navigate: vi.fn(), reset: vi.fn() };
|
||||||
|
await handleDeepLink(
|
||||||
|
{ kind: 'message', emailId: 'M1', threadId: 'T1', accountId: 'a@example.org@mail' },
|
||||||
|
{ navigation: navigation as never, resolveThreadId: async () => null, switchAccount: async () => true },
|
||||||
|
);
|
||||||
|
expect(navigation.reset).not.toHaveBeenCalled();
|
||||||
|
expect(navigation.navigate).toHaveBeenCalledWith('EmailThread', { emailId: 'M1', threadId: 'T1' });
|
||||||
|
// A notification tap goes the same way (App navigateToNotificationTap).
|
||||||
|
expect(await enterLinkAccount(navigation as never, 'gone@example.org@mail', async () => false)).toBe(false);
|
||||||
|
expect(await enterLinkAccount(navigation as never, undefined, async () => 'switched')).toBe(true);
|
||||||
|
expect(navigation.reset).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('parks the settings tab and opens the Settings tab', async () => {
|
it('parks the settings tab and opens the Settings tab', async () => {
|
||||||
const navigation = nav();
|
const navigation = nav();
|
||||||
await handleDeepLink(
|
await handleDeepLink(
|
||||||
|
|||||||
@@ -276,6 +276,40 @@ export function routeParkedSignInLink(accountCount: number): 'add-account' | 'ac
|
|||||||
return 'add-account';
|
return 'add-account';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What switching to the account a link or notification names came to: false
|
||||||
|
* when that account is not signed in here (or the switch failed), true when
|
||||||
|
* it was the active one already, 'switched' when the app moved to it.
|
||||||
|
*/
|
||||||
|
export type AccountSwitchResult = boolean | 'switched';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a link or notification switched accounts, the screens on the stack
|
||||||
|
* are the old account's. Start the stack over on the new account's inbox, so
|
||||||
|
* whatever the link opens goes on top of it and Back lands there.
|
||||||
|
*/
|
||||||
|
export function resetToAccountInbox(navigation: NavigationContainerRefWithCurrent<RootStackParamList>): void {
|
||||||
|
setPendingMailFolder('inbox');
|
||||||
|
navigation.reset({ index: 0, routes: [{ name: 'MainTabs', params: { screen: 'Mail' } }] } as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move to the account `accountId` names, if it is not the active one, and
|
||||||
|
* start the stack over when the app moved (resetToAccountInbox). False when
|
||||||
|
* the account is not signed in here: nothing may be opened then.
|
||||||
|
*/
|
||||||
|
export async function enterLinkAccount(
|
||||||
|
navigation: NavigationContainerRefWithCurrent<RootStackParamList>,
|
||||||
|
accountId: string | undefined,
|
||||||
|
switchAccount: ((accountId: string) => Promise<AccountSwitchResult>) | undefined,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!accountId || !switchAccount) return true;
|
||||||
|
const result = await switchAccount(accountId);
|
||||||
|
if (!result) return false;
|
||||||
|
if (result === 'switched' && navigation.isReady()) resetToAccountInbox(navigation);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeepLinkNavigator {
|
export interface DeepLinkNavigator {
|
||||||
navigation: NavigationContainerRefWithCurrent<RootStackParamList>;
|
navigation: NavigationContainerRefWithCurrent<RootStackParamList>;
|
||||||
// Resolve a message id to its thread (EmailThread needs both). Returns
|
// Resolve a message id to its thread (EmailThread needs both). Returns
|
||||||
@@ -286,8 +320,9 @@ export interface DeepLinkNavigator {
|
|||||||
// be loaded.
|
// be loaded.
|
||||||
openDraft?: (emailId: string, jmapAccountId?: string) => Promise<boolean>;
|
openDraft?: (emailId: string, jmapAccountId?: string) => Promise<boolean>;
|
||||||
// Switch to the account a permalink names (`?account=`); resolves false
|
// Switch to the account a permalink names (`?account=`); resolves false
|
||||||
// when that account is not signed in on this device.
|
// when that account is not signed in on this device, 'switched' when the
|
||||||
switchAccount?: (accountId: string) => Promise<boolean>;
|
// app moved to it (see AccountSwitchResult).
|
||||||
|
switchAccount?: (accountId: string) => Promise<AccountSwitchResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Navigate for a parsed link. Returns false when nothing could be opened. */
|
/** Navigate for a parsed link. Returns false when nothing could be opened. */
|
||||||
@@ -295,9 +330,7 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr
|
|||||||
const { navigation } = nav;
|
const { navigation } = nav;
|
||||||
if (!navigation.isReady()) return false;
|
if (!navigation.isReady()) return false;
|
||||||
|
|
||||||
if ('accountId' in link && link.accountId && nav.switchAccount) {
|
if ('accountId' in link && !(await enterLinkAccount(navigation, link.accountId, nav.switchAccount))) return false;
|
||||||
if (!(await nav.switchAccount(link.accountId))) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (link.kind) {
|
switch (link.kind) {
|
||||||
case 'message': {
|
case 'message': {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import ConfirmStep from './login/ConfirmStep';
|
|||||||
import PasswordStep from './login/PasswordStep';
|
import PasswordStep from './login/PasswordStep';
|
||||||
import SigningInStep, { type SigningInPhase } from './login/SigningInStep';
|
import SigningInStep, { type SigningInPhase } from './login/SigningInStep';
|
||||||
import { loginBackAction } from './login/login-back';
|
import { loginBackAction } from './login/login-back';
|
||||||
|
import { reauthStart, type LoginStepName } from './login/reauth-start';
|
||||||
import { isSignInCancelled } from '../lib/sign-in-abort';
|
import { isSignInCancelled } from '../lib/sign-in-abort';
|
||||||
|
|
||||||
interface LoginScreenProps {
|
interface LoginScreenProps {
|
||||||
@@ -42,7 +43,7 @@ interface LoginScreenProps {
|
|||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StepName = 'choose' | 'email' | 'server' | 'confirm' | 'password';
|
type StepName = LoginStepName;
|
||||||
|
|
||||||
const RECENT_EMAILS_KEY = 'login:recentEmails:v1';
|
const RECENT_EMAILS_KEY = 'login:recentEmails:v1';
|
||||||
|
|
||||||
@@ -92,11 +93,20 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
const accounts = useAccountStore((state) => state.accounts);
|
const accounts = useAccountStore((state) => state.accounts);
|
||||||
const activeAccountId = useAccountStore((state) => state.activeAccountId);
|
const activeAccountId = useAccountStore((state) => state.activeAccountId);
|
||||||
|
|
||||||
const [step, setStep] = React.useState<StepName>('choose');
|
// Signing in to an account again (its stored sign-in was refused) starts
|
||||||
|
// on its form, filled in. Read once: the store clears it on success.
|
||||||
|
const [reauth] = React.useState(() => useAuthStore.getState().reauth);
|
||||||
|
const start = React.useMemo(() => reauthStart(reauth), [reauth]);
|
||||||
|
// Closed without signing in: the request is answered (the account row
|
||||||
|
// still offers it again).
|
||||||
|
React.useEffect(() => () => {
|
||||||
|
if (reauth && useAuthStore.getState().reauth === reauth) useAuthStore.getState().clearReauth();
|
||||||
|
}, [reauth]);
|
||||||
|
const [step, setStep] = React.useState<StepName>(start.step);
|
||||||
const [history, setHistory] = React.useState<StepName[]>([]);
|
const [history, setHistory] = React.useState<StepName[]>([]);
|
||||||
const [email, setEmail] = React.useState('');
|
const [email, setEmail] = React.useState(start.email);
|
||||||
const [serverInput, setServerInput] = React.useState('');
|
const [serverInput, setServerInput] = React.useState(start.serverUrl);
|
||||||
const [serverUrl, setServerUrl] = React.useState('');
|
const [serverUrl, setServerUrl] = React.useState(start.serverUrl);
|
||||||
// Drives the confirm step's heading: we only claim to have "found" a server
|
// Drives the confirm step's heading: we only claim to have "found" a server
|
||||||
// when discovery actually found one.
|
// when discovery actually found one.
|
||||||
const [serverDiscovered, setServerDiscovered] = React.useState(false);
|
const [serverDiscovered, setServerDiscovered] = React.useState(false);
|
||||||
@@ -107,10 +117,17 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
const [totpRequired, setTotpRequired] = React.useState(false);
|
const [totpRequired, setTotpRequired] = React.useState(false);
|
||||||
// Access-token (Bearer) sign-in in place of username and password, for
|
// Access-token (Bearer) sign-in in place of username and password, for
|
||||||
// providers that hand out API tokens (Fastmail accepts nothing else).
|
// providers that hand out API tokens (Fastmail accepts nothing else).
|
||||||
const [tokenMode, setTokenMode] = React.useState(false);
|
const [tokenMode, setTokenMode] = React.useState(start.tokenMode);
|
||||||
const [accessToken, setAccessToken] = React.useState('');
|
const [accessToken, setAccessToken] = React.useState('');
|
||||||
const [failedDomain, setFailedDomain] = React.useState<string | null>(null);
|
const [failedDomain, setFailedDomain] = React.useState<string | null>(null);
|
||||||
const [notice, setNotice] = React.useState<LoginErrorCopy | null>(null);
|
const [notice, setNotice] = React.useState<LoginErrorCopy | null>(() => (reauth ? {
|
||||||
|
title: t('login.mobile.reauth_title', 'Sign in again'),
|
||||||
|
detail: t(
|
||||||
|
'login.mobile.reauth_detail',
|
||||||
|
'The server no longer accepts the saved sign-in for {account}. Sign in again to keep using this account.',
|
||||||
|
{ account: reauth.username },
|
||||||
|
),
|
||||||
|
} : null));
|
||||||
const [searching, setSearching] = React.useState(false);
|
const [searching, setSearching] = React.useState(false);
|
||||||
const [busy, setBusy] = React.useState<SigningInPhase | null>(null);
|
const [busy, setBusy] = React.useState<SigningInPhase | null>(null);
|
||||||
const [scannerVisible, setScannerVisible] = React.useState(false);
|
const [scannerVisible, setScannerVisible] = React.useState(false);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReauthTarget } from '../../stores/auth-store';
|
||||||
|
|
||||||
|
export type LoginStepName = 'choose' | 'email' | 'server' | 'confirm' | 'password';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where signing in to an account again starts (auth store keepForSignIn): a
|
||||||
|
* pasted token or a password account at the form, with the token field or
|
||||||
|
* the address filled in; an OAuth account at the server's sign-in page.
|
||||||
|
*/
|
||||||
|
export function reauthStart(reauth: ReauthTarget | null): {
|
||||||
|
step: LoginStepName;
|
||||||
|
email: string;
|
||||||
|
serverUrl: string;
|
||||||
|
tokenMode: boolean;
|
||||||
|
} {
|
||||||
|
if (!reauth) return { step: 'choose', email: '', serverUrl: '', tokenMode: false };
|
||||||
|
return {
|
||||||
|
step: reauth.method === 'oauth' ? 'confirm' : 'password',
|
||||||
|
email: reauth.method === 'token' ? '' : reauth.username,
|
||||||
|
serverUrl: reauth.serverUrl,
|
||||||
|
tokenMode: reauth.method === 'token',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -80,6 +80,7 @@ vi.mock('../../api/jmap-client', () => {
|
|||||||
clearAccountCredentials: vi.fn(async () => undefined),
|
clearAccountCredentials: vi.fn(async () => undefined),
|
||||||
clearAllCredentials: vi.fn(async () => undefined),
|
clearAllCredentials: vi.fn(async () => undefined),
|
||||||
getStoredOAuthTokens: vi.fn(async () => null),
|
getStoredOAuthTokens: vi.fn(async () => null),
|
||||||
|
getStoredCredentials: vi.fn(async () => null),
|
||||||
onAuthFailure: vi.fn(() => () => undefined),
|
onAuthFailure: vi.fn(() => () => undefined),
|
||||||
onTokenRefresh: vi.fn(() => () => undefined),
|
onTokenRefresh: vi.fn(() => () => undefined),
|
||||||
hasAccountCapability: vi.fn(() => true),
|
hasAccountCapability: vi.fn(() => true),
|
||||||
@@ -206,7 +207,7 @@ describe('signing out of the active account (Stalwart 1.0)', () => {
|
|||||||
expect(useOutboxStore.getState().activeAccountId).toBe(B);
|
expect(useOutboxStore.getState().activeAccountId).toBe(B);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips a next account whose sign-in is refused', async () => {
|
it('skips a next account whose sign-in is refused, which stays for a new sign-in', async () => {
|
||||||
await signedIn(A, B, C);
|
await signedIn(A, B, C);
|
||||||
client.loadErrors = { [B]: new AuthenticationError('Invalid credentials') };
|
client.loadErrors = { [B]: new AuthenticationError('Invalid credentials') };
|
||||||
|
|
||||||
@@ -214,7 +215,10 @@ describe('signing out of the active account (Stalwart 1.0)', () => {
|
|||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
expect(where()).toEqual({ signedIn: true, auth: C, registry: C, email: C, client: C });
|
expect(where()).toEqual({ signedIn: true, auth: C, registry: C, email: C, client: C });
|
||||||
expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual([C]);
|
expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual([B, C]);
|
||||||
|
expect(useAccountStore.getState().getAccountById(B)?.needsSignIn).toBe(true);
|
||||||
|
// Landing on C does not open a sign-in to B; its row offers it.
|
||||||
|
expect(useAuthStore.getState().reauth).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -281,18 +285,18 @@ describe('what a sign-out leaves behind', () => {
|
|||||||
expect(leftovers().feeds).toEqual([`sub-${A}`, `sub-${B}`]);
|
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 () => {
|
it('keeps the feeds of an account whose sign-in is refused on the switch to it', async () => {
|
||||||
await signedIn(A, B);
|
await signedIn(A, B);
|
||||||
useCalendarSubscriptionsStore.setState({ subscriptions: [feed(A), feed(B)] });
|
useCalendarSubscriptionsStore.setState({ subscriptions: [feed(A), feed(B)] });
|
||||||
client.loadErrors = { [B]: new AuthenticationError('Invalid credentials') };
|
client.loadErrors = { [B]: new AuthenticationError('Invalid credentials') };
|
||||||
|
|
||||||
await useAuthStore.getState().switchAccount(B);
|
await useAuthStore.getState().switchAccount(B);
|
||||||
|
|
||||||
expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual([A]);
|
expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual([A, B]);
|
||||||
expect(leftovers().feeds).toEqual([`sub-${A}`]);
|
expect(leftovers().feeds).toEqual([`sub-${A}`, `sub-${B}`]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears them when a switch evicts the last account', async () => {
|
it('keeps them when a switch to the last account is refused', async () => {
|
||||||
// Signed out of the active account while the last one's server was
|
// Signed out of the active account while the last one's server was
|
||||||
// away; it is refused when the user picks it.
|
// away; it is refused when the user picks it.
|
||||||
await signedIn(A);
|
await signedIn(A);
|
||||||
@@ -304,24 +308,26 @@ describe('what a sign-out leaves behind', () => {
|
|||||||
|
|
||||||
await useAuthStore.getState().switchAccount(A);
|
await useAuthStore.getState().switchAccount(A);
|
||||||
|
|
||||||
expect(useAccountStore.getState().accounts).toEqual([]);
|
expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual([A]);
|
||||||
expect(leftovers()).toEqual({ searches: [], feeds: [] });
|
expect(leftovers()).toEqual({ searches: ['invoice'], feeds: [`sub-${A}`] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears them when the restore on launch evicts the last account', async () => {
|
it('keeps them when the restore on launch is refused', async () => {
|
||||||
await signedIn(A);
|
await signedIn(A);
|
||||||
useAuthStore.setState({ isAuthenticated: false, session: null, activeAccountId: null, accountId: null });
|
useAuthStore.setState({ isAuthenticated: false, session: null, activeAccountId: null, accountId: null });
|
||||||
useSearchHistoryStore.setState({ recentSearches: ['invoice'] });
|
useSearchHistoryStore.setState({ recentSearches: ['invoice'] });
|
||||||
useCalendarSubscriptionsStore.setState({ subscriptions: [feed(A)] });
|
useCalendarSubscriptionsStore.setState({ subscriptions: [feed(A)] });
|
||||||
client.loadErrors = { [A]: new AuthenticationError('Invalid credentials') };
|
client.loadErrors = { [A]: new AuthenticationError('Invalid credentials') };
|
||||||
|
|
||||||
expect(await useAuthStore.getState().restoreSession()).toBe(false);
|
// The cached mail shows while the app asks for a new sign-in.
|
||||||
|
expect(await useAuthStore.getState().restoreSession()).toBe(true);
|
||||||
|
|
||||||
expect(useAccountStore.getState().accounts).toEqual([]);
|
expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual([A]);
|
||||||
expect(leftovers()).toEqual({ searches: [], feeds: [] });
|
expect(useAuthStore.getState().reauth?.accountId).toBe(A);
|
||||||
|
expect(leftovers()).toEqual({ searches: ['invoice'], feeds: [`sub-${A}`] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears them when a session retry evicts the last account', async () => {
|
it('keeps them when a session retry is refused', async () => {
|
||||||
await signedIn(A);
|
await signedIn(A);
|
||||||
useAuthStore.setState({ session: null });
|
useAuthStore.setState({ session: null });
|
||||||
useSearchHistoryStore.setState({ recentSearches: ['invoice'] });
|
useSearchHistoryStore.setState({ recentSearches: ['invoice'] });
|
||||||
@@ -330,8 +336,9 @@ describe('what a sign-out leaves behind', () => {
|
|||||||
|
|
||||||
expect(await useAuthStore.getState().retrySession()).toBe(false);
|
expect(await useAuthStore.getState().retrySession()).toBe(false);
|
||||||
|
|
||||||
expect(useAccountStore.getState().accounts).toEqual([]);
|
expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual([A]);
|
||||||
expect(leftovers()).toEqual({ searches: [], feeds: [] });
|
expect(useAuthStore.getState().reauth?.accountId).toBe(A);
|
||||||
|
expect(leftovers()).toEqual({ searches: ['invoice'], feeds: [`sub-${A}`] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears them when the last account signs out', async () => {
|
it('clears them when the last account signs out', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// The server refusing an account's stored sign-in (a Stalwart 0.16 server
|
||||||
|
// moved to 1.0, an expired pasted token, a changed password) used to remove
|
||||||
|
// the account with "Your session has expired". It stays now, with its caches
|
||||||
|
// and credentials, and the app asks for a new sign-in to it.
|
||||||
|
|
||||||
|
const stored = vi.hoisted(() => ({ creds: null as Record<string, unknown> | null }));
|
||||||
|
|
||||||
|
vi.mock('../../api/jmap-client', () => ({
|
||||||
|
jmapClient: {
|
||||||
|
connect: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
loadAccount: vi.fn(),
|
||||||
|
consumeLegacyCredentials: vi.fn(async () => null),
|
||||||
|
clearAccountCredentials: vi.fn(async () => undefined),
|
||||||
|
clearAllCredentials: vi.fn(async () => undefined),
|
||||||
|
getStoredCredentials: vi.fn(async () => stored.creds),
|
||||||
|
getStoredOAuthTokens: vi.fn(async () => null),
|
||||||
|
reset: vi.fn(),
|
||||||
|
snapshot: vi.fn(() => ({ session: 'live', credentials: 'live', accountId: 'live' })),
|
||||||
|
restoreSnapshot: vi.fn(),
|
||||||
|
onAuthFailure: vi.fn(() => () => undefined),
|
||||||
|
onTokenRefresh: vi.fn(() => () => undefined),
|
||||||
|
hasAccountCapability: vi.fn(() => false),
|
||||||
|
request: vi.fn(async () => { throw new Error('not mocked'); }),
|
||||||
|
accountId: 'acc-1',
|
||||||
|
currentSession: null,
|
||||||
|
},
|
||||||
|
AuthenticationError: class AuthenticationError extends Error {
|
||||||
|
constructor(msg: string) { super(msg); this.name = 'AuthenticationError'; }
|
||||||
|
},
|
||||||
|
NetworkError: class NetworkError extends Error {
|
||||||
|
constructor(msg: string) { super(msg); this.name = 'NetworkError'; }
|
||||||
|
},
|
||||||
|
SessionSupersededError: class SessionSupersededError extends Error {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../lib/push-notifications', () => ({
|
||||||
|
teardownPushNotifications: vi.fn(async () => undefined),
|
||||||
|
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { AuthenticationError, jmapClient } from '../../api/jmap-client';
|
||||||
|
import { useAuthStore } from '../auth-store';
|
||||||
|
import { useAccountStore, type AccountEntry } from '../account-store';
|
||||||
|
import { reauthStart } from '../../screens/login/reauth-start';
|
||||||
|
|
||||||
|
const loadAccount = jmapClient.loadAccount as ReturnType<typeof vi.fn>;
|
||||||
|
const clearCredentials = jmapClient.clearAccountCredentials as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const A = 'usera@example.org@mail.example.org';
|
||||||
|
const B = 'userb@example.org@mail.example.org';
|
||||||
|
|
||||||
|
function entry(id: string, username: string): AccountEntry {
|
||||||
|
return {
|
||||||
|
id, username, serverUrl: 'https://mail.example.org', displayName: username, email: username,
|
||||||
|
avatarColor: '#000', lastLoginAt: 1, isConnected: false, hasError: false, isDefault: id === A,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
stored.creds = { serverUrl: 'https://mail.example.org', username: 'usera@example.org', password: 'old' };
|
||||||
|
useAccountStore.setState({
|
||||||
|
accounts: [entry(A, 'usera@example.org'), entry(B, 'userb@example.org')],
|
||||||
|
activeAccountId: A,
|
||||||
|
defaultAccountId: A,
|
||||||
|
});
|
||||||
|
useAuthStore.setState({
|
||||||
|
isAuthenticated: false, isLoading: false, hasRestoredSession: false, error: null,
|
||||||
|
serverUrl: null, username: null, session: null, accountId: null, activeAccountId: null, client: null,
|
||||||
|
reauth: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a refused stored sign-in', () => {
|
||||||
|
it('keeps the account on a cold start and asks for a new sign-in to it', async () => {
|
||||||
|
loadAccount.mockRejectedValue(new AuthenticationError('Session expired'));
|
||||||
|
|
||||||
|
await useAuthStore.getState().restoreSession();
|
||||||
|
|
||||||
|
const account = useAccountStore.getState().getAccountById(A);
|
||||||
|
expect(account).toBeDefined();
|
||||||
|
expect(account?.needsSignIn).toBe(true);
|
||||||
|
expect(clearCredentials).not.toHaveBeenCalled();
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
expect(state.error).toBeNull();
|
||||||
|
expect(state.isAuthenticated).toBe(true);
|
||||||
|
expect(state.reauth).toEqual({
|
||||||
|
accountId: A, serverUrl: 'https://mail.example.org', username: 'usera@example.org', method: 'password',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks a pasted-token account for a new token', async () => {
|
||||||
|
stored.creds = { serverUrl: 'https://mail.example.org', username: 'usera@example.org', password: '', accessToken: 'tok' };
|
||||||
|
loadAccount.mockRejectedValue(new AuthenticationError('Session expired'));
|
||||||
|
|
||||||
|
await useAuthStore.getState().restoreSession();
|
||||||
|
|
||||||
|
const reauth = useAuthStore.getState().reauth;
|
||||||
|
expect(reauth?.method).toBe('token');
|
||||||
|
expect(reauthStart(reauth)).toEqual({ step: 'password', email: '', serverUrl: 'https://mail.example.org', tokenMode: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an account switched to and goes back to the one in use', async () => {
|
||||||
|
useAuthStore.setState({ isAuthenticated: true, session: { apiUrl: 'x' } as never, activeAccountId: A });
|
||||||
|
loadAccount.mockRejectedValue(new AuthenticationError('Session expired'));
|
||||||
|
|
||||||
|
await useAuthStore.getState().switchAccount(B);
|
||||||
|
|
||||||
|
expect(useAccountStore.getState().getAccountById(B)?.needsSignIn).toBe(true);
|
||||||
|
expect(clearCredentials).not.toHaveBeenCalled();
|
||||||
|
expect(jmapClient.restoreSnapshot).toHaveBeenCalled();
|
||||||
|
expect(useAuthStore.getState().reauth?.accountId).toBe(B);
|
||||||
|
expect(useAuthStore.getState().error).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reauthStart', () => {
|
||||||
|
it('opens a password account on the form and an OAuth one on its sign-in page, filled in', () => {
|
||||||
|
expect(reauthStart({ accountId: A, serverUrl: 'https://m', username: 'u@x', method: 'password' }))
|
||||||
|
.toEqual({ step: 'password', email: 'u@x', serverUrl: 'https://m', tokenMode: false });
|
||||||
|
expect(reauthStart({ accountId: A, serverUrl: 'https://m', username: 'u@x', method: 'oauth' }))
|
||||||
|
.toEqual({ step: 'confirm', email: 'u@x', serverUrl: 'https://m', tokenMode: false });
|
||||||
|
expect(reauthStart(null).step).toBe('choose');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { createPersistStorage } from './persist-storage';
|
import { createPersistStorage } from './persist-storage';
|
||||||
import { AccountLimitError, generateAccountId, MAX_ACCOUNTS } from '../lib/account-utils';
|
import { AccountLimitError, generateAccountId, MAX_ACCOUNTS, setKnownAccounts } from '../lib/account-utils';
|
||||||
import { generateAvatarColor } from '../lib/avatar-utils';
|
import { generateAvatarColor } from '../lib/avatar-utils';
|
||||||
|
|
||||||
export interface AccountEntry {
|
export interface AccountEntry {
|
||||||
@@ -15,6 +15,10 @@ export interface AccountEntry {
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
// The server refused the stored sign-in (a password changed, a token that
|
||||||
|
// expired, a server moved to Stalwart 1.0): the account stays, and the
|
||||||
|
// user is asked to sign in to it again.
|
||||||
|
needsSignIn?: boolean;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
// JMAP primary account id (from the session's primaryAccounts). Recorded
|
// JMAP primary account id (from the session's primaryAccounts). Recorded
|
||||||
// when push is set up so a relay payload can be routed to this entry.
|
// when push is set up so a relay payload can be routed to this entry.
|
||||||
@@ -58,12 +62,17 @@ export const useAccountStore = create<AccountState>()(
|
|||||||
a.id === id
|
a.id === id
|
||||||
? {
|
? {
|
||||||
...a,
|
...a,
|
||||||
|
// The same server under another URL (a path, a scheme):
|
||||||
|
// the credentials just stored carry the new one.
|
||||||
|
serverUrl: entry.serverUrl || a.serverUrl,
|
||||||
displayName: entry.displayName || a.displayName,
|
displayName: entry.displayName || a.displayName,
|
||||||
email: entry.email || a.email,
|
email: entry.email || a.email,
|
||||||
lastLoginAt: entry.lastLoginAt,
|
lastLoginAt: entry.lastLoginAt,
|
||||||
isConnected: entry.isConnected,
|
isConnected: entry.isConnected,
|
||||||
hasError: entry.hasError,
|
hasError: entry.hasError,
|
||||||
errorMessage: entry.errorMessage,
|
errorMessage: entry.errorMessage,
|
||||||
|
// Signed in again.
|
||||||
|
needsSignIn: false,
|
||||||
}
|
}
|
||||||
: a,
|
: a,
|
||||||
),
|
),
|
||||||
@@ -180,3 +189,8 @@ export const useAccountStore = create<AccountState>()(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// generateAccountId keeps the ids accounts were signed in under, which it
|
||||||
|
// reads from the registry (see lib/account-utils).
|
||||||
|
setKnownAccounts(useAccountStore.getState().accounts);
|
||||||
|
useAccountStore.subscribe((state) => setKnownAccounts(state.accounts));
|
||||||
|
|||||||
+142
-41
@@ -9,7 +9,7 @@ import {
|
|||||||
} from '../api/jmap-client';
|
} from '../api/jmap-client';
|
||||||
import type { JMAPSession } from '../api/types';
|
import type { JMAPSession } from '../api/types';
|
||||||
import { fetchAccountDisplayName, isStalwartSupported } from '../api/account-security';
|
import { fetchAccountDisplayName, isStalwartSupported } from '../api/account-security';
|
||||||
import { useAccountStore } from './account-store';
|
import { useAccountStore, type AccountEntry } from './account-store';
|
||||||
import { useEmailStore } from './email-store';
|
import { useEmailStore } from './email-store';
|
||||||
import { useContactsStore } from './contacts-store';
|
import { useContactsStore } from './contacts-store';
|
||||||
import { useCalendarStore } from './calendar-store';
|
import { useCalendarStore } from './calendar-store';
|
||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
forgetRefreshRotations,
|
forgetRefreshRotations,
|
||||||
type HandoffResult,
|
type HandoffResult,
|
||||||
} from '../lib/oauth';
|
} from '../lib/oauth';
|
||||||
import { discoverOAuthMetadata, loginWithPkce, probeWebmail, revokeRefreshToken } from '../lib/oauth-native';
|
import { discoverOAuthMetadata, endProviderSession, loginWithPkce, probeWebmail, revokeRefreshToken } from '../lib/oauth-native';
|
||||||
import {
|
import {
|
||||||
teardownPushNotifications,
|
teardownPushNotifications,
|
||||||
teardownPushNotificationsForAccount,
|
teardownPushNotificationsForAccount,
|
||||||
@@ -77,6 +77,20 @@ async function waitForHydration(store: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An account whose stored sign-in the server refused, to be signed in to
|
||||||
|
* again: the sign-in screen opens on it with the server and the username
|
||||||
|
* filled in. The method is how it signed in: a pasted access token is asked
|
||||||
|
* for again, a password account goes to the password form, an OAuth one to
|
||||||
|
* the server's sign-in page.
|
||||||
|
*/
|
||||||
|
export interface ReauthTarget {
|
||||||
|
accountId: string;
|
||||||
|
serverUrl: string;
|
||||||
|
username: string;
|
||||||
|
method: 'password' | 'oauth' | 'token';
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -94,6 +108,8 @@ export interface AuthState {
|
|||||||
* on the next successful sign-in.
|
* on the next successful sign-in.
|
||||||
*/
|
*/
|
||||||
pendingTotpLogin: { serverUrl: string; username: string; password: string } | null;
|
pendingTotpLogin: { serverUrl: string; username: string; password: string } | null;
|
||||||
|
/** The account to sign in to again, set when its stored sign-in was refused. */
|
||||||
|
reauth: ReauthTarget | null;
|
||||||
|
|
||||||
login: (
|
login: (
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
@@ -115,6 +131,9 @@ export interface AuthState {
|
|||||||
restoreSession: () => Promise<boolean>;
|
restoreSession: () => Promise<boolean>;
|
||||||
retrySession: () => Promise<boolean>;
|
retrySession: () => Promise<boolean>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
|
/** Ask for a new sign-in to an account (sets reauth). */
|
||||||
|
requestReauth: (accountId: string) => Promise<void>;
|
||||||
|
clearReauth: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The messages the viewer read, their rendered documents and their heights,
|
// The messages the viewer read, their rendered documents and their heights,
|
||||||
@@ -203,6 +222,47 @@ async function checkServerChange(accountId: string, session: JMAPSession): Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The registry names the server the account's stored credentials sign in
|
||||||
|
// to. Before ports were part of account ids, signing in to a second server
|
||||||
|
// on the same host stored its credentials under the first server's account
|
||||||
|
// and left the registry naming the first server. That account has been the
|
||||||
|
// second server's since (its mail, its sends), and now says so; the ids
|
||||||
|
// stay consistent with it (see generateAccountId).
|
||||||
|
function followStoredServer(accountId: string): void {
|
||||||
|
const served = jmapClient.serverUrl;
|
||||||
|
const accountStore = useAccountStore.getState();
|
||||||
|
const entry = accountStore.getAccountById(accountId);
|
||||||
|
if (served && entry && entry.serverUrl !== served) accountStore.updateAccount(accountId, { serverUrl: served });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The message an account the server refused carries in the registry (the
|
||||||
|
// account rows translate it from needsSignIn).
|
||||||
|
const SIGN_IN_AGAIN = 'Sign in again';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server refused an account's stored sign-in (401: the password was
|
||||||
|
* changed, a pasted token expired, the server moved from Stalwart 0.16 to
|
||||||
|
* 1.0 and lost the grant). The account stays, with its caches and settings,
|
||||||
|
* and is marked for a new sign-in; it used to be removed with "Your session
|
||||||
|
* has expired", which lost everything kept for it. Its credentials stay too
|
||||||
|
* until the new sign-in replaces them. Null when the account is gone.
|
||||||
|
*/
|
||||||
|
async function keepForSignIn(accountId: string): Promise<ReauthTarget | null> {
|
||||||
|
const entry = useAccountStore.getState().getAccountById(accountId);
|
||||||
|
if (!entry) return null;
|
||||||
|
const creds = await jmapClient.getStoredCredentials(accountId).catch(() => null);
|
||||||
|
const method: ReauthTarget['method'] = creds?.accessToken ? (creds.tokenEndpoint ? 'oauth' : 'token') : 'password';
|
||||||
|
if (useAccountStore.getState().getAccountById(accountId)) {
|
||||||
|
useAccountStore.getState().updateAccount(accountId, {
|
||||||
|
isConnected: false,
|
||||||
|
hasError: true,
|
||||||
|
errorMessage: SIGN_IN_AGAIN,
|
||||||
|
needsSignIn: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { accountId, serverUrl: entry.serverUrl, username: creds?.username || entry.username, method };
|
||||||
|
}
|
||||||
|
|
||||||
// The outbox replays against whatever account the JMAP client serves, and a
|
// The outbox replays against whatever account the JMAP client serves, and a
|
||||||
// replaced server may have given the queued ids to other messages. So it
|
// replaced server may have given the queued ids to other messages. So it
|
||||||
// runs only once the account's session is up and its server was checked.
|
// runs only once the account's session is up and its server was checked.
|
||||||
@@ -441,7 +501,9 @@ function refuseAddWhenFull(set: (partial: Partial<AuthState>) => void, opts?: Si
|
|||||||
// desktop's own refresh token, and newer ones hand out a separate grant whose
|
// desktop's own refresh token, and newer ones hand out a separate grant whose
|
||||||
// refresh token only the webmail's token proxy understands. The account's
|
// refresh token only the webmail's token proxy understands. The account's
|
||||||
// chain also leaves the in-process rotation memory either way, so a stale copy
|
// chain also leaves the in-process rotation memory either way, so a stale copy
|
||||||
// refreshed after the sign-out can't pick up its tokens from there.
|
// refreshed after the sign-out can't pick up its tokens from there. A browser
|
||||||
|
// sign-in's provider session is ended too, in the background: the sign-out
|
||||||
|
// never waits for the logout page (see endProviderSession).
|
||||||
async function revokeStoredRefreshToken(accountId: string): Promise<void> {
|
async function revokeStoredRefreshToken(accountId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const entry = useAccountStore.getState().getAccountById(accountId);
|
const entry = useAccountStore.getState().getAccountById(accountId);
|
||||||
@@ -449,6 +511,7 @@ async function revokeStoredRefreshToken(accountId: string): Promise<void> {
|
|||||||
forgetRefreshRotations(tokens?.refreshToken);
|
forgetRefreshRotations(tokens?.refreshToken);
|
||||||
if (!entry || !tokens || tokens.source === 'pairing') return;
|
if (!entry || !tokens || tokens.source === 'pairing') return;
|
||||||
await revokeRefreshToken(entry.serverUrl, tokens);
|
await revokeRefreshToken(entry.serverUrl, tokens);
|
||||||
|
void endProviderSession(entry.serverUrl, tokens);
|
||||||
} catch {
|
} catch {
|
||||||
// never block sign-out
|
// never block sign-out
|
||||||
}
|
}
|
||||||
@@ -538,6 +601,7 @@ function applyConnectedState(
|
|||||||
accountId: jmapClient.accountId,
|
accountId: jmapClient.accountId,
|
||||||
activeAccountId: accountId,
|
activeAccountId: accountId,
|
||||||
client: jmapClient,
|
client: jmapClient,
|
||||||
|
reauth: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,6 +617,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
activeAccountId: null,
|
activeAccountId: null,
|
||||||
client: null,
|
client: null,
|
||||||
pendingTotpLogin: null,
|
pendingTotpLogin: null,
|
||||||
|
reauth: null,
|
||||||
|
|
||||||
login: async (serverUrl, typedUsername, password, opts) => {
|
login: async (serverUrl, typedUsername, password, opts) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
@@ -811,16 +876,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
// afresh each time: the state taken above still lists the account just
|
// afresh each time: the state taken above still lists the account just
|
||||||
// removed, and signing out of the last one "switched" to it, which did
|
// removed, and signing out of the last one "switched" to it, which did
|
||||||
// nothing, and left the app on an empty mail view.
|
// nothing, and left the app on an empty mail view.
|
||||||
for (let tries = useAccountStore.getState().accounts.length; tries > 0; tries--) {
|
// Its server did not answer, or refused its stored sign-in: show what is
|
||||||
const registry = useAccountStore.getState();
|
// cached for it, as a cold start does. The session retry brings an
|
||||||
const next = registry.getDefaultAccount() ?? registry.accounts[0];
|
// unreachable one up; a refused one asks for a new sign-in.
|
||||||
if (!next) break;
|
const showCached = (next: AccountEntry, reauth: ReauthTarget | null) => {
|
||||||
await get().switchAccount(next.id);
|
|
||||||
// Landed, or another switch has begun and decides.
|
|
||||||
if (get().activeAccountId === next.id || pendingSwitch) return;
|
|
||||||
if (!useAccountStore.getState().getAccountById(next.id)) continue; // its sign-in was refused
|
|
||||||
// Its server did not answer: show what is cached for it, as a cold
|
|
||||||
// start does, and let the session retry bring it up.
|
|
||||||
useAccountStore.getState().setActiveAccount(next.id);
|
useAccountStore.getState().setActiveAccount(next.id);
|
||||||
useEmailStore.getState().setActiveAccount(next.id);
|
useEmailStore.getState().setActiveAccount(next.id);
|
||||||
set({
|
set({
|
||||||
@@ -833,7 +892,35 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
accountId: null,
|
accountId: null,
|
||||||
activeAccountId: next.id,
|
activeAccountId: next.id,
|
||||||
client: jmapClient,
|
client: jmapClient,
|
||||||
|
reauth,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
// An account whose sign-in is refused stays for a new sign-in
|
||||||
|
// (keepForSignIn) and the next one is tried; it opens only when no
|
||||||
|
// other account comes up.
|
||||||
|
let refused: { account: AccountEntry; reauth: ReauthTarget | null } | null = null;
|
||||||
|
const tried = new Set<string>();
|
||||||
|
for (let tries = useAccountStore.getState().accounts.length; tries > 0; tries--) {
|
||||||
|
const registry = useAccountStore.getState();
|
||||||
|
const next = [registry.getDefaultAccount(), ...registry.accounts]
|
||||||
|
.find((a): a is AccountEntry => !!a && !tried.has(a.id));
|
||||||
|
if (!next) break;
|
||||||
|
tried.add(next.id);
|
||||||
|
await get().switchAccount(next.id);
|
||||||
|
// Landed, or another switch has begun and decides.
|
||||||
|
if (get().activeAccountId === next.id || pendingSwitch) return;
|
||||||
|
const after = useAccountStore.getState().getAccountById(next.id);
|
||||||
|
if (!after) continue;
|
||||||
|
if (after.needsSignIn) {
|
||||||
|
refused ??= { account: after, reauth: get().reauth };
|
||||||
|
set({ reauth: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
showCached(after, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (refused) {
|
||||||
|
showCached(refused.account, refused.reauth);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -965,13 +1052,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isCurrent() || err instanceof SessionSupersededError) return;
|
if (!isCurrent() || err instanceof SessionSupersededError) return;
|
||||||
if (err instanceof AuthenticationError) {
|
if (err instanceof AuthenticationError) {
|
||||||
await jmapClient.clearAccountCredentials(accountId).catch(() => undefined);
|
// Back to the account the switch left, and ask for a new sign-in
|
||||||
|
// to this one (see keepForSignIn).
|
||||||
|
const reauth = await keepForSignIn(accountId);
|
||||||
if (!isCurrent()) return;
|
if (!isCurrent()) return;
|
||||||
accountStore.removeAccount(accountId);
|
|
||||||
useEmailStore.getState().removeAccount(accountId);
|
|
||||||
restorePrevious();
|
restorePrevious();
|
||||||
set({ isLoading: false, error: 'Session expired for this account' });
|
set({ isLoading: false, error: null, reauth });
|
||||||
await forgetRemovedAccount(accountId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// NetworkError or anything else - keep the previous active account
|
// NetworkError or anything else - keep the previous active account
|
||||||
@@ -993,6 +1079,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
set({ isLoading: false, error: 'Failed to load session' });
|
set({ isLoading: false, error: 'Failed to load session' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
followStoredServer(accountId);
|
||||||
await checkServerChange(accountId, session);
|
await checkServerChange(accountId, session);
|
||||||
if (!isCurrent()) return;
|
if (!isCurrent()) return;
|
||||||
|
|
||||||
@@ -1001,6 +1088,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
isConnected: true,
|
isConnected: true,
|
||||||
hasError: false,
|
hasError: false,
|
||||||
errorMessage: undefined,
|
errorMessage: undefined,
|
||||||
|
needsSignIn: false,
|
||||||
lastLoginAt: Date.now(),
|
lastLoginAt: Date.now(),
|
||||||
});
|
});
|
||||||
applyConnectedState(set, session, target.serverUrl, target.username, accountId);
|
applyConnectedState(set, session, target.serverUrl, target.username, accountId);
|
||||||
@@ -1120,13 +1208,25 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (err instanceof AuthenticationError) {
|
if (err instanceof AuthenticationError) {
|
||||||
// Server reachable but credentials rejected — drop them.
|
// Server reachable but the stored sign-in refused: keep the account
|
||||||
await jmapClient.clearAccountCredentials(target.id).catch(() => undefined);
|
// and what is cached for it, as for an unreachable server, and ask
|
||||||
accountStore.removeAccount(target.id);
|
// for a new sign-in (see keepForSignIn).
|
||||||
useEmailStore.getState().removeAccount(target.id);
|
const reauth = await keepForSignIn(target.id);
|
||||||
await forgetRemovedAccount(target.id);
|
accountStore.setActiveAccount(target.id);
|
||||||
set({ isLoading: false, hasRestoredSession: true, error: 'Session expired' });
|
set({
|
||||||
return false;
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
hasRestoredSession: true,
|
||||||
|
error: null,
|
||||||
|
serverUrl: target.serverUrl,
|
||||||
|
username: target.username,
|
||||||
|
session: null,
|
||||||
|
accountId: null,
|
||||||
|
activeAccountId: target.id,
|
||||||
|
client: jmapClient,
|
||||||
|
reauth,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -1136,9 +1236,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
isConnected: true,
|
isConnected: true,
|
||||||
hasError: false,
|
hasError: false,
|
||||||
errorMessage: undefined,
|
errorMessage: undefined,
|
||||||
|
needsSignIn: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = jmapClient.currentSession!;
|
const session = jmapClient.currentSession!;
|
||||||
|
followStoredServer(target.id);
|
||||||
await checkServerChange(target.id, session);
|
await checkServerChange(target.id, session);
|
||||||
applyConnectedState(set, session, target.serverUrl, target.username, target.id);
|
applyConnectedState(set, session, target.serverUrl, target.username, target.id);
|
||||||
perfMark('restore-done');
|
perfMark('restore-done');
|
||||||
@@ -1174,6 +1276,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
const accountStore = useAccountStore.getState();
|
const accountStore = useAccountStore.getState();
|
||||||
const target = accountStore.getAccountById(activeAccountId);
|
const target = accountStore.getAccountById(activeAccountId);
|
||||||
if (!target) return false;
|
if (!target) return false;
|
||||||
|
// The server refused its sign-in: only a new one brings it back.
|
||||||
|
if (target.needsSignIn) return false;
|
||||||
try {
|
try {
|
||||||
const ok = await jmapClient.loadAccount(activeAccountId);
|
const ok = await jmapClient.loadAccount(activeAccountId);
|
||||||
if (!ok || !isCurrent()) return false;
|
if (!ok || !isCurrent()) return false;
|
||||||
@@ -1181,8 +1285,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
isConnected: true,
|
isConnected: true,
|
||||||
hasError: false,
|
hasError: false,
|
||||||
errorMessage: undefined,
|
errorMessage: undefined,
|
||||||
|
needsSignIn: false,
|
||||||
});
|
});
|
||||||
const fresh = jmapClient.currentSession!;
|
const fresh = jmapClient.currentSession!;
|
||||||
|
followStoredServer(activeAccountId);
|
||||||
await checkServerChange(activeAccountId, fresh);
|
await checkServerChange(activeAccountId, fresh);
|
||||||
if (!isCurrent()) return false;
|
if (!isCurrent()) return false;
|
||||||
applyConnectedState(set, fresh, target.serverUrl, target.username, activeAccountId);
|
applyConnectedState(set, fresh, target.serverUrl, target.username, activeAccountId);
|
||||||
@@ -1191,22 +1297,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isCurrent() || err instanceof SessionSupersededError) return false;
|
if (!isCurrent() || err instanceof SessionSupersededError) return false;
|
||||||
if (err instanceof AuthenticationError) {
|
if (err instanceof AuthenticationError) {
|
||||||
// Now we know the credentials are bad — fall back to logout flow.
|
// Now we know the stored sign-in is refused: the account stays on
|
||||||
await jmapClient.clearAccountCredentials(activeAccountId).catch(() => undefined);
|
// what is cached for it and asks for a new sign-in (keepForSignIn).
|
||||||
accountStore.removeAccount(activeAccountId);
|
const reauth = await keepForSignIn(activeAccountId);
|
||||||
await forgetRemovedAccount(activeAccountId);
|
if (isCurrent()) set({ isLoading: false, error: null, reauth });
|
||||||
set({
|
|
||||||
isAuthenticated: false,
|
|
||||||
isLoading: false,
|
|
||||||
hasRestoredSession: true,
|
|
||||||
error: 'Session expired',
|
|
||||||
serverUrl: null,
|
|
||||||
username: null,
|
|
||||||
session: null,
|
|
||||||
accountId: null,
|
|
||||||
activeAccountId: null,
|
|
||||||
client: null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// NetworkError or anything else: stay where we are.
|
// NetworkError or anything else: stay where we are.
|
||||||
return false;
|
return false;
|
||||||
@@ -1214,6 +1308,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
requestReauth: async (accountId) => {
|
||||||
|
const reauth = await keepForSignIn(accountId);
|
||||||
|
if (reauth) set({ reauth });
|
||||||
|
},
|
||||||
|
|
||||||
|
clearReauth: () => set({ reauth: null }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// A 401 on a live session (revoked password/token, expired refresh token)
|
// A 401 on a live session (revoked password/token, expired refresh token)
|
||||||
|
|||||||
Reference in New Issue
Block a user