From 815de9906a014c2eb1a4fe2bd8cf1b3077f03c9c Mon Sep 17 00:00:00 2001 From: Mathias Magnusson Date: Thu, 14 Aug 2025 18:42:27 +0200 Subject: Add passkey authentication --- src/auth.tsx | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/db/schema.ts | 34 +++++++++++++++++++-- src/index.tsx | 21 +++++++++---- 3 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 src/auth.tsx (limited to 'src') diff --git a/src/auth.tsx b/src/auth.tsx new file mode 100644 index 0000000..ebda74e --- /dev/null +++ b/src/auth.tsx @@ -0,0 +1,93 @@ +import { Hono } from "hono"; +import * as swa from "@simplewebauthn/server"; +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { RP_ID, ORIGIN, db } from "./index.js"; +import { sessionTable, userTable, webauthnChallenges } from "./db/schema.js"; +import { stringify, parse } from "superjson"; +import { setCookie } from "hono/cookie"; + +let app = new Hono(); + +export const LoginForm = () =>
+ + +
; + +app.post("/register-begin", async c => { + const username = randomUUID(); + let options = await swa.generateRegistrationOptions({ + rpName: "uneven", + rpID: RP_ID, + userName: username, + authenticatorSelection: { + residentKey: "required", + userVerification: "preferred", + }, + }); + await db.insert(webauthnChallenges).values({ key: "register:" + username, challenge: options.challenge }); + return c.html( +
+ ); +}); + +app.post("/register-finish", async c => { + let { resp, username } = await c.req.json(); + let [{ chall }] = await db.delete(webauthnChallenges).where(eq(webauthnChallenges.key, "register:" + username)).returning({ chall: webauthnChallenges.challenge }); + let r = await swa.verifyRegistrationResponse({ response: resp, expectedChallenge: chall, expectedOrigin: ORIGIN }); + if (!r.verified || !("registrationInfo" in r)) return c.html(

Could not verify registration response!

); + await db.insert(userTable).values({ name: username, passkey: stringify(r.registrationInfo), passkeyId: r.registrationInfo!.credential.id }); + return c.html( +

You now have an account!

+ ); +}); + +app.post("/login-begin", async c => { + let options = await swa.generateAuthenticationOptions({ + rpID: RP_ID, + userVerification: "preferred", + }); + let key = randomUUID(); + await db.insert(webauthnChallenges).values({ challenge: options.challenge, key: "login:" + key }); + return c.html( +
+ ); +}); + +app.post("/login-finish", async c => { + let { resp, key } = await c.req.json(); + let [{ chall }] = await db.delete(webauthnChallenges).where(eq(webauthnChallenges.key, "login:" + key)).returning({ chall: webauthnChallenges.challenge }); + let [{ id: userId, passkey }] = await db.select().from(userTable).where(user => eq(user.passkeyId, resp.id)); + if (!passkey) return c.html(

Who are you?

); + let r = await swa.verifyAuthenticationResponse({ + response: resp, + expectedChallenge: chall, + expectedOrigin: ORIGIN, + expectedRPID: RP_ID, + requireUserVerification: false, + credential: parse(passkey)!.credential, + }); + if (!r.verified) return c.html(

Could not verify authentication response!

); + let uuid = randomUUID(); + await db.insert(sessionTable).values({ userId, uuid }); + setCookie(c, "session", uuid); + return c.html(

Logged in!

); +}); + +export default app; diff --git a/src/db/schema.ts b/src/db/schema.ts index 15da652..01b7228 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,5 +1,33 @@ -import { text, sqliteTable } from "drizzle-orm/sqlite-core"; +import { relations } from "drizzle-orm"; +import { text, sqliteTable, integer } from "drizzle-orm/sqlite-core"; -export const groupsTable = sqliteTable("groups", { - name: text("name").notNull(), +export const groupTable = sqliteTable("groups", { + id: integer().primaryKey(), + name: text().notNull(), +}); + +export const userTable = sqliteTable("users", { + id: integer().primaryKey(), + name: text().unique().notNull(), + passkey: text(), + passkeyId: text("passkey_id").notNull(), +}); + +export const sessionTable = sqliteTable("sessions", { + id: integer().primaryKey(), + uuid: text().unique().notNull(), + userId: integer("user_id").notNull(), +}); + +export const sessionRelations = relations(sessionTable, ({ one }) => ({ + user: one(userTable, { + fields: [sessionTable.userId], + references: [userTable.id], + }), +})); + +export const webauthnChallenges = sqliteTable("webauthn_challenges", { + id: integer().primaryKey(), + challenge: text().notNull(), + key: text().notNull(), }); diff --git a/src/index.tsx b/src/index.tsx index c72d766..e029577 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,13 +2,17 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { createClient } from "@libsql/client"; import { drizzle } from "drizzle-orm/libsql"; -import { groupsTable } from "./db/schema.js"; +import { groupTable } from "./db/schema.js"; +import authRouter, { LoginForm } from "./auth.js"; -const app = new Hono(); -const db = drizzle(createClient({ url: "file:data.db" })); +export const RP_ID = "localhost"; // "uneven.0m.nu"; +export const ORIGIN = `http://${RP_ID}`; + +let app = new Hono(); +export let db = drizzle(createClient({ url: "file:data.db" })); async function Groups() { - const result = await db.select().from(groupsTable).all(); + let result = await db.select().from(groupTable).all(); return
    { result.map(group =>
  • {group.name}
  • ) @@ -20,10 +24,13 @@ app.get("/", c => c.html( - + + + Uneven + @@ -39,7 +46,9 @@ app.get("/button", c => c.html( >disco button! )); +app.route("/auth", authRouter); + serve({ fetch: app.fetch, - port: 3000, + port: 80, }, info => console.log(`Server is running on http://localhost:${info.port}`)); -- cgit v1.2.3