diff --git a/package-lock.json b/package-lock.json index 051bfbc9..a126479f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,6 @@ "@react-navigation/native-stack": "^7.14.10", "date-fns": "^4.1.0", "expo": "~54.0.33", - "expo-auth-session": "~7.0.8", - "expo-crypto": "~15.0.7", "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.21", "expo-image-picker": "~17.0.10", @@ -5206,33 +5204,6 @@ } } }, - "node_modules/expo-auth-session": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.11.tgz", - "integrity": "sha512-AhWtt/m9rb1Po77X/VBFbeE6UTgbm2vXP2iCblUSRsHCw2qD6lO0ulKUB8Xyxy9FtoI9yrNQ1iwCNgIIgo8VYQ==", - "license": "MIT", - "dependencies": { - "expo-application": "~7.0.8", - "expo-constants": "~18.0.13", - "expo-crypto": "~15.0.9", - "expo-linking": "~8.0.12", - "expo-web-browser": "~15.0.11", - "invariant": "^2.2.4" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, - "node_modules/expo-auth-session/node_modules/expo-application": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz", - "integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, "node_modules/expo-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", @@ -5247,18 +5218,6 @@ "react-native": "*" } }, - "node_modules/expo-crypto": { - "version": "15.0.9", - "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz", - "integrity": "sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==", - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.0" - }, - "peerDependencies": { - "expo": "*" - } - }, "node_modules/expo-document-picker": { "version": "14.0.8", "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz", @@ -5308,20 +5267,6 @@ "expo": "*" } }, - "node_modules/expo-linking": { - "version": "8.0.12", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz", - "integrity": "sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==", - "license": "MIT", - "dependencies": { - "expo-constants": "~18.0.13", - "invariant": "^2.2.4" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, "node_modules/expo-localization": { "version": "17.0.8", "resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz", diff --git a/package.json b/package.json index 054f811e..29b19f31 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,6 @@ "@react-navigation/native-stack": "^7.14.10", "date-fns": "^4.1.0", "expo": "~54.0.33", - "expo-auth-session": "~7.0.8", - "expo-crypto": "~15.0.7", "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.21", "expo-image-picker": "~17.0.10", diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 0db94b3c..bf000a03 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -8,11 +8,6 @@ import type { import { CAPABILITIES } from './types'; import { generateAccountId } from '../lib/account-utils'; import { secureFetch } from '../lib/client-cert'; -import { refreshAccessToken, type OAuthTokens } from '../lib/oauth'; - -// Refresh OAuth access tokens this many ms before they actually expire so -// in-flight requests don't race the expiry window. -const TOKEN_REFRESH_LEEWAY_MS = 60_000; const LEGACY_CREDENTIALS_KEY = 'jmap_credentials'; const CREDENTIALS_PREFIX = 'jmap_credentials__'; @@ -27,12 +22,6 @@ export interface StoredCredentials { username: string; password: string; accessToken?: string; - // OAuth-only — present when credentials originate from the OAuth login - // flow rather than password auth. - refreshToken?: string; - expiresAt?: number; - tokenEndpoint?: string; - clientId?: string; } export class JMAPClient { @@ -104,115 +93,6 @@ export class JMAPClient { return this.session; } - // OAuth login. Fetches the JMAP session to discover the authenticated - // username from the session.username field, persists credentials under that - // username's account id, and returns both the session and the resolved - // username so the auth-store can register the account. - async connectWithOAuth( - serverUrl: string, - tokens: OAuthTokens, - ): Promise<{ session: JMAPSession; username: string; accountId: string }> { - const baseUrl = serverUrl.replace(/\/+$/, ''); - this.credentials = { - serverUrl: baseUrl, - username: '', - password: '', - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - expiresAt: tokens.expiresAt, - tokenEndpoint: tokens.tokenEndpoint, - clientId: tokens.clientId, - }; - - this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl); - this._accountId = this.resolveAccountId(this.session); - - // The JMAP session document carries the authenticated user's identifier - // — use it as the username so per-account storage keys are stable across - // restarts and OAuth re-logins. - const username = this.session.username || tokens.accessToken.slice(0, 8); - this.credentials.username = username; - - const accountId = generateAccountId(username, baseUrl); - await SecureStore.setItemAsync( - credentialsKey(accountId), - JSON.stringify(this.credentials), - ); - - return { session: this.session, username, accountId }; - } - - // Replace the access token (and possibly refresh token) on the in-memory - // credentials and re-persist. Used both by the proactive expiry check and - // by the reactive 401-retry path. - private async persistRefreshedTokens(next: OAuthTokens): Promise { - if (!this.credentials) return; - this.credentials = { - ...this.credentials, - accessToken: next.accessToken, - refreshToken: next.refreshToken ?? this.credentials.refreshToken, - expiresAt: next.expiresAt, - tokenEndpoint: next.tokenEndpoint, - clientId: next.clientId, - }; - const accountId = generateAccountId( - this.credentials.username, - this.credentials.serverUrl, - ); - await SecureStore.setItemAsync( - credentialsKey(accountId), - JSON.stringify(this.credentials), - ); - } - - private hasRefreshableToken(): boolean { - return Boolean(this.credentials?.refreshToken && this.credentials.tokenEndpoint && this.credentials.clientId); - } - - private currentOAuthTokens(): OAuthTokens | null { - if (!this.credentials?.accessToken || !this.credentials.tokenEndpoint || !this.credentials.clientId) { - return null; - } - return { - accessToken: this.credentials.accessToken, - refreshToken: this.credentials.refreshToken, - expiresAt: this.credentials.expiresAt, - tokenEndpoint: this.credentials.tokenEndpoint, - clientId: this.credentials.clientId, - }; - } - - // Refresh proactively when we're within the leeway window of expiry. Quiet - // no-op for password credentials or when the token is still fresh. - private async ensureFreshToken(): Promise { - const tokens = this.currentOAuthTokens(); - if (!tokens) return; - if (!this.hasRefreshableToken()) return; - if (tokens.expiresAt == null) return; - if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return; - try { - const next = await refreshAccessToken(tokens); - await this.persistRefreshedTokens(next); - } catch { - // Surface as AuthenticationError on the next 401; refresh may be - // temporarily failing (network) and that path already retries. - } - } - - // Reactive refresh: called after a 401 to attempt a single retry with a - // freshly-minted access token. Returns true if a new token was obtained. - private async forceRefreshToken(): Promise { - const tokens = this.currentOAuthTokens(); - if (!tokens || !this.hasRefreshableToken()) return false; - try { - const next = await refreshAccessToken(tokens); - await this.persistRefreshedTokens(next); - return true; - } catch { - return false; - } - } - // Legacy single-slot restore - kept for backward-compat tests. New code // should use loadAccount(accountId) driven by the account registry. async restoreSession(): Promise { @@ -355,23 +235,13 @@ export class JMAPClient { private async fetchSession(baseUrl: string): Promise { const url = `${baseUrl}/.well-known/jmap`; - await this.ensureFreshToken(); - let response = await secureFetch(url, { + const response = await secureFetch(url, { headers: { Authorization: this.authHeader, Accept: 'application/json', }, }); - if (response.status === 401 && (await this.forceRefreshToken())) { - response = await secureFetch(url, { - headers: { - Authorization: this.authHeader, - Accept: 'application/json', - }, - }); - } - if (response.status === 401) { throw new AuthenticationError('Invalid credentials'); } @@ -410,21 +280,14 @@ export class JMAPClient { methodCalls, }; - await this.ensureFreshToken(); - const doFetch = () => - secureFetch(this.session!.apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: this.authHeader, - }, - body: JSON.stringify(body), - }); - - let response = await doFetch(); - if (response.status === 401 && (await this.forceRefreshToken())) { - response = await doFetch(); - } + const response = await secureFetch(this.session.apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: this.authHeader, + }, + body: JSON.stringify(body), + }); if (response.status === 401) { throw new AuthenticationError('Session expired'); @@ -472,12 +335,9 @@ export class JMAPClient { async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise { const url = this.getBlobDownloadUrl(blobId, name, type); - await this.ensureFreshToken(); - const doFetch = () => secureFetch(url, { headers: { Authorization: this.authHeader } }); - let response = await doFetch(); - if (response.status === 401 && (await this.forceRefreshToken())) { - response = await doFetch(); - } + const response = await secureFetch(url, { + headers: { Authorization: this.authHeader }, + }); if (response.status === 401) throw new AuthenticationError('Session expired'); if (!response.ok) throw new Error(`Failed to fetch blob: ${response.status}`); return response.arrayBuffer(); diff --git a/src/api/types.ts b/src/api/types.ts index 09946458..81fc1cbf 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -9,10 +9,6 @@ export interface JMAPSession { accounts: Record; capabilities: Record; state: string; - // RFC 8620 §2 — authenticated user identifier. Optional because not all - // servers populate it; OAuth login uses it to derive the account label - // when there's no password username to fall back on. - username?: string; } export interface JMAPAccountInfo { diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index d5ce90ae..b6690435 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -1,157 +1,83 @@ -import * as AuthSession from 'expo-auth-session'; import * as WebBrowser from 'expo-web-browser'; -import { secureFetch } from './client-cert'; -// OAuth client ID registered (or accepted unregistered, when the server has -// requireClientRegistration=false like Stalwart 0.16) for the mobile app. -export const OAUTH_CLIENT_ID = 'bulwark-mobile'; +// Webmail-mediated login. Instead of doing OAuth directly against the JMAP +// server (which would need a registered client_id and a mobile redirect URI +// allow-listed on the IdP), the app opens the webmail's /mobile-handoff page, +// the user signs in there with their normal credentials, and the webmail +// redirects back to the app's custom scheme with the verified credentials in +// the URL fragment. Fragments aren't sent to the server, so the password +// never appears in HTTP access logs along the way. -// offline_access is required for Stalwart to return a refresh_token; without -// it the session dies as soon as the access token expires. -export const OAUTH_SCOPES = ['openid', 'email', 'profile', 'offline_access']; +export const HANDOFF_REDIRECT_URI = 'bulwarkmobile://auth/callback'; -export interface OAuthDiscovery { - authorization_endpoint: string; - token_endpoint: string; - revocation_endpoint?: string; +export interface HandoffCredentials { + serverUrl: string; + username: string; + password: string; } -export interface OAuthTokens { - accessToken: string; - refreshToken?: string; - expiresAt?: number; // epoch ms - tokenEndpoint: string; - clientId: string; -} - -export class OAuthError extends Error {} -export class OAuthCancelledError extends OAuthError { +export class HandoffError extends Error {} +export class HandoffCancelledError extends HandoffError { constructor() { - super('OAuth cancelled'); + super('Sign-in cancelled'); } } -export async function discoverOAuth(serverUrl: string): Promise { - const baseUrl = serverUrl.replace(/\/+$/, ''); - const urls = [ - `${baseUrl}/.well-known/oauth-authorization-server`, - `${baseUrl}/.well-known/openid-configuration`, - ]; - const errors: string[] = []; - for (const url of urls) { - try { - const res = await secureFetch(url, { headers: { Accept: 'application/json' } }); - if (!res.ok) { - errors.push(`${url} → HTTP ${res.status}`); - continue; - } - const data = await res.json(); - if (data.authorization_endpoint && data.token_endpoint) { - return { - authorization_endpoint: data.authorization_endpoint, - token_endpoint: data.token_endpoint, - revocation_endpoint: data.revocation_endpoint, - }; - } - errors.push(`${url} missing endpoints`); - } catch (err) { - errors.push(`${url}: ${err instanceof Error ? err.message : String(err)}`); - } - } - throw new OAuthError(`OAuth discovery failed: ${errors.join('; ')}`); +function randomState(): string { + const bytes = new Uint8Array(16); + for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); + let s = ''; + for (const b of bytes) s += b.toString(16).padStart(2, '0'); + return s; } -// Returns the redirect URI the browser will land on after the user signs in. -// For dev (Expo Go) this is the auth.expo.io proxy; for standalone/prebuild -// builds this is the app's custom scheme (bulwarkmobile://auth/callback). -function getRedirectUri(): string { - return AuthSession.makeRedirectUri({ scheme: 'bulwarkmobile', path: 'auth/callback' }); +function buildHandoffUrl(webmailUrl: string, state: string): string { + const base = webmailUrl.replace(/\/+$/, ''); + const params = new URLSearchParams({ + redirect_uri: HANDOFF_REDIRECT_URI, + state, + }); + return `${base}/mobile-handoff?${params.toString()}`; } -export async function runOAuthFlow(serverUrl: string): Promise { - const discovery = await discoverOAuth(serverUrl); - const redirectUri = getRedirectUri(); +function parseFragment(url: string): URLSearchParams { + const hashIdx = url.indexOf('#'); + if (hashIdx === -1) return new URLSearchParams(); + return new URLSearchParams(url.slice(hashIdx + 1)); +} - const request = new AuthSession.AuthRequest({ - clientId: OAUTH_CLIENT_ID, - scopes: OAUTH_SCOPES, - redirectUri, - responseType: AuthSession.ResponseType.Code, - usePKCE: true, - }); +export async function runWebmailHandoff(webmailUrl: string): Promise { + const state = randomState(); + const handoffUrl = buildHandoffUrl(webmailUrl, state); - // promptAsync() opens the system browser tab and resolves when the OAuth - // server redirects back to the app via the custom scheme. - const result = await request.promptAsync({ - authorizationEndpoint: discovery.authorization_endpoint, - }); + const result = await WebBrowser.openAuthSessionAsync(handoffUrl, HANDOFF_REDIRECT_URI); if (result.type === 'cancel' || result.type === 'dismiss') { - throw new OAuthCancelledError(); + throw new HandoffCancelledError(); } - if (result.type !== 'success') { - const msg = - result.type === 'error' && result.error - ? result.error.description || result.error.message || result.type - : result.type; - throw new OAuthError(`Authorization failed: ${msg}`); + if (result.type !== 'success' || !result.url) { + throw new HandoffError(`Sign-in failed: ${result.type}`); } - const code = result.params.code; - if (!code) throw new OAuthError('Authorization response missing code'); - // Exchange the authorization code for tokens. Stalwart returns a JSON body - // with access_token, refresh_token, expires_in. - const tokenResult = await AuthSession.exchangeCodeAsync( - { - clientId: OAUTH_CLIENT_ID, - code, - redirectUri, - extraParams: request.codeVerifier ? { code_verifier: request.codeVerifier } : undefined, - }, - { tokenEndpoint: discovery.token_endpoint }, - ); + const params = parseFragment(result.url); + const err = params.get('error'); + if (err) throw new HandoffError(err); - return { - accessToken: tokenResult.accessToken, - refreshToken: tokenResult.refreshToken, - expiresAt: - tokenResult.expiresIn != null - ? Date.now() + tokenResult.expiresIn * 1000 - : undefined, - tokenEndpoint: discovery.token_endpoint, - clientId: OAUTH_CLIENT_ID, - }; -} - -export async function refreshAccessToken( - tokens: OAuthTokens, -): Promise { - if (!tokens.refreshToken) { - throw new OAuthError('No refresh token available'); + // CSRF guard: the state we generated must round-trip through the webmail + // unchanged. A mismatch means the redirect didn't come from the flow we + // started, and the rest of the fragment shouldn't be trusted. + if (params.get('state') !== state) { + throw new HandoffError('State mismatch'); } - const refreshed = await AuthSession.refreshAsync( - { clientId: tokens.clientId, refreshToken: tokens.refreshToken }, - { tokenEndpoint: tokens.tokenEndpoint }, - ); - return { - accessToken: refreshed.accessToken, - // Rotated refresh tokens replace the previous one; some IdPs omit it and - // expect the client to reuse the original. - refreshToken: refreshed.refreshToken ?? tokens.refreshToken, - expiresAt: - refreshed.expiresIn != null - ? Date.now() + refreshed.expiresIn * 1000 - : undefined, - tokenEndpoint: tokens.tokenEndpoint, - clientId: tokens.clientId, - }; -} -// Required to dismiss the in-app browser tab once the OAuth redirect fires. -// Safe to call once at module load; expo-web-browser exposes it via a method -// that no-ops on platforms where it isn't needed. -export function warmUpAuthBrowser(): void { - void WebBrowser.warmUpAsync().catch(() => undefined); + const serverUrl = params.get('server_url'); + const username = params.get('username'); + const password = params.get('password'); + if (!serverUrl || !username || !password) { + throw new HandoffError('Sign-in response missing credentials'); + } + + return { serverUrl, username, password }; } WebBrowser.maybeCompleteAuthSession(); diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index 204a7650..30776bdd 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -20,7 +20,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const login = useAuthStore((state) => state.login); - const loginOAuth = useAuthStore((state) => state.loginOAuth); + const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail); const isLoading = useAuthStore((state) => state.isLoading); const error = useAuthStore((state) => state.error); const clearError = useAuthStore((state) => state.clearError); @@ -31,7 +31,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree const [showPassword, setShowPassword] = React.useState(false); const canSubmit = Boolean(serverUrl.trim() && email.trim() && password); - const canOAuth = Boolean(serverUrl.trim()); + const canHandoff = Boolean(serverUrl.trim()); const handleLogin = async () => { if (!canSubmit) { @@ -46,13 +46,14 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree } }; - const handleOAuth = async () => { - if (!canOAuth) return; + const handleWebmailHandoff = async () => { + if (!canHandoff) return; try { const before = useAuthStore.getState().isAuthenticated; - await loginOAuth(serverUrl.trim(), { addAccount: isAddMode }); - // loginOAuth swallows OAuthCancelledError silently — only fire the - // navigation hook when authentication actually completed. + await loginViaWebmail(serverUrl.trim(), { addAccount: isAddMode }); + // loginViaWebmail swallows the "user cancelled the browser" case + // silently — only fire the navigation hook when authentication + // actually completed. const after = useAuthStore.getState().isAuthenticated; if (after && !before) { onLogin?.(); @@ -169,17 +170,17 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree - {/* OAuth buttons */} + {/* Webmail-mediated login */} diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 18f9ee18..5701d72e 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -6,7 +6,7 @@ import { useEmailStore } from './email-store'; import { useContactsStore } from './contacts-store'; import { useCalendarStore } from './calendar-store'; import { generateAccountId } from '../lib/account-utils'; -import { runOAuthFlow, OAuthCancelledError } from '../lib/oauth'; +import { runWebmailHandoff, HandoffCancelledError } from '../lib/oauth'; import { teardownPushNotifications, teardownPushNotificationsForAccount, @@ -39,7 +39,7 @@ export interface AuthState { client: typeof jmapClient | null; login: (serverUrl: string, username: string, password: string, opts?: { addAccount?: boolean }) => Promise; - loginOAuth: (serverUrl: string, opts?: { addAccount?: boolean }) => Promise; + loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise; logout: () => Promise; logoutAll: () => Promise; switchAccount: (accountId: string) => Promise; @@ -169,49 +169,25 @@ export const useAuthStore = create((set, get) => ({ } }, - loginOAuth: async (serverUrl, opts) => { + loginViaWebmail: async (webmailUrl, opts) => { set({ isLoading: true, error: null }); + let creds; try { - if (opts?.addAccount && get().isAuthenticated) { - jmapClient.reset(); - useContactsStore.getState().reset(); - useCalendarStore.getState().reset(); - } - - const baseUrl = serverUrl.replace(/\/+$/, ''); - const tokens = await runOAuthFlow(baseUrl); - const { session, username, accountId } = await jmapClient.connectWithOAuth(baseUrl, tokens); - - const accountStore = useAccountStore.getState(); - accountStore.addAccount({ - serverUrl: baseUrl, - username, - displayName: username, - email: username, - lastLoginAt: Date.now(), - isConnected: true, - hasError: false, - }); - accountStore.setActiveAccount(accountId); - useEmailStore.getState().setActiveAccount(accountId); - - applyConnectedState(set, session, baseUrl, username, accountId); + creds = await runWebmailHandoff(webmailUrl); } catch (err) { - if (err instanceof OAuthCancelledError) { - // User backed out of the browser tab — no error message, just stop - // the spinner. This matches the common "I changed my mind" path. + if (err instanceof HandoffCancelledError) { + // User closed the browser tab — quiet exit, no error banner. set({ isLoading: false, error: null }); return; } - const message = - err instanceof AuthenticationError - ? 'Authentication rejected by server' - : err instanceof Error - ? err.message - : 'OAuth login failed'; + const message = err instanceof Error ? err.message : 'Sign-in failed'; set({ isLoading: false, error: message }); throw err; } + // Hand the verified credentials to the existing password login path so + // session establishment, account registration, and feature-store wiring + // all behave identically to a manual sign-in. + await get().login(creds.serverUrl, creds.username, creds.password, opts); }, logout: async () => { diff --git a/src/test-setup.ts b/src/test-setup.ts index ee127d19..ebc4847e 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -65,32 +65,10 @@ vi.mock('expo-secure-store', () => ({ deleteItemAsync: vi.fn(async () => undefined), })); -// expo-auth-session and expo-web-browser transitively pull in -// expo-modules-core, which evaluates RN-only globals at module load. The unit -// tests don't exercise the real OAuth flow, so stubbing the surface is -// enough — individual tests that need the real shapes can override. -vi.mock('expo-auth-session', () => ({ - AuthRequest: class { - codeVerifier?: string; - async promptAsync() { - return { type: 'cancel' as const }; - } - }, - ResponseType: { Code: 'code' }, - exchangeCodeAsync: vi.fn(async () => ({ - accessToken: 'test-access', - refreshToken: 'test-refresh', - expiresIn: 3600, - })), - refreshAsync: vi.fn(async () => ({ - accessToken: 'test-access-refreshed', - refreshToken: 'test-refresh-rotated', - expiresIn: 3600, - })), - makeRedirectUri: () => 'bulwarkmobile://auth/callback', -})); - +// expo-web-browser transitively pulls in expo-modules-core, which evaluates +// RN-only globals at module load. The unit tests don't exercise the real +// browser handoff flow, so stubbing the surface is enough. vi.mock('expo-web-browser', () => ({ - warmUpAsync: vi.fn(async () => undefined), + openAuthSessionAsync: vi.fn(async () => ({ type: 'cancel' as const })), maybeCompleteAuthSession: vi.fn(), }));