spb/note-spboucher-ai
Public
1import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";2import { eq } from "drizzle-orm";3import { db, schema } from "@/db";4import { getSession, createSession, setSessionCookie, clientIp } from "@/lib/auth/session";5import { assertSameOrigin, jsonError, HttpError } from "@/lib/auth/guard";6import { rateLimit } from "@/lib/auth/ratelimit";7import { audit } from "@/lib/audit";8import { headers } from "next/headers";910const rpID = () => process.env.WEBAUTHN_RP_ID ?? "localhost";11const origin = () => process.env.WEBAUTHN_ORIGIN ?? process.env.APP_URL ?? "http://localhost:8480";12const b64 = (b: Uint8Array | string) => (typeof b === "string" ? b : Buffer.from(b).toString("base64url"));1314/** GET ?step=options → authentication options (login, no session). GET ?step=register → registration options (needs session). */15export async function GET(req: Request) {16 try {17 const step = new URL(req.url).searchParams.get("step");18 const ip = clientIp(await headers());19 if (!rateLimit(`pk:${ip}`, 20, 10 * 60_000)) throw new HttpError(429, "Too many attempts");20 if (step === "register") {21 const s = await getSession(); if (!s) throw new HttpError(401, "Unauthenticated");22 const existing = await db.select().from(schema.webauthnCredentials).where(eq(schema.webauthnCredentials.userId, s.id));23 const options = await generateRegistrationOptions({ rpName: "Note SPBoucher AI", rpID: rpID(), userName: s.username, userDisplayName: s.displayName, attestationType: "none", authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" }, excludeCredentials: existing.map((c) => ({ id: c.id, transports: (c.transports ?? []) as any })) });24 const [ch] = await db.insert(schema.webauthnChallenges).values({ userId: s.id, challenge: options.challenge, kind: "register" }).returning();25 return Response.json({ challengeId: ch.id, options });26 }27 const options = await generateAuthenticationOptions({ rpID: rpID(), userVerification: "preferred" });28 const [ch] = await db.insert(schema.webauthnChallenges).values({ challenge: options.challenge, kind: "auth" }).returning();29 return Response.json({ challengeId: ch.id, options });30 } catch (e) { return jsonError(e); }31}32/** POST { challengeId, response, label? } → verify authentication (login) or registration (when session exists and challenge kind=register). */33export async function POST(req: Request) {34 try {35 await assertSameOrigin();36 const { challengeId, response, label } = await req.json();37 const [ch] = await db.select().from(schema.webauthnChallenges).where(eq(schema.webauthnChallenges.id, String(challengeId)));38 if (!ch || Date.now() - ch.createdAt.getTime() > 5 * 60_000) throw new HttpError(400, "Challenge expired");39 await db.delete(schema.webauthnChallenges).where(eq(schema.webauthnChallenges.id, ch.id));40 const ip = clientIp(await headers());41 if (ch.kind === "register") {42 const s = await getSession(); if (!s || s.id !== ch.userId) throw new HttpError(401, "Unauthenticated");43 const v = await verifyRegistrationResponse({ response, expectedChallenge: ch.challenge, expectedOrigin: origin(), expectedRPID: rpID() });44 if (!v.verified || !v.registrationInfo) throw new HttpError(400, "Registration failed");45 const { credential, credentialDeviceType, credentialBackedUp } = v.registrationInfo;46 await db.insert(schema.webauthnCredentials).values({ id: credential.id, userId: s.id, publicKey: b64(credential.publicKey), counter: credential.counter, transports: credential.transports ?? [], deviceType: credentialDeviceType, backedUp: credentialBackedUp, label: String(label ?? "Passkey").slice(0, 60) });47 await audit(s.id, "auth.passkey_registered", undefined, { label }, ip);48 return Response.json({ ok: true });49 }50 const credId = String(response?.id ?? "");51 const [cred] = await db.select().from(schema.webauthnCredentials).where(eq(schema.webauthnCredentials.id, credId));52 if (!cred) throw new HttpError(401, "Unknown passkey");53 const v = await verifyAuthenticationResponse({ response, expectedChallenge: ch.challenge, expectedOrigin: origin(), expectedRPID: rpID(), credential: { id: cred.id, publicKey: new Uint8Array(Buffer.from(cred.publicKey, "base64url")), counter: cred.counter, transports: (cred.transports ?? []) as any } });54 if (!v.verified) { await audit(cred.userId, "auth.passkey_failed", undefined, {}, ip); throw new HttpError(401, "Passkey verification failed"); }55 await db.update(schema.webauthnCredentials).set({ counter: v.authenticationInfo.newCounter, lastUsedAt: new Date() }).where(eq(schema.webauthnCredentials.id, cred.id));56 const token = await createSession(cred.userId, { mfaPassed: true }); await setSessionCookie(token);57 await audit(cred.userId, "auth.login_passkey", undefined, {}, ip);58 return Response.json({ ok: true });59 } catch (e) { return jsonError(e); }60}61/** DELETE { id } → remove a passkey. */62export async function DELETE(req: Request) {63 try { await assertSameOrigin(); const s = await getSession(); if (!s) throw new HttpError(401, "Unauthenticated"); const { id } = await req.json(); await db.delete(schema.webauthnCredentials).where(eq(schema.webauthnCredentials.id, String(id))); await audit(s.id, "auth.passkey_removed", undefined, { id }); return Response.json({ ok: true }); } catch (e) { return jsonError(e); }64}65