SPB Git
5.4 KB · 65 lines typescript
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
Files · main
Menu

Theme