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

215 lines
8.5 KiB
TypeScript

import { logger } from '@/lib/logger';
import { stalwartPasswordLogin, type StalwartPasswordLoginResult } from '@/lib/auth/stalwart-password-login';
import type { PairingGrant } from '@/lib/auth/pairing-store';
import { DEFAULT_CLIENT_ID, getClientSecret, getRequiredConfig } from '@/lib/oauth/token-exchange';
import { JmapAuthVerificationError, verifyJmapIdentity } from '@/lib/auth/verify-jmap-auth';
import { fetchJmapSession } from '@/lib/stalwart/jmap-api';
import { createAppPassword } from '@/lib/impersonation/app-password';
// The password step-up for "Link mobile app": the desktop user types their
// current password (and TOTP code), and the server uses it once to mint the
// phone's own sign-in. The password is never stored, logged or returned -
// except in the last fallback, for servers that offer nothing better.
//
// In order of preference:
// 1. An OAuth grant from Stalwart's structured login (/api/auth, which also
// takes the TOTP code), for the client the app itself uses natively, so
// the phone renews it directly. If the server insists on a registered
// client, the webmail's own client (as the password+TOTP login uses).
// 2. No structured login, but a Stalwart account: an app password the user
// can see and delete ("Bulwark mobile (paired <date>)").
// 3. Neither (not Stalwart): the verified account password, which is what
// the login page's mobile handoff has always passed to the app.
/** The client id and redirect the app uses for its own sign-in (src/lib/totp-login.ts). */
export const MOBILE_CLIENT_ID = DEFAULT_CLIENT_ID;
export const MOBILE_REDIRECT_URI = 'bulwarkmobile://auth/callback';
export type PasswordGrantError =
| 'invalid_credentials'
| 'totp_required'
| 'server_unreachable'
| 'pairing_unavailable';
export type PasswordGrantResult = { ok: true; grant: PairingGrant } | { ok: false; error: PasswordGrantError };
export interface PasswordGrantOptions {
/** JMAP server of the signed-in account (from its verified session context). */
serverUrl: string;
/** Whether that server is admin-configured (else: rebinding-safe fetch). */
trusted: boolean;
/** Its server entry, for per-server OAuth client settings. */
serverId: string | null;
/** The account's login name. */
username: string;
password: string;
totp?: string;
/** The desktop's OAuth callback URL, for the webmail-client attempt. */
webmailRedirectUri?: string;
now?: Date;
}
function configuredClient(serverId: string | null): { clientId: string; clientSecret: string } {
let clientId = DEFAULT_CLIENT_ID;
try {
clientId = getRequiredConfig(serverId, { fallbackClientId: DEFAULT_CLIENT_ID }).clientId;
} catch {
// No server configured (custom endpoints only): the default client.
}
return { clientId, clientSecret: getClientSecret(serverId) };
}
function oauthGrant(
opts: PasswordGrantOptions,
result: Extract<StalwartPasswordLoginResult, { ok: true }>,
clientId: string,
confidential: boolean,
): PairingGrant {
return {
flow: 'oauth',
serverUrl: opts.serverUrl,
serverId: opts.serverId,
accessToken: result.tokens.access_token,
...(result.tokens.refresh_token ? { refreshToken: result.tokens.refresh_token } : {}),
expiresIn: result.tokens.expires_in ?? 3600,
issuedAt: (opts.now ?? new Date()).getTime(),
tokenEndpoint: result.tokenEndpoint,
clientId,
confidential,
trusted: opts.trusted,
};
}
/** A credential verdict ends the attempt; anything else may try the next way. */
function verdict(result: StalwartPasswordLoginResult): PasswordGrantResult | null {
if (result.ok) return null;
switch (result.error) {
case 'invalid_credentials':
case 'totp_required':
return { ok: false, error: result.error };
case 'login_unreachable':
case 'invalid_server_url':
return { ok: false, error: 'server_unreachable' };
default:
return null;
}
}
function pairedDescription(now: Date): string {
return `Bulwark mobile (paired ${now.toISOString().slice(0, 10)})`;
}
export async function mintGrantWithPassword(opts: PasswordGrantOptions): Promise<PasswordGrantResult> {
const now = opts.now ?? new Date();
const configured = configuredClient(opts.serverId);
// The secret belongs to the admin's server; a user-chosen one never sees it.
if (!opts.trusted) configured.clientSecret = '';
// 1a. The app's own client. It is public unless the admin registered it
// with a secret for the webmail too - then the secret is ours to send, and
// the phone must renew through the webmail.
const mobileSecret = configured.clientId === MOBILE_CLIENT_ID ? configured.clientSecret : '';
const first = await stalwartPasswordLogin({
serverUrl: opts.serverUrl,
username: opts.username,
password: opts.password,
totp: opts.totp,
clientId: MOBILE_CLIENT_ID,
clientSecret: mobileSecret,
redirectUri: MOBILE_REDIRECT_URI,
trusted: opts.trusted,
});
if (first.ok) return { ok: true, grant: oauthGrant(opts, first, MOBILE_CLIENT_ID, mobileSecret !== '') };
const firstVerdict = verdict(first);
if (firstVerdict) return firstVerdict;
const hasStructuredLogin = first.error !== 'login_endpoint_missing';
// 1b. The server wants a registered client or redirect: the webmail's own,
// exactly as its password+TOTP login is set up.
if (hasStructuredLogin && opts.webmailRedirectUri
&& (configured.clientId !== MOBILE_CLIENT_ID || opts.webmailRedirectUri !== MOBILE_REDIRECT_URI)) {
const second = await stalwartPasswordLogin({
serverUrl: opts.serverUrl,
username: opts.username,
password: opts.password,
totp: opts.totp,
clientId: configured.clientId,
clientSecret: configured.clientSecret,
redirectUri: opts.webmailRedirectUri,
trusted: opts.trusted,
});
if (second.ok) {
return { ok: true, grant: oauthGrant(opts, second, configured.clientId, configured.clientSecret !== '') };
}
const secondVerdict = verdict(second);
if (secondVerdict) return secondVerdict;
logger.warn('Pair step-up: structured login refused both clients', { first: first.error, second: second.error });
}
// 2./3. Basic auth. A TOTP account cannot use it on Stalwart 0.16 (it
// answers 402 when the password is right), so with a code there is nothing
// left to try.
const authHeader = `Basic ${Buffer.from(`${opts.username}:${opts.password}`).toString('base64')}`;
try {
await verifyJmapIdentity(opts.serverUrl, authHeader, opts.username, { trusted: opts.trusted });
} catch (error) {
if (error instanceof JmapAuthVerificationError && error.status === 401) {
return { ok: false, error: 'invalid_credentials' };
}
// Stalwart answers Basic auth for a TOTP account with 402 when the
// password is right.
if (error instanceof JmapAuthVerificationError && error.upstreamStatus === 402) {
return { ok: false, error: opts.totp ? 'pairing_unavailable' : 'totp_required' };
}
logger.warn('Pair step-up: credential check failed', {
status: error instanceof JmapAuthVerificationError ? error.status : undefined,
});
return { ok: false, error: 'server_unreachable' };
}
const session = await fetchJmapSession(opts.serverUrl, authHeader, { trusted: opts.trusted }).catch(() => null);
const isStalwart = !!session && Object.values(
(session as { accounts?: Record<string, { accountCapabilities?: Record<string, unknown> }> }).accounts ?? {},
).some((account) => !!account?.accountCapabilities?.['urn:stalwart:jmap']);
if (isStalwart) {
try {
const appPassword = await createAppPassword({
serverUrl: opts.serverUrl,
authHeader,
description: pairedDescription(now),
trusted: opts.trusted,
});
return {
ok: true,
grant: {
flow: 'password',
serverUrl: opts.serverUrl,
username: opts.username,
password: appPassword.secret,
credential: 'app-password',
},
};
} catch (error) {
// An account that may not create app passwords (permissions, quota)
// gets no phone credential at all rather than its real password: on a
// Stalwart server that is a policy, not a missing feature.
logger.warn('Pair step-up: app password was not created', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return { ok: false, error: 'pairing_unavailable' };
}
}
return {
ok: true,
grant: {
flow: 'password',
serverUrl: opts.serverUrl,
username: opts.username,
password: opts.password,
credential: 'account-password',
},
};
}