feat: implement OAuth token handling and webmail-mediated login flow

This commit is contained in:
Linus Rath
2026-05-19 00:45:42 +02:00
parent 67f7f83621
commit bf20c0af58
4 changed files with 309 additions and 43 deletions
+152 -17
View File
@@ -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<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,
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<void> {
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<boolean> {
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<boolean> {
@@ -235,12 +354,18 @@ export class JMAPClient {
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
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<ArrayBuffer> {
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();
+4
View File
@@ -9,6 +9,10 @@ export interface JMAPSession {
accounts: Record<string, JMAPAccountInfo>;
capabilities: Record<string, unknown>;
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 {
+103 -20
View File
@@ -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<HandoffCredentials> {
export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResult> {
const state = randomState();
const handoffUrl = buildHandoffUrl(webmailUrl, state);
@@ -70,14 +85,82 @@ export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffCred
throw new HandoffError('State mismatch');
}
const flow = params.get('flow');
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');
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 };
}
return { 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'}`);
}
// 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 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();
+50 -6
View File
@@ -171,9 +171,9 @@ export const useAuthStore = create<AuthState>((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<AuthState>((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 () => {