Files
webmail/lib/auth/pairing-store.ts
T
Linus Rath 1446fd39dc fix: link the mobile app from every account with a sign-in of its own
"Link Mobile App" only appeared for OAuth sessions, its step-up could
only go through the identity provider (which failed without an OAuth
client and bounced password+TOTP users to the provider's login page),
and it handed the phone the desktop's own refresh token. Revoking that
token on sign-out, or a rotating provider, signed the phone out too; a
confidential client stopped the phone at its first token expiry; and an
identity provider on another host was refused by the app.

The step-up now mints the phone's own grant:

- Password and password+TOTP accounts confirm the current password (and
  code) in the dialog. The server uses it once to get an OAuth grant
  from Stalwart's structured login for the app's own client, retries
  with the webmail's client when the server insists on a registered one,
  and falls back to an app password ("Bulwark mobile (paired <date>)")
  or, on servers that offer neither, the verified password. Failed
  attempts are limited per account so the webmail's address cannot be
  banned by guessing through it.
- SSO accounts keep the provider round trip, now with `offline_access`
  where offered; its fresh tokens become the phone's grant instead of
  being thrown away, and must belong to the account being paired.
- The account and server always come from the slot's verified session
  context, never from the request.

Grants live 5 minutes server-side, codes 2 minutes, both single use; the
store is pinned on `globalThis`. Redeem answers `invalid_code`,
`expired_code` or `used_code`, and the desktop polls a separate status
id to replace the QR with "Signed in on your phone". A phone whose
client is confidential, or whose provider is on another host, renews
through `/api/auth/pair/token` with a sealed refresh token, so current
app builds work unchanged. Signing out discards a pending grant but no
longer affects a paired phone.

The dialog shows for every account (not in Lite), with a copyable
sign-in link, a TOTP field when needed, and the app's own "Scan a
sign-in code" wording.
2026-09-27 04:16:18 +02:00

236 lines
8.6 KiB
TypeScript

import { randomBytes } from 'crypto';
// Cross-device login pairing ("Link mobile app"). A signed-in desktop proves
// who it is again (password + TOTP, or a fresh IdP login), which mints a
// *grant*: sign-in material for the phone that is independent of the
// desktop's own session. /api/auth/pair/create then issues a short-lived,
// single-use *code* for that grant, rendered as a QR. The app scans it and
// redeems the code at /api/auth/pair/redeem for the grant. The code itself
// carries no secrets; the grant never leaves the server until its code is
// redeemed, exactly once.
//
// A grant outlives a single code (the desktop may show a new QR after the
// first one expired without asking for the password again), but it is handed
// out at most once: redeeming any of its codes consumes it and invalidates
// the others.
//
// Records are kept after they stop being usable, as tombstones without their
// secrets, so a late scan can be told "already used" rather than "expired",
// and the desktop can poll whether its code was redeemed.
//
// Storage is an in-process Map, pinned on globalThis so dev HMR and duplicate
// module evaluation (route bundles) share one instance. That is sufficient for
// the single-instance deployments this webmail targets; a multi-instance
// deployment needs sticky sessions or a shared store keyed the same way.
export type PairingGrant =
| {
flow: 'oauth';
/** JMAP server the phone connects to. */
serverUrl: string;
/** Server entry the grant was minted against, for the refresh proxy. */
serverId: string | null;
accessToken: string;
refreshToken?: string;
/** Access token lifetime in seconds, counted from `issuedAt`. */
expiresIn?: number;
issuedAt: number;
/** The token endpoint that minted the grant (never a proxy URL). */
tokenEndpoint: string;
clientId: string;
/** Refreshing needs a client secret the phone must not have. */
confidential: boolean;
/** The token endpoint belongs to an admin-configured server or issuer. */
trusted: boolean;
}
| {
flow: 'password';
serverUrl: string;
username: string;
/** An app password minted for the phone, or the account password. */
password: string;
credential: 'app-password' | 'account-password';
};
export type PairingStatus = 'pending' | 'redeemed' | 'expired' | 'unknown';
export type RedeemResult =
| { ok: true; grant: PairingGrant; webmailBase: string | null }
| { ok: false; reason: 'invalid' | 'expired' | 'used' };
/** How long a grant stays available for new codes after the step-up. */
export const GRANT_TTL_MS = 5 * 60 * 1000;
/** How long one code can be scanned. */
export const CODE_TTL_MS = 2 * 60 * 1000;
/** How long spent records are remembered, for status polls and late scans. */
const TOMBSTONE_MS = 10 * 60 * 1000;
const CODE_BYTES = 32; // 256 bits of entropy
const ID_BYTES = 16;
interface GrantRecord {
grant: PairingGrant | null; // null once consumed or expired
expiresAt: number;
/** Which signed-in account the step-up was for: `<slot>:<username>`. */
owner: string;
forgetAt: number;
}
interface CodeRecord {
grantId: string;
statusId: string;
webmailBase: string | null;
expiresAt: number;
state: 'pending' | 'redeemed' | 'superseded';
forgetAt: number;
}
interface Store {
grants: Map<string, GrantRecord>;
codes: Map<string, CodeRecord>;
statusIndex: Map<string, string>; // statusId -> code
}
const STORE_KEY = '__bulwarkPairingStore';
function store(): Store {
const g = globalThis as typeof globalThis & { [STORE_KEY]?: Store };
if (!g[STORE_KEY]) {
g[STORE_KEY] = { grants: new Map(), codes: new Map(), statusIndex: new Map() };
}
return g[STORE_KEY];
}
// Wipe secrets of anything past its expiry and forget tombstones past theirs.
// Called on every operation so memory stays bounded without a timer.
function sweep(now: number): void {
const { grants, codes, statusIndex } = store();
for (const [id, record] of grants) {
if (record.expiresAt <= now) record.grant = null;
if (record.forgetAt <= now) grants.delete(id);
}
for (const [code, record] of codes) {
if (record.forgetAt <= now) {
codes.delete(code);
statusIndex.delete(record.statusId);
}
}
}
/** Keep the step-up result until a code for it is redeemed or it expires. */
export function stashGrant(grant: PairingGrant, owner: string, now = Date.now()): string {
sweep(now);
const id = randomBytes(ID_BYTES).toString('hex');
store().grants.set(id, {
grant,
owner,
expiresAt: now + GRANT_TTL_MS,
forgetAt: now + GRANT_TTL_MS + TOMBSTONE_MS,
});
return id;
}
/** Whether `grantId` can still be paired, by the account it was minted for. */
export function isGrantAvailable(grantId: string, owner: string, now = Date.now()): boolean {
sweep(now);
const record = store().grants.get(grantId);
return !!record && record.grant !== null && record.owner === owner && record.expiresAt > now;
}
/** Drop a grant (sign-out, a new step-up): its codes stop working. */
export function discardGrant(grantId: string, now = Date.now()): void {
sweep(now);
const record = store().grants.get(grantId);
if (record) record.grant = null;
}
/**
* Issue a code for an available grant. Earlier codes of the same grant are
* superseded, so only the QR on screen can be redeemed. Returns null when the
* grant is gone, so the caller asks for a new step-up.
*/
export function createPairingCode(
grantId: string,
owner: string,
webmailBase: string | null,
now = Date.now(),
): { code: string; statusId: string; expiresIn: number } | null {
if (!isGrantAvailable(grantId, owner, now)) return null;
const { codes, statusIndex, grants } = store();
for (const record of codes.values()) {
if (record.grantId === grantId && record.state === 'pending') record.state = 'superseded';
}
const code = randomBytes(CODE_BYTES).toString('hex');
const statusId = randomBytes(ID_BYTES).toString('hex');
// A code never outlives its grant.
const expiresAt = Math.min(now + CODE_TTL_MS, grants.get(grantId)!.expiresAt);
codes.set(code, {
grantId,
statusId,
webmailBase,
expiresAt,
state: 'pending',
forgetAt: expiresAt + TOMBSTONE_MS,
});
statusIndex.set(statusId, code);
return { code, statusId, expiresIn: Math.max(0, Math.floor((expiresAt - now) / 1000)) };
}
/**
* Hand out the grant behind `code`, once. A code nobody issued is `invalid`;
* one that timed out, or was replaced by a newer QR, is `expired`; one that
* was redeemed before (by any code of its grant) is `used`. Distinguishing
* them leaks nothing useful: codes carry 256 bits and cannot be guessed.
*/
export function redeemPairingCode(code: string, now = Date.now()): RedeemResult {
sweep(now);
const { codes, grants } = store();
const record = codes.get(code);
if (!record) return { ok: false, reason: 'invalid' };
if (record.state === 'redeemed') return { ok: false, reason: 'used' };
const grantRecord = grants.get(record.grantId);
if (record.state === 'superseded' || record.expiresAt <= now) {
return { ok: false, reason: grantRecord && grantRecord.grant === null && wasRedeemed(record.grantId) ? 'used' : 'expired' };
}
if (!grantRecord || grantRecord.grant === null) {
return { ok: false, reason: wasRedeemed(record.grantId) ? 'used' : 'expired' };
}
const grant = grantRecord.grant;
grantRecord.grant = null;
record.state = 'redeemed';
for (const other of codes.values()) {
if (other !== record && other.grantId === record.grantId && other.state === 'pending') {
other.state = 'superseded';
}
}
return { ok: true, grant, webmailBase: record.webmailBase };
}
function wasRedeemed(grantId: string): boolean {
for (const record of store().codes.values()) {
if (record.grantId === grantId && record.state === 'redeemed') return true;
}
return false;
}
/** What the desktop shows for its QR: still waiting, used by the phone, or dead. */
export function getPairingStatus(statusId: string, now = Date.now()): PairingStatus {
sweep(now);
const { statusIndex, codes, grants } = store();
const code = statusIndex.get(statusId);
const record = code ? codes.get(code) : undefined;
if (!record) return 'unknown';
if (record.state === 'redeemed') return 'redeemed';
if (record.state === 'superseded' || record.expiresAt <= now) return 'expired';
// Its grant was discarded (sign-out, a newer step-up): the code is dead.
if (!grants.get(record.grantId)?.grant) return 'expired';
return 'pending';
}
/** Test hook: forget everything. */
export function resetPairingStoreForTests(): void {
const s = store();
s.grants.clear();
s.codes.clear();
s.statusIndex.clear();
}