Compare commits

...
4 Commits
9 changed files with 213 additions and 267 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.36
0.1.38
+2 -57
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.36",
"version": "0.1.38",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.36",
"version": "0.1.38",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -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",
+1 -3
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.36",
"version": "0.1.38",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -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",
+28 -33
View File
@@ -8,7 +8,7 @@ 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';
import { refreshOAuthAccessToken, 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.
@@ -27,8 +27,9 @@ export interface StoredCredentials {
username: string;
password: string;
accessToken?: string;
// OAuth-only — present when credentials originate from the OAuth login
// flow rather than password auth.
// OAuth-only — present when credentials came in via the webmail handoff
// OAuth path rather than password auth. The token endpoint and client id
// are needed for refresh; without them the access token would just expire.
refreshToken?: string;
expiresAt?: number;
tokenEndpoint?: string;
@@ -104,10 +105,9 @@ 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.
// OAuth login via webmail handoff. The webmail did the OAuth dance against
// Stalwart and handed us a complete token bundle. We persist the bundle
// so subsequent launches can refresh without prompting the user again.
async connectWithOAuth(
serverUrl: string,
tokens: OAuthTokens,
@@ -142,14 +142,13 @@ export class JMAPClient {
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<void> {
if (!this.credentials) return;
this.credentials = {
...this.credentials,
accessToken: next.accessToken,
// Some IdPs rotate refresh tokens; others reuse. Fall back to the
// existing one when the response omits it.
refreshToken: next.refreshToken ?? this.credentials.refreshToken,
expiresAt: next.expiresAt,
tokenEndpoint: next.tokenEndpoint,
@@ -165,12 +164,13 @@ export class JMAPClient {
);
}
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) {
if (
!this.credentials?.accessToken ||
!this.credentials.refreshToken ||
!this.credentials.tokenEndpoint ||
!this.credentials.clientId
) {
return null;
}
return {
@@ -182,30 +182,29 @@ export class JMAPClient {
};
}
// Refresh proactively when we're within the leeway window of expiry. Quiet
// no-op for password credentials or when the token is still fresh.
// Proactive refresh: when the access token is about to expire, swap it for
// a fresh one. Quiet no-op for password credentials.
private async ensureFreshToken(): Promise<void> {
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);
const next = await refreshOAuthAccessToken(tokens);
await this.persistRefreshedTokens(next);
} catch {
// Surface as AuthenticationError on the next 401; refresh may be
// temporarily failing (network) and that path already retries.
// temporarily failing (network) and the reactive retry path catches it.
}
}
// 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.
// Reactive refresh after a 401. Returns true if a fresh token was obtained
// so the caller can retry the original request.
private async forceRefreshToken(): Promise<boolean> {
const tokens = this.currentOAuthTokens();
if (!tokens || !this.hasRefreshableToken()) return false;
if (!tokens) return false;
try {
const next = await refreshAccessToken(tokens);
const next = await refreshOAuthAccessToken(tokens);
await this.persistRefreshedTokens(next);
return true;
} catch {
@@ -356,20 +355,16 @@ export class JMAPClient {
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
const url = `${baseUrl}/.well-known/jmap`;
await this.ensureFreshToken();
let response = await secureFetch(url, {
headers: {
Authorization: this.authHeader,
Accept: 'application/json',
},
});
if (response.status === 401 && (await this.forceRefreshToken())) {
response = await secureFetch(url, {
const doFetch = () =>
secureFetch(url, {
headers: {
Authorization: this.authHeader,
Accept: 'application/json',
},
});
let response = await doFetch();
if (response.status === 401 && (await this.forceRefreshToken())) {
response = await doFetch();
}
if (response.status === 401) {
+2 -2
View File
@@ -10,8 +10,8 @@ export interface JMAPSession {
capabilities: Record<string, unknown>;
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.
// servers populate it; OAuth login uses it as the account label when
// there's no password username to fall back on.
username?: string;
}
+128 -119
View File
@@ -1,20 +1,14 @@
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. The app opens the webmail's normal login page with
// extra `mobile_redirect_uri` and `mobile_state` query params. The webmail
// uses its existing password and OAuth flows, and once the user is signed
// in, redirects back to the app's custom scheme with credentials packed into
// the URL fragment. Fragments aren't sent to the server, so password and
// token material don't appear 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 interface OAuthDiscovery {
authorization_endpoint: string;
token_endpoint: string;
revocation_endpoint?: string;
}
export const HANDOFF_REDIRECT_URI = 'bulwarkmobile://auth/callback';
export interface OAuthTokens {
accessToken: string;
@@ -24,134 +18,149 @@ export interface OAuthTokens {
clientId: string;
}
export class OAuthError extends Error {}
export class OAuthCancelledError extends OAuthError {
constructor() {
super('OAuth cancelled');
}
}
export async function discoverOAuth(serverUrl: string): Promise<OAuthDiscovery> {
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)}`);
export type HandoffResult =
| {
flow: 'password';
serverUrl: string;
username: string;
password: string;
}
| {
flow: 'oauth';
serverUrl: string;
tokens: OAuthTokens;
};
export class HandoffError extends Error {}
export class HandoffCancelledError extends HandoffError {
constructor() {
super('Sign-in cancelled');
}
throw new OAuthError(`OAuth discovery failed: ${errors.join('; ')}`);
}
// 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 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;
}
export async function runOAuthFlow(serverUrl: string): Promise<OAuthTokens> {
const discovery = await discoverOAuth(serverUrl);
const redirectUri = getRedirectUri();
const request = new AuthSession.AuthRequest({
clientId: OAUTH_CLIENT_ID,
scopes: OAUTH_SCOPES,
redirectUri,
responseType: AuthSession.ResponseType.Code,
usePKCE: true,
function buildHandoffUrl(webmailUrl: string, state: string): string {
const base = webmailUrl.replace(/\/+$/, '');
const params = new URLSearchParams({
mobile_redirect_uri: HANDOFF_REDIRECT_URI,
mobile_state: state,
});
return `${base}/login?${params.toString()}`;
}
// 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,
});
function parseFragment(url: string): URLSearchParams {
const hashIdx = url.indexOf('#');
if (hashIdx === -1) return new URLSearchParams();
return new URLSearchParams(url.slice(hashIdx + 1));
}
export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResult> {
const state = randomState();
const handoffUrl = buildHandoffUrl(webmailUrl, state);
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,
};
// 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 flow = params.get('flow');
const serverUrl = params.get('server_url');
if (!serverUrl) throw new HandoffError('Sign-in response missing server URL');
if (flow === 'password') {
const username = params.get('username');
const password = params.get('password');
if (!username || !password) {
throw new HandoffError('Sign-in response missing credentials');
}
return { flow: 'password', serverUrl, username, password };
}
if (flow === 'oauth') {
const accessToken = params.get('access_token');
const tokenEndpoint = params.get('token_endpoint');
const clientId = params.get('client_id');
if (!accessToken || !tokenEndpoint || !clientId) {
throw new HandoffError('Sign-in response missing OAuth tokens');
}
const refreshToken = params.get('refresh_token') ?? undefined;
const expiresIn = params.get('expires_in');
return {
flow: 'oauth',
serverUrl,
tokens: {
accessToken,
refreshToken,
expiresAt: expiresIn ? Date.now() + parseInt(expiresIn, 10) * 1000 : undefined,
tokenEndpoint,
clientId,
},
};
}
throw new HandoffError(`Unknown sign-in flow: ${flow ?? 'missing'}`);
}
export async function refreshAccessToken(
tokens: OAuthTokens,
): Promise<OAuthTokens> {
// OAuth refresh — exchanges the refresh token at the original token endpoint
// for a new access token. Returns the updated bundle so the caller can
// persist it.
export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAuthTokens> {
if (!tokens.refreshToken) {
throw new OAuthError('No refresh token available');
throw new HandoffError('No refresh token available');
}
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refreshToken,
client_id: tokens.clientId,
});
const response = await secureFetch(tokens.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
});
if (!response.ok) {
throw new HandoffError(`Token refresh failed: ${response.status}`);
}
const data = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!data.access_token) {
throw new HandoffError('Token refresh response missing access_token');
}
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,
accessToken: data.access_token,
refreshToken: data.refresh_token ?? tokens.refreshToken,
expiresAt: data.expires_in ? Date.now() + data.expires_in * 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);
}
WebBrowser.maybeCompleteAuthSession();
+12 -11
View File
@@ -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
<View style={styles.dividerLine} />
</View>
{/* OAuth buttons */}
{/* Webmail-mediated login */}
<View style={styles.oauthRow}>
<Button
variant="outline"
size="md"
onPress={() => {
void handleOAuth();
void handleWebmailHandoff();
}}
disabled={!canOAuth || isLoading}
disabled={!canHandoff || isLoading}
>
Sign in with OAuth
Sign in via webmail
</Button>
</View>
</View>
+35 -15
View File
@@ -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<void>;
loginOAuth: (serverUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
logout: () => Promise<void>;
logoutAll: () => Promise<void>;
switchAccount: (accountId: string) => Promise<void>;
@@ -169,8 +169,33 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
},
loginOAuth: async (serverUrl, opts) => {
loginViaWebmail: async (webmailUrl, opts) => {
set({ isLoading: true, error: null });
let result;
try {
result = await runWebmailHandoff(webmailUrl);
} catch (err) {
if (err instanceof HandoffCancelledError) {
// User closed the browser tab — quiet exit, no error banner.
set({ isLoading: false, error: null });
return;
}
const message = err instanceof Error ? err.message : 'Sign-in failed';
set({ isLoading: false, error: message });
throw err;
}
if (result.flow === 'password') {
// Hand the credentials to the existing password login path so account
// registration + feature-store wiring all behave identically to a
// manual sign-in.
await get().login(result.serverUrl, result.username, result.password, opts);
return;
}
// OAuth — the webmail did the dance against Stalwart and handed us a
// token bundle. Bootstrap the JMAP session with Bearer auth and let
// ensure/forceRefreshToken keep it alive going forward.
try {
if (opts?.addAccount && get().isAuthenticated) {
jmapClient.reset();
@@ -178,13 +203,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useCalendarStore.getState().reset();
}
const baseUrl = serverUrl.replace(/\/+$/, '');
const tokens = await runOAuthFlow(baseUrl);
const { session, username, accountId } = await jmapClient.connectWithOAuth(baseUrl, tokens);
const { session, username, accountId } = await jmapClient.connectWithOAuth(
result.serverUrl,
result.tokens,
);
const accountStore = useAccountStore.getState();
accountStore.addAccount({
serverUrl: baseUrl,
serverUrl: result.serverUrl.replace(/\/+$/, ''),
username,
displayName: username,
email: username,
@@ -195,20 +221,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
accountStore.setActiveAccount(accountId);
useEmailStore.getState().setActiveAccount(accountId);
applyConnectedState(set, session, baseUrl, username, accountId);
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
} 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.
set({ isLoading: false, error: null });
return;
}
const message =
err instanceof AuthenticationError
? 'Authentication rejected by server'
: err instanceof Error
? err.message
: 'OAuth login failed';
: 'OAuth sign-in failed';
set({ isLoading: false, error: message });
throw err;
}
+4 -26
View File
@@ -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(),
}));