feat: add QR code device pairing for mobile app login
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user