summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/auth.tsx93
-rw-r--r--src/db/schema.ts34
-rw-r--r--src/index.tsx21
3 files changed, 139 insertions, 9 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;
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 <ul>{
result.map(group => <li>{group.name}</li>)
@@ -20,10 +24,13 @@ app.get("/", c => c.html(
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js" integrity="sha384-Akqfrbj/HpNVo8k11SXBb6TlBWmXXlYQrCSqEWmyKJe+hDm3Z/B2WVG4smwBkRVm" crossorigin="anonymous"></script>
+ <script src="https://unpkg.com/htmx.org@2.0.6/dist/htmx.min.js" integrity="sha384-Akqfrbj/HpNVo8k11SXBb6TlBWmXXlYQrCSqEWmyKJe+hDm3Z/B2WVG4smwBkRVm" crossorigin="anonymous"></script>
+ <script src="https://unpkg.com/hyperscript.org@0.9.14" integrity="sha384-NzchC8z9HmP/Ed8cheGl9XuSrFSkDNHPiDl+ujbHE0F0I7tWC4rUnwPXP+7IvVZv" crossorigin="anonymous"></script>
+ <script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js" integrity="sha384-x+9k/LwnOU31Uw0BjGIuH0mJYPM4b5yBa/0GkqcR5tlgphBf9LtXYySTFNK/UtL3" crossorigin="anonymous"></script>
<title>Uneven</title>
</head>
<body>
+ <LoginForm />
<Groups />
<button hx-get="/button" hx-swap="outerHTML">click me!</button>
</body>
@@ -39,7 +46,9 @@ app.get("/button", c => c.html(
>disco button!</button>
));
+app.route("/auth", authRouter);
+
serve({
fetch: app.fetch,
- port: 3000,
+ port: 80,
}, info => console.log(`Server is running on http://localhost:${info.port}`));