diff --git a/app/(main)/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx index a0845972..bbd8076c 100644 --- a/app/(main)/[locale]/auth/callback/page.tsx +++ b/app/(main)/[locale]/auth/callback/page.tsx @@ -32,6 +32,42 @@ function OAuthCallbackInner() { return; } + // Step-up re-auth for device pairing: the QR generator sent the user here + // via prompt=login. Don't create a login session — just confirm the fresh + // auth (sets the short-lived pairing proof cookie) and bounce back to the + // Security settings, where the QR generation auto-resumes. + let pairReauthResume = false; + try { + pairReauthResume = sessionStorage.getItem("pair_reauth_resume") === "1"; + } catch { /* sessionStorage unavailable */ } + if (pairReauthResume && state) { + try { sessionStorage.removeItem("pair_reauth_resume"); } catch { /* ignore */ } + (async () => { + try { + const res = await apiFetch("/api/auth/reauth/sso/complete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ code, state }), + }); + if (!res.ok) { + setError("token_exchange_failed"); + return; + } + try { + sessionStorage.setItem("pair_reauth_done", "1"); + // Land back on the Security tab (readPersistedTab reads this key). + sessionStorage.setItem("settings-deep-link-tab", "security"); + } catch { /* ignore */ } + const prefix = getPathPrefix(params.locale as string); + router.push(`${prefix}/${params.locale}/settings`); + } catch { + setError("token_exchange_failed"); + } + })(); + return; + } + const savedState = sessionStorage.getItem("oauth_state"); if (savedState) { diff --git a/app/api/auth/pair/create/route.ts b/app/api/auth/pair/create/route.ts index f3b71819..6d98ddd2 100644 --- a/app/api/auth/pair/create/route.ts +++ b/app/api/auth/pair/create/route.ts @@ -5,6 +5,7 @@ import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oaut import { buildOAuthParams, getRequiredConfig, getTokenEndpoint } from '@/lib/oauth/token-exchange'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { createPairing } from '@/lib/auth/pairing-store'; +import { hasValidPairReauth } from '@/lib/auth/pair-reauth'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; // Desktop side of the cross-device QR login. The caller must be a signed-in @@ -22,6 +23,13 @@ import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; export async function POST(request: NextRequest) { const cookieStore = await cookies(); try { + // Step-up gate: minting a pairing code grants new-device access, so it + // requires a recent fresh IdP re-authentication (see the reauth SSO flow). + // The client turns this 401 into a re-auth redirect, then retries. + if (!(await hasValidPairReauth())) { + return NextResponse.json({ error: 'reauth_required' }, { status: 401 }); + } + const body = await request.json().catch(() => ({})); const slot = typeof body.slot === 'number' && body.slot >= 0 && body.slot < MAX_ACCOUNT_SLOTS diff --git a/app/api/auth/reauth/sso/complete/route.ts b/app/api/auth/reauth/sso/complete/route.ts new file mode 100644 index 00000000..2e8d6cd7 --- /dev/null +++ b/app/api/auth/reauth/sso/complete/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { logger } from '@/lib/logger'; +import { decryptPayload } from '@/lib/auth/crypto'; +import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange'; +import { setPairReauth } from '@/lib/auth/pair-reauth'; + +// Completes the step-up re-authentication for device pairing. The user was sent +// to the IdP with prompt=login (see /api/auth/sso/start with purpose=reauth); +// here we verify the returned code against the pending state and exchange it to +// confirm a fresh login actually happened, then set the short-lived pairing +// re-auth proof cookie. We deliberately do NOT issue a login session or write +// any refresh-token cookies — the user is already signed in; this only proves +// recency for the pairing action. + +const SSO_PENDING_COOKIE = 'sso_pending'; +const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; + +export async function POST(request: NextRequest) { + const cookieStore = await cookies(); + try { + const { code, state } = await request.json(); + if (!code || !state) { + return NextResponse.json({ error: 'Missing code or state' }, { status: 400 }); + } + + const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value; + if (!pendingCookie) { + return NextResponse.json({ error: 'No pending re-auth session' }, { status: 400 }); + } + + const pending = decryptPayload(pendingCookie); + cookieStore.delete(SSO_PENDING_COOKIE); + if (!pending) { + return NextResponse.json({ error: 'Invalid re-auth session' }, { status: 400 }); + } + + // Only honor pending sessions that were started for the reauth purpose, so + // a normal login code can't be redirected into setting a pairing proof. + if (pending.purpose !== 'reauth') { + return NextResponse.json({ error: 'Not a re-auth session' }, { status: 400 }); + } + if (pending.state !== state) { + return NextResponse.json({ error: 'State mismatch' }, { status: 400 }); + } + const createdAt = pending.created_at as number; + if (!createdAt || Date.now() - createdAt > SSO_PENDING_MAX_AGE_MS) { + return NextResponse.json({ error: 'Re-auth session expired' }, { status: 400 }); + } + + const codeVerifier = pending.code_verifier as string; + const redirectUri = pending.redirect_uri as string; + const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null; + if (!codeVerifier || !redirectUri) { + return NextResponse.json({ error: 'Invalid re-auth session data' }, { status: 400 }); + } + + // A successful exchange proves the user just authenticated at the IdP (the + // freshness is enforced by prompt=login on the authorize request). We don't + // keep the resulting tokens. + await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId); + + await setPairReauth(); + return NextResponse.json({ ok: true }); + } catch (error) { + cookieStore.delete(SSO_PENDING_COOKIE); + logger.error('Reauth complete error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Re-authentication failed' }, { status: 401 }); + } +} diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts index 9edfbd31..896e1eb0 100644 --- a/app/api/auth/sso/start/route.ts +++ b/app/api/auth/sso/start/route.ts @@ -30,8 +30,14 @@ export async function POST(request: NextRequest) { server_id: bodyServerId, mobile_redirect_uri: rawMobileRedirectUri, mobile_state: rawMobileState, + purpose: rawPurpose, } = await request.json(); + // `reauth` drives the step-up flow for device pairing: it forces a fresh + // IdP login (prompt=login) and the /reauth/sso/complete handler sets the + // short-lived pairing re-auth proof instead of logging the user in again. + const isReauth = rawPurpose === 'reauth'; + if (!redirect_uri || typeof redirect_uri !== 'string') { return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 }); } @@ -85,6 +91,7 @@ export async function POST(request: NextRequest) { ...(serverId ? { server_id: serverId } : {}), ...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}), ...(mobileState ? { mobile_state: mobileState } : {}), + ...(isReauth ? { purpose: 'reauth' } : {}), }; const encrypted = encryptPayload(pendingData); @@ -109,6 +116,14 @@ export async function POST(request: NextRequest) { authUrl.searchParams.set('ui_locales', locale); } + // Force a fresh credential entry for step-up re-auth. prompt=login and + // max_age=0 both ask the IdP to re-authenticate even if it has an active + // session; honoring them depends on the IdP supporting these OIDC params. + if (isReauth) { + authUrl.searchParams.set('prompt', 'login'); + authUrl.searchParams.set('max_age', '0'); + } + return NextResponse.json({ authorize_url: authUrl.toString(), state, diff --git a/components/settings/account-security-settings.tsx b/components/settings/account-security-settings.tsx index 64a5b2d0..911244aa 100644 --- a/components/settings/account-security-settings.tsx +++ b/components/settings/account-security-settings.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; import QRCode from 'qrcode'; import * as OTPAuth from 'otpauth'; @@ -646,6 +647,8 @@ function EmailClientSection() { // only the server URL and the one-time code — never tokens. function LinkDeviceSection() { const t = useTranslations('settings.security'); + const params = useParams(); + const locale = params.locale as string; const [qrDataUrl, setQrDataUrl] = useState(null); const [remaining, setRemaining] = useState(0); const [loading, setLoading] = useState(false); @@ -663,7 +666,33 @@ function LinkDeviceSection() { return () => clearInterval(timer); }, [remaining]); - const generate = async () => { + // Send the user to the IdP for a fresh login (prompt=login). On return the + // callback page sets the pairing re-auth proof and bounces back here, where + // the resume effect below re-runs generate(). + const startReauth = useCallback(async () => { + try { + sessionStorage.setItem('pair_reauth_resume', '1'); + } catch { /* sessionStorage unavailable */ } + const prefix = getPathPrefix(locale); + const redirectUri = `${window.location.origin}${prefix}/${locale}/auth/callback`; + const res = await apiFetch('/api/auth/sso/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ redirect_uri: redirectUri, locale, purpose: 'reauth' }), + }); + if (!res.ok) { + setError(t('link_device.error')); + return; + } + const { authorize_url } = await res.json(); + window.location.href = authorize_url; + }, [locale, t]); + + // `fromResume` guards against a redirect loop: if we just completed re-auth + // and pair/create still demands it, surface an error instead of bouncing to + // the IdP again. + const generate = useCallback(async (fromResume = false) => { setLoading(true); setError(null); try { @@ -677,6 +706,11 @@ function LinkDeviceSection() { body: JSON.stringify({ slot }), }); if (!res.ok) { + const errBody = await res.json().catch(() => null); + if (res.status === 401 && errBody?.error === 'reauth_required' && !fromResume) { + await startReauth(); + return; + } setError(t('link_device.error')); return; } @@ -698,7 +732,17 @@ function LinkDeviceSection() { } finally { setLoading(false); } - }; + }, [t, startReauth]); + + // Auto-resume after returning from the step-up re-auth round-trip. + useEffect(() => { + let resume = false; + try { + resume = sessionStorage.getItem('pair_reauth_done') === '1'; + if (resume) sessionStorage.removeItem('pair_reauth_done'); + } catch { /* sessionStorage unavailable */ } + if (resume) void generate(true); + }, [generate]); return (
@@ -722,7 +766,7 @@ function LinkDeviceSection() { {error &&

{error}

} -