馃 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}