馃 distributed transcription service
thistle.dunkirk.sh
1import {
2 generateAuthenticationOptions,
3 generateRegistrationOptions,
4 type VerifiedAuthenticationResponse,
5 type VerifiedRegistrationResponse,
6 verifyAuthenticationResponse,
7 verifyRegistrationResponse,
8} from "@simplewebauthn/server";
9import type {
10 AuthenticationResponseJSON,
11 RegistrationResponseJSON,
12} from "@simplewebauthn/types";
13import db from "../db/schema";
14import type { User } from "./auth";
15
16export interface Passkey {
17 id: string;
18 user_id: number;
19 credential_id: string;
20 public_key: string;
21 counter: number;
22 transports: string | null;
23 name: string | null;
24 created_at: number;
25 last_used_at: number | null;
26}
27
28export interface RegistrationChallenge {
29 challenge: string;
30 user_id: number;
31 expires_at: number;
32}
33
34export interface AuthenticationChallenge {
35 challenge: string;
36 expires_at: number;
37}
38
39// In-memory challenge storage
40const registrationChallenges = new Map<string, RegistrationChallenge>();
41const authenticationChallenges = new Map<string, AuthenticationChallenge>();
42
43// Challenge TTL: 5 minutes
44const CHALLENGE_TTL = 5 * 60 * 1000;
45
46// Cleanup expired challenges every minute
47setInterval(() => {
48 const now = Date.now();
49 for (const [challenge, data] of registrationChallenges.entries()) {
50 if (data.expires_at < now) {
51 registrationChallenges.delete(challenge);
52 }
53 }
54 for (const [challenge, data] of authenticationChallenges.entries()) {
55 if (data.expires_at < now) {
56 authenticationChallenges.delete(challenge);
57 }
58 }
59}, 60 * 1000);
60
61/**
62 * Get RP ID and origin based on environment
63 */
64function getRPConfig(): { rpID: string; rpName: string; origin: string } {
65 return {
66 rpID: process.env.RP_ID || "localhost",
67 rpName: "Thistle",
68 origin: process.env.ORIGIN || "http://localhost:3000",
69 };
70}
71
72/**
73 * Generate registration options for a user
74 */
75export async function createRegistrationOptions(user: User) {
76 const { rpID, rpName } = getRPConfig();
77
78 // Get existing credentials to exclude
79 const existingCredentials = getPasskeysForUser(user.id);
80
81 const options = await generateRegistrationOptions({
82 rpName,
83 rpID,
84 userName: user.email,
85 userDisplayName: user.name || user.email,
86 attestationType: "none",
87 excludeCredentials: existingCredentials.map((cred) => ({
88 id: cred.credential_id,
89 transports: cred.transports?.split(",") as
90 | ("usb" | "nfc" | "ble" | "internal" | "hybrid")[]
91 | undefined,
92 })),
93 authenticatorSelection: {
94 residentKey: "preferred",
95 userVerification: "preferred",
96 },
97 });
98
99 // Store challenge
100 registrationChallenges.set(options.challenge, {
101 challenge: options.challenge,
102 user_id: user.id,
103 expires_at: Date.now() + CHALLENGE_TTL,
104 });
105
106 return options;
107}
108
109/**
110 * Verify registration response and create passkey
111 */
112export async function verifyAndCreatePasskey(
113 response: RegistrationResponseJSON,
114 expectedChallenge: string,
115 name?: string,
116): Promise<Passkey> {
117 // Validate challenge exists
118 const challengeData = registrationChallenges.get(expectedChallenge);
119 if (!challengeData) {
120 throw new Error("Invalid or expired challenge");
121 }
122
123 if (challengeData.expires_at < Date.now()) {
124 registrationChallenges.delete(expectedChallenge);
125 throw new Error("Challenge expired");
126 }
127
128 const { origin, rpID } = getRPConfig();
129
130 // Verify the registration
131 let verification: VerifiedRegistrationResponse;
132 try {
133 verification = await verifyRegistrationResponse({
134 response,
135 expectedChallenge,
136 expectedOrigin: origin,
137 expectedRPID: rpID,
138 });
139 } catch (error) {
140 throw new Error(
141 `Registration verification failed: ${error instanceof Error ? error.message : "Unknown error"}`,
142 );
143 }
144
145 if (!verification.verified || !verification.registrationInfo) {
146 throw new Error("Registration verification failed");
147 }
148
149 // Remove used challenge
150 registrationChallenges.delete(expectedChallenge);
151
152 const { credential } = verification.registrationInfo;
153
154 // Create passkey
155 // credential.id is a base64url string in SimpleWebAuthn v13
156 // credential.publicKey is a Uint8Array that needs conversion
157 const passkeyId = crypto.randomUUID();
158 const credentialIdBase64 = credential.id;
159 const publicKeyBase64 = Buffer.from(credential.publicKey).toString(
160 "base64url",
161 );
162 const transports = response.response.transports?.join(",") || null;
163
164 db.run(
165 `INSERT INTO passkeys (id, user_id, credential_id, public_key, counter, transports, name)
166 VALUES (?, ?, ?, ?, ?, ?, ?)`,
167 [
168 passkeyId,
169 challengeData.user_id,
170 credentialIdBase64,
171 publicKeyBase64,
172 credential.counter,
173 transports,
174 name || null,
175 ],
176 );
177
178 const passkey = db
179 .query<Passkey, [string]>(
180 `SELECT id, user_id, credential_id, public_key, counter, transports, name, created_at, last_used_at
181 FROM passkeys WHERE id = ?`,
182 )
183 .get(passkeyId);
184
185 if (!passkey) {
186 throw new Error("Failed to create passkey");
187 }
188
189 return passkey;
190}
191
192/**
193 * Generate authentication options
194 */
195export async function createAuthenticationOptions(email?: string) {
196 const { rpID } = getRPConfig();
197
198 let allowCredentials: Array<{
199 id: string;
200 transports?: ("usb" | "nfc" | "ble" | "internal" | "hybrid")[];
201 }> = [];
202
203 // If email provided, only allow that user's credentials
204 if (email) {
205 const user = db
206 .query<{ id: number }, [string]>("SELECT id FROM users WHERE email = ?")
207 .get(email);
208
209 if (user) {
210 const credentials = getPasskeysForUser(user.id);
211 allowCredentials = credentials.map((cred) => ({
212 id: cred.credential_id,
213 transports: cred.transports?.split(",") as
214 | ("usb" | "nfc" | "ble" | "internal" | "hybrid")[]
215 | undefined,
216 }));
217 }
218 }
219
220 const options = await generateAuthenticationOptions({
221 rpID,
222 allowCredentials:
223 allowCredentials.length > 0 ? allowCredentials : undefined,
224 userVerification: "preferred",
225 });
226
227 // Store challenge
228 authenticationChallenges.set(options.challenge, {
229 challenge: options.challenge,
230 expires_at: Date.now() + CHALLENGE_TTL,
231 });
232
233 return options;
234}
235
236/**
237 * Verify authentication response
238 */
239export async function verifyAndAuthenticatePasskey(
240 response: AuthenticationResponseJSON,
241 expectedChallenge: string,
242): Promise<{ passkey: Passkey; user: User } | { error: string }> {
243 // Validate challenge
244 const challengeData = authenticationChallenges.get(expectedChallenge);
245 if (!challengeData) {
246 throw new Error("Invalid or expired challenge");
247 }
248
249 if (challengeData.expires_at < Date.now()) {
250 authenticationChallenges.delete(expectedChallenge);
251 throw new Error("Challenge expired");
252 }
253
254 // Get passkey by credential ID
255 // response.id is already base64url encoded string from SimpleWebAuthn
256 const passkey = db
257 .query<Passkey, [string]>(
258 `SELECT id, user_id, credential_id, public_key, counter, transports, name, created_at, last_used_at
259 FROM passkeys WHERE credential_id = ?`,
260 )
261 .get(response.id);
262
263 if (!passkey) {
264 return { error: "Passkey not found" };
265 }
266
267 const { origin, rpID } = getRPConfig();
268
269 // Verify the authentication
270 let verification: VerifiedAuthenticationResponse;
271 try {
272 verification = await verifyAuthenticationResponse({
273 response,
274 expectedChallenge,
275 expectedOrigin: origin,
276 expectedRPID: rpID,
277 credential: {
278 id: passkey.credential_id,
279 publicKey: Buffer.from(passkey.public_key, "base64url"),
280 counter: passkey.counter,
281 },
282 });
283 } catch (error) {
284 throw new Error(
285 `Authentication verification failed: ${error instanceof Error ? error.message : "Unknown error"}`,
286 );
287 }
288
289 if (!verification.verified) {
290 throw new Error("Authentication verification failed");
291 }
292
293 // Remove used challenge
294 authenticationChallenges.delete(expectedChallenge);
295
296 // Update last used timestamp and counter for passkey
297 const now = Math.floor(Date.now() / 1000);
298 db.run("UPDATE passkeys SET last_used_at = ?, counter = ? WHERE id = ?", [
299 now,
300 verification.authenticationInfo.newCounter,
301 passkey.id,
302 ]);
303
304 // Update user's last_login
305 db.run("UPDATE users SET last_login = ? WHERE id = ?", [
306 now,
307 passkey.user_id,
308 ]);
309
310 // Get user
311 const user = db
312 .query<User, [number]>(
313 "SELECT id, email, name, avatar, created_at, role, last_login FROM users WHERE id = ?",
314 )
315 .get(passkey.user_id);
316
317 if (!user) {
318 throw new Error("User not found");
319 }
320
321 return { passkey, user };
322}
323
324/**
325 * Get all passkeys for a user
326 */
327export function getPasskeysForUser(userId: number): Passkey[] {
328 return db
329 .query<Passkey, [number]>(
330 `SELECT id, user_id, credential_id, public_key, counter, transports, name, created_at, last_used_at
331 FROM passkeys WHERE user_id = ? ORDER BY created_at DESC`,
332 )
333 .all(userId);
334}
335
336/**
337 * Delete a passkey
338 */
339export function deletePasskey(passkeyId: string, userId: number): void {
340 db.run("DELETE FROM passkeys WHERE id = ? AND user_id = ?", [
341 passkeyId,
342 userId,
343 ]);
344}
345
346/**
347 * Update passkey name
348 */
349export function updatePasskeyName(
350 passkeyId: string,
351 userId: number,
352 name: string,
353): void {
354 db.run("UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?", [
355 name,
356 passkeyId,
357 userId,
358 ]);
359}