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.tsxBlame429 lines · 2 contributors
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}
5db1b25copilot-swe-agent[bot]131 aria-label="Passkey name"
2df1f8cClaude132 style="width: 160px"
133 />
134 <button type="submit" class="btn btn-sm">
135 save
136 </button>
137 </form>
138 <form
e7e240eClaude139 method="post"
2df1f8cClaude140 action={`/settings/passkeys/${k.id}/delete`}
141 onsubmit="return confirm('Remove this passkey?')"
142 >
143 <button type="submit" class="btn btn-sm btn-danger">
144 remove
145 </button>
146 </form>
147 </div>
148 </div>
149 ))
150 )}
151 </div>
152
153 <script
154 dangerouslySetInnerHTML={{
155 __html: /* js */ `
156 (function () {
157 const btn = document.getElementById('pk-add-btn');
158 const status = document.getElementById('pk-add-status');
159 if (!btn) return;
160 function b64uToBuf(s) {
161 s = s.replace(/-/g,'+').replace(/_/g,'/');
162 while (s.length % 4) s += '=';
163 const bin = atob(s);
164 const buf = new Uint8Array(bin.length);
165 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
166 return buf.buffer;
167 }
168 function bufToB64u(buf) {
169 const bytes = new Uint8Array(buf);
170 let bin = '';
171 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
172 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
173 }
174 btn.addEventListener('click', async function () {
175 if (!window.PublicKeyCredential) {
176 status.textContent = 'Passkeys not supported in this browser.';
177 return;
178 }
179 status.textContent = 'Preparing…';
180 try {
181 const optsRes = await fetch('/api/passkeys/register/options', {
182 method: 'POST',
183 headers: { 'content-type': 'application/json' },
184 body: '{}'
185 });
186 if (!optsRes.ok) throw new Error('options failed');
187 const { options, sessionKey } = await optsRes.json();
188 options.challenge = b64uToBuf(options.challenge);
189 options.user.id = b64uToBuf(options.user.id);
190 if (options.excludeCredentials) {
191 options.excludeCredentials = options.excludeCredentials.map(function (c) {
192 return Object.assign({}, c, { id: b64uToBuf(c.id) });
193 });
194 }
195 status.textContent = 'Touch your authenticator…';
196 const cred = await navigator.credentials.create({ publicKey: options });
197 const resp = {
198 id: cred.id,
199 rawId: bufToB64u(cred.rawId),
200 type: cred.type,
201 response: {
202 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
203 attestationObject: bufToB64u(cred.response.attestationObject),
204 transports: cred.response.getTransports ? cred.response.getTransports() : []
205 },
206 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
207 };
208 const verifyRes = await fetch('/api/passkeys/register/verify', {
209 method: 'POST',
210 headers: { 'content-type': 'application/json' },
211 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
212 });
213 if (!verifyRes.ok) {
214 const j = await verifyRes.json().catch(() => ({}));
215 throw new Error(j.error || 'verify failed');
216 }
217 status.textContent = 'Saved. Reloading…';
218 window.location.reload();
219 } catch (e) {
220 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
221 }
222 });
223 })();
224 `,
225 }}
226 />
227 </div>
228 </Layout>
229 );
230});
231
232passkeys.post("/settings/passkeys/:id/delete", async (c) => {
233 const user = c.get("user")!;
234 const id = c.req.param("id");
235 try {
236 const [row] = await db
237 .select({ id: userPasskeys.id, userId: userPasskeys.userId })
238 .from(userPasskeys)
239 .where(eq(userPasskeys.id, id))
240 .limit(1);
241 if (!row || row.userId !== user.id) {
242 return c.redirect("/settings/passkeys?error=Not+found");
243 }
244 await db.delete(userPasskeys).where(eq(userPasskeys.id, id));
245 await audit({
246 userId: user.id,
247 action: "passkey.delete",
248 targetType: "passkey",
249 targetId: id,
250 });
251 return c.redirect("/settings/passkeys?success=Passkey+removed");
252 } catch (err) {
253 console.error("[passkeys] delete:", err);
254 return c.redirect("/settings/passkeys?error=Service+unavailable");
255 }
256});
257
258passkeys.post("/settings/passkeys/:id/rename", async (c) => {
259 const user = c.get("user")!;
260 const id = c.req.param("id");
261 const body = await c.req.parseBody();
262 const name = String(body.name || "").trim().slice(0, 60);
263 if (!name) {
264 return c.redirect("/settings/passkeys?error=Name+required");
265 }
266 try {
267 const [row] = await db
268 .select({ id: userPasskeys.id, userId: userPasskeys.userId })
269 .from(userPasskeys)
270 .where(eq(userPasskeys.id, id))
271 .limit(1);
272 if (!row || row.userId !== user.id) {
273 return c.redirect("/settings/passkeys?error=Not+found");
274 }
275 await db
276 .update(userPasskeys)
277 .set({ name })
278 .where(eq(userPasskeys.id, id));
279 return c.redirect("/settings/passkeys?success=Renamed");
280 } catch (err) {
281 console.error("[passkeys] rename:", err);
282 return c.redirect("/settings/passkeys?error=Service+unavailable");
283 }
284});
285
286// --- Registration JSON endpoints (authed) -----------------------------------
287
288passkeys.post("/api/passkeys/register/options", async (c) => {
289 const user = c.get("user")!;
290 try {
291 const existing = await db
292 .select({ credentialId: userPasskeys.credentialId })
293 .from(userPasskeys)
294 .where(eq(userPasskeys.userId, user.id));
295 const { options, sessionKey } = await startRegistration({
296 userId: user.id,
297 userName: user.username,
298 userDisplayName: user.displayName || user.username,
299 excludeCredentialIds: existing.map((e) => e.credentialId),
300 });
301 return c.json({ options, sessionKey });
302 } catch (err) {
303 console.error("[passkeys] register/options:", err);
304 return c.json({ error: "Service unavailable" }, 503);
305 }
306});
307
308passkeys.post("/api/passkeys/register/verify", async (c) => {
309 const user = c.get("user")!;
310 let body: { sessionKey: string; response: any };
311 try {
312 body = await c.req.json();
313 } catch {
314 return c.json({ error: "Invalid JSON" }, 400);
315 }
316 if (!body.sessionKey || !body.response) {
317 return c.json({ error: "sessionKey and response required" }, 400);
318 }
319 const result = await finishRegistration({
320 sessionKey: body.sessionKey,
321 response: body.response,
322 });
323 if (!result.ok) return c.json({ error: result.error }, 400);
324
325 try {
326 const transports = Array.isArray(body.response?.response?.transports)
327 ? JSON.stringify(body.response.response.transports)
328 : null;
329 await db.insert(userPasskeys).values({
330 userId: user.id,
331 credentialId: result.credentialId,
332 publicKey: result.publicKey,
333 counter: result.counter,
334 transports,
335 });
336 await audit({
337 userId: user.id,
338 action: "passkey.create",
339 targetType: "passkey",
340 metadata: { credentialId: result.credentialId },
341 });
342 return c.json({ ok: true });
343 } catch (err: any) {
344 if (String(err?.message || err).includes("user_passkeys")) {
345 return c.json({ error: "Credential already registered" }, 409);
346 }
347 console.error("[passkeys] register save:", err);
348 return c.json({ error: "Service unavailable" }, 503);
349 }
350});
351
352// --- Authentication JSON endpoints (unauthed) -------------------------------
353
354passkeys.post("/api/passkeys/auth/options", async (c) => {
355 let body: { username?: string };
356 try {
357 body = await c.req.json();
358 } catch {
359 body = {};
360 }
361 try {
362 let userId: string | undefined;
363 let allowCreds: string[] = [];
364 if (body.username) {
365 const [u] = await db
366 .select({ id: users.id })
367 .from(users)
368 .where(eq(users.username, body.username.trim().toLowerCase()))
369 .limit(1);
370 if (u) {
371 userId = u.id;
372 const rows = await db
373 .select({ credentialId: userPasskeys.credentialId })
374 .from(userPasskeys)
375 .where(eq(userPasskeys.userId, u.id));
376 allowCreds = rows.map((r) => r.credentialId);
377 }
378 }
379 const { options, sessionKey } = await startAuthentication({
380 userId,
381 allowCredentialIds: allowCreds,
382 });
383 return c.json({ options, sessionKey });
384 } catch (err) {
385 console.error("[passkeys] auth/options:", err);
386 return c.json({ error: "Service unavailable" }, 503);
387 }
388});
389
390passkeys.post("/api/passkeys/auth/verify", async (c) => {
391 let body: { sessionKey: string; response: any };
392 try {
393 body = await c.req.json();
394 } catch {
395 return c.json({ error: "Invalid JSON" }, 400);
396 }
397 if (!body.sessionKey || !body.response) {
398 return c.json({ error: "sessionKey and response required" }, 400);
399 }
400 const result = await finishAuthentication({
401 sessionKey: body.sessionKey,
402 response: body.response,
403 });
404 if (!result.ok) return c.json({ error: result.error }, 400);
405
406 try {
407 // Passkey is phishing-resistant + user-verifying; skip TOTP prompt.
408 const token = generateSessionToken();
409 await db.insert(sessions).values({
410 userId: result.userId,
411 token,
412 expiresAt: sessionExpiry(),
413 requires2fa: false,
414 });
415 setCookie(c, "session", token, sessionCookieOptions());
416 await audit({
417 userId: result.userId,
418 action: "passkey.login",
419 targetType: "passkey",
420 metadata: { credentialId: result.credentialId },
421 });
422 return c.json({ ok: true });
423 } catch (err) {
424 console.error("[passkeys] auth/verify:", err);
425 return c.json({ error: "Service unavailable" }, 503);
426 }
427});
428
429export default passkeys;