mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: keep iOS credentials where a notification extension can read them
This commit is contained in:
@@ -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<typeof vi.fn>).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<typeof vi.fn>).mockReset();
|
||||
});
|
||||
|
||||
it('updatePassword rewrites the stored credential', async () => {
|
||||
const SecureStore = await import('expo-secure-store');
|
||||
const client = await connected();
|
||||
|
||||
+47
-20
@@ -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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
await SecureStore.setItemAsync(credentialsKey(accountId), JSON.stringify(creds));
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(creds));
|
||||
}
|
||||
|
||||
// ── Blob Download ─────────────────────────────────────
|
||||
|
||||
@@ -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<void> {
|
||||
const options = sharedStoreOptions();
|
||||
if (!options) return;
|
||||
await SecureStore.setItemAsync(pushAccountKey(jmapAccountId), JSON.stringify(account), options);
|
||||
}
|
||||
|
||||
export async function removeIosPushAccount(jmapAccountId: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user