diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index bf000a03..6c7e8d3f 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 { 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. +const TOKEN_REFRESH_LEEWAY_MS = 60_000; const LEGACY_CREDENTIALS_KEY = 'jmap_credentials'; const CREDENTIALS_PREFIX = 'jmap_credentials__'; @@ -22,6 +27,13 @@ export interface StoredCredentials { username: string; password: string; accessToken?: string; + // 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; + clientId?: string; } export class JMAPClient { @@ -93,6 +105,113 @@ export class JMAPClient { return this.session; } + // 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, + ): 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 }; + } + + private async persistRefreshedTokens(next: OAuthTokens): Promise { + 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, + clientId: next.clientId, + }; + const accountId = generateAccountId( + this.credentials.username, + this.credentials.serverUrl, + ); + await SecureStore.setItemAsync( + credentialsKey(accountId), + JSON.stringify(this.credentials), + ); + } + + private currentOAuthTokens(): OAuthTokens | null { + if ( + !this.credentials?.accessToken || + !this.credentials.refreshToken || + !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, + }; + } + + // 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 { + const tokens = this.currentOAuthTokens(); + if (!tokens) return; + if (tokens.expiresAt == null) return; + if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return; + try { + const next = await refreshOAuthAccessToken(tokens); + await this.persistRefreshedTokens(next); + } catch { + // Surface as AuthenticationError on the next 401; refresh may be + // temporarily failing (network) and the reactive retry path catches it. + } + } + + // 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 { + const tokens = this.currentOAuthTokens(); + if (!tokens) return false; + try { + const next = await refreshOAuthAccessToken(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,12 +354,18 @@ export class JMAPClient { private async fetchSession(baseUrl: string): Promise { const url = `${baseUrl}/.well-known/jmap`; - const response = await secureFetch(url, { - headers: { - Authorization: this.authHeader, - Accept: 'application/json', - }, - }); + await this.ensureFreshToken(); + 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) { throw new AuthenticationError('Invalid credentials'); @@ -280,14 +405,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 +467,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..b1b085e1 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 as 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 b6690435..48d153b8 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -1,21 +1,36 @@ import * as WebBrowser from 'expo-web-browser'; +import { secureFetch } from './client-cert'; -// 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. +// 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. export const HANDOFF_REDIRECT_URI = 'bulwarkmobile://auth/callback'; -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 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() { @@ -34,10 +49,10 @@ function randomState(): string { function buildHandoffUrl(webmailUrl: string, state: string): string { const base = webmailUrl.replace(/\/+$/, ''); const params = new URLSearchParams({ - redirect_uri: HANDOFF_REDIRECT_URI, - state, + mobile_redirect_uri: HANDOFF_REDIRECT_URI, + mobile_state: state, }); - return `${base}/mobile-handoff?${params.toString()}`; + return `${base}/login?${params.toString()}`; } function parseFragment(url: string): URLSearchParams { @@ -46,7 +61,7 @@ function parseFragment(url: string): URLSearchParams { return new URLSearchParams(url.slice(hashIdx + 1)); } -export async function runWebmailHandoff(webmailUrl: string): Promise { +export async function runWebmailHandoff(webmailUrl: string): Promise { const state = randomState(); const handoffUrl = buildHandoffUrl(webmailUrl, state); @@ -70,14 +85,82 @@ export async function runWebmailHandoff(webmailUrl: string): Promise { + if (!tokens.refreshToken) { + 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'); + } + return { + 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, + }; } WebBrowser.maybeCompleteAuthSession(); diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 5701d72e..beb3893d 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -171,9 +171,9 @@ export const useAuthStore = create((set, get) => ({ loginViaWebmail: async (webmailUrl, opts) => { set({ isLoading: true, error: null }); - let creds; + let result; try { - creds = await runWebmailHandoff(webmailUrl); + result = await runWebmailHandoff(webmailUrl); } catch (err) { if (err instanceof HandoffCancelledError) { // User closed the browser tab — quiet exit, no error banner. @@ -184,10 +184,54 @@ export const useAuthStore = create((set, get) => ({ 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); + + 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(); + useContactsStore.getState().reset(); + useCalendarStore.getState().reset(); + } + + const { session, username, accountId } = await jmapClient.connectWithOAuth( + result.serverUrl, + result.tokens, + ); + + const accountStore = useAccountStore.getState(); + accountStore.addAccount({ + serverUrl: result.serverUrl.replace(/\/+$/, ''), + username, + displayName: username, + email: username, + lastLoginAt: Date.now(), + isConnected: true, + hasError: false, + }); + accountStore.setActiveAccount(accountId); + useEmailStore.getState().setActiveAccount(accountId); + + applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId); + } catch (err) { + const message = + err instanceof AuthenticationError + ? 'Authentication rejected by server' + : err instanceof Error + ? err.message + : 'OAuth sign-in failed'; + set({ isLoading: false, error: message }); + throw err; + } }, logout: async () => {