summaryrefslogtreecommitdiff
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
parent923c7c6b1a6549a6c5012713a22d5cf6e478f994 (diff)
downloaduneven-815de9906a014c2eb1a4fe2bd8cf1b3077f03c9c.tar.gz
Add passkey authentication
-rw-r--r--.gitignore2
-rw-r--r--migrations/0000_bitter_xorn.sql25
-rw-r--r--migrations/0000_parallel_overlord.sql3
-rw-r--r--migrations/meta/0000_snapshot.json125
-rw-r--r--migrations/meta/_journal.json4
-rw-r--r--package.json7
-rw-r--r--pnpm-lock.yaml158
-rw-r--r--src/auth.tsx93
-rw-r--r--src/db/schema.ts34
-rw-r--r--src/index.tsx21
10 files changed, 456 insertions, 16 deletions
diff --git a/.gitignore b/.gitignore
index 36fabb6..719d2d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,5 @@ lerna-debug.log*
# misc
.DS_Store
+
+/data.db
diff --git a/migrations/0000_bitter_xorn.sql b/migrations/0000_bitter_xorn.sql
new file mode 100644
index 0000000..4f4c445
--- /dev/null
+++ b/migrations/0000_bitter_xorn.sql
@@ -0,0 +1,25 @@
+CREATE TABLE `groups` (
+ `id` integer PRIMARY KEY NOT NULL,
+ `name` text NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE `sessions` (
+ `id` integer PRIMARY KEY NOT NULL,
+ `uuid` text NOT NULL,
+ `user_id` integer NOT NULL
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `sessions_uuid_unique` ON `sessions` (`uuid`);--> statement-breakpoint
+CREATE TABLE `users` (
+ `id` integer PRIMARY KEY NOT NULL,
+ `name` text NOT NULL,
+ `passkey` text,
+ `passkey_id` text NOT NULL
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `users_name_unique` ON `users` (`name`);--> statement-breakpoint
+CREATE TABLE `webauthn_challenges` (
+ `id` integer PRIMARY KEY NOT NULL,
+ `challenge` text NOT NULL,
+ `key` text NOT NULL
+);
diff --git a/migrations/0000_parallel_overlord.sql b/migrations/0000_parallel_overlord.sql
deleted file mode 100644
index c1c0d28..0000000
--- a/migrations/0000_parallel_overlord.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-CREATE TABLE `groups` (
- `name` text NOT NULL
-);
diff --git a/migrations/meta/0000_snapshot.json b/migrations/meta/0000_snapshot.json
index 245d657..ba0da3c 100644
--- a/migrations/meta/0000_snapshot.json
+++ b/migrations/meta/0000_snapshot.json
@@ -1,18 +1,141 @@
{
"version": "6",
"dialect": "sqlite",
- "id": "2b9ab579-4d91-4045-b35e-b5cceebe74de",
+ "id": "a2343f4c-71c5-4992-b49e-d30f2a4f08c0",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"groups": {
"name": "groups",
"columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sessions": {
+ "name": "sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uuid": {
+ "name": "uuid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "sessions_uuid_unique": {
+ "name": "sessions_uuid_unique",
+ "columns": [
+ "uuid"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
+ },
+ "passkey": {
+ "name": "passkey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "passkey_id": {
+ "name": "passkey_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "users_name_unique": {
+ "name": "users_name_unique",
+ "columns": [
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "webauthn_challenges": {
+ "name": "webauthn_challenges",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "challenge": {
+ "name": "challenge",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
}
},
"indexes": {},
diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json
index ccb08a8..3f28ad0 100644
--- a/migrations/meta/_journal.json
+++ b/migrations/meta/_journal.json
@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
- "when": 1755176694946,
- "tag": "0000_parallel_overlord",
+ "when": 1755189001309,
+ "tag": "0000_bitter_xorn",
"breakpoints": true
}
]
diff --git a/package.json b/package.json
index 831c042..fefacd0 100644
--- a/package.json
+++ b/package.json
@@ -11,11 +11,16 @@
},
"dependencies": {
"@hono/node-server": "^1.18.2",
+ "@hono/zod-validator": "^0.7.2",
"@libsql/client": "^0.15.11",
+ "@simplewebauthn/server": "^13.1.2",
"drizzle-orm": "^0.44.4",
- "hono": "^4.9.1"
+ "hono": "^4.9.1",
+ "superjson": "^2.2.2",
+ "zod": "^4.0.17"
},
"devDependencies": {
+ "@simplewebauthn/types": "^12.0.0",
"@types/node": "^20.11.17",
"drizzle-kit": "^0.31.4",
"tsx": "^4.7.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 87a2533..c7dcbbb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,16 +11,31 @@ importers:
'@hono/node-server':
specifier: ^1.18.2
version: 1.18.2(hono@4.9.1)
+ '@hono/zod-validator':
+ specifier: ^0.7.2
+ version: 0.7.2(hono@4.9.1)(zod@4.0.17)
'@libsql/client':
specifier: ^0.15.11
version: 0.15.11
+ '@simplewebauthn/server':
+ specifier: ^13.1.2
+ version: 13.1.2
drizzle-orm:
specifier: ^0.44.4
version: 0.44.4(@libsql/client@0.15.11)
hono:
specifier: ^4.9.1
version: 4.9.1
+ superjson:
+ specifier: ^2.2.2
+ version: 2.2.2
+ zod:
+ specifier: ^4.0.17
+ version: 4.0.17
devDependencies:
+ '@simplewebauthn/types':
+ specifier: ^12.0.0
+ version: 12.0.0
'@types/node':
specifier: ^20.11.17
version: 20.19.10
@@ -338,12 +353,24 @@ packages:
cpu: [x64]
os: [win32]
+ '@hexagon/base64@1.1.28':
+ resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
+
'@hono/node-server@1.18.2':
resolution: {integrity: sha512-icgNvC0vRYivzyuSSaUv9ttcwtN8fDyd1k3AOIBDJgYd84tXRZSS6na8X54CY/oYoFTNhEmZraW/Rb9XYwX4KA==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
+ '@hono/zod-validator@0.7.2':
+ resolution: {integrity: sha512-ub5eL/NeZ4eLZawu78JpW/J+dugDAYhwqUIdp9KYScI6PZECij4Hx4UsrthlEUutqDDhPwRI0MscUfNkvn/mqQ==}
+ peerDependencies:
+ hono: '>=3.9.0'
+ zod: ^3.25.0 || ^4.0.0
+
+ '@levischuck/tiny-cbor@0.2.11':
+ resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
+
'@libsql/client@0.15.11':
resolution: {integrity: sha512-JB8RWRs+cAbHX35/dQ9wD3m4W5EVGevq1fFqiHKTT4Pa5HR7WrcGRVT+8NL2M7gtTlOvyPh9zzms2DPLBCswig==}
@@ -408,15 +435,45 @@ packages:
'@neon-rs/load@0.0.4':
resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==}
+ '@peculiar/asn1-android@2.4.0':
+ resolution: {integrity: sha512-YFueREq97CLslZZBI8dKzis7jMfEHSLxM+nr0Zdx1POiXFLjqqwoY5s0F1UimdBiEw/iKlHey2m56MRDv7Jtyg==}
+
+ '@peculiar/asn1-ecc@2.4.0':
+ resolution: {integrity: sha512-fJiYUBCJBDkjh347zZe5H81BdJ0+OGIg0X9z06v8xXUoql3MFeENUX0JsjCaVaU9A0L85PefLPGYkIoGpTnXLQ==}
+
+ '@peculiar/asn1-rsa@2.4.0':
+ resolution: {integrity: sha512-6PP75voaEnOSlWR9sD25iCQyLgFZHXbmxvUfnnDcfL6Zh5h2iHW38+bve4LfH7a60x7fkhZZNmiYqAlAff9Img==}
+
+ '@peculiar/asn1-schema@2.4.0':
+ resolution: {integrity: sha512-umbembjIWOrPSOzEGG5vxFLkeM8kzIhLkgigtsOrfLKnuzxWxejAcUX+q/SoZCdemlODOcr5WiYa7+dIEzBXZQ==}
+
+ '@peculiar/asn1-x509@2.4.0':
+ resolution: {integrity: sha512-F7mIZY2Eao2TaoVqigGMLv+NDdpwuBKU1fucHPONfzaBS4JXXCNCmfO0Z3dsy7JzKGqtDcYC1mr9JjaZQZNiuw==}
+
+ '@simplewebauthn/server@13.1.2':
+ resolution: {integrity: sha512-VwoDfvLXSCaRiD+xCIuyslU0HLxVggeE5BL06+GbsP2l1fGf5op8e0c3ZtKoi+vSg1q4ikjtAghC23ze2Q3H9g==}
+ engines: {node: '>=20.0.0'}
+
+ '@simplewebauthn/types@12.0.0':
+ resolution: {integrity: sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA==}
+
'@types/node@20.19.10':
resolution: {integrity: sha512-iAFpG6DokED3roLSP0K+ybeDdIX6Bc0Vd3mLW5uDqThPWtNos3E+EqOM11mPQHKzfWHqEBuLjIlsBQQ8CsISmQ==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
+ asn1js@3.0.6:
+ resolution: {integrity: sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==}
+ engines: {node: '>=12.0.0'}
+
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+ copy-anything@3.0.5:
+ resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
+ engines: {node: '>=12.13'}
+
data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
@@ -565,6 +622,10 @@ packages:
resolution: {integrity: sha512-qfvdJ42t6CQE0N/iSCa8KsW8SQqYD67YB+TYbwPHlnALvX+s7ynh8otR1NEk5jXtUg73gpV/B82OSufDmwtX3w==}
engines: {node: '>=16.9.0'}
+ is-what@4.1.16:
+ resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
+ engines: {node: '>=12.13'}
+
js-base64@3.7.8:
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
@@ -588,6 +649,13 @@ packages:
promise-limit@2.7.0:
resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==}
+ pvtsutils@1.3.6:
+ resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
+
+ pvutils@1.1.3:
+ resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
+ engines: {node: '>=6.0.0'}
+
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -598,6 +666,13 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
+ superjson@2.2.2:
+ resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==}
+ engines: {node: '>=16'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
tsx@4.20.4:
resolution: {integrity: sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==}
engines: {node: '>=18.0.0'}
@@ -635,6 +710,9 @@ packages:
utf-8-validate:
optional: true
+ zod@4.0.17:
+ resolution: {integrity: sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==}
+
snapshots:
'@drizzle-team/brocli@0.10.2': {}
@@ -793,10 +871,19 @@ snapshots:
'@esbuild/win32-x64@0.25.9':
optional: true
+ '@hexagon/base64@1.1.28': {}
+
'@hono/node-server@1.18.2(hono@4.9.1)':
dependencies:
hono: 4.9.1
+ '@hono/zod-validator@0.7.2(hono@4.9.1)(zod@4.0.17)':
+ dependencies:
+ hono: 4.9.1
+ zod: 4.0.17
+
+ '@levischuck/tiny-cbor@0.2.11': {}
+
'@libsql/client@0.15.11':
dependencies:
'@libsql/core': 0.15.11
@@ -861,6 +948,51 @@ snapshots:
'@neon-rs/load@0.0.4': {}
+ '@peculiar/asn1-android@2.4.0':
+ dependencies:
+ '@peculiar/asn1-schema': 2.4.0
+ asn1js: 3.0.6
+ tslib: 2.8.1
+
+ '@peculiar/asn1-ecc@2.4.0':
+ dependencies:
+ '@peculiar/asn1-schema': 2.4.0
+ '@peculiar/asn1-x509': 2.4.0
+ asn1js: 3.0.6
+ tslib: 2.8.1
+
+ '@peculiar/asn1-rsa@2.4.0':
+ dependencies:
+ '@peculiar/asn1-schema': 2.4.0
+ '@peculiar/asn1-x509': 2.4.0
+ asn1js: 3.0.6
+ tslib: 2.8.1
+
+ '@peculiar/asn1-schema@2.4.0':
+ dependencies:
+ asn1js: 3.0.6
+ pvtsutils: 1.3.6
+ tslib: 2.8.1
+
+ '@peculiar/asn1-x509@2.4.0':
+ dependencies:
+ '@peculiar/asn1-schema': 2.4.0
+ asn1js: 3.0.6
+ pvtsutils: 1.3.6
+ tslib: 2.8.1
+
+ '@simplewebauthn/server@13.1.2':
+ dependencies:
+ '@hexagon/base64': 1.1.28
+ '@levischuck/tiny-cbor': 0.2.11
+ '@peculiar/asn1-android': 2.4.0
+ '@peculiar/asn1-ecc': 2.4.0
+ '@peculiar/asn1-rsa': 2.4.0
+ '@peculiar/asn1-schema': 2.4.0
+ '@peculiar/asn1-x509': 2.4.0
+
+ '@simplewebauthn/types@12.0.0': {}
+
'@types/node@20.19.10':
dependencies:
undici-types: 6.21.0
@@ -869,8 +1001,18 @@ snapshots:
dependencies:
'@types/node': 20.19.10
+ asn1js@3.0.6:
+ dependencies:
+ pvtsutils: 1.3.6
+ pvutils: 1.1.3
+ tslib: 2.8.1
+
buffer-from@1.1.2: {}
+ copy-anything@3.0.5:
+ dependencies:
+ is-what: 4.1.16
+
data-uri-to-buffer@4.0.1: {}
debug@4.4.1:
@@ -971,6 +1113,8 @@ snapshots:
hono@4.9.1: {}
+ is-what@4.1.16: {}
+
js-base64@3.7.8: {}
libsql@0.5.17:
@@ -1000,6 +1144,12 @@ snapshots:
promise-limit@2.7.0: {}
+ pvtsutils@1.3.6:
+ dependencies:
+ tslib: 2.8.1
+
+ pvutils@1.1.3: {}
+
resolve-pkg-maps@1.0.0: {}
source-map-support@0.5.21:
@@ -1009,6 +1159,12 @@ snapshots:
source-map@0.6.1: {}
+ superjson@2.2.2:
+ dependencies:
+ copy-anything: 3.0.5
+
+ tslib@2.8.1: {}
+
tsx@4.20.4:
dependencies:
esbuild: 0.25.9
@@ -1029,3 +1185,5 @@ snapshots:
web-streams-polyfill@3.3.3: {}
ws@8.18.3: {}
+
+ zod@4.0.17: {}
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}`));