Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

passkeys.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

passkeys.tsxBlame428 lines · 1 contributor
2df1f8cClaude1/**
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
e7e240eClaude122 method="post"
2df1f8cClaude123 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
e7e240eClaude138 method="post"
2df1f8cClaude139 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;