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