From 1d050f84690c7f9ba8143877036beb1ad24a850f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:41:55 +0200 Subject: [PATCH] feat: add QR code device pairing for mobile app login --- app/api/auth/pair/create/route.ts | 88 +++++++++++++++ app/api/auth/pair/redeem/route.ts | 36 +++++++ .../settings/account-security-settings.tsx | 100 +++++++++++++++++- lib/auth/pairing-store.ts | 62 +++++++++++ locales/en/common.json | 11 ++ 5 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 app/api/auth/pair/create/route.ts create mode 100644 app/api/auth/pair/redeem/route.ts create mode 100644 lib/auth/pairing-store.ts diff --git a/app/api/auth/pair/create/route.ts b/app/api/auth/pair/create/route.ts new file mode 100644 index 00000000..f3b71819 --- /dev/null +++ b/app/api/auth/pair/create/route.ts @@ -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 }); + } +} diff --git a/app/api/auth/pair/redeem/route.ts b/app/api/auth/pair/redeem/route.ts new file mode 100644 index 00000000..136aff69 --- /dev/null +++ b/app/api/auth/pair/redeem/route.ts @@ -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 }); + } +} diff --git a/components/settings/account-security-settings.tsx b/components/settings/account-security-settings.tsx index 2c9d0e11..4a2e34ab 100644 --- a/components/settings/account-security-settings.tsx +++ b/components/settings/account-security-settings.tsx @@ -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(null); + const [remaining, setRemaining] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
+ +

{t('link_device.title')}

+
+

{t('link_device.description')}

+ + {qrDataUrl && remaining > 0 && ( +
+
+ Pairing QR code +
+

{t('link_device.instructions')}

+

+ {t('link_device.expires_in', { seconds: remaining })} +

+
+ )} + + {error &&

{error}

} + + +
+ ); +} + export function AccountSecuritySettings() { const t = useTranslations('settings.security'); const { isStalwart, isProbing, probe, fetchAll, fetchAuthInfo } = useAccountSecurityStore(); @@ -706,6 +802,8 @@ export function AccountSecuritySettings() { <>
+
+ )} diff --git a/lib/auth/pairing-store.ts b/lib/auth/pairing-store.ts new file mode 100644 index 00000000..2604143a --- /dev/null +++ b/lib/auth/pairing-store.ts @@ -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(); + +// 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; +} diff --git a/locales/en/common.json b/locales/en/common.json index 394ba782..947b4453 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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": {