mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
"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.
71 lines
3.1 KiB
TypeScript
71 lines
3.1 KiB
TypeScript
import type { cookies } from 'next/headers';
|
|
import { encryptPayload, decryptPayload } from '@/lib/auth/crypto';
|
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
|
import { discardGrant } from '@/lib/auth/pairing-store';
|
|
|
|
// Step-up proof for device pairing. Linking a phone provisions a new
|
|
// long-lived login, so the desktop must prove who it is again first: the
|
|
// current password (and TOTP) or a fresh IdP login. The step-up mints the
|
|
// phone's own grant (see pairing-store); this short-lived, encrypted,
|
|
// httpOnly cookie remembers which grant, and for which signed-in account, so
|
|
// the desktop can show a new QR within the window without asking again.
|
|
|
|
type CookieStore = Awaited<ReturnType<typeof cookies>>;
|
|
|
|
const PAIR_REAUTH_COOKIE = 'pair_reauth';
|
|
const PAIR_REAUTH_TTL_MS = 5 * 60 * 1000; // matches the grant's lifetime
|
|
|
|
export interface PairReauthProof {
|
|
grantId: string;
|
|
owner: string;
|
|
}
|
|
|
|
/** The account a step-up is valid for: the cookie slot and its verified user. */
|
|
export function pairingOwner(slot: number, username: string): string {
|
|
return `${slot}:${username.trim().toLowerCase()}`;
|
|
}
|
|
|
|
export function setPairReauthInStore(cookieStore: CookieStore, proof: PairReauthProof): void {
|
|
// A new step-up replaces the previous grant; its QR must stop working.
|
|
const previous = readPairReauthFromStore(cookieStore);
|
|
if (previous && previous.grantId !== proof.grantId) discardGrant(previous.grantId);
|
|
const value = encryptPayload(
|
|
{ purpose: 'pair', created_at: Date.now(), grant_id: proof.grantId, owner: proof.owner },
|
|
'pair-reauth',
|
|
);
|
|
cookieStore.set(PAIR_REAUTH_COOKIE, value, {
|
|
...getCookieOptions(),
|
|
maxAge: Math.floor(PAIR_REAUTH_TTL_MS / 1000),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The live proof, or null. A proof from before grants existed (no grant id)
|
|
* fails closed, so the user is simply asked to confirm again.
|
|
*/
|
|
export function readPairReauthFromStore(cookieStore: CookieStore): PairReauthProof | null {
|
|
const raw = cookieStore.get(PAIR_REAUTH_COOKIE)?.value;
|
|
if (!raw) return null;
|
|
const data = decryptPayload(raw, 'pair-reauth');
|
|
if (!data || data.purpose !== 'pair') return null;
|
|
const createdAt = typeof data.created_at === 'number' ? data.created_at : 0;
|
|
// Belt-and-suspenders alongside the cookie maxAge: an old proof whose
|
|
// timestamp is outside the window is rejected even if the cookie survived.
|
|
if (Date.now() - createdAt > PAIR_REAUTH_TTL_MS) return null;
|
|
if (typeof data.grant_id !== 'string' || typeof data.owner !== 'string') return null;
|
|
return { grantId: data.grant_id, owner: data.owner };
|
|
}
|
|
|
|
/**
|
|
* Sign-out: forget the proof and kill its grant, so a QR left on screen dies.
|
|
* With `slot`, only when the proof belongs to that account.
|
|
*/
|
|
export function clearPairReauthInStore(cookieStore: CookieStore, slot?: number): void {
|
|
const raw = cookieStore.get(PAIR_REAUTH_COOKIE)?.value;
|
|
if (!raw) return;
|
|
const proof = readPairReauthFromStore(cookieStore);
|
|
if (slot !== undefined && proof && !proof.owner.startsWith(`${slot}:`)) return;
|
|
if (proof) discardGrant(proof.grantId);
|
|
cookieStore.delete(PAIR_REAUTH_COOKIE);
|
|
}
|