馃 distributed transcription service
thistle.dunkirk.sh
1import {
2 generateAuthenticationOptions,
3 generateRegistrationOptions,
4 verifyAuthenticationResponse,
5 verifyRegistrationResponse,
6 type VerifiedAuthenticationResponse,
7 type VerifiedRegistrationResponse,
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("base64url");
167 const transports = response.response.transports?.join(",") || null;
168
169 db.run(
170 `INSERT INTO passkeys (id, user_id, credential_id, public_key, counter, transports, name)
171 VALUES (?, ?, ?, ?, ?, ?, ?)`,
172 [
173 passkeyId,
174 challengeData.user_id,
175 credentialIdBase64,
176 publicKeyBase64,
177 credential.counter,
178 transports,
179 name || null,
180 ],
181 );
182
183 const passkey = db
184 .query<Passkey, [string]>(
185 `SELECT id, user_id, credential_id, public_key, counter, transports, name, created_at, last_used_at
186 FROM passkeys WHERE id = ?`,
187 )
188 .get(passkeyId);
189
190 if (!passkey) {
191 throw new Error("Failed to create passkey");
192 }
193
194 return passkey;
195}
196
197/**
198 * Generate authentication options
199 */
200export async function createAuthenticationOptions(email?: string) {
201 const { rpID } = getRPConfig();
202
203 let allowCredentials: Array<{
204 id: string;
205 transports?: ("usb" | "nfc" | "ble" | "internal" | "hybrid")[];
206 }> = [];
207
208 // If email provided, only allow that user's credentials
209 if (email) {
210 const user = db
211 .query<{ id: number }, [string]>("SELECT id FROM users WHERE email = ?")
212 .get(email);
213
214 if (user) {
215 const credentials = getPasskeysForUser(user.id);
216 allowCredentials = credentials.map((cred) => ({
217 id: cred.credential_id,
218 transports: cred.transports?.split(",") as
219 | ("usb" | "nfc" | "ble" | "internal" | "hybrid")[]
220 | undefined,
221 }));
222 }
223 }
224
225 const options = await generateAuthenticationOptions({
226 rpID,
227 allowCredentials: allowCredentials.length > 0 ? allowCredentials : undefined,
228 userVerification: "preferred",
229 });
230
231 // Store challenge
232 authenticationChallenges.set(options.challenge, {
233 challenge: options.challenge,
234 expires_at: Date.now() + CHALLENGE_TTL,
235 });
236
237 return options;
238}
239
240/**
241 * Verify authentication response
242 */
243export async function verifyAndAuthenticatePasskey(
244 response: AuthenticationResponseJSON,
245 expectedChallenge: string,
246): Promise<{ passkey: Passkey; user: User }> {
247 // Validate challenge
248 const challengeData = authenticationChallenges.get(expectedChallenge);
249 if (!challengeData) {
250 throw new Error("Invalid or expired challenge");
251 }
252
253 if (challengeData.expires_at < Date.now()) {
254 authenticationChallenges.delete(expectedChallenge);
255 throw new Error("Challenge expired");
256 }
257
258 // Get passkey by credential ID
259 // response.id is already base64url encoded string from SimpleWebAuthn
260 const passkey = db
261 .query<Passkey, [string]>(
262 `SELECT id, user_id, credential_id, public_key, counter, transports, name, created_at, last_used_at
263 FROM passkeys WHERE credential_id = ?`,
264 )
265 .get(response.id);
266
267 if (!passkey) {
268 throw new Error("Passkey not found");
269 }
270
271 const { origin, rpID } = getRPConfig();
272
273 // Verify the authentication
274 let verification: VerifiedAuthenticationResponse;
275 try {
276 verification = await verifyAuthenticationResponse({
277 response,
278 expectedChallenge,
279 expectedOrigin: origin,
280 expectedRPID: rpID,
281 credential: {
282 id: passkey.credential_id,
283 publicKey: Buffer.from(passkey.public_key, "base64url"),
284 counter: passkey.counter,
285 },
286 });
287 } catch (error) {
288 throw new Error(
289 `Authentication verification failed: ${error instanceof Error ? error.message : "Unknown error"}`,
290 );
291 }
292
293 if (!verification.verified) {
294 throw new Error("Authentication verification failed");
295 }
296
297 // Remove used challenge
298 authenticationChallenges.delete(expectedChallenge);
299
300 // Update last used timestamp and counter
301 const now = Math.floor(Date.now() / 1000);
302 db.run(
303 "UPDATE passkeys SET last_used_at = ?, counter = ? WHERE id = ?",
304 [now, verification.authenticationInfo.newCounter, passkey.id],
305 );
306
307 // Get user
308 const user = db
309 .query<User, [number]>(
310 "SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?",
311 )
312 .get(passkey.user_id);
313
314 if (!user) {
315 throw new Error("User not found");
316 }
317
318 return { passkey, user };
319}
320
321/**
322 * Get all passkeys for a user
323 */
324export function getPasskeysForUser(userId: number): Passkey[] {
325 return db
326 .query<Passkey, [number]>(
327 `SELECT id, user_id, credential_id, public_key, counter, transports, name, created_at, last_used_at
328 FROM passkeys WHERE user_id = ? ORDER BY created_at DESC`,
329 )
330 .all(userId);
331}
332
333/**
334 * Delete a passkey
335 */
336export function deletePasskey(passkeyId: string, userId: number): void {
337 db.run("DELETE FROM passkeys WHERE id = ? AND user_id = ?", [
338 passkeyId,
339 userId,
340 ]);
341}
342
343/**
344 * Update passkey name
345 */
346export function updatePasskeyName(
347 passkeyId: string,
348 userId: number,
349 name: string,
350): void {
351 db.run("UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?", [
352 name,
353 passkeyId,
354 userId,
355 ]);
356}