mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: sign in with OAuth (PKCE) directly against the mail server and revoke refresh tokens on sign-out
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
refreshOAuthAccessToken,
|
refreshOAuthAccessToken,
|
||||||
TransientRefreshError,
|
TransientRefreshError,
|
||||||
type OAuthTokens,
|
type OAuthTokens,
|
||||||
|
type OAuthTokenSource,
|
||||||
} from '../lib/oauth';
|
} from '../lib/oauth';
|
||||||
import { FirstTouchGate } from './first-touch-gate';
|
import { FirstTouchGate } from './first-touch-gate';
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ export interface StoredCredentials {
|
|||||||
expiresAt?: number;
|
expiresAt?: number;
|
||||||
tokenEndpoint?: string;
|
tokenEndpoint?: string;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
|
tokenSource?: OAuthTokenSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -301,6 +303,7 @@ export class JMAPClient {
|
|||||||
expiresAt: tokens.expiresAt,
|
expiresAt: tokens.expiresAt,
|
||||||
tokenEndpoint: tokens.tokenEndpoint,
|
tokenEndpoint: tokens.tokenEndpoint,
|
||||||
clientId: tokens.clientId,
|
clientId: tokens.clientId,
|
||||||
|
tokenSource: tokens.source,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||||
@@ -364,6 +367,21 @@ export class JMAPClient {
|
|||||||
expiresAt: this.credentials.expiresAt,
|
expiresAt: this.credentials.expiresAt,
|
||||||
tokenEndpoint: this.credentials.tokenEndpoint,
|
tokenEndpoint: this.credentials.tokenEndpoint,
|
||||||
clientId: this.credentials.clientId,
|
clientId: this.credentials.clientId,
|
||||||
|
source: this.credentials.tokenSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OAuth bundle stored for `accountId` (null for password accounts). */
|
||||||
|
async getStoredOAuthTokens(accountId: string): Promise<OAuthTokens | null> {
|
||||||
|
const creds = await this.getStoredCredentials(accountId);
|
||||||
|
if (!creds?.accessToken || !creds.refreshToken || !creds.tokenEndpoint || !creds.clientId) return null;
|
||||||
|
return {
|
||||||
|
accessToken: creds.accessToken,
|
||||||
|
refreshToken: creds.refreshToken,
|
||||||
|
expiresAt: creds.expiresAt,
|
||||||
|
tokenEndpoint: creds.tokenEndpoint,
|
||||||
|
clientId: creds.clientId,
|
||||||
|
source: creds.tokenSource,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,13 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lower.includes('no bulwark webmail or sign-in service')) {
|
||||||
|
return {
|
||||||
|
title: `No sign-in page at ${host}`,
|
||||||
|
detail: 'This server has no Bulwark webmail and no OAuth sign-in. Use a password (or an app password) instead.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (lower.includes('pairing code')) {
|
if (lower.includes('pairing code')) {
|
||||||
return {
|
return {
|
||||||
title: 'That code has expired',
|
title: 'That code has expired',
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// Direct OAuth 2.0 / OIDC authorization-code + PKCE login against the mail
|
||||||
|
// server's own authorization server (Stalwart's built-in OAuth, Keycloak,
|
||||||
|
// Authentik…), for deployments where no Bulwark webmail lives at the JMAP
|
||||||
|
// origin and the browser hand-off therefore has nothing to open.
|
||||||
|
//
|
||||||
|
// Mirrors the webmail's client-side PKCE flow (`lib/oauth/discovery.ts`,
|
||||||
|
// `lib/oauth/pkce.ts`, login page `handleOAuthLogin`): discover the metadata
|
||||||
|
// document, open the authorization endpoint in the system browser with a
|
||||||
|
// S256 challenge, receive the code on `bulwarkmobile://auth/callback`, and
|
||||||
|
// exchange it at the token endpoint. The resulting bundle is the same shape
|
||||||
|
// the hand-off produces, so `connectWithOAuth` and the refresh path apply.
|
||||||
|
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
import { secureFetch } from './client-cert';
|
||||||
|
import { HANDOFF_REDIRECT_URI, HandoffError, HandoffCancelledError, type OAuthTokens } from './oauth';
|
||||||
|
import { generateCodeChallenge, generateCodeVerifier, DEFAULT_CLIENT_ID } from './totp-login';
|
||||||
|
import { randomHex } from './random';
|
||||||
|
|
||||||
|
export interface OAuthMetadata {
|
||||||
|
issuer?: string;
|
||||||
|
authorization_endpoint: string;
|
||||||
|
token_endpoint: string;
|
||||||
|
revocation_endpoint?: string;
|
||||||
|
scopes_supported?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DISCOVERY_TIMEOUT_MS = 8_000;
|
||||||
|
|
||||||
|
function withTimeout(ms: number): { signal?: AbortSignal; done: () => void } {
|
||||||
|
if (typeof AbortController === 'undefined') return { done: () => undefined };
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), ms);
|
||||||
|
return { signal: controller.signal, done: () => clearTimeout(timer) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameHost(a: string, b: string): boolean {
|
||||||
|
const host = (u: string) => (/^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)/i.exec(u)?.[1] ?? '').toLowerCase().replace(/:\d+$/, '');
|
||||||
|
const ha = host(a);
|
||||||
|
const hb = host(b);
|
||||||
|
return !!ha && !!hb && (ha === hb || ha.endsWith(`.${hb}`) || hb.endsWith(`.${ha}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 8414 / OIDC discovery on the mail server origin. Returns null when the
|
||||||
|
* server advertises no authorization server (plain Stalwart without OAuth,
|
||||||
|
* or a non-Stalwart JMAP server).
|
||||||
|
*/
|
||||||
|
export async function discoverOAuthMetadata(serverUrl: string): Promise<OAuthMetadata | null> {
|
||||||
|
const base = serverUrl.replace(/\/+$/, '');
|
||||||
|
const urls = [
|
||||||
|
`${base}/.well-known/oauth-authorization-server`,
|
||||||
|
`${base}/.well-known/openid-configuration`,
|
||||||
|
];
|
||||||
|
for (const url of urls) {
|
||||||
|
const t = withTimeout(DISCOVERY_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await secureFetch(url, { headers: { Accept: 'application/json' }, signal: t.signal });
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const data = (await res.json()) as Partial<OAuthMetadata>;
|
||||||
|
if (
|
||||||
|
typeof data.authorization_endpoint === 'string' &&
|
||||||
|
typeof data.token_endpoint === 'string' &&
|
||||||
|
/^https?:\/\//i.test(data.authorization_endpoint) &&
|
||||||
|
/^https?:\/\//i.test(data.token_endpoint)
|
||||||
|
) {
|
||||||
|
return data as OAuthMetadata;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// try the next document
|
||||||
|
} finally {
|
||||||
|
t.done();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is a Bulwark webmail serving the given origin? The hand-off opens
|
||||||
|
* `${origin}/login?mobile_redirect_uri=…`, which only works when it is.
|
||||||
|
*/
|
||||||
|
export async function probeWebmail(serverUrl: string): Promise<boolean> {
|
||||||
|
const base = serverUrl.replace(/\/+$/, '');
|
||||||
|
const t = withTimeout(DISCOVERY_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await secureFetch(`${base}/api/config`, { headers: { Accept: 'application/json' }, signal: t.signal });
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const type = res.headers.get('content-type') ?? '';
|
||||||
|
if (!/json/i.test(type)) return false;
|
||||||
|
const data = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||||||
|
return !!data && typeof data === 'object';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
t.done();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCallbackParams(url: string): URLSearchParams {
|
||||||
|
const q = url.indexOf('?');
|
||||||
|
const h = url.indexOf('#');
|
||||||
|
const query = q >= 0 ? url.slice(q + 1, h > q ? h : undefined) : '';
|
||||||
|
const params = new URLSearchParams(query);
|
||||||
|
if (h >= 0) {
|
||||||
|
for (const [k, v] of new URLSearchParams(url.slice(h + 1))) {
|
||||||
|
if (!params.has(k)) params.set(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the authorization-code + PKCE flow in the system browser. Throws
|
||||||
|
* `HandoffCancelledError` when the user closes the browser.
|
||||||
|
*/
|
||||||
|
export async function loginWithPkce(
|
||||||
|
serverUrl: string,
|
||||||
|
metadata: OAuthMetadata,
|
||||||
|
opts?: { clientId?: string; scopes?: string; redirectUri?: string },
|
||||||
|
): Promise<OAuthTokens> {
|
||||||
|
const clientId = opts?.clientId ?? DEFAULT_CLIENT_ID;
|
||||||
|
const redirectUri = opts?.redirectUri ?? HANDOFF_REDIRECT_URI;
|
||||||
|
const verifier = generateCodeVerifier();
|
||||||
|
const challenge = generateCodeChallenge(verifier);
|
||||||
|
const state = randomHex(16);
|
||||||
|
const scope = opts?.scopes
|
||||||
|
?? (metadata.scopes_supported?.length
|
||||||
|
? ['openid', 'email', 'profile', 'offline_access'].filter((s) => metadata.scopes_supported!.includes(s)).join(' ') || metadata.scopes_supported.join(' ')
|
||||||
|
: 'openid email profile offline_access');
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope,
|
||||||
|
state,
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
const authUrl = `${metadata.authorization_endpoint}${metadata.authorization_endpoint.includes('?') ? '&' : '?'}${params.toString()}`;
|
||||||
|
|
||||||
|
const result = await WebBrowser.openAuthSessionAsync(authUrl, redirectUri);
|
||||||
|
if (result.type === 'cancel' || result.type === 'dismiss') throw new HandoffCancelledError();
|
||||||
|
if (result.type !== 'success' || !result.url) throw new HandoffError(`Sign-in failed: ${result.type}`);
|
||||||
|
|
||||||
|
const cb = parseCallbackParams(result.url);
|
||||||
|
const err = cb.get('error');
|
||||||
|
if (err) throw new HandoffError(cb.get('error_description') || err);
|
||||||
|
if (cb.get('state') !== state) throw new HandoffError('State mismatch');
|
||||||
|
const code = cb.get('code');
|
||||||
|
if (!code) throw new HandoffError('Sign-in response missing authorization code');
|
||||||
|
|
||||||
|
if (!sameHost(metadata.token_endpoint, serverUrl) && !/^https:\/\//i.test(metadata.token_endpoint)) {
|
||||||
|
throw new HandoffError('Token endpoint is not trusted');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_verifier: verifier,
|
||||||
|
});
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await secureFetch(metadata.token_endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new HandoffError(`Could not reach the token endpoint: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = (await response.text().catch(() => '')).slice(0, 200);
|
||||||
|
throw new HandoffError(`Token exchange failed: ${response.status}${detail ? ` ${detail}` : ''}`);
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
access_token?: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
};
|
||||||
|
if (!data.access_token) throw new HandoffError('Token response missing access_token');
|
||||||
|
return {
|
||||||
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token,
|
||||||
|
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
|
||||||
|
tokenEndpoint: metadata.token_endpoint,
|
||||||
|
clientId,
|
||||||
|
source: 'native',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort RFC 7009 revocation of a refresh token on sign-out. Never
|
||||||
|
* throws; the caller is already dropping the credentials.
|
||||||
|
*/
|
||||||
|
export async function revokeRefreshToken(serverUrl: string, tokens: OAuthTokens): Promise<void> {
|
||||||
|
if (!tokens.refreshToken) return;
|
||||||
|
try {
|
||||||
|
const metadata = await discoverOAuthMetadata(serverUrl);
|
||||||
|
const endpoint = metadata?.revocation_endpoint;
|
||||||
|
if (!endpoint || !/^https?:\/\//i.test(endpoint)) return;
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
token: tokens.refreshToken,
|
||||||
|
token_type_hint: 'refresh_token',
|
||||||
|
client_id: tokens.clientId,
|
||||||
|
});
|
||||||
|
await secureFetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,12 +11,18 @@ import { randomHex } from './random';
|
|||||||
|
|
||||||
export const HANDOFF_REDIRECT_URI = 'bulwarkmobile://auth/callback';
|
export const HANDOFF_REDIRECT_URI = 'bulwarkmobile://auth/callback';
|
||||||
|
|
||||||
|
export type OAuthTokenSource = 'handoff' | 'pairing' | 'totp' | 'native';
|
||||||
|
|
||||||
export interface OAuthTokens {
|
export interface OAuthTokens {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
expiresAt?: number; // epoch ms
|
expiresAt?: number; // epoch ms
|
||||||
tokenEndpoint: string;
|
tokenEndpoint: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
// Where the bundle came from. A paired phone shares the desktop's refresh
|
||||||
|
// token, so revoking it on sign-out would also sign the desktop out;
|
||||||
|
// every other source is safe to revoke.
|
||||||
|
source?: OAuthTokenSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HandoffResult =
|
export type HandoffResult =
|
||||||
@@ -162,6 +168,7 @@ export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResu
|
|||||||
expiresAt: expiresIn ? Date.now() + parseInt(expiresIn, 10) * 1000 : undefined,
|
expiresAt: expiresIn ? Date.now() + parseInt(expiresIn, 10) * 1000 : undefined,
|
||||||
tokenEndpoint,
|
tokenEndpoint,
|
||||||
clientId,
|
clientId,
|
||||||
|
source: 'handoff',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -255,6 +262,7 @@ export async function redeemPairingCode(webmailUrl: string, code: string): Promi
|
|||||||
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
|
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
|
||||||
tokenEndpoint: data.token_endpoint,
|
tokenEndpoint: data.token_endpoint,
|
||||||
clientId: data.client_id,
|
clientId: data.client_id,
|
||||||
|
source: 'pairing',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -318,6 +326,7 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
|
|||||||
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
|
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
|
||||||
tokenEndpoint: tokens.tokenEndpoint,
|
tokenEndpoint: tokens.tokenEndpoint,
|
||||||
clientId: tokens.clientId,
|
clientId: tokens.clientId,
|
||||||
|
source: tokens.source,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
activeRefreshes.delete(cacheKey);
|
activeRefreshes.delete(cacheKey);
|
||||||
|
|||||||
@@ -162,5 +162,6 @@ export async function exchangePasswordForTokens(
|
|||||||
expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
|
expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
|
||||||
tokenEndpoint: `${base}/auth/token`,
|
tokenEndpoint: `${base}/auth/token`,
|
||||||
clientId,
|
clientId,
|
||||||
|
source: 'totp',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ vi.mock('../../api/jmap-client', () => ({
|
|||||||
onTokenRefresh: vi.fn(() => () => undefined),
|
onTokenRefresh: vi.fn(() => () => undefined),
|
||||||
hasAccountCapability: vi.fn(() => false),
|
hasAccountCapability: vi.fn(() => false),
|
||||||
request: vi.fn(async () => { throw new Error('not mocked'); }),
|
request: vi.fn(async () => { throw new Error('not mocked'); }),
|
||||||
|
getStoredOAuthTokens: vi.fn(async () => null),
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
|
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
|
||||||
username: 'user',
|
username: 'user',
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { useContactsStore } from './contacts-store';
|
|||||||
import { useCalendarStore } from './calendar-store';
|
import { useCalendarStore } from './calendar-store';
|
||||||
import { useFilterStore } from './filter-store';
|
import { useFilterStore } from './filter-store';
|
||||||
import { generateAccountId } from '../lib/account-utils';
|
import { generateAccountId } from '../lib/account-utils';
|
||||||
import { runWebmailHandoff, redeemPairingCode, HandoffCancelledError, type HandoffResult } from '../lib/oauth';
|
import { runWebmailHandoff, redeemPairingCode, HandoffCancelledError, HandoffError, type HandoffResult } from '../lib/oauth';
|
||||||
|
import { discoverOAuthMetadata, loginWithPkce, probeWebmail, revokeRefreshToken } from '../lib/oauth-native';
|
||||||
import {
|
import {
|
||||||
teardownPushNotifications,
|
teardownPushNotifications,
|
||||||
teardownPushNotificationsForAccount,
|
teardownPushNotificationsForAccount,
|
||||||
@@ -48,6 +49,8 @@ export interface AuthState {
|
|||||||
opts?: { addAccount?: boolean; totp?: string },
|
opts?: { addAccount?: boolean; totp?: string },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||||
|
/** OAuth/OIDC (PKCE) straight against the mail server's authorization server. */
|
||||||
|
loginViaOAuth: (serverUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||||
loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
logoutAll: () => Promise<void>;
|
logoutAll: () => Promise<void>;
|
||||||
@@ -107,6 +110,19 @@ function refetchFeatureStores(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort RFC 7009 revocation of an account's refresh token on sign-out.
|
||||||
|
// A QR-paired phone shares the desktop's token, so those are left alone.
|
||||||
|
async function revokeStoredRefreshToken(accountId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entry = useAccountStore.getState().getAccountById(accountId);
|
||||||
|
const tokens = await jmapClient.getStoredOAuthTokens(accountId);
|
||||||
|
if (!entry || !tokens || tokens.source === 'pairing') return;
|
||||||
|
await revokeRefreshToken(entry.serverUrl, tokens);
|
||||||
|
} catch {
|
||||||
|
// never block sign-out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh the registry's display name / address from the server (#900): the
|
// Refresh the registry's display name / address from the server (#900): the
|
||||||
// primary identity first, then the Stalwart principal's "Full name" when the
|
// primary identity first, then the Stalwart principal's "Full name" when the
|
||||||
// account advertises the extension. Fire-and-forget; a failure keeps whatever
|
// account advertises the extension. Fire-and-forget; a failure keeps whatever
|
||||||
@@ -270,6 +286,20 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
|
|
||||||
loginViaWebmail: async (webmailUrl, opts) => {
|
loginViaWebmail: async (webmailUrl, opts) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
|
// Discovery finds the *JMAP* host; a Bulwark webmail is not necessarily
|
||||||
|
// served there. Opening `/login?mobile_redirect_uri=…` on a bare Stalwart
|
||||||
|
// lands on a 404 or the admin page, so check first and fall back to the
|
||||||
|
// server's own OAuth (PKCE) when the webmail is missing.
|
||||||
|
if (!(await probeWebmail(webmailUrl))) {
|
||||||
|
const metadata = await discoverOAuthMetadata(webmailUrl);
|
||||||
|
if (metadata) {
|
||||||
|
await get().loginViaOAuth(webmailUrl, opts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = 'No Bulwark webmail or sign-in service found at this address. Use a password instead.';
|
||||||
|
set({ isLoading: false, error: message });
|
||||||
|
throw new HandoffError(message);
|
||||||
|
}
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = await runWebmailHandoff(webmailUrl);
|
result = await runWebmailHandoff(webmailUrl);
|
||||||
@@ -309,6 +339,37 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loginViaOAuth: async (serverUrl, opts) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const base = serverUrl.replace(/\/+$/, '');
|
||||||
|
let tokens;
|
||||||
|
try {
|
||||||
|
const metadata = await discoverOAuthMetadata(base);
|
||||||
|
if (!metadata) throw new HandoffError('This server does not offer OAuth sign-in');
|
||||||
|
tokens = await loginWithPkce(base, metadata);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HandoffCancelledError) {
|
||||||
|
set({ isLoading: false, error: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = err instanceof Error ? err.message : 'Sign-in failed';
|
||||||
|
set({ isLoading: false, error: message });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await completeOAuthHandoff(set, get, { flow: 'oauth', serverUrl: base, tokens }, opts);
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
loginViaPairing: async (webmailUrl, code, opts) => {
|
loginViaPairing: async (webmailUrl, code, opts) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
let result;
|
let result;
|
||||||
@@ -356,6 +417,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
|
|
||||||
// Clear credentials for this account first
|
// Clear credentials for this account first
|
||||||
if (currentId) {
|
if (currentId) {
|
||||||
|
await revokeStoredRefreshToken(currentId);
|
||||||
await jmapClient.clearAccountCredentials(currentId);
|
await jmapClient.clearAccountCredentials(currentId);
|
||||||
accountStore.removeAccount(currentId);
|
accountStore.removeAccount(currentId);
|
||||||
} else {
|
} else {
|
||||||
@@ -395,6 +457,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
const accountStore = useAccountStore.getState();
|
const accountStore = useAccountStore.getState();
|
||||||
const ids = accountStore.accounts.map((a) => a.id);
|
const ids = accountStore.accounts.map((a) => a.id);
|
||||||
await teardownPushNotifications().catch(() => undefined);
|
await teardownPushNotifications().catch(() => undefined);
|
||||||
|
for (const id of ids) await revokeStoredRefreshToken(id);
|
||||||
await jmapClient.clearAllCredentials(ids);
|
await jmapClient.clearAllCredentials(ids);
|
||||||
jmapClient.reset();
|
jmapClient.reset();
|
||||||
clearAllFeatureStores();
|
clearAllFeatureStores();
|
||||||
@@ -510,6 +573,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
const accountStore = useAccountStore.getState();
|
const accountStore = useAccountStore.getState();
|
||||||
if (!accountStore.getAccountById(accountId)) return;
|
if (!accountStore.getAccountById(accountId)) return;
|
||||||
await teardownPushNotificationsForAccount(accountId).catch(() => undefined);
|
await teardownPushNotificationsForAccount(accountId).catch(() => undefined);
|
||||||
|
await revokeStoredRefreshToken(accountId);
|
||||||
await jmapClient.clearAccountCredentials(accountId).catch(() => undefined);
|
await jmapClient.clearAccountCredentials(accountId).catch(() => undefined);
|
||||||
useEmailStore.getState().removeAccount(accountId);
|
useEmailStore.getState().removeAccount(accountId);
|
||||||
accountStore.removeAccount(accountId);
|
accountStore.removeAccount(accountId);
|
||||||
|
|||||||
Reference in New Issue
Block a user