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

webauthn.ts

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

webauthn.tsBlame230 lines · 1 contributor
2df1f8cClaude1/**
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}