diff options
Diffstat (limited to 'src/auth.tsx')
-rw-r--r-- | src/auth.tsx | 93 |
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; |