mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67f7f83621 | ||
|
|
086d398058 |
Generated
+2
-57
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.36",
|
||||
"version": "0.1.37",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.36",
|
||||
"version": "0.1.37",
|
||||
"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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.36",
|
||||
"version": "0.1.37",
|
||||
"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",
|
||||
|
||||
+12
-152
@@ -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<void> {
|
||||
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<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);
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
@@ -355,23 +235,13 @@ export class JMAPClient {
|
||||
|
||||
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
|
||||
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<ArrayBuffer> {
|
||||
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();
|
||||
|
||||
@@ -9,10 +9,6 @@ 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 to derive the account label
|
||||
// when there's no password username to fall back on.
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface JMAPAccountInfo {
|
||||
|
||||
+56
-130
@@ -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<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)}`);
|
||||
}
|
||||
}
|
||||
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<OAuthTokens> {
|
||||
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<HandoffCredentials> {
|
||||
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<OAuthTokens> {
|
||||
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();
|
||||
|
||||
+12
-11
@@ -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>
|
||||
|
||||
+12
-36
@@ -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,49 +169,25 @@ export const useAuthStore = create<AuthState>((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 () => {
|
||||
|
||||
+4
-26
@@ -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(),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user