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

Theme