summaryrefslogtreecommitdiff
path: root/src/auth.tsx
diff options
context:
space:
mode:
authorMathias Magnusson <mathias@magnusson.space>2025-08-14 18:42:27 +0200
committerMathias Magnusson <mathias@magnusson.space>2025-08-14 18:44:44 +0200
commit815de9906a014c2eb1a4fe2bd8cf1b3077f03c9c (patch)
tree9b773698f66dbc325f7823496db30477e0cbdd07 /src/auth.tsx
parent923c7c6b1a6549a6c5012713a22d5cf6e478f994 (diff)
downloaduneven-815de9906a014c2eb1a4fe2bd8cf1b3077f03c9c.tar.gz
Add passkey authentication
Diffstat (limited to 'src/auth.tsx')
-rw-r--r--src/auth.tsx93
1 files changed, 93 insertions, 0 deletions
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 = () => <section class="register-form">
+ <button hx-post="/auth/register-begin" hx-target="closest .register-form" hx-swap="outerHTML">register</button>
+ <button hx-post="/auth/login-begin" hx-target="closest .register-form" hx-swap="outerHTML">login</button>
+</section>;
+
+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(
+ <section
+ class="register-form"
+ _={`
+ on load
+ call SimpleWebAuthnBrowser.startRegistration(${JSON.stringify(options)})
+ fetch /auth/register-finish with method:"POST", body:JSON.stringify({ resp: it, username: ${JSON.stringify(username)} })
+ put the result into my.outerHTML
+ `}
+ />
+ );
+});
+
+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(<p style="color: red">Could not verify registration response!</p>);
+ await db.insert(userTable).values({ name: username, passkey: stringify(r.registrationInfo), passkeyId: r.registrationInfo!.credential.id });
+ return c.html(
+ <p>You now have an account!</p>
+ );
+});
+
+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(
+ <section
+ class="register-form"
+ _={`
+ on load
+ call SimpleWebAuthnBrowser.startAuthentication(${JSON.stringify(options)})
+ fetch /auth/login-finish with method:"POST", body:JSON.stringify({ resp: it, key: ${JSON.stringify(key)} })
+ put the result into my.outerHTML
+ `}
+ />
+ );
+});
+
+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(<p style="color: red">Who are you?</p>);
+ let r = await swa.verifyAuthenticationResponse({
+ response: resp,
+ expectedChallenge: chall,
+ expectedOrigin: ORIGIN,
+ expectedRPID: RP_ID,
+ requireUserVerification: false,
+ credential: parse<swa.VerifiedRegistrationResponse["registrationInfo"]>(passkey)!.credential,
+ });
+ if (!r.verified) return c.html(<p style="color: red">Could not verify authentication response!</p>);
+ let uuid = randomUUID();
+ await db.insert(sessionTable).values({ userId, uuid });
+ setCookie(c, "session", uuid);
+ return c.html(<p>Logged in!</p>);
+});
+
+export default app;