Files
webmail/lib/auth/pair-bundle.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

137 lines
4.9 KiB
TypeScript

import { encryptPayload, decryptPayload } from '@/lib/auth/crypto';
import type { PairingGrant } from '@/lib/auth/pairing-store';
// What a paired phone receives, and how it renews its access token.
//
// The phone refreshes with `client_id` only, against the `token_endpoint` it
// was given, and current app builds accept that endpoint only on the JMAP
// server's or the webmail's host (or a parent/child domain of either). So
// the phone talks to the provider directly only when both hold; otherwise it
// is pointed at the webmail's token proxy (/api/auth/pair/token), which adds
// the client secret server-side. The refresh token it carries then is sealed
// under SESSION_SECRET, so the proxy renews only tokens this webmail handed
// to a phone and knows where each one belongs, and never works as a
// secret-adding relay for arbitrary tokens.
export const PAIR_TOKEN_PROXY_PATH = '/api/auth/pair/token';
export interface SealedRefresh {
refreshToken: string;
serverId: string | null;
clientId: string;
/** The endpoint that minted the grant (the provider, never the proxy). */
tokenEndpoint: string;
/** False for a user-chosen server: renewals must use the rebinding-safe fetch. */
trusted: boolean;
}
export function sealPhoneRefreshToken(value: SealedRefresh): string {
return encryptPayload(
{ r: value.refreshToken, s: value.serverId, c: value.clientId, e: value.tokenEndpoint, t: value.trusted },
'pair-refresh',
);
}
export function openPhoneRefreshToken(sealed: string): SealedRefresh | null {
let data: Record<string, unknown> | null;
try {
data = decryptPayload(sealed, 'pair-refresh');
} catch {
return null;
}
if (!data) return null;
const { r, s, c, e, t } = data;
if (typeof r !== 'string' || !r || typeof c !== 'string' || !c || typeof e !== 'string' || !e) return null;
if (s !== null && typeof s !== 'string') return null;
return { refreshToken: r, serverId: s, clientId: c, tokenEndpoint: e, trusted: t === true };
}
function hostOf(url: string): string {
try {
return new URL(url).hostname.toLowerCase();
} catch {
return '';
}
}
function isLoopbackHttp(url: string): boolean {
const host = hostOf(url);
return url.toLowerCase().startsWith('http://') && (host === 'localhost' || host === '127.0.0.1' || host === '10.0.2.2');
}
/**
* Mirrors the app's `isAcceptableTokenEndpoint` (src/lib/oauth.ts): https (or
* loopback http for development), on the host family of the JMAP server or
* the webmail.
*/
export function appAcceptsTokenEndpoint(tokenEndpoint: string, serverUrl: string, webmailBase: string | null): boolean {
if (!tokenEndpoint.toLowerCase().startsWith('https://') && !isLoopbackHttp(tokenEndpoint)) return false;
const host = hostOf(tokenEndpoint);
if (!host) return false;
const allowed = [hostOf(serverUrl), webmailBase ? hostOf(webmailBase) : ''].filter(Boolean);
return allowed.some((h) => host === h || host.endsWith(`.${h}`) || h.endsWith(`.${host}`));
}
export type RedeemBundle =
| {
flow: 'oauth';
server_url: string;
access_token: string;
refresh_token?: string;
expires_in?: number;
token_endpoint: string;
client_id: string;
}
| { flow: 'password'; server_url: string; username: string; password: string };
/**
* The redeem response for a grant. `webmailBase` is the public base the
* desktop showed in the QR (origin plus mount path), used for the proxy URL.
*/
export function buildRedeemBundle(
grant: PairingGrant,
webmailBase: string | null,
now = Date.now(),
): RedeemBundle {
if (grant.flow === 'password') {
return { flow: 'password', server_url: grant.serverUrl, username: grant.username, password: grant.password };
}
const expiresIn = typeof grant.expiresIn === 'number'
? Math.max(0, grant.expiresIn - Math.floor((now - grant.issuedAt) / 1000))
: undefined;
const direct = !grant.confidential && appAcceptsTokenEndpoint(grant.tokenEndpoint, grant.serverUrl, webmailBase);
if (direct || !webmailBase) {
return {
flow: 'oauth',
server_url: grant.serverUrl,
access_token: grant.accessToken,
...(grant.refreshToken ? { refresh_token: grant.refreshToken } : {}),
...(expiresIn !== undefined ? { expires_in: expiresIn } : {}),
token_endpoint: grant.tokenEndpoint,
client_id: grant.clientId,
};
}
return {
flow: 'oauth',
server_url: grant.serverUrl,
access_token: grant.accessToken,
...(grant.refreshToken
? {
refresh_token: sealPhoneRefreshToken({
refreshToken: grant.refreshToken,
serverId: grant.serverId,
clientId: grant.clientId,
tokenEndpoint: grant.tokenEndpoint,
trusted: grant.trusted,
}),
}
: {}),
...(expiresIn !== undefined ? { expires_in: expiresIn } : {}),
token_endpoint: `${webmailBase.replace(/\/+$/, '')}${PAIR_TOKEN_PROXY_PATH}`,
client_id: grant.clientId,
};
}