From c991fe7b6df55ce159754bc446efd1866dfb58b5 Mon Sep 17 00:00:00 2001 From: Jack Mechem Date: Fri, 1 May 2026 14:33:52 -0700 Subject: [PATCH] Security key support --- app/api/auth/login/route.ts | 16 +- app/api/auth/register/finish/route.ts | 17 +++ app/api/auth/register/start/route.ts | 20 +++ app/api/auth/verify/route.ts | 29 ++++ app/auth/page.tsx | 148 ++++++++++++++----- app/enroll/page.tsx | 201 ++++++++++++++++++++++++++ 6 files changed, 384 insertions(+), 47 deletions(-) create mode 100644 app/api/auth/register/finish/route.ts create mode 100644 app/api/auth/register/start/route.ts create mode 100644 app/api/auth/verify/route.ts create mode 100644 app/enroll/page.tsx diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 756bfae..0a42f0b 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -1,14 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; export async function POST(req: NextRequest) { - const { username, password, totp } = await req.json(); + const { username, password } = await req.json(); const res = await fetch("http://localhost:3001/auth/login", { method: "POST", headers: { Authorization: "Basic " + - Buffer.from(`${username}:${password}${totp}`).toString("base64"), + Buffer.from(`${username}:${password}`).toString("base64"), }, }); @@ -16,14 +16,6 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); } - const { token } = await res.json(); - const response = NextResponse.json({ success: true }); - response.cookies.set("token", token, { - httpOnly: true, - secure: true, - sameSite: "strict", - maxAge: 60 * 60 * 8, - path: "/", - }); - return response; + // Returns { session_id, challenge } — browser completes the WebAuthn step + return NextResponse.json(await res.json()); } diff --git a/app/api/auth/register/finish/route.ts b/app/api/auth/register/finish/route.ts new file mode 100644 index 0000000..322c871 --- /dev/null +++ b/app/api/auth/register/finish/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const body = await req.json(); + + const res = await fetch("http://localhost:3001/auth/register/finish", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + return NextResponse.json({ error: "Registration failed" }, { status: 400 }); + } + + return NextResponse.json(await res.json()); +} diff --git a/app/api/auth/register/start/route.ts b/app/api/auth/register/start/route.ts new file mode 100644 index 0000000..92ee1f6 --- /dev/null +++ b/app/api/auth/register/start/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const { username, password } = await req.json(); + + const res = await fetch("http://localhost:3001/auth/register/start", { + method: "POST", + headers: { + Authorization: + "Basic " + + Buffer.from(`${username}:${password}`).toString("base64"), + }, + }); + + if (!res.ok) { + return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); + } + + return NextResponse.json(await res.json()); +} diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts new file mode 100644 index 0000000..ca52c69 --- /dev/null +++ b/app/api/auth/verify/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const body = await req.json(); + + const res = await fetch("http://localhost:3001/auth/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + return NextResponse.json( + { error: "YubiKey verification failed" }, + { status: 401 }, + ); + } + + const { token } = await res.json(); + const response = NextResponse.json({ success: true }); + response.cookies.set("token", token, { + httpOnly: true, + secure: true, + sameSite: "strict", + maxAge: 60 * 60 * 8, + path: "/", + }); + return response; +} diff --git a/app/auth/page.tsx b/app/auth/page.tsx index 3754201..c25f747 100644 --- a/app/auth/page.tsx +++ b/app/auth/page.tsx @@ -2,14 +2,30 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; +function b64uToBuf(b64u: string): ArrayBuffer { + const b64 = b64u.replace(/-/g, "+").replace(/_/g, "/"); + const bin = atob(b64); + const buf = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); + return buf.buffer; +} + +function bufToB64u(buf: ArrayBuffer): string { + const bytes = new Uint8Array(buf); + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +type Status = "idle" | "checking" | "waiting_yubikey" | "verifying"; + export default function AuthPage() { const router = useRouter(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); - const [totp, setTotp] = useState(""); const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - const [checking, setChecking] = useState(true); + const [status, setStatus] = useState("idle"); + const [initialChecking, setInitialChecking] = useState(true); useEffect(() => { async function checkAuth() { @@ -22,7 +38,7 @@ export default function AuthPage() { router.push(callbackUrl); } } finally { - setChecking(false); + setInitialChecking(false); } } checkAuth(); @@ -30,29 +46,96 @@ export default function AuthPage() { async function handleLogin(e: React.FormEvent) { e.preventDefault(); - setLoading(true); setError(""); + setStatus("checking"); + try { - const res = await fetch("/api/auth/login", { + // Step 1: verify password → get WebAuthn challenge + const loginRes = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username, password, totp }), + body: JSON.stringify({ username, password }), }); - if (!res.ok) { - setError("Invalid credentials or authenticator code."); - setLoading(false); + + if (!loginRes.ok) { + setError("Invalid credentials."); + setStatus("idle"); return; } + + const { session_id, challenge } = await loginRes.json(); + + // Step 2: prompt YubiKey tap + setStatus("waiting_yubikey"); + const opts = challenge.publicKey; + opts.challenge = b64uToBuf(opts.challenge); + if (opts.allowCredentials) { + opts.allowCredentials = opts.allowCredentials.map( + (c: { id: string; type: string; transports?: string[] }) => ({ + ...c, + id: b64uToBuf(c.id), + }), + ); + } + + let cred: PublicKeyCredential; + try { + cred = (await navigator.credentials.get({ + publicKey: opts, + })) as PublicKeyCredential; + } catch (err: unknown) { + setError( + "YubiKey error: " + + (err instanceof Error ? err.message : "cancelled"), + ); + setStatus("idle"); + return; + } + + // Step 3: verify assertion → set cookie + setStatus("verifying"); + const assertion = cred.response as AuthenticatorAssertionResponse; + + const verifyRes = await fetch("/api/auth/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + session_id, + credential: { + id: cred.id, + rawId: bufToB64u(cred.rawId), + type: cred.type, + response: { + authenticatorData: bufToB64u(assertion.authenticatorData), + clientDataJSON: bufToB64u(assertion.clientDataJSON), + signature: bufToB64u(assertion.signature), + userHandle: assertion.userHandle + ? bufToB64u(assertion.userHandle) + : null, + }, + extensions: {}, + }, + }), + }); + + if (!verifyRes.ok) { + setError("YubiKey verification failed."); + setStatus("idle"); + return; + } + const callbackUrl = new URLSearchParams(window.location.search).get("callbackUrl") ?? "/"; router.push(callbackUrl); } catch { setError("Something went wrong. Try again."); - setLoading(false); + setStatus("idle"); } } - if (checking) return null; + if (initialChecking) return null; + + const busy = status !== "idle"; return (
@@ -79,13 +162,14 @@ export default function AuthPage() { value={username} onChange={(e) => setUsername(e.target.value)} required + disabled={busy} autoComplete="username" className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm text-gray-900 bg-gray-50 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-50 transition-colors" /> {/* Password */} -
+
@@ -94,44 +178,38 @@ export default function AuthPage() { value={password} onChange={(e) => setPassword(e.target.value)} required + disabled={busy} autoComplete="current-password" className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm text-gray-900 bg-gray-50 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-50 transition-colors" />
- {/* TOTP */} -
- - - setTotp(e.target.value.replace(/\D/g, "").slice(0, 6)) - } - required - autoComplete="one-time-code" - inputMode="numeric" - placeholder="000000" - className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm text-gray-900 bg-gray-50 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-50 transition-colors tracking-widest" - /> -
+ {/* YubiKey cue */} + {status === "waiting_yubikey" && ( +

+ Touch your YubiKey… +

+ )} {/* Error */} - {error &&

{error}

} + {error && ( +

{error}

+ )} {/* Submit */}
diff --git a/app/enroll/page.tsx b/app/enroll/page.tsx new file mode 100644 index 0000000..67f65ec --- /dev/null +++ b/app/enroll/page.tsx @@ -0,0 +1,201 @@ +"use client"; +import { useState } from "react"; + +function b64uToBuf(b64u: string): ArrayBuffer { + const b64 = b64u.replace(/-/g, "+").replace(/_/g, "/"); + const bin = atob(b64); + const buf = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); + return buf.buffer; +} + +function bufToB64u(buf: ArrayBuffer): string { + const bytes = new Uint8Array(buf); + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +type Status = "idle" | "starting" | "waiting_yubikey" | "saving" | "done" | "error"; + +export default function EnrollPage() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [status, setStatus] = useState("idle"); + const [message, setMessage] = useState(""); + + async function enroll(e: React.FormEvent) { + e.preventDefault(); + setStatus("starting"); + setMessage(""); + + try { + // Step 1: get registration challenge + const startRes = await fetch("/api/auth/register/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + + if (!startRes.ok) { + setMessage("Invalid credentials."); + setStatus("error"); + return; + } + + const { session_id, challenge } = await startRes.json(); + + // Step 2: create credential with YubiKey + setStatus("waiting_yubikey"); + const opts = challenge.publicKey; + opts.challenge = b64uToBuf(opts.challenge); + opts.user.id = b64uToBuf(opts.user.id); + if (opts.excludeCredentials) { + opts.excludeCredentials = opts.excludeCredentials.map( + (c: { id: string; type: string; transports?: string[] }) => ({ + ...c, + id: b64uToBuf(c.id), + }), + ); + } + + let cred: PublicKeyCredential; + try { + cred = (await navigator.credentials.create({ + publicKey: opts, + })) as PublicKeyCredential; + } catch (err: unknown) { + setMessage( + "YubiKey error: " + + (err instanceof Error ? err.message : "cancelled"), + ); + setStatus("error"); + return; + } + + // Step 3: save credential + setStatus("saving"); + const attestation = cred.response as AuthenticatorAttestationResponse; + + const finishRes = await fetch("/api/auth/register/finish", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + session_id, + credential: { + id: cred.id, + rawId: bufToB64u(cred.rawId), + type: cred.type, + response: { + attestationObject: bufToB64u(attestation.attestationObject), + clientDataJSON: bufToB64u(attestation.clientDataJSON), + transports: attestation.getTransports + ? attestation.getTransports() + : [], + }, + extensions: {}, + }, + }), + }); + + if (!finishRes.ok) { + setMessage("Registration failed. Check server logs."); + setStatus("error"); + return; + } + + setStatus("done"); + setMessage("YubiKey enrolled! You can now log in."); + } catch { + setMessage("Something went wrong. Try again."); + setStatus("error"); + } + } + + const canSubmit = status === "idle" || status === "error"; + const busy = !canSubmit; + + return ( +
+
+ {/* Header */} +
+

+ dell-xps-nixos-serv +

+

+ Enroll YubiKey +

+

One-time security key setup

+
+ + {status === "done" ? ( +

{message}

+ ) : ( +
+ {/* Username */} +
+ + setUsername(e.target.value)} + required + disabled={busy} + autoComplete="username" + className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm text-gray-900 bg-gray-50 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-50 transition-colors" + /> +
+ + {/* Password */} +
+ + setPassword(e.target.value)} + required + disabled={busy} + autoComplete="current-password" + className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm text-gray-900 bg-gray-50 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-50 transition-colors" + /> +
+ + {/* YubiKey cue */} + {status === "waiting_yubikey" && ( +

+ Touch your YubiKey… +

+ )} + + {/* Error */} + {status === "error" && message && ( +

{message}

+ )} + + {/* Submit */} + +
+ )} +
+
+ ); +}