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