diff --git a/src/api/__tests__/jmap-client-hardening.test.ts b/src/api/__tests__/jmap-client-hardening.test.ts index e6b16cac..3133c072 100644 --- a/src/api/__tests__/jmap-client-hardening.test.ts +++ b/src/api/__tests__/jmap-client-hardening.test.ts @@ -418,6 +418,40 @@ describe('JMAPClient hardening', () => { expect(client.authHeader).toBe('Bearer new'); }); + // On iOS the notification extension refreshes OAuth tokens too and writes + // the new bundle back. Refreshing again with the refresh token in memory + // would spend a token the server has already replaced. + it('retries a 401 with a newer stored token instead of refreshing again', async () => { + const SecureStore = await import('expo-secure-store'); + const client = new JMAPClient(); + mockFetch([{ status: 200, json: SESSION }]); + await client.connectWithOAuth('https://mail.example.com', { + accessToken: 'old', + refreshToken: 'r1', + expiresAt: Date.now() + 3_600_000, + tokenEndpoint: 'https://mail.example.com/auth/token', + clientId: 'c', + }); + (SecureStore.getItemAsync as ReturnType).mockResolvedValue(JSON.stringify({ + serverUrl: 'https://mail.example.com', + username: 'user@example.com', + password: '', + accessToken: 'from-extension', + refreshToken: 'r2', + expiresAt: Date.now() + 7_200_000, + tokenEndpoint: 'https://mail.example.com/auth/token', + clientId: 'c', + })); + const fetch = mockFetch([ + { status: 401 }, + { status: 200, json: { methodResponses: [['Mailbox/get', {}, '0']] } }, + ]); + await client.request([['Mailbox/get', {}, '0']]); + expect(mockRefresh).not.toHaveBeenCalled(); + expect((fetch.mock.calls[1][1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer from-extension' }); + (SecureStore.getItemAsync as ReturnType).mockReset(); + }); + it('updatePassword rewrites the stored credential', async () => { const SecureStore = await import('expo-secure-store'); const client = await connected(); diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 04c3ddc3..7752e3e4 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -15,6 +15,7 @@ import { type OAuthTokenSource, } from '../lib/oauth'; import { FirstTouchGate } from './first-touch-gate'; +import { writeSharedSecret, moveToSharedGroup } from '../lib/ios-push-shared'; import { beginOwnWrite, recordOwnEmailWrites } from './own-writes'; // Refresh OAuth access tokens this many ms before they actually expire so @@ -40,7 +41,7 @@ const LEGACY_CREDENTIALS_KEY = 'jmap_credentials'; const CREDENTIALS_PREFIX = 'jmap_credentials__'; // SecureStore keys: letters, digits, ".", "-", "_" only - no "@" or "/". -function credentialsKey(accountId: string): string { +export function credentialsKey(accountId: string): string { return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_'); } @@ -260,10 +261,7 @@ export class JMAPClient { this.firstTouchGate.reset(); const accountId = generateAccountId(username, baseUrl); - await SecureStore.setItemAsync( - credentialsKey(accountId), - JSON.stringify(this.credentials), - ); + await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials)); return this.session; } @@ -342,10 +340,7 @@ export class JMAPClient { this.credentials.username = username; const accountId = generateAccountId(username, baseUrl); - await SecureStore.setItemAsync( - credentialsKey(accountId), - JSON.stringify(this.credentials), - ); + await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials)); return { session: this.session, username, accountId }; } @@ -367,15 +362,36 @@ export class JMAPClient { this.credentials.username, this.credentials.serverUrl, ); - await SecureStore.setItemAsync( - credentialsKey(accountId), - JSON.stringify(this.credentials), - ); + await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials)); for (const l of this.tokenRefreshListeners) { try { l(); } catch { /* ignore */ } } } + /** + * Take the stored OAuth tokens when they are newer than the ones in memory: + * on iOS the notification extension refreshes them too and writes the new + * bundle back. True when the in-memory tokens changed. + */ + private async adoptStoredTokens(): Promise { + const current = this.credentials; + if (!current?.accessToken || !current.refreshToken) return false; + const accountId = generateAccountId(current.username, current.serverUrl); + const stored = await this.getStoredCredentials(accountId).catch(() => null); + if (!stored?.accessToken || stored.accessToken === current.accessToken) return false; + if ((stored.expiresAt ?? 0) <= (current.expiresAt ?? 0)) return false; + this.credentials = { + ...current, + accessToken: stored.accessToken, + refreshToken: stored.refreshToken ?? current.refreshToken, + expiresAt: stored.expiresAt, + }; + for (const l of this.tokenRefreshListeners) { + try { l(); } catch { /* ignore */ } + } + return true; + } + private currentOAuthTokens(): OAuthTokens | null { if ( !this.credentials?.accessToken || @@ -414,10 +430,17 @@ export class JMAPClient { // consumers of `authHeader` (SSE stream, file downloads) can make sure the // header they capture is not about to expire. async ensureFreshToken(): Promise { - const tokens = this.currentOAuthTokens(); + let tokens = this.currentOAuthTokens(); if (!tokens) return; if (tokens.expiresAt == null) return; if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return; + // Another process may have refreshed already (the iOS notification + // extension); use its tokens rather than spend a refresh token the + // server has replaced. + if (await this.adoptStoredTokens()) { + tokens = this.currentOAuthTokens(); + if (!tokens || (tokens.expiresAt != null && tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS)) return; + } try { const next = await refreshOAuthAccessToken(tokens); await this.persistRefreshedTokens(next); @@ -433,6 +456,10 @@ export class JMAPClient { // the account instead of evicting it (webmail 1.7.6 "keep the session when // the auth server is briefly unreachable"). async forceRefreshToken(): Promise { + if (!this.currentOAuthTokens()) return false; + // A newer token in storage (refreshed by the iOS notification extension) + // is worth a retry before refreshing again. + if (await this.adoptStoredTokens()) return true; const tokens = this.currentOAuthTokens(); if (!tokens) return false; try { @@ -459,10 +486,7 @@ export class JMAPClient { this.credentials.username, this.credentials.serverUrl, ); - await SecureStore.setItemAsync( - credentialsKey(accountId), - JSON.stringify(this.credentials), - ); + await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials)); } // Legacy single-slot restore - kept for backward-compat tests. New code @@ -490,6 +514,9 @@ export class JMAPClient { async loadAccount(accountId: string): Promise { const stored = await SecureStore.getItemAsync(credentialsKey(accountId)); if (!stored) return false; + // iOS: credentials saved before the notification extension existed move + // to the keychain group it can read (a no-op elsewhere and afterwards). + void moveToSharedGroup(credentialsKey(accountId)).catch(() => undefined); let creds: StoredCredentials; try { @@ -566,7 +593,7 @@ export class JMAPClient { const creds: StoredCredentials = JSON.parse(stored); // Re-save under the per-account key before dropping the legacy entry const accountId = generateAccountId(creds.username, creds.serverUrl); - await SecureStore.setItemAsync(credentialsKey(accountId), stored); + await writeSharedSecret(credentialsKey(accountId), stored); await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY); return creds; } catch { @@ -1159,7 +1186,7 @@ export class JMAPClient { } async setStoredCredentials(accountId: string, creds: StoredCredentials): Promise { - await SecureStore.setItemAsync(credentialsKey(accountId), JSON.stringify(creds)); + await writeSharedSecret(credentialsKey(accountId), JSON.stringify(creds)); } // ── Blob Download ───────────────────────────────────── diff --git a/src/lib/ios-push-shared.ts b/src/lib/ios-push-shared.ts new file mode 100644 index 00000000..7e476e4d --- /dev/null +++ b/src/lib/ios-push-shared.ts @@ -0,0 +1,118 @@ +// What the app shares with the iOS notification service extension +// (plugins/ios-push): account credentials, where to reach each pushed account, +// and the badge counts. They live in a keychain access group both targets +// belong to; nothing else of the app is visible to the extension. +// +// Readable after the first unlock: a push usually arrives while the phone is +// locked, and a notification that has to wait for the user to unlock would +// show only "New message". + +import * as SecureStore from 'expo-secure-store'; +import { Platform } from 'react-native'; + +type Extra = { iosSharedKeychainGroup?: unknown; iosAppKeychainGroup?: unknown; apnsEnvironment?: unknown }; + +// Loaded on first use, on iOS only: the module pulls in native code that +// Android's headless tasks and the unit tests can do without. +function extra(): Extra { + const { default: Constants } = require('expo-constants') as typeof import('expo-constants'); + return (Constants.expoConfig?.extra ?? {}) as Extra; +} + +/** The APNs host this build's device tokens belong to. */ +export function apnsEnvironment(): 'production' | 'sandbox' { + return extra().apnsEnvironment === 'sandbox' ? 'sandbox' : 'production'; +} + +/** The access group shared with the extension, or null off iOS. */ +export function sharedKeychainGroup(): string | null { + if (Platform.OS !== 'ios') return null; + const group = extra().iosSharedKeychainGroup; + return typeof group === 'string' && group ? group : null; +} + +/** Options for an item the extension must read. Undefined off iOS. */ +export function sharedStoreOptions(): SecureStore.SecureStoreOptions | undefined { + const group = sharedKeychainGroup(); + if (!group) return undefined; + return { accessGroup: group, keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK }; +} + +/** Options naming the app's own access group, where items used to live. */ +export function appStoreOptions(): SecureStore.SecureStoreOptions | undefined { + if (Platform.OS !== 'ios') return undefined; + const group = extra().iosAppKeychainGroup; + return typeof group === 'string' && group ? { accessGroup: group } : undefined; +} + +// SecureStore keys: letters, digits, ".", "-", "_" only. The extension +// builds the same key from the push's account id (NotificationService.swift). +function keyPart(id: string): string { + return id.replace(/[^a-zA-Z0-9._-]/g, '_'); +} + +export function pushAccountKey(jmapAccountId: string): string { + return `bulwark_push_account__${keyPart(jmapAccountId)}`; +} + +export const PUSH_SETTINGS_KEY = 'bulwark_push_settings'; + +export interface IosPushAccount { + /** SecureStore key of the account's credentials. */ + credentialsKey: string; + /** The JMAP API URL as the app uses it (rewritten to the server's host). */ + apiUrl: string; +} + +export async function writeIosPushAccount(jmapAccountId: string, account: IosPushAccount): Promise { + const options = sharedStoreOptions(); + if (!options) return; + await SecureStore.setItemAsync(pushAccountKey(jmapAccountId), JSON.stringify(account), options); +} + +export async function removeIosPushAccount(jmapAccountId: string): Promise { + const options = sharedStoreOptions(); + if (!options) return; + await SecureStore.deleteItemAsync(pushAccountKey(jmapAccountId), options).catch(() => undefined); +} + +export interface IosPushSettings { + /** Show the unread count on the app icon. */ + badge: boolean; + /** JMAP id of the active account: the badge is its inbox unread count. */ + activeAccountId: string | null; + /** The few texts the extension writes itself, in the app's language. */ + strings: { noSubject: string; more: string }; +} + +export async function writeIosPushSettings(settings: IosPushSettings): Promise { + const options = sharedStoreOptions(); + if (!options) return; + await SecureStore.setItemAsync(PUSH_SETTINGS_KEY, JSON.stringify(settings), options); +} + +/** + * Store a credentials item where the extension can read it. Written to the + * shared group first, then removed from the app's own group, so a crash in + * between leaves two copies rather than none. Off iOS a plain write. + */ +export async function writeSharedSecret(key: string, value: string): Promise { + const shared = sharedStoreOptions(); + if (!shared) { + await SecureStore.setItemAsync(key, value); + return; + } + await SecureStore.setItemAsync(key, value, shared); + const own = appStoreOptions(); + if (own) await SecureStore.deleteItemAsync(key, own).catch(() => undefined); +} + +/** Move an item saved before the shared group existed. True if it moved. */ +export async function moveToSharedGroup(key: string): Promise { + const own = appStoreOptions(); + if (!own || !sharedStoreOptions()) return false; + const value = await SecureStore.getItemAsync(key, own).catch(() => null); + if (value === null) return false; + await writeSharedSecret(key, value); + return true; +}