feat: add QR code device pairing for mobile app login

This commit is contained in:
Linus Rath
2026-06-05 17:41:55 +02:00
parent 2e4c0f9eea
commit 1d050f8469
5 changed files with 296 additions and 1 deletions
+88
View File
@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { buildOAuthParams, getRequiredConfig, getTokenEndpoint } from '@/lib/oauth/token-exchange';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { createPairing } from '@/lib/auth/pairing-store';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
// Desktop side of the cross-device QR login. The caller must be a signed-in
// webmail session (its refresh token lives in the httpOnly jmap_rt cookie). We
// refresh that token to (a) prove the session is live and (b) obtain a fresh
// access token to hand the phone, then stash the bundle under a one-time
// pairing code. The desktop renders the returned code as a QR; the phone
// redeems it at /api/auth/pair/redeem.
//
// Token sharing note: the phone receives the SAME refresh token as the desktop.
// That is correct for OAuth servers (such as Stalwart in its default config)
// that do not rotate refresh tokens on use. If the server rotates refresh
// tokens, the two devices would fight over the latest token — such deployments
// should disable rotation for this client or use a token-exchange grant.
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
try {
const body = await request.json().catch(() => ({}));
const slot =
typeof body.slot === 'number' && body.slot >= 0 && body.slot < MAX_ACCOUNT_SLOTS
? body.slot
: 0;
const cookieName = refreshTokenCookieName(slot);
const refreshToken = cookieStore.get(cookieName)?.value;
if (!refreshToken) {
return NextResponse.json({ error: 'Not signed in' }, { status: 401 });
}
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
const tokenEndpoint = await getTokenEndpoint(serverId);
const params = buildOAuthParams({ grant_type: 'refresh_token', refresh_token: refreshToken }, serverId);
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
logger.warn('Pair create: refresh failed', { status: tokenResponse.status, error: errorText });
// Stale session — clear the dead cookie so the user is prompted to log
// back in, mirroring the token route's behaviour.
cookieStore.delete(cookieName);
cookieStore.delete(refreshTokenServerCookieName(slot));
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
}
const tokens = await tokenResponse.json();
if (!tokens.access_token) {
logger.error('Pair create: refresh response missing access_token');
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
}
// If the server rotated the refresh token, persist the new one back to the
// desktop's cookie so this very session keeps working. The phone will get
// the same (new) token below.
const effectiveRefreshToken = tokens.refresh_token || refreshToken;
if (tokens.refresh_token) {
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
}
const { clientId, serverUrl } = getRequiredConfig(serverId);
const { code, expiresIn } = createPairing({
accessToken: tokens.access_token,
refreshToken: effectiveRefreshToken,
expiresIn: tokens.expires_in,
tokenEndpoint,
clientId,
serverUrl,
serverId,
});
return NextResponse.json({ pairing_code: code, server_url: serverUrl, expires_in: expiresIn });
} catch (error) {
logger.error('Pair create error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { consumePairing } from '@/lib/auth/pairing-store';
// Phone side of the cross-device QR login. The app POSTs the pairing code it
// scanned; we hand back the OAuth token bundle the desktop stashed at
// /api/auth/pair/create. The code is the only credential required — it is
// high-entropy, single-use, and expires within ~2 minutes — so this route is
// intentionally unauthenticated (the scanning device has no webmail cookies).
export async function POST(request: NextRequest) {
try {
const { pairing_code: pairingCode } = await request.json().catch(() => ({}));
if (!pairingCode || typeof pairingCode !== 'string') {
return NextResponse.json({ error: 'Missing pairing code' }, { status: 400 });
}
const tokens = consumePairing(pairingCode);
if (!tokens) {
// Unknown, expired, or already redeemed — do not distinguish.
return NextResponse.json({ error: 'Invalid or expired pairing code' }, { status: 400 });
}
return NextResponse.json({
flow: 'oauth',
server_url: tokens.serverUrl,
access_token: tokens.accessToken,
...(tokens.refreshToken ? { refresh_token: tokens.refreshToken } : {}),
...(typeof tokens.expiresIn === 'number' ? { expires_in: tokens.expiresIn } : {}),
token_endpoint: tokens.tokenEndpoint,
client_id: tokens.clientId,
});
} catch (error) {
logger.error('Pair redeem error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -4,12 +4,14 @@ import { useState, useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import QRCode from 'qrcode';
import * as OTPAuth from 'otpauth';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor, Terminal } from 'lucide-react';
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor, Terminal, QrCode } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { useAccountSecurityStore, type AppPasswordInfo, type ApiKeyInfo, type AppCredentialInput } from '@/stores/account-security-store';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
import { toast } from '@/stores/toast-store';
import { cn } from '@/lib/utils';
import { sanitizeI18nHtml } from '@/lib/email-sanitization';
@@ -638,6 +640,100 @@ function EmailClientSection() {
);
}
// Cross-device QR login. A signed-in (OAuth/SSO) session mints a short-lived
// pairing code via /api/auth/pair/create; we render it as a QR that the mobile
// app scans to sign in without re-typing credentials. The QR payload carries
// only the server URL and the one-time code — never tokens.
function LinkDeviceSection() {
const t = useTranslations('settings.security');
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [remaining, setRemaining] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasGenerated, setHasGenerated] = useState(false);
// Tick the countdown down to zero, then drop the (now useless) QR so the
// user is nudged to generate a fresh one.
useEffect(() => {
if (remaining <= 0) {
setQrDataUrl(null);
return;
}
const timer = setInterval(() => setRemaining((r) => Math.max(0, r - 1)), 1000);
return () => clearInterval(timer);
}, [remaining]);
const generate = async () => {
setLoading(true);
setError(null);
try {
// Pair the account whose session cookie we'll actually refresh — the
// active account's slot.
const slot = useAccountStore.getState().getActiveAccount()?.cookieSlot ?? 0;
const res = await apiFetch('/api/auth/pair/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ slot }),
});
if (!res.ok) {
setError(t('link_device.error'));
return;
}
const data = await res.json();
const code = data.pairing_code as string;
const expiresIn = typeof data.expires_in === 'number' ? data.expires_in : 120;
// The phone redeems the code against THIS webmail (where the pairing
// record lives), so the QR carries the webmail base — origin plus any
// mount prefix — not the JMAP server URL. The JMAP server_url comes back
// in the redeem response.
const webmailBase = `${window.location.origin}${getPathPrefix()}`;
const payload = `bulwarkmail://pair?server=${encodeURIComponent(webmailBase)}&code=${encodeURIComponent(code)}`;
const dataUrl = await QRCode.toDataURL(payload, { width: 240, margin: 1 });
setQrDataUrl(dataUrl);
setRemaining(expiresIn);
setHasGenerated(true);
} catch {
setError(t('link_device.error'));
} finally {
setLoading(false);
}
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<QrCode className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('link_device.title')}</h4>
</div>
<p className="text-xs text-muted-foreground">{t('link_device.description')}</p>
{qrDataUrl && remaining > 0 && (
<div className="p-3 bg-muted/70 dark:bg-muted/40 rounded-md space-y-2">
<div className="flex justify-center">
<img src={qrDataUrl} alt="Pairing QR code" className="rounded bg-white p-2" />
</div>
<p className="text-xs text-muted-foreground text-center">{t('link_device.instructions')}</p>
<p className="text-[11px] text-muted-foreground text-center">
{t('link_device.expires_in', { seconds: remaining })}
</p>
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
<Button variant="outline" size="sm" onClick={generate} disabled={loading}>
{loading ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<QrCode className="w-3 h-3 mr-1" />
)}
{hasGenerated ? t('link_device.regenerate') : t('link_device.generate')}
</Button>
</div>
);
}
export function AccountSecuritySettings() {
const t = useTranslations('settings.security');
const { isStalwart, isProbing, probe, fetchAll, fetchAuthInfo } = useAccountSecurityStore();
@@ -706,6 +802,8 @@ export function AccountSecuritySettings() {
<>
<div className="border-t border-border" />
<EmailClientSection />
<div className="border-t border-border" />
<LinkDeviceSection />
</>
)}
+62
View File
@@ -0,0 +1,62 @@
import { randomBytes } from 'crypto';
// Cross-device login pairing. A signed-in desktop session mints a short-lived,
// single-use pairing code (see /api/auth/pair/create) which is rendered as a
// QR. The mobile app scans it and redeems the code (see /api/auth/pair/redeem)
// for the OAuth token bundle, so the phone is signed in without re-typing
// anything. The code itself carries no secrets — the tokens never leave the
// server until the matching code is redeemed exactly once.
//
// Storage is an in-process Map. That is sufficient for the single-instance
// (pm2) deployments this webmail targets; a multi-instance deployment would
// need to swap this for a shared store (Redis) keyed the same way. Records are
// tiny and expire within PAIRING_TTL_MS, so memory pressure is negligible.
export interface PairingTokens {
accessToken: string;
refreshToken?: string;
expiresIn?: number;
tokenEndpoint: string;
clientId: string;
serverUrl: string;
serverId: string | null;
}
interface PairingRecord extends PairingTokens {
expiresAt: number; // epoch ms
}
const PAIRING_TTL_MS = 2 * 60 * 1000; // 2 minutes — enough time to scan
const CODE_BYTES = 32; // 256 bits of entropy
const store = new Map<string, PairingRecord>();
// Drop any expired records. Called on every create/consume so the Map can't
// grow without bound even if codes are minted and never redeemed.
function sweep(now: number): void {
for (const [code, record] of store) {
if (record.expiresAt <= now) store.delete(code);
}
}
export function createPairing(tokens: PairingTokens): { code: string; expiresIn: number } {
const now = Date.now();
sweep(now);
const code = randomBytes(CODE_BYTES).toString('hex');
store.set(code, { ...tokens, expiresAt: now + PAIRING_TTL_MS });
return { code, expiresIn: Math.floor(PAIRING_TTL_MS / 1000) };
}
// Single-use: a successful lookup removes the record so a code can never be
// redeemed twice. Returns null for unknown, expired, or already-redeemed codes
// — the caller must not distinguish these to avoid leaking code validity.
export function consumePairing(code: string): PairingTokens | null {
const now = Date.now();
sweep(now);
const record = store.get(code);
if (!record) return null;
store.delete(code);
if (record.expiresAt <= now) return null;
const { expiresAt: _expiresAt, ...tokens } = record;
return tokens;
}
+11
View File
@@ -1383,6 +1383,17 @@
"copy": "Copy",
"copied": "Copied",
"password_instructions": "Use your JMAP username above along with an app password to sign in to your email client. Create an app password in the section above if you haven't already."
},
"link_device": {
"title": "Link Mobile App",
"description": "Sign in to the Bulwark Mail mobile app without typing anything. Generate a QR code here and scan it from the app's login screen.",
"generate": "Show QR code",
"regenerate": "Show a new code",
"instructions": "Open the Bulwark Mail app, tap \"Scan QR code\" on the login screen, and point your camera here.",
"expires_in": "This code expires in {seconds} seconds. It can only be used once.",
"expired": "This code has expired.",
"generating": "Generating…",
"error": "Couldn't create a pairing code. Please try again."
}
},
"identities": {