Files
webmail/lib/auth/pair-bundle.ts
Linus Rath 377c283d86 fix: say up front when the mobile app cannot use an http mail server
The app refuses a JMAP server over plain http (loopback aside), yet the
desktop issued a code, reported "Signed in on your phone" once it was
redeemed, and the phone failed. Linking now stops before the step-up
with an explanation.
2026-09-27 04:53:49 +02:00

145 lines
5.2 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');
}
/**
* Whether the app will connect to this JMAP server at all: https, or loopback
* http for development (the app's own rule for a paired `server_url`).
*/
export function appAcceptsServerUrl(serverUrl: string): boolean {
return serverUrl.toLowerCase().startsWith('https://') || isLoopbackHttp(serverUrl);
}
/**
* 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,
};
}