diff --git a/app.config.js b/app.config.js index 0af05beb..b879604e 100644 --- a/app.config.js +++ b/app.config.js @@ -20,6 +20,7 @@ module.exports = { expo: { name: 'Bulwark Mobile', slug: 'bulwark-mobile', + scheme: 'bulwarkmobile', version: VERSION, orientation: 'portrait', icon: './assets/icon.png', diff --git a/package-lock.json b/package-lock.json index be0fbdbb..b3409817 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,8 @@ "@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", @@ -25,6 +27,7 @@ "expo-secure-store": "~15.0.8", "expo-sharing": "~14.0.8", "expo-status-bar": "~3.0.9", + "expo-web-browser": "~15.0.8", "lucide-react-native": "^1.7.0", "react": "19.1.0", "react-native": "0.81.5", @@ -5203,6 +5206,33 @@ } } }, + "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", @@ -5217,6 +5247,18 @@ "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", @@ -5266,6 +5308,20 @@ "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", @@ -5418,6 +5474,16 @@ "react-native": "*" } }, + "node_modules/expo-web-browser": { + "version": "15.0.11", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz", + "integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", diff --git a/package.json b/package.json index ee17624f..d4ff1d84 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "@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", @@ -31,6 +33,7 @@ "expo-secure-store": "~15.0.8", "expo-sharing": "~14.0.8", "expo-status-bar": "~3.0.9", + "expo-web-browser": "~15.0.8", "lucide-react-native": "^1.7.0", "react": "19.1.0", "react-native": "0.81.5", diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index bf000a03..0db94b3c 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -8,6 +8,11 @@ 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__'; @@ -22,6 +27,12 @@ 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 { @@ -93,6 +104,115 @@ 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 { @@ -235,13 +355,23 @@ export class JMAPClient { private async fetchSession(baseUrl: string): Promise { const url = `${baseUrl}/.well-known/jmap`; - const response = await secureFetch(url, { + 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, { + headers: { + Authorization: this.authHeader, + Accept: 'application/json', + }, + }); + } + if (response.status === 401) { throw new AuthenticationError('Invalid credentials'); } @@ -280,14 +410,21 @@ export class JMAPClient { methodCalls, }; - const response = await secureFetch(this.session.apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: this.authHeader, - }, - body: JSON.stringify(body), - }); + 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(); + } if (response.status === 401) { throw new AuthenticationError('Session expired'); @@ -335,9 +472,12 @@ export class JMAPClient { async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise { const url = this.getBlobDownloadUrl(blobId, name, type); - const response = await secureFetch(url, { - headers: { Authorization: this.authHeader }, - }); + 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(); + } 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 81fc1cbf..09946458 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -9,6 +9,10 @@ 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 new file mode 100644 index 00000000..d5ce90ae --- /dev/null +++ b/src/lib/oauth.ts @@ -0,0 +1,157 @@ +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'; + +// 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 interface OAuthTokens { + accessToken: string; + refreshToken?: string; + expiresAt?: number; // epoch ms + tokenEndpoint: string; + clientId: string; +} + +export class OAuthError extends Error {} +export class OAuthCancelledError extends OAuthError { + constructor() { + super('OAuth 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('; ')}`); +} + +// 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' }); +} + +export async function runOAuthFlow(serverUrl: string): Promise { + 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, + }); + + // 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, + }); + + if (result.type === 'cancel' || result.type === 'dismiss') { + throw new OAuthCancelledError(); + } + 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}`); + } + 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 }, + ); + + 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'); + } + 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); +} + +WebBrowser.maybeCompleteAuthSession(); diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index e5b414a4..204a7650 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -20,6 +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 isLoading = useAuthStore((state) => state.isLoading); const error = useAuthStore((state) => state.error); const clearError = useAuthStore((state) => state.clearError); @@ -30,6 +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 handleLogin = async () => { if (!canSubmit) { @@ -44,6 +46,24 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree } }; + const handleOAuth = async () => { + if (!canOAuth) 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. + const after = useAuthStore.getState().isAuthenticated; + if (after && !before) { + onLogin?.(); + } else if (isAddMode && after) { + onLogin?.(); + } + } catch { + // Store state already contains the user-facing error. + } + }; + const updateField = React.useCallback( (setter: React.Dispatch>) => (value: string) => { if (useAuthStore.getState().error) { @@ -151,8 +171,15 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree {/* OAuth buttons */} - diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 4cc02735..18f9ee18 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -6,6 +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 { teardownPushNotifications, teardownPushNotificationsForAccount, @@ -38,6 +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; logout: () => Promise; logoutAll: () => Promise; switchAccount: (accountId: string) => Promise; @@ -167,6 +169,51 @@ export const useAuthStore = create((set, get) => ({ } }, + loginOAuth: async (serverUrl, opts) => { + set({ isLoading: true, error: null }); + 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); + } 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'; + set({ isLoading: false, error: message }); + throw err; + } + }, + logout: async () => { const accountStore = useAccountStore.getState(); const currentId = get().activeAccountId; diff --git a/src/test-setup.ts b/src/test-setup.ts index f60eb572..ee127d19 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -64,3 +64,33 @@ vi.mock('expo-secure-store', () => ({ getItemAsync: vi.fn(async () => null), 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', +})); + +vi.mock('expo-web-browser', () => ({ + warmUpAsync: vi.fn(async () => undefined), + maybeCompleteAuthSession: vi.fn(), +}));