feat: require re-authentication for device pairing and SSO
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="space-y-3">
|
||||
@@ -722,7 +766,7 @@ function LinkDeviceSection() {
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={generate} disabled={loading}>
|
||||
<Button variant="outline" size="sm" onClick={() => void generate()} disabled={loading}>
|
||||
{loading ? (
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { encryptPayload, decryptPayload } from '@/lib/auth/crypto';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
// Step-up re-authentication proof for device pairing. Generating a pairing QR
|
||||
// provisions a new long-lived login on another device, so we require the user
|
||||
// to have re-authenticated at the IdP within a short window first (see the
|
||||
// reauth SSO flow). This proof is a short-lived, encrypted, httpOnly cookie —
|
||||
// it is set only after a fresh IdP login and checked by /api/auth/pair/create.
|
||||
|
||||
const PAIR_REAUTH_COOKIE = 'pair_reauth';
|
||||
const PAIR_REAUTH_TTL_MS = 5 * 60 * 1000; // 5 minutes — long enough to render the QR
|
||||
|
||||
export async function setPairReauth(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
const value = encryptPayload({ purpose: 'pair', created_at: Date.now() });
|
||||
cookieStore.set(PAIR_REAUTH_COOKIE, value, {
|
||||
...getCookieOptions(),
|
||||
maxAge: Math.floor(PAIR_REAUTH_TTL_MS / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
export async function hasValidPairReauth(): Promise<boolean> {
|
||||
const cookieStore = await cookies();
|
||||
const raw = cookieStore.get(PAIR_REAUTH_COOKIE)?.value;
|
||||
if (!raw) return false;
|
||||
const data = decryptPayload(raw);
|
||||
if (!data || data.purpose !== 'pair') return false;
|
||||
const createdAt = typeof data.created_at === 'number' ? data.created_at : 0;
|
||||
// Belt-and-suspenders alongside the cookie maxAge: a forged/old proof whose
|
||||
// timestamp is outside the window is rejected even if the cookie survived.
|
||||
return Date.now() - createdAt <= PAIR_REAUTH_TTL_MS;
|
||||
}
|
||||
|
||||
export async function clearPairReauth(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(PAIR_REAUTH_COOKIE);
|
||||
}
|
||||
Reference in New Issue
Block a user