Files
webmail/lib/auth/lite-tokens.ts
T

543 lines
20 KiB
TypeScript

import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce';
import { IS_LITE_STALWART, getLiteInjectedClientId } from '@/lib/lite';
/**
* Cookie-free sessions for the static Lite build.
*
* The regular build keeps refresh tokens and "remember me" passwords in
* encrypted cookies written by /api/auth/*. Lite has no server, so it does
* what Stalwart's own web admin does: authenticate against the mail server's
* structured login endpoint straight from the browser
*
* POST <server>/api/auth (password + optional MFA token, PKCE challenge)
* -> authorization code
* POST <server>/auth/token (grant_type=authorization_code)
* -> access token (memory) + refresh token
*
* (or, for an account an external OpenID provider signs in, through that
* provider's redirect flow: lib/auth/lite-oauth.ts)
*
* and renew with `grant_type=refresh_token`. The tokens are what persists:
* localStorage when the user ticked "remember me", sessionStorage (this tab
* only) otherwise. The current access token is kept beside the refresh token
* so a reload resumes with it instead of renewing early. Servers without
* /api/auth (older Stalwart, other JMAP servers) fall back to Basic auth;
* those sessions can only survive a reload inside the same tab.
*
* Trade-off, documented in LITE-README.md: a refresh token in web storage is
* readable by any script on the origin. That is the standard SPA position,
* and strictly better than storing the password.
*/
/**
* OAuth client id registered with Stalwart. Mirrors DEFAULT_CLIENT_ID in
* lib/oauth/token-exchange.ts, which cannot be imported here: that module
* reads secrets from the filesystem and must stay out of the browser bundle.
*/
export const LITE_CLIENT_ID = 'bulwark-webmail';
/**
* The client id Lite presents to Stalwart: the `oauthClientId` of the
* Stalwart `Application` that serves the bundle (injected into the root
* index.html, see lib/lite.ts) when there is one, else `LITE_CLIENT_ID`.
*/
export function getLiteClientId(): string {
return getLiteInjectedClientId() || LITE_CLIENT_ID;
}
export type LiteLoginErrorCode =
| 'endpoint_missing'
| 'totp_required'
| 'invalid_credentials'
| 'login_failed'
| 'token_exchange_failed'
| 'refresh_rejected';
export class LiteLoginError extends Error {
constructor(readonly code: LiteLoginErrorCode, readonly status?: number, detail?: string) {
super(detail ? `${code}: ${detail}` : code);
this.name = 'LiteLoginError';
}
}
export interface LiteTokens {
accessToken: string;
expiresIn: number;
refreshToken: string | null;
}
interface StoredRefreshToken {
serverUrl: string;
username: string;
refreshToken: string;
/** Client the token was issued to; refreshes must present the same one. */
clientId?: string;
/**
* Where the token is renewed when that is not `<serverUrl>/auth/token`: an
* OAuth sign-in through the external provider Stalwart delegates a domain
* to (lib/auth/lite-oauth.ts) is refreshed at that provider.
*/
tokenEndpoint?: string;
/** RFC 7009 endpoint that revokes the token on sign-out, when discovery named one. */
revocationEndpoint?: string;
}
interface StoredAccessToken {
accessToken: string;
/** Unix seconds. */
expiresAt: number;
}
/** A password (Basic auth) or the access token of a token login (Bearer auth). */
type StoredBasicSession =
| { serverUrl: string; username: string; password: string; token?: undefined }
| { serverUrl: string; username: string; token: string; password?: undefined };
const REFRESH_KEY_PREFIX = 'bulwark-lite:refresh:';
const ACCESS_KEY_PREFIX = 'bulwark-lite:access:';
const BASIC_KEY_PREFIX = 'bulwark-lite:basic:';
/**
* Seconds of life a cached access token needs left to be reused. Mirrors
* ACCESS_TOKEN_MIN_REMAINING_SECONDS in lib/oauth/tokens.ts, which cannot be
* imported here (server-only): inside this window the scheduled renewal is
* due anyway, and providers that gate refresh on `nbf` have opened by then.
*/
const ACCESS_TOKEN_MIN_REMAINING_SECONDS = 60;
function trimUrl(url: string): string {
return url.replace(/\/+$/, '');
}
function storage(kind: 'local' | 'session'): Storage | null {
try {
if (typeof window === 'undefined') return null;
return kind === 'local' ? window.localStorage : window.sessionStorage;
} catch {
return null;
}
}
function readJson<T>(store: Storage | null, key: string): T | null {
try {
const raw = store?.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
function writeJson(store: Storage | null, key: string, value: unknown): void {
try {
store?.setItem(key, JSON.stringify(value));
} catch {
// Storage full or blocked: the session simply won't survive a reload.
}
}
function remove(store: Storage | null, key: string): void {
try {
store?.removeItem(key);
} catch {
// ignore
}
}
// ---------------------------------------------------------------------------
// Storage
// ---------------------------------------------------------------------------
/**
* Persists a refresh token for an account slot. `persistent` (remember me)
* survives browser restarts; otherwise the token lives with the tab.
*/
export function saveLiteRefreshToken(
slot: number,
entry: StoredRefreshToken,
persistent: boolean,
): void {
const key = `${REFRESH_KEY_PREFIX}${slot}`;
remove(storage('local'), key);
remove(storage('session'), key);
writeJson(storage(persistent ? 'local' : 'session'), key, { ...entry, serverUrl: trimUrl(entry.serverUrl) });
}
export function readLiteRefreshToken(slot: number): StoredRefreshToken | null {
const key = `${REFRESH_KEY_PREFIX}${slot}`;
const entry = readJson<StoredRefreshToken>(storage('local'), key) ?? readJson<StoredRefreshToken>(storage('session'), key);
return entry && typeof entry.refreshToken === 'string' && entry.refreshToken ? entry : null;
}
/** Where the slot's refresh token currently lives, so a rotation stays put. */
function refreshTokenIsPersistent(slot: number): boolean {
return readJson<StoredRefreshToken>(storage('local'), `${REFRESH_KEY_PREFIX}${slot}`) !== null;
}
/**
* Records whose refresh token the slot holds. An OAuth sign-in stores the
* token before it knows the account: only the JMAP session names it.
*/
export function nameLiteRefreshToken(slot: number, username: string): void {
const stored = readLiteRefreshToken(slot);
if (!stored || stored.username === username) return;
saveLiteRefreshToken(slot, { ...stored, username }, refreshTokenIsPersistent(slot));
}
/** Forgets the slot's refresh token and the access token cached beside it. */
export function clearLiteRefreshToken(slot: number): void {
for (const key of [`${REFRESH_KEY_PREFIX}${slot}`, `${ACCESS_KEY_PREFIX}${slot}`]) {
remove(storage('local'), key);
remove(storage('session'), key);
}
}
/**
* Caches the slot's current access token, so a reload resumes with it rather
* than spending the refresh token. Providers that stamp refresh tokens with
* `nbf` (Rauthy: iat + access token lifetime - 60s) refuse an early renewal,
* and a refused renewal ends the session (#552). `persistent` follows the
* refresh token: a remembered session reopened in a new tab needs it too.
*/
export function saveLiteAccessToken(slot: number, accessToken: string, expiresIn: number, persistent: boolean): void {
const key = `${ACCESS_KEY_PREFIX}${slot}`;
remove(storage('local'), key);
remove(storage('session'), key);
const entry: StoredAccessToken = { accessToken, expiresAt: Math.floor(Date.now() / 1000) + expiresIn };
writeJson(storage(persistent ? 'local' : 'session'), key, entry);
}
/** The slot's cached access token, while it has more than a minute to live. */
export function readLiteAccessToken(slot: number): { accessToken: string; expiresIn: number } | null {
const key = `${ACCESS_KEY_PREFIX}${slot}`;
const entry = readJson<StoredAccessToken>(storage('local'), key) ?? readJson<StoredAccessToken>(storage('session'), key);
if (!entry || typeof entry.accessToken !== 'string' || !entry.accessToken || !Number.isFinite(entry.expiresAt)) return null;
const expiresIn = entry.expiresAt - Math.floor(Date.now() / 1000);
return expiresIn >= ACCESS_TOKEN_MIN_REMAINING_SECONDS ? { accessToken: entry.accessToken, expiresIn } : null;
}
/**
* Basic-auth fallback for servers without token login, and access-token
* logins: the credentials stay with this tab (sessionStorage) so a reload
* does not end the session.
*/
export function saveLiteBasicSession(slot: number, entry: StoredBasicSession): void {
writeJson(storage('session'), `${BASIC_KEY_PREFIX}${slot}`, { ...entry, serverUrl: trimUrl(entry.serverUrl) });
}
export function readLiteBasicSession(slot: number): StoredBasicSession | null {
const entry = readJson<StoredBasicSession>(storage('session'), `${BASIC_KEY_PREFIX}${slot}`);
return entry && (typeof entry.password === 'string' || typeof entry.token === 'string') ? entry : null;
}
export function clearLiteBasicSession(slot: number): void {
remove(storage('session'), `${BASIC_KEY_PREFIX}${slot}`);
}
export function clearLiteSlot(slot: number): void {
clearLiteRefreshToken(slot);
clearLiteBasicSession(slot);
}
// ---------------------------------------------------------------------------
// Revocation
// ---------------------------------------------------------------------------
// Sign-out waits for this, so an unresponsive endpoint must not hold it up.
const REVOCATION_TIMEOUT_MS = 3000;
/** The revocation endpoint a Stalwart server advertises, if any. */
async function discoverRevocationEndpoint(serverUrl: string): Promise<string | null> {
for (const path of ['/.well-known/oauth-authorization-server', '/.well-known/openid-configuration']) {
try {
const response = await fetch(`${serverUrl}${path}`, { signal: AbortSignal.timeout(REVOCATION_TIMEOUT_MS) });
if (!response.ok) continue;
const endpoint = ((await response.json()) as { revocation_endpoint?: unknown }).revocation_endpoint;
return typeof endpoint === 'string' && /^https?:\/\//.test(endpoint) ? endpoint : null;
} catch {
// Try the next document.
}
}
return null;
}
async function revokeRefreshToken(entry: StoredRefreshToken): Promise<void> {
// A token from an external provider is only revoked where its own
// discovery said; Stalwart's own tokens can be looked up.
const endpoint = entry.revocationEndpoint
?? (entry.tokenEndpoint ? null : await discoverRevocationEndpoint(trimUrl(entry.serverUrl)));
if (!endpoint) return;
try {
await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
token: entry.refreshToken,
token_type_hint: 'refresh_token',
client_id: entry.clientId || getLiteClientId(),
}).toString(),
signal: AbortSignal.timeout(REVOCATION_TIMEOUT_MS),
// Finishes even when sign-out navigates away first.
keepalive: true,
redirect: 'error',
});
} catch {
// Best effort: the token is gone from this browser either way.
}
}
/**
* Sign a slot out: forget its credentials at once, then revoke its refresh
* token so a copy taken from this browser's storage stops working too.
*/
export async function revokeLiteSlot(slot: number): Promise<void> {
const entry = readLiteRefreshToken(slot);
clearLiteSlot(slot);
if (entry) await revokeRefreshToken(entry);
}
/** {@link revokeLiteSlot} for every slot. */
export async function revokeAllLiteSessions(): Promise<void> {
const entries: StoredRefreshToken[] = [];
for (const kind of ['local', 'session'] as const) {
const store = storage(kind);
if (!store) continue;
try {
for (let i = 0; i < store.length; i++) {
const key = store.key(i);
if (!key?.startsWith(REFRESH_KEY_PREFIX)) continue;
const entry = readJson<StoredRefreshToken>(store, key);
if (entry && typeof entry.refreshToken === 'string' && entry.refreshToken) entries.push(entry);
}
} catch {
continue;
}
}
clearAllLiteSessions();
await Promise.all(entries.map(revokeRefreshToken));
}
export function clearAllLiteSessions(): void {
for (const kind of ['local', 'session'] as const) {
const store = storage(kind);
if (!store) continue;
const keys: string[] = [];
try {
for (let i = 0; i < store.length; i++) {
const key = store.key(i);
if (key && [REFRESH_KEY_PREFIX, ACCESS_KEY_PREFIX, BASIC_KEY_PREFIX].some((prefix) => key.startsWith(prefix))) keys.push(key);
}
} catch {
continue;
}
keys.forEach((key) => remove(store, key));
}
}
// ---------------------------------------------------------------------------
// Stalwart endpoints
// ---------------------------------------------------------------------------
interface LoginResponse {
type?: string;
client_code?: string;
}
interface TokenResponse {
access_token?: string;
expires_in?: number;
refresh_token?: string;
error?: string;
}
/**
* Password (+ optional TOTP) login against Stalwart's structured endpoint.
* Mirrors app/api/auth/totp-token-exchange/route.ts, minus the server hop.
*/
export async function liteTokenLogin(params: {
serverUrl: string;
username: string;
password: string;
totp?: string;
redirectUri: string;
clientId?: string;
}): Promise<LiteTokens> {
const base = trimUrl(params.serverUrl);
const clientId = params.clientId || getLiteClientId();
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const loginResponse = await fetch(`${base}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'authCode',
accountName: params.username,
accountSecret: params.password,
...(params.totp ? { mfaToken: params.totp } : {}),
clientId,
redirectUri: params.redirectUri,
codeChallenge: challenge,
codeChallengeMethod: 'S256',
}),
});
if (!loginResponse.ok) {
if (loginResponse.status === 404 || loginResponse.status === 405 || loginResponse.status === 501) {
throw new LiteLoginError('endpoint_missing', loginResponse.status);
}
const detail = (await loginResponse.text().catch(() => '')).slice(0, 200);
throw new LiteLoginError('login_failed', loginResponse.status, detail);
}
const login = (await loginResponse.json().catch(() => ({}))) as LoginResponse;
switch (login.type) {
case 'authenticated':
break;
case 'mfaRequired':
throw new LiteLoginError('totp_required', 401);
default:
throw new LiteLoginError('invalid_credentials', 401);
}
if (!login.client_code) throw new LiteLoginError('login_failed', 502, 'missing client_code');
const tokenResponse = await fetch(`${base}/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: login.client_code,
client_id: clientId,
redirect_uri: params.redirectUri,
code_verifier: verifier,
}).toString(),
});
const tokens = (await tokenResponse.json().catch(() => ({}))) as TokenResponse;
if (!tokenResponse.ok || !tokens.access_token) {
throw new LiteLoginError('token_exchange_failed', tokenResponse.status, tokens.error);
}
return {
accessToken: tokens.access_token,
expiresIn: tokens.expires_in || 3600,
refreshToken: tokens.refresh_token ?? null,
};
}
/**
* Second half of the OAuth redirect flow (lib/auth/lite-oauth.ts): trades the
* authorization code for tokens at the endpoint discovery named, which is
* Stalwart's own `/auth/token` or the external provider's. Mirrors the POST
* branch of app/api/auth/token/route.ts, minus the server hop; a public
* client, so PKCE stands in for the secret.
*/
export async function liteExchangeAuthorizationCode(params: {
tokenEndpoint: string;
code: string;
codeVerifier: string;
redirectUri: string;
clientId: string;
}): Promise<LiteTokens> {
const response = await fetch(params.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: params.code,
client_id: params.clientId,
redirect_uri: params.redirectUri,
code_verifier: params.codeVerifier,
}).toString(),
});
const tokens = (await response.json().catch(() => ({}))) as TokenResponse;
if (!response.ok || !tokens.access_token) {
throw new LiteLoginError('token_exchange_failed', response.status, tokens.error);
}
return {
accessToken: tokens.access_token,
expiresIn: tokens.expires_in || 3600,
refreshToken: tokens.refresh_token ?? null,
};
}
/**
* Renews the slot's access token. A rejected refresh token clears the slot and
* throws `refresh_rejected`; a network failure or 5xx propagates untouched so
* callers treat it as an outage, not a sign-out.
*/
export async function liteRefreshTokens(slot: number, clientIdOverride?: string): Promise<LiteTokens> {
const stored = readLiteRefreshToken(slot);
if (!stored) throw new LiteLoginError('refresh_rejected', 401, 'no refresh token');
const clientId = clientIdOverride || stored.clientId || getLiteClientId();
const response = await fetch(stored.tokenEndpoint || `${stored.serverUrl}/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: stored.refreshToken,
client_id: clientId,
}).toString(),
});
// Only a definitive refusal (400/401/403) ends the session, as in the
// server build. A rate limit (429), a request timeout (408) or a 5xx is an
// outage: the refresh token stays, so the session resumes once the server
// answers again.
const refused = response.status === 400 || response.status === 401 || response.status === 403;
if (!response.ok && !refused) {
throw new LiteLoginError('token_exchange_failed', response.status);
}
const tokens = (await response.json().catch(() => ({}))) as TokenResponse;
if (!response.ok || !tokens.access_token) {
clearLiteRefreshToken(slot);
throw new LiteLoginError('refresh_rejected', response.status || 401, tokens.error);
}
const persistent = refreshTokenIsPersistent(slot);
if (tokens.refresh_token && tokens.refresh_token !== stored.refreshToken) {
saveLiteRefreshToken(slot, { ...stored, refreshToken: tokens.refresh_token }, persistent);
}
const expiresIn = tokens.expires_in || 3600;
saveLiteAccessToken(slot, tokens.access_token, expiresIn, persistent);
return {
accessToken: tokens.access_token,
expiresIn,
refreshToken: tokens.refresh_token ?? stored.refreshToken,
};
}
// ---------------------------------------------------------------------------
// Capability probe
// ---------------------------------------------------------------------------
const probeCache = new Map<string, Promise<boolean | null>>();
/**
* Whether `serverUrl` exposes Stalwart's structured login (and therefore
* "remember me" across browser restarts). `null` means the probe could not
* tell (unreachable, CORS blocked) - callers then attempt token login anyway
* and fall back on `endpoint_missing`.
*/
export function probeLiteTokenLogin(serverUrl: string): Promise<boolean | null> {
const base = trimUrl(serverUrl);
if (!/^https?:\/\//.test(base)) return Promise.resolve(false);
// Served by Stalwart itself (an Application bundle, 0.16+): the page's own
// origin has /api/auth. Probing would only log a 400 for the empty body.
if (IS_LITE_STALWART && typeof window !== 'undefined' && base === window.location.origin) return Promise.resolve(true);
let pending = probeCache.get(base);
if (!pending) {
pending = fetch(`${base}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
.then((res) => !(res.status === 404 || res.status === 405 || res.status === 501))
.catch(() => null);
probeCache.set(base, pending);
}
return pending;
}
/** Test hook. */
export function resetLiteProbeCache(): void {
probeCache.clear();
}