Commit2df1f8cunknown_key
feat(BLOCK-B5): WebAuthn / passkeys (register, authenticate, manage)
feat(BLOCK-B5): WebAuthn / passkeys (register, authenticate, manage) - Adds @simplewebauthn/server as a dependency - New tables: user_passkeys, webauthn_challenges (migration 0006) - Config getters: webauthnRpId, webauthnOrigin, webauthnRpName - src/lib/webauthn.ts: thin wrapper around @simplewebauthn/server that persists challenges in the DB keyed by a random sessionKey - src/routes/passkeys.tsx: /settings/passkeys UI + add/delete/rename forms; /api/passkeys/register/* for authed registration; /api/passkeys/auth/* for unauthed sign-in (passkey is phishing-resistant + user-verifying, so skips TOTP prompt) - Login page gains a "Sign in with passkey" button with inline JS glue using navigator.credentials.get
10 files changed+962−02df1f8c0bb0b438592d1cb4709f2882e5738d728
10 changed files+962−0
Modifiedbun.lock+45−0View fileUnifiedSplit
@@ -8,6 +8,7 @@
88 "@anthropic-ai/sdk": "^0.88.0",
99 "@hono/node-server": "^1.13.0",
1010 "@neondatabase/serverless": "^0.10.0",
11 "@simplewebauthn/server": "^13.3.0",
1112 "drizzle-orm": "^0.39.0",
1213 "highlight.js": "^11.11.0",
1314 "hono": "^4.7.0",
@@ -77,18 +78,50 @@
7778
7879 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="],
7980
81 "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
82
8083 "@hono/node-server": ["@hono/node-server@1.19.13", "", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="],
8184
85 "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
86
8287 "@neondatabase/serverless": ["@neondatabase/serverless@0.10.4", "", { "dependencies": { "@types/pg": "8.11.6" } }, "sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA=="],
8388
89 "@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="],
90
91 "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw=="],
92
93 "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w=="],
94
95 "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g=="],
96
97 "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.6.1", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw=="],
98
99 "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw=="],
100
101 "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.6.1", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-pfx": "^2.6.1", "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw=="],
102
103 "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA=="],
104
105 "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.6.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="],
106
107 "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA=="],
108
109 "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ=="],
110
111 "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="],
112
84113 "@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
85114
115 "@simplewebauthn/server": ["@simplewebauthn/server@13.3.0", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ=="],
116
86117 "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
87118
88119 "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
89120
90121 "@types/pg": ["@types/pg@8.11.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ=="],
91122
123 "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="],
124
92125 "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
93126
94127 "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
@@ -141,6 +174,12 @@
141174
142175 "postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="],
143176
177 "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
178
179 "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
180
181 "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
182
144183 "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
145184
146185 "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -153,6 +192,10 @@
153192
154193 "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
155194
195 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
196
197 "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="],
198
156199 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
157200
158201 "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
@@ -161,6 +204,8 @@
161204
162205 "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
163206
207 "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
208
164209 "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
165210
166211 "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
Addeddrizzle/0006_webauthn_passkeys.sql+33−0View fileUnifiedSplit
@@ -0,0 +1,33 @@
1-- Gluecron migration 0006: Block B5 — WebAuthn passkeys.
2
3--> statement-breakpoint
4CREATE TABLE IF NOT EXISTS "user_passkeys" (
5 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
6 "user_id" uuid NOT NULL,
7 "credential_id" text NOT NULL UNIQUE,
8 "public_key" text NOT NULL,
9 "counter" integer DEFAULT 0 NOT NULL,
10 "transports" text,
11 "name" text NOT NULL DEFAULT 'Passkey',
12 "last_used_at" timestamp,
13 "created_at" timestamp DEFAULT now() NOT NULL,
14 CONSTRAINT "user_passkeys_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
15);
16
17--> statement-breakpoint
18CREATE INDEX IF NOT EXISTS "passkeys_user" ON "user_passkeys" ("user_id");
19
20--> statement-breakpoint
21CREATE TABLE IF NOT EXISTS "webauthn_challenges" (
22 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
23 "user_id" uuid,
24 "session_key" text NOT NULL UNIQUE,
25 "challenge" text NOT NULL,
26 "kind" text NOT NULL,
27 "expires_at" timestamp NOT NULL,
28 "created_at" timestamp DEFAULT now() NOT NULL,
29 CONSTRAINT "webauthn_challenges_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
30);
31
32--> statement-breakpoint
33CREATE INDEX IF NOT EXISTS "webauthn_challenges_expires" ON "webauthn_challenges" ("expires_at");
Modifiedpackage.json+1−0View fileUnifiedSplit
@@ -15,6 +15,7 @@
1515 "@anthropic-ai/sdk": "^0.88.0",
1616 "@hono/node-server": "^1.13.0",
1717 "@neondatabase/serverless": "^0.10.0",
18 "@simplewebauthn/server": "^13.3.0",
1819 "drizzle-orm": "^0.39.0",
1920 "highlight.js": "^11.11.0",
2021 "hono": "^4.7.0",
Modifiedsrc/__tests__/green-ecosystem.test.ts+50−0View fileUnifiedSplit
@@ -744,3 +744,53 @@ describe("2FA routes (B4)", () => {
744744 expect(loc.startsWith("/login")).toBe(true);
745745 });
746746});
747
748describe("passkeys routes (B5)", () => {
749 it("GET /settings/passkeys redirects unauthenticated users to /login", async () => {
750 const res = await app.request("/settings/passkeys");
751 expect([301, 302, 303, 307]).toContain(res.status);
752 const loc = res.headers.get("location") || "";
753 expect(loc.startsWith("/login")).toBe(true);
754 });
755
756 it("POST /api/passkeys/register/options redirects unauthenticated users to /login", async () => {
757 const res = await app.request("/api/passkeys/register/options", {
758 method: "POST",
759 headers: { "content-type": "application/json" },
760 body: "{}",
761 });
762 expect([301, 302, 303, 307]).toContain(res.status);
763 const loc = res.headers.get("location") || "";
764 expect(loc.startsWith("/login")).toBe(true);
765 });
766
767 it("POST /api/passkeys/auth/verify returns 400 when body is invalid", async () => {
768 const res = await app.request("/api/passkeys/auth/verify", {
769 method: "POST",
770 headers: { "content-type": "application/json" },
771 body: "not json",
772 });
773 // Either 400 (bad JSON) or 503 (DB down) — never a 500.
774 expect([400, 503]).toContain(res.status);
775 });
776
777 it("POST /api/passkeys/auth/verify rejects missing fields", async () => {
778 const res = await app.request("/api/passkeys/auth/verify", {
779 method: "POST",
780 headers: { "content-type": "application/json" },
781 body: "{}",
782 });
783 expect([400, 503]).toContain(res.status);
784 });
785
786 it("POST /settings/passkeys/:id/delete redirects unauthenticated users to /login", async () => {
787 const res = await app.request("/settings/passkeys/abc/delete", {
788 method: "POST",
789 headers: { "content-type": "application/x-www-form-urlencoded" },
790 body: "",
791 });
792 expect([301, 302, 303, 307]).toContain(res.status);
793 const loc = res.headers.get("location") || "";
794 expect(loc.startsWith("/login")).toBe(true);
795 });
796});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -35,6 +35,7 @@ import reactionRoutes from "./routes/reactions";
3535import savedReplyRoutes from "./routes/saved-replies";
3636import deploymentRoutes from "./routes/deployments";
3737import orgRoutes from "./routes/orgs";
38import passkeyRoutes from "./routes/passkeys";
3839import webRoutes from "./routes/web";
3940
4041const app = new Hono();
@@ -75,6 +76,9 @@ app.route("/", settingsRoutes);
7576// 2FA / TOTP settings (Block B4)
7677app.route("/", settings2faRoutes);
7778
79// WebAuthn / passkey routes (Block B5)
80app.route("/", passkeyRoutes);
81
7882// Theme toggle (dark/light cookie)
7983app.route("/", themeRoutes);
8084
Modifiedsrc/db/schema.ts+54−0View fileUnifiedSplit
@@ -823,3 +823,57 @@ export const userRecoveryCodes = pgTable(
823823
824824export type UserTotp = typeof userTotp.$inferSelect;
825825export type UserRecoveryCode = typeof userRecoveryCodes.$inferSelect;
826
827/**
828 * WebAuthn passkeys (Block B5).
829 *
830 * Each row is one registered authenticator. The `credentialId` is the
831 * globally-unique identifier the browser returns; `publicKey` is the
832 * COSE-encoded public key we use to verify signatures. `counter` tracks
833 * the authenticator's signature counter for replay-protection.
834 *
835 * `transports` is a JSON array (stored as text) of the
836 * AuthenticatorTransport values ("usb" | "nfc" | "ble" | "internal" | "hybrid").
837 */
838export const userPasskeys = pgTable(
839 "user_passkeys",
840 {
841 id: uuid("id").primaryKey().defaultRandom(),
842 userId: uuid("user_id")
843 .notNull()
844 .references(() => users.id, { onDelete: "cascade" }),
845 credentialId: text("credential_id").notNull().unique(),
846 publicKey: text("public_key").notNull(), // base64url of COSE key
847 counter: integer("counter").default(0).notNull(),
848 transports: text("transports"), // JSON array string
849 name: text("name").notNull().default("Passkey"),
850 lastUsedAt: timestamp("last_used_at"),
851 createdAt: timestamp("created_at").defaultNow().notNull(),
852 },
853 (table) => [index("passkeys_user").on(table.userId)]
854);
855
856/**
857 * Short-lived WebAuthn challenges. A row is written when we issue options
858 * (registration or authentication) and deleted after the verify step or when
859 * it expires (5 min). Keeping them in the DB lets us verify without sticky
860 * sessions or client-side state.
861 */
862export const webauthnChallenges = pgTable(
863 "webauthn_challenges",
864 {
865 id: uuid("id").primaryKey().defaultRandom(),
866 userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
867 // For passwordless login we don't know the user yet, so userId is nullable
868 // and we bind the challenge to a short-lived cookie token instead.
869 sessionKey: text("session_key").notNull().unique(),
870 challenge: text("challenge").notNull(),
871 kind: text("kind").notNull(), // "register" | "authenticate"
872 expiresAt: timestamp("expires_at").notNull(),
873 createdAt: timestamp("created_at").defaultNow().notNull(),
874 },
875 (table) => [index("webauthn_challenges_expires").on(table.expiresAt)]
876);
877
878export type UserPasskey = typeof userPasskeys.$inferSelect;
879export type WebauthnChallenge = typeof webauthnChallenges.$inferSelect;
Modifiedsrc/lib/config.ts+21−0View fileUnifiedSplit
@@ -45,4 +45,25 @@ export const config = {
4545 ""
4646 );
4747 },
48 /**
49 * WebAuthn relying-party ID (domain only, no scheme/port). Derived from
50 * appBaseUrl unless overridden. Passkeys issued for one RP ID can't be
51 * replayed against another, so this must be stable.
52 */
53 get webauthnRpId() {
54 if (process.env.WEBAUTHN_RP_ID) return process.env.WEBAUTHN_RP_ID;
55 try {
56 return new URL(this.appBaseUrl).hostname;
57 } catch {
58 return "localhost";
59 }
60 },
61 /** WebAuthn expected origin (must include scheme + port). */
62 get webauthnOrigin() {
63 return process.env.WEBAUTHN_ORIGIN || this.appBaseUrl;
64 },
65 /** Human-facing RP name shown by the browser. */
66 get webauthnRpName() {
67 return process.env.WEBAUTHN_RP_NAME || "gluecron";
68 },
4869};
Addedsrc/lib/webauthn.ts+230−0View fileUnifiedSplit
@@ -0,0 +1,230 @@
1/**
2 * WebAuthn / passkey helpers (Block B5).
3 *
4 * Thin wrapper over @simplewebauthn/server that:
5 * - reads RP config from `src/lib/config.ts`
6 * - persists short-lived challenges in `webauthn_challenges`
7 * - converts between base64url (used in the browser) and bytes as needed
8 */
9
10import { and, eq, lt } from "drizzle-orm";
11import {
12 generateRegistrationOptions,
13 verifyRegistrationResponse,
14 generateAuthenticationOptions,
15 verifyAuthenticationResponse,
16} from "@simplewebauthn/server";
17import type {
18 RegistrationResponseJSON,
19 AuthenticationResponseJSON,
20} from "@simplewebauthn/server";
21import { db } from "../db";
22import { webauthnChallenges, userPasskeys } from "../db/schema";
23import { config } from "./config";
24
25const CHALLENGE_TTL_MS = 5 * 60 * 1000;
26
27function newSessionKey(): string {
28 const bytes = crypto.getRandomValues(new Uint8Array(24));
29 return Array.from(bytes)
30 .map((b) => b.toString(16).padStart(2, "0"))
31 .join("");
32}
33
34async function gcExpiredChallenges(): Promise<void> {
35 try {
36 await db
37 .delete(webauthnChallenges)
38 .where(lt(webauthnChallenges.expiresAt, new Date()));
39 } catch {
40 /* best-effort GC */
41 }
42}
43
44export async function startRegistration(opts: {
45 userId: string;
46 userName: string;
47 userDisplayName?: string;
48 excludeCredentialIds?: string[];
49}) {
50 await gcExpiredChallenges();
51 const options = await generateRegistrationOptions({
52 rpName: config.webauthnRpName,
53 rpID: config.webauthnRpId,
54 userName: opts.userName,
55 userDisplayName: opts.userDisplayName || opts.userName,
56 userID: new TextEncoder().encode(opts.userId),
57 attestationType: "none",
58 excludeCredentials: (opts.excludeCredentialIds || []).map((id) => ({
59 id,
60 })),
61 authenticatorSelection: {
62 residentKey: "preferred",
63 userVerification: "preferred",
64 },
65 });
66
67 const sessionKey = newSessionKey();
68 await db.insert(webauthnChallenges).values({
69 userId: opts.userId,
70 sessionKey,
71 challenge: options.challenge,
72 kind: "register",
73 expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
74 });
75
76 return { options, sessionKey };
77}
78
79export async function finishRegistration(opts: {
80 sessionKey: string;
81 response: RegistrationResponseJSON;
82}): Promise<
83 | { ok: true; credentialId: string; publicKey: string; counter: number }
84 | { ok: false; error: string }
85> {
86 try {
87 const [chal] = await db
88 .select()
89 .from(webauthnChallenges)
90 .where(
91 and(
92 eq(webauthnChallenges.sessionKey, opts.sessionKey),
93 eq(webauthnChallenges.kind, "register")
94 )
95 )
96 .limit(1);
97 if (!chal) return { ok: false, error: "Challenge not found or expired" };
98 if (new Date(chal.expiresAt) < new Date()) {
99 return { ok: false, error: "Challenge expired" };
100 }
101
102 const verification = await verifyRegistrationResponse({
103 response: opts.response,
104 expectedChallenge: chal.challenge,
105 expectedOrigin: config.webauthnOrigin,
106 expectedRPID: config.webauthnRpId,
107 requireUserVerification: false,
108 });
109
110 // One-shot: remove the challenge whether verification passed or not.
111 await db
112 .delete(webauthnChallenges)
113 .where(eq(webauthnChallenges.sessionKey, opts.sessionKey));
114
115 if (!verification.verified || !verification.registrationInfo) {
116 return { ok: false, error: "Registration did not verify" };
117 }
118
119 const { credential } = verification.registrationInfo;
120 return {
121 ok: true,
122 credentialId: credential.id,
123 publicKey: Buffer.from(credential.publicKey).toString("base64url"),
124 counter: credential.counter,
125 };
126 } catch (err) {
127 console.error("[webauthn] finishRegistration:", err);
128 return { ok: false, error: "Verification failed" };
129 }
130}
131
132export async function startAuthentication(opts: {
133 userId?: string;
134 allowCredentialIds?: string[];
135}) {
136 await gcExpiredChallenges();
137 const options = await generateAuthenticationOptions({
138 rpID: config.webauthnRpId,
139 allowCredentials: (opts.allowCredentialIds || []).map((id) => ({ id })),
140 userVerification: "preferred",
141 });
142
143 const sessionKey = newSessionKey();
144 await db.insert(webauthnChallenges).values({
145 userId: opts.userId || null,
146 sessionKey,
147 challenge: options.challenge,
148 kind: "authenticate",
149 expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
150 });
151
152 return { options, sessionKey };
153}
154
155export async function finishAuthentication(opts: {
156 sessionKey: string;
157 response: AuthenticationResponseJSON;
158}): Promise<
159 | {
160 ok: true;
161 userId: string;
162 credentialId: string;
163 newCounter: number;
164 }
165 | { ok: false; error: string }
166> {
167 try {
168 const [chal] = await db
169 .select()
170 .from(webauthnChallenges)
171 .where(
172 and(
173 eq(webauthnChallenges.sessionKey, opts.sessionKey),
174 eq(webauthnChallenges.kind, "authenticate")
175 )
176 )
177 .limit(1);
178 if (!chal) return { ok: false, error: "Challenge not found or expired" };
179 if (new Date(chal.expiresAt) < new Date()) {
180 return { ok: false, error: "Challenge expired" };
181 }
182
183 const credentialId = opts.response.id;
184 const [pk] = await db
185 .select()
186 .from(userPasskeys)
187 .where(eq(userPasskeys.credentialId, credentialId))
188 .limit(1);
189 if (!pk) {
190 return { ok: false, error: "Unknown credential" };
191 }
192
193 const verification = await verifyAuthenticationResponse({
194 response: opts.response,
195 expectedChallenge: chal.challenge,
196 expectedOrigin: config.webauthnOrigin,
197 expectedRPID: config.webauthnRpId,
198 credential: {
199 id: pk.credentialId,
200 publicKey: new Uint8Array(Buffer.from(pk.publicKey, "base64url")),
201 counter: pk.counter,
202 },
203 requireUserVerification: false,
204 });
205
206 await db
207 .delete(webauthnChallenges)
208 .where(eq(webauthnChallenges.sessionKey, opts.sessionKey));
209
210 if (!verification.verified) {
211 return { ok: false, error: "Authentication did not verify" };
212 }
213
214 const newCounter = verification.authenticationInfo.newCounter;
215 await db
216 .update(userPasskeys)
217 .set({ counter: newCounter, lastUsedAt: new Date() })
218 .where(eq(userPasskeys.id, pk.id));
219
220 return {
221 ok: true,
222 userId: pk.userId,
223 credentialId: pk.credentialId,
224 newCounter,
225 };
226 } catch (err) {
227 console.error("[webauthn] finishAuthentication:", err);
228 return { ok: false, error: "Verification failed" };
229 }
230}
Modifiedsrc/routes/auth.tsx+96−0View fileUnifiedSplit
@@ -193,9 +193,105 @@ auth.get("/login", (c) => {
193193 Sign in
194194 </button>
195195 </form>
196 <div
197 style="margin: 16px 0; text-align: center; color: var(--text-muted); font-size: 12px"
198 >
199 — or —
200 </div>
201 <div style="text-align: center">
202 <button
203 type="button"
204 id="pk-signin-btn"
205 class="btn"
206 style="width: 100%"
207 >
208 Sign in with passkey
209 </button>
210 <div
211 id="pk-signin-status"
212 style="margin-top: 8px; color: var(--text-muted); font-size: 12px; min-height: 16px"
213 />
214 </div>
196215 <p class="auth-switch">
197216 New to gluecron? <a href="/register">Create an account</a>
198217 </p>
218 <script
219 dangerouslySetInnerHTML={{
220 __html: /* js */ `
221 (function () {
222 const btn = document.getElementById('pk-signin-btn');
223 const status = document.getElementById('pk-signin-status');
224 const userInput = document.getElementById('username');
225 const redirect = ${JSON.stringify(redirect || "/")};
226 if (!btn) return;
227 function b64uToBuf(s) {
228 s = s.replace(/-/g,'+').replace(/_/g,'/');
229 while (s.length % 4) s += '=';
230 const bin = atob(s);
231 const buf = new Uint8Array(bin.length);
232 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
233 return buf.buffer;
234 }
235 function bufToB64u(buf) {
236 const bytes = new Uint8Array(buf);
237 let bin = '';
238 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
239 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
240 }
241 btn.addEventListener('click', async function () {
242 if (!window.PublicKeyCredential) {
243 status.textContent = 'Passkeys not supported in this browser.';
244 return;
245 }
246 status.textContent = 'Preparing…';
247 try {
248 const username = (userInput && userInput.value || '').trim();
249 const optsRes = await fetch('/api/passkeys/auth/options', {
250 method: 'POST',
251 headers: { 'content-type': 'application/json' },
252 body: JSON.stringify(username ? { username: username } : {})
253 });
254 if (!optsRes.ok) throw new Error('options failed');
255 const { options, sessionKey } = await optsRes.json();
256 options.challenge = b64uToBuf(options.challenge);
257 if (options.allowCredentials) {
258 options.allowCredentials = options.allowCredentials.map(function (c) {
259 return Object.assign({}, c, { id: b64uToBuf(c.id) });
260 });
261 }
262 status.textContent = 'Touch your authenticator…';
263 const cred = await navigator.credentials.get({ publicKey: options });
264 const resp = {
265 id: cred.id,
266 rawId: bufToB64u(cred.rawId),
267 type: cred.type,
268 response: {
269 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
270 authenticatorData: bufToB64u(cred.response.authenticatorData),
271 signature: bufToB64u(cred.response.signature),
272 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
273 },
274 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
275 };
276 const verifyRes = await fetch('/api/passkeys/auth/verify', {
277 method: 'POST',
278 headers: { 'content-type': 'application/json' },
279 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
280 });
281 if (!verifyRes.ok) {
282 const j = await verifyRes.json().catch(function () { return {}; });
283 throw new Error(j.error || 'verify failed');
284 }
285 status.textContent = 'Signed in. Redirecting…';
286 window.location.href = redirect;
287 } catch (e) {
288 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
289 }
290 });
291 })();
292 `,
293 }}
294 />
199295 </div>
200296 </Layout>
201297 );
Addedsrc/routes/passkeys.tsx+428−0View fileUnifiedSplit
@@ -0,0 +1,428 @@
1/**
2 * WebAuthn passkey routes (Block B5).
3 *
4 * Registration (authed):
5 * POST /api/passkeys/register/options → challenge + pubkey-cred-params
6 * POST /api/passkeys/register/verify → save credential
7 * GET /settings/passkeys → list + add + rename + delete
8 * POST /settings/passkeys/:id/delete
9 * POST /settings/passkeys/:id/rename
10 *
11 * Authentication (unauthed):
12 * POST /api/passkeys/auth/options → challenge (username optional)
13 * POST /api/passkeys/auth/verify → issues full session on success
14 *
15 * The browser-side glue lives in `/views/components.tsx`
16 * (`PasskeyScript`) — vanilla JS using the native `navigator.credentials` API.
17 */
18
19import { Hono } from "hono";
20import { setCookie } from "hono/cookie";
21import { and, eq } from "drizzle-orm";
22import { db } from "../db";
23import { users, userPasskeys, sessions } from "../db/schema";
24import type { AuthEnv } from "../middleware/auth";
25import { requireAuth } from "../middleware/auth";
26import { Layout } from "../views/layout";
27import {
28 startRegistration,
29 finishRegistration,
30 startAuthentication,
31 finishAuthentication,
32} from "../lib/webauthn";
33import {
34 generateSessionToken,
35 sessionCookieOptions,
36 sessionExpiry,
37} from "../lib/auth";
38import { audit } from "../lib/notify";
39
40const passkeys = new Hono<AuthEnv>();
41
42passkeys.use("/settings/passkeys", requireAuth);
43passkeys.use("/settings/passkeys/*", requireAuth);
44passkeys.use("/api/passkeys/register/*", requireAuth);
45
46// --- Settings UI ------------------------------------------------------------
47
48passkeys.get("/settings/passkeys", async (c) => {
49 const user = c.get("user")!;
50 const error = c.req.query("error");
51 const success = c.req.query("success");
52
53 let keys: (typeof userPasskeys.$inferSelect)[] = [];
54 try {
55 keys = await db
56 .select()
57 .from(userPasskeys)
58 .where(eq(userPasskeys.userId, user.id));
59 } catch (err) {
60 console.error("[passkeys] list:", err);
61 }
62
63 return c.html(
64 <Layout title="Passkeys" user={user}>
65 <div class="settings-container">
66 <div class="breadcrumb">
67 <a href="/settings">settings</a>
68 <span>/</span>
69 <span>passkeys</span>
70 </div>
71 <h2>Passkeys</h2>
72 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
73 {success && (
74 <div class="auth-success">{decodeURIComponent(success)}</div>
75 )}
76 <p style="color: var(--text-muted); font-size: 13px">
77 Passkeys are a phishing-resistant replacement for passwords. Your
78 device stores the private key and never shares it — sign-in is a
79 single Touch ID / Face ID / security-key tap.
80 </p>
81
82 <div style="margin: 16px 0">
83 <button
84 type="button"
85 id="pk-add-btn"
86 class="btn btn-primary"
87 >
88 Add a passkey
89 </button>
90 <span
91 id="pk-add-status"
92 style="color: var(--text-muted); font-size: 13px; margin-left: 8px"
93 />
94 </div>
95
96 <div
97 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
98 >
99 {keys.length === 0 ? (
100 <div
101 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
102 >
103 No passkeys registered yet.
104 </div>
105 ) : (
106 keys.map((k) => (
107 <div
108 style="padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary)"
109 >
110 <div>
111 <strong>{k.name}</strong>
112 <div
113 style="color: var(--text-muted); font-size: 12px; margin-top: 2px"
114 >
115 added {new Date(k.createdAt).toLocaleDateString()}
116 {k.lastUsedAt &&
117 ` · last used ${new Date(k.lastUsedAt).toLocaleDateString()}`}
118 </div>
119 </div>
120 <div style="display: flex; gap: 6px">
121 <form
122 method="POST"
123 action={`/settings/passkeys/${k.id}/rename`}
124 style="display: flex; gap: 4px"
125 >
126 <input
127 type="text"
128 name="name"
129 defaultValue={k.name}
130 maxLength={60}
131 style="width: 160px"
132 />
133 <button type="submit" class="btn btn-sm">
134 save
135 </button>
136 </form>
137 <form
138 method="POST"
139 action={`/settings/passkeys/${k.id}/delete`}
140 onsubmit="return confirm('Remove this passkey?')"
141 >
142 <button type="submit" class="btn btn-sm btn-danger">
143 remove
144 </button>
145 </form>
146 </div>
147 </div>
148 ))
149 )}
150 </div>
151
152 <script
153 dangerouslySetInnerHTML={{
154 __html: /* js */ `
155 (function () {
156 const btn = document.getElementById('pk-add-btn');
157 const status = document.getElementById('pk-add-status');
158 if (!btn) return;
159 function b64uToBuf(s) {
160 s = s.replace(/-/g,'+').replace(/_/g,'/');
161 while (s.length % 4) s += '=';
162 const bin = atob(s);
163 const buf = new Uint8Array(bin.length);
164 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
165 return buf.buffer;
166 }
167 function bufToB64u(buf) {
168 const bytes = new Uint8Array(buf);
169 let bin = '';
170 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
171 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
172 }
173 btn.addEventListener('click', async function () {
174 if (!window.PublicKeyCredential) {
175 status.textContent = 'Passkeys not supported in this browser.';
176 return;
177 }
178 status.textContent = 'Preparing…';
179 try {
180 const optsRes = await fetch('/api/passkeys/register/options', {
181 method: 'POST',
182 headers: { 'content-type': 'application/json' },
183 body: '{}'
184 });
185 if (!optsRes.ok) throw new Error('options failed');
186 const { options, sessionKey } = await optsRes.json();
187 options.challenge = b64uToBuf(options.challenge);
188 options.user.id = b64uToBuf(options.user.id);
189 if (options.excludeCredentials) {
190 options.excludeCredentials = options.excludeCredentials.map(function (c) {
191 return Object.assign({}, c, { id: b64uToBuf(c.id) });
192 });
193 }
194 status.textContent = 'Touch your authenticator…';
195 const cred = await navigator.credentials.create({ publicKey: options });
196 const resp = {
197 id: cred.id,
198 rawId: bufToB64u(cred.rawId),
199 type: cred.type,
200 response: {
201 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
202 attestationObject: bufToB64u(cred.response.attestationObject),
203 transports: cred.response.getTransports ? cred.response.getTransports() : []
204 },
205 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
206 };
207 const verifyRes = await fetch('/api/passkeys/register/verify', {
208 method: 'POST',
209 headers: { 'content-type': 'application/json' },
210 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
211 });
212 if (!verifyRes.ok) {
213 const j = await verifyRes.json().catch(() => ({}));
214 throw new Error(j.error || 'verify failed');
215 }
216 status.textContent = 'Saved. Reloading…';
217 window.location.reload();
218 } catch (e) {
219 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
220 }
221 });
222 })();
223 `,
224 }}
225 />
226 </div>
227 </Layout>
228 );
229});
230
231passkeys.post("/settings/passkeys/:id/delete", async (c) => {
232 const user = c.get("user")!;
233 const id = c.req.param("id");
234 try {
235 const [row] = await db
236 .select({ id: userPasskeys.id, userId: userPasskeys.userId })
237 .from(userPasskeys)
238 .where(eq(userPasskeys.id, id))
239 .limit(1);
240 if (!row || row.userId !== user.id) {
241 return c.redirect("/settings/passkeys?error=Not+found");
242 }
243 await db.delete(userPasskeys).where(eq(userPasskeys.id, id));
244 await audit({
245 userId: user.id,
246 action: "passkey.delete",
247 targetType: "passkey",
248 targetId: id,
249 });
250 return c.redirect("/settings/passkeys?success=Passkey+removed");
251 } catch (err) {
252 console.error("[passkeys] delete:", err);
253 return c.redirect("/settings/passkeys?error=Service+unavailable");
254 }
255});
256
257passkeys.post("/settings/passkeys/:id/rename", async (c) => {
258 const user = c.get("user")!;
259 const id = c.req.param("id");
260 const body = await c.req.parseBody();
261 const name = String(body.name || "").trim().slice(0, 60);
262 if (!name) {
263 return c.redirect("/settings/passkeys?error=Name+required");
264 }
265 try {
266 const [row] = await db
267 .select({ id: userPasskeys.id, userId: userPasskeys.userId })
268 .from(userPasskeys)
269 .where(eq(userPasskeys.id, id))
270 .limit(1);
271 if (!row || row.userId !== user.id) {
272 return c.redirect("/settings/passkeys?error=Not+found");
273 }
274 await db
275 .update(userPasskeys)
276 .set({ name })
277 .where(eq(userPasskeys.id, id));
278 return c.redirect("/settings/passkeys?success=Renamed");
279 } catch (err) {
280 console.error("[passkeys] rename:", err);
281 return c.redirect("/settings/passkeys?error=Service+unavailable");
282 }
283});
284
285// --- Registration JSON endpoints (authed) -----------------------------------
286
287passkeys.post("/api/passkeys/register/options", async (c) => {
288 const user = c.get("user")!;
289 try {
290 const existing = await db
291 .select({ credentialId: userPasskeys.credentialId })
292 .from(userPasskeys)
293 .where(eq(userPasskeys.userId, user.id));
294 const { options, sessionKey } = await startRegistration({
295 userId: user.id,
296 userName: user.username,
297 userDisplayName: user.displayName || user.username,
298 excludeCredentialIds: existing.map((e) => e.credentialId),
299 });
300 return c.json({ options, sessionKey });
301 } catch (err) {
302 console.error("[passkeys] register/options:", err);
303 return c.json({ error: "Service unavailable" }, 503);
304 }
305});
306
307passkeys.post("/api/passkeys/register/verify", async (c) => {
308 const user = c.get("user")!;
309 let body: { sessionKey: string; response: any };
310 try {
311 body = await c.req.json();
312 } catch {
313 return c.json({ error: "Invalid JSON" }, 400);
314 }
315 if (!body.sessionKey || !body.response) {
316 return c.json({ error: "sessionKey and response required" }, 400);
317 }
318 const result = await finishRegistration({
319 sessionKey: body.sessionKey,
320 response: body.response,
321 });
322 if (!result.ok) return c.json({ error: result.error }, 400);
323
324 try {
325 const transports = Array.isArray(body.response?.response?.transports)
326 ? JSON.stringify(body.response.response.transports)
327 : null;
328 await db.insert(userPasskeys).values({
329 userId: user.id,
330 credentialId: result.credentialId,
331 publicKey: result.publicKey,
332 counter: result.counter,
333 transports,
334 });
335 await audit({
336 userId: user.id,
337 action: "passkey.create",
338 targetType: "passkey",
339 metadata: { credentialId: result.credentialId },
340 });
341 return c.json({ ok: true });
342 } catch (err: any) {
343 if (String(err?.message || err).includes("user_passkeys")) {
344 return c.json({ error: "Credential already registered" }, 409);
345 }
346 console.error("[passkeys] register save:", err);
347 return c.json({ error: "Service unavailable" }, 503);
348 }
349});
350
351// --- Authentication JSON endpoints (unauthed) -------------------------------
352
353passkeys.post("/api/passkeys/auth/options", async (c) => {
354 let body: { username?: string };
355 try {
356 body = await c.req.json();
357 } catch {
358 body = {};
359 }
360 try {
361 let userId: string | undefined;
362 let allowCreds: string[] = [];
363 if (body.username) {
364 const [u] = await db
365 .select({ id: users.id })
366 .from(users)
367 .where(eq(users.username, body.username.trim().toLowerCase()))
368 .limit(1);
369 if (u) {
370 userId = u.id;
371 const rows = await db
372 .select({ credentialId: userPasskeys.credentialId })
373 .from(userPasskeys)
374 .where(eq(userPasskeys.userId, u.id));
375 allowCreds = rows.map((r) => r.credentialId);
376 }
377 }
378 const { options, sessionKey } = await startAuthentication({
379 userId,
380 allowCredentialIds: allowCreds,
381 });
382 return c.json({ options, sessionKey });
383 } catch (err) {
384 console.error("[passkeys] auth/options:", err);
385 return c.json({ error: "Service unavailable" }, 503);
386 }
387});
388
389passkeys.post("/api/passkeys/auth/verify", async (c) => {
390 let body: { sessionKey: string; response: any };
391 try {
392 body = await c.req.json();
393 } catch {
394 return c.json({ error: "Invalid JSON" }, 400);
395 }
396 if (!body.sessionKey || !body.response) {
397 return c.json({ error: "sessionKey and response required" }, 400);
398 }
399 const result = await finishAuthentication({
400 sessionKey: body.sessionKey,
401 response: body.response,
402 });
403 if (!result.ok) return c.json({ error: result.error }, 400);
404
405 try {
406 // Passkey is phishing-resistant + user-verifying; skip TOTP prompt.
407 const token = generateSessionToken();
408 await db.insert(sessions).values({
409 userId: result.userId,
410 token,
411 expiresAt: sessionExpiry(),
412 requires2fa: false,
413 });
414 setCookie(c, "session", token, sessionCookieOptions());
415 await audit({
416 userId: result.userId,
417 action: "passkey.login",
418 targetType: "passkey",
419 metadata: { credentialId: result.credentialId },
420 });
421 return c.json({ ok: true });
422 } catch (err) {
423 console.error("[passkeys] auth/verify:", err);
424 return c.json({ error: "Service unavailable" }, 503);
425 }
426});
427
428export default passkeys;
0429