馃 distributed transcription service thistle.dunkirk.sh
at v0.1.0 9.1 kB view raw
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}