my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server

feat: use ssr safe fetch as suggested by @thisismissem.social

dunkirk.sh c4a55dcf 03c6a5e5

verified
+656 -214
+31 -16
src/client/index.ts
··· 1 - import { 2 - startRegistration, 3 - } from "@simplewebauthn/browser"; 1 + import { startRegistration } from "@simplewebauthn/browser"; 4 2 5 3 const token = localStorage.getItem("indiko_session"); 6 4 const footer = document.getElementById("footer") as HTMLElement; ··· 8 6 const subtitle = document.getElementById("subtitle") as HTMLElement; 9 7 const recentApps = document.getElementById("recentApps") as HTMLElement; 10 8 const passkeysList = document.getElementById("passkeysList") as HTMLElement; 11 - const addPasskeyBtn = document.getElementById("addPasskeyBtn") as HTMLButtonElement; 9 + const addPasskeyBtn = document.getElementById( 10 + "addPasskeyBtn", 11 + ) as HTMLButtonElement; 12 12 const toast = document.getElementById("toast") as HTMLElement; 13 13 14 14 // Profile form elements ··· 320 320 const passkeys = data.passkeys as Passkey[]; 321 321 322 322 if (passkeys.length === 0) { 323 - passkeysList.innerHTML = '<div class="empty">No passkeys registered</div>'; 323 + passkeysList.innerHTML = 324 + '<div class="empty">No passkeys registered</div>'; 324 325 return; 325 326 } 326 327 327 328 passkeysList.innerHTML = passkeys 328 329 .map((passkey) => { 329 - const createdDate = new Date(passkey.created_at * 1000).toLocaleDateString(); 330 + const createdDate = new Date( 331 + passkey.created_at * 1000, 332 + ).toLocaleDateString(); 330 333 331 334 return ` 332 335 <div class="passkey-item" data-passkey-id="${passkey.id}"> ··· 336 339 </div> 337 340 <div class="passkey-actions"> 338 341 <button type="button" class="rename-passkey-btn" data-passkey-id="${passkey.id}">rename</button> 339 - ${passkeys.length > 1 ? `<button type="button" class="delete-passkey-btn" data-passkey-id="${passkey.id}">delete</button>` : ''} 342 + ${passkeys.length > 1 ? `<button type="button" class="delete-passkey-btn" data-passkey-id="${passkey.id}">delete</button>` : ""} 340 343 </div> 341 344 </div> 342 345 `; ··· 365 368 } 366 369 367 370 function showRenameForm(passkeyId: number) { 368 - const passkeyItem = document.querySelector(`[data-passkey-id="${passkeyId}"]`); 371 + const passkeyItem = document.querySelector( 372 + `[data-passkey-id="${passkeyId}"]`, 373 + ); 369 374 if (!passkeyItem) return; 370 375 371 376 const infoDiv = passkeyItem.querySelector(".passkey-info"); ··· 389 394 input.select(); 390 395 391 396 // Save button 392 - infoDiv.querySelector(".save-rename-btn")?.addEventListener("click", async () => { 393 - await renamePasskeyHandler(passkeyId, input.value); 394 - }); 397 + infoDiv 398 + .querySelector(".save-rename-btn") 399 + ?.addEventListener("click", async () => { 400 + await renamePasskeyHandler(passkeyId, input.value); 401 + }); 395 402 396 403 // Cancel button 397 - infoDiv.querySelector(".cancel-rename-btn")?.addEventListener("click", () => { 398 - loadPasskeys(); 399 - }); 404 + infoDiv 405 + .querySelector(".cancel-rename-btn") 406 + ?.addEventListener("click", () => { 407 + loadPasskeys(); 408 + }); 400 409 401 410 // Enter to save 402 411 input.addEventListener("keypress", async (e) => { ··· 443 452 } 444 453 445 454 async function deletePasskeyHandler(passkeyId: number) { 446 - if (!confirm("Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.")) { 455 + if ( 456 + !confirm( 457 + "Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.", 458 + ) 459 + ) { 447 460 return; 448 461 } 449 462 ··· 496 509 addPasskeyBtn.textContent = "verifying..."; 497 510 498 511 // Ask for a name 499 - const name = prompt("Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):"); 512 + const name = prompt( 513 + "Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):", 514 + ); 500 515 501 516 // Verify registration 502 517 const verifyRes = await fetch("/api/passkeys/add/verify", {
+12 -3
src/index.ts
··· 365 365 366 366 if (expiredOrphans.length > 0) { 367 367 if (action === "suspend") { 368 - await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "suspend"); 368 + await updateOrphanedAccounts( 369 + { ...result, orphanedUsers: expiredOrphans }, 370 + "suspend", 371 + ); 369 372 } else if (action === "deactivate") { 370 - await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "deactivate"); 373 + await updateOrphanedAccounts( 374 + { ...result, orphanedUsers: expiredOrphans }, 375 + "deactivate", 376 + ); 371 377 } else if (action === "remove") { 372 - await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "remove"); 378 + await updateOrphanedAccounts( 379 + { ...result, orphanedUsers: expiredOrphans }, 380 + "remove", 381 + ); 373 382 } 374 383 console.log( 375 384 `[LDAP Cleanup] ${action === "remove" ? "Removed" : action === "suspend" ? "Suspended" : "Deactivated"} ${expiredOrphans.length} LDAP orphan accounts (grace period: ${gracePeriod}s)`,
+263
src/lib/ssrf-safe-fetch.ts
··· 1 + /** 2 + * SSRF-safe fetch implementation. 3 + * 4 + * Prevents Server-Side Request Forgery attacks by: 5 + * 1. Blocking private/internal IP addresses in URLs 6 + * 2. Blocking local hostnames (.local, .localhost, .internal, etc.) 7 + * 3. Validating redirect targets 8 + * 9 + * @see https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/ 10 + */ 11 + 12 + export type SafeFetchResult<T> = 13 + | { success: true; data: T } 14 + | { success: false; error: string }; 15 + 16 + /** 17 + * Check if an IP address is in a private/reserved range. 18 + * Covers all ranges that should not be accessed from the internet. 19 + */ 20 + function isPrivateIP(ip: string): boolean { 21 + // IPv4 private/reserved ranges 22 + const ipv4Patterns = [ 23 + /^0\./, // 0.0.0.0/8 - Current network 24 + /^10\./, // 10.0.0.0/8 - Private 25 + /^127\./, // 127.0.0.0/8 - Loopback 26 + /^169\.254\./, // 169.254.0.0/16 - Link-local (including AWS metadata 169.254.169.254) 27 + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12 - Private 28 + /^192\.0\.0\./, // 192.0.0.0/24 - IETF Protocol Assignments 29 + /^192\.0\.2\./, // 192.0.2.0/24 - TEST-NET-1 30 + /^192\.88\.99\./, // 192.88.99.0/24 - 6to4 Relay Anycast 31 + /^192\.168\./, // 192.168.0.0/16 - Private 32 + /^198\.1[8-9]\./, // 198.18.0.0/15 - Benchmarking 33 + /^198\.51\.100\./, // 198.51.100.0/24 - TEST-NET-2 34 + /^203\.0\.113\./, // 203.0.113.0/24 - TEST-NET-3 35 + /^22[4-9]\./, // 224.0.0.0/4 - Multicast 36 + /^23[0-9]\./, // 224.0.0.0/4 - Multicast 37 + /^24[0-9]\./, // 240.0.0.0/4 - Reserved 38 + /^25[0-5]\./, // 240.0.0.0/4 - Reserved (including broadcast 255.255.255.255) 39 + ]; 40 + 41 + for (const pattern of ipv4Patterns) { 42 + if (pattern.test(ip)) { 43 + return true; 44 + } 45 + } 46 + 47 + // IPv6 private/reserved - handle both bracketed [::1] and plain ::1 48 + const ipv6 = ip.replace(/^\[|\]$/g, "").toLowerCase(); 49 + 50 + // Loopback ::1 51 + if (ipv6 === "::1") return true; 52 + 53 + // Unspecified :: 54 + if (ipv6 === "::") return true; 55 + 56 + // Link-local fe80::/10 57 + if ( 58 + ipv6.startsWith("fe80:") || 59 + ipv6.startsWith("fe8") || 60 + ipv6.startsWith("fe9") || 61 + ipv6.startsWith("fea") || 62 + ipv6.startsWith("feb") 63 + ) { 64 + return true; 65 + } 66 + 67 + // Unique local fc00::/7 (includes fd00::/8) 68 + if (ipv6.startsWith("fc") || ipv6.startsWith("fd")) { 69 + return true; 70 + } 71 + 72 + // IPv4-mapped IPv6 addresses ::ffff:x.x.x.x 73 + const ipv4MappedMatch = ipv6.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i); 74 + if (ipv4MappedMatch?.[1]) { 75 + return isPrivateIP(ipv4MappedMatch[1]); 76 + } 77 + 78 + return false; 79 + } 80 + 81 + /** 82 + * Check if a hostname is a local/internal hostname that should not be fetched. 83 + */ 84 + function isLocalHostname(hostname: string): boolean { 85 + const lower = hostname.toLowerCase(); 86 + 87 + // Exact matches for localhost variants 88 + if (lower === "localhost" || lower === "localhost.localdomain") { 89 + return true; 90 + } 91 + 92 + // Check for local TLDs and suffixes 93 + const localSuffixes = [ 94 + ".local", 95 + ".localhost", 96 + ".localdomain", 97 + ".internal", 98 + ".home", 99 + ".lan", 100 + ".corp", 101 + ".test", 102 + ".invalid", 103 + ".example", 104 + // Cloud provider metadata hostnames 105 + ".metadata.google.internal", 106 + ".compute.internal", 107 + ]; 108 + 109 + for (const suffix of localSuffixes) { 110 + if (lower.endsWith(suffix)) { 111 + return true; 112 + } 113 + } 114 + 115 + // AWS/cloud metadata hostnames 116 + if ( 117 + lower === "metadata.google.internal" || 118 + lower === "instance-data" || 119 + lower === "metadata" 120 + ) { 121 + return true; 122 + } 123 + 124 + return false; 125 + } 126 + 127 + /** 128 + * Validate that a URL is safe to fetch (not pointing to internal resources). 129 + */ 130 + export function validateExternalURL(urlString: string): { 131 + safe: boolean; 132 + error?: string; 133 + } { 134 + let url: URL; 135 + try { 136 + url = new URL(urlString); 137 + } catch { 138 + return { safe: false, error: "Invalid URL" }; 139 + } 140 + 141 + // Must be HTTP or HTTPS 142 + if (url.protocol !== "http:" && url.protocol !== "https:") { 143 + return { safe: false, error: "URL must use http or https protocol" }; 144 + } 145 + 146 + // Check for credentials in URL (potential abuse vector) 147 + if (url.username || url.password) { 148 + return { safe: false, error: "URL must not contain credentials" }; 149 + } 150 + 151 + const hostname = url.hostname; 152 + 153 + // Check if hostname is a local hostname 154 + if (isLocalHostname(hostname)) { 155 + return { safe: false, error: "Cannot fetch from local/internal hostnames" }; 156 + } 157 + 158 + // Check if hostname is an IP address in private range 159 + // IPv4 check 160 + if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { 161 + if (isPrivateIP(hostname)) { 162 + return { 163 + safe: false, 164 + error: "Cannot fetch from private/reserved IP addresses", 165 + }; 166 + } 167 + } 168 + 169 + // IPv6 check (bracketed in URLs) 170 + if (hostname.startsWith("[") && hostname.endsWith("]")) { 171 + if (isPrivateIP(hostname)) { 172 + return { 173 + safe: false, 174 + error: "Cannot fetch from private/reserved IP addresses", 175 + }; 176 + } 177 + } 178 + 179 + // Check port - block common internal service ports 180 + const blockedPorts = [ 181 + "22", // SSH 182 + "23", // Telnet 183 + "25", // SMTP 184 + "53", // DNS 185 + "110", // POP3 186 + "143", // IMAP 187 + "445", // SMB 188 + "3306", // MySQL 189 + "5432", // PostgreSQL 190 + "6379", // Redis 191 + "11211", // Memcached 192 + "27017", // MongoDB 193 + ]; 194 + 195 + if (url.port && blockedPorts.includes(url.port)) { 196 + return { safe: false, error: `Port ${url.port} is not allowed` }; 197 + } 198 + 199 + return { safe: true }; 200 + } 201 + 202 + /** 203 + * Perform a fetch with SSRF protection. 204 + * 205 + * This validates the URL before fetching and adds additional protections. 206 + */ 207 + export async function safeFetch( 208 + url: string, 209 + options: { 210 + timeout?: number; 211 + headers?: Record<string, string>; 212 + } = {}, 213 + ): Promise<SafeFetchResult<Response>> { 214 + // Validate URL before fetching 215 + const validation = validateExternalURL(url); 216 + if (!validation.safe) { 217 + return { 218 + success: false, 219 + error: validation.error || "URL validation failed", 220 + }; 221 + } 222 + 223 + const { timeout = 5000, headers = {} } = options; 224 + 225 + try { 226 + const controller = new AbortController(); 227 + const timeoutId = setTimeout(() => controller.abort(), timeout); 228 + 229 + const response = await fetch(url, { 230 + method: "GET", 231 + headers: { 232 + Accept: "application/json, text/html", 233 + "User-Agent": "Indiko/1.0 (OAuth Client Metadata Fetcher)", 234 + ...headers, 235 + }, 236 + signal: controller.signal, 237 + redirect: "follow", 238 + }); 239 + 240 + clearTimeout(timeoutId); 241 + 242 + // After redirect, validate the final URL 243 + if (response.url && response.url !== url) { 244 + const finalValidation = validateExternalURL(response.url); 245 + if (!finalValidation.safe) { 246 + return { 247 + success: false, 248 + error: `Redirect to unsafe URL: ${finalValidation.error}`, 249 + }; 250 + } 251 + } 252 + 253 + return { success: true, data: response }; 254 + } catch (error) { 255 + if (error instanceof Error) { 256 + if (error.name === "AbortError") { 257 + return { success: false, error: "Request timed out" }; 258 + } 259 + return { success: false, error: `Fetch failed: ${error.message}` }; 260 + } 261 + return { success: false, error: "Fetch failed: Unknown error" }; 262 + } 263 + }
+15 -5
src/routes/api.ts
··· 1 1 import { db } from "../db"; 2 - import { verifyDomain, validateProfileURL } from "./indieauth"; 2 + import { validateProfileURL, verifyDomain } from "./indieauth"; 3 3 4 4 function getSessionUser( 5 5 req: Request, 6 - ): { username: string; userId: number; is_admin: boolean; tier: string } | Response { 6 + ): 7 + | { username: string; userId: number; is_admin: boolean; tier: string } 8 + | Response { 7 9 const authHeader = req.headers.get("Authorization"); 8 10 9 11 if (!authHeader || !authHeader.startsWith("Bearer ")) { ··· 193 195 const origin = process.env.ORIGIN || "http://localhost:3000"; 194 196 const indikoProfileUrl = `${origin}/u/${user.username}`; 195 197 196 - const verification = await verifyDomain(validation.canonicalUrl!, indikoProfileUrl); 198 + const verification = await verifyDomain( 199 + validation.canonicalUrl!, 200 + indikoProfileUrl, 201 + ); 197 202 if (!verification.success) { 198 203 return Response.json( 199 204 { error: verification.error || "Failed to verify domain" }, ··· 508 513 return Response.json({ success: true }); 509 514 } 510 515 511 - export async function updateUserTier(req: Request, userId: string): Promise<Response> { 516 + export async function updateUserTier( 517 + req: Request, 518 + userId: string, 519 + ): Promise<Response> { 512 520 const user = getSessionUser(req); 513 521 if (user instanceof Response) { 514 522 return user; ··· 536 544 537 545 const targetUser = db 538 546 .query("SELECT id, username, tier FROM users WHERE id = ?") 539 - .get(targetUserId) as { id: number; username: string; tier: string } | undefined; 547 + .get(targetUserId) as 548 + | { id: number; username: string; tier: string } 549 + | undefined; 540 550 541 551 if (!targetUser) { 542 552 return Response.json({ error: "User not found" }, { status: 404 });
+16 -5
src/routes/auth.ts
··· 1 1 import { 2 2 type AuthenticationResponseJSON, 3 + generateAuthenticationOptions, 4 + generateRegistrationOptions, 3 5 type PublicKeyCredentialCreationOptionsJSON, 4 6 type PublicKeyCredentialRequestOptionsJSON, 5 7 type RegistrationResponseJSON, 6 8 type VerifiedAuthenticationResponse, 7 9 type VerifiedRegistrationResponse, 8 - generateAuthenticationOptions, 9 - generateRegistrationOptions, 10 10 verifyAuthenticationResponse, 11 11 verifyRegistrationResponse, 12 12 } from "@simplewebauthn/server"; ··· 381 381 382 382 // Check if user exists and is active 383 383 const user = db 384 - .query("SELECT id, status, provisioned_via_ldap, last_ldap_verified_at FROM users WHERE username = ?") 385 - .get(username) as { id: number; status: string; provisioned_via_ldap: number; last_ldap_verified_at: number | null } | undefined; 384 + .query( 385 + "SELECT id, status, provisioned_via_ldap, last_ldap_verified_at FROM users WHERE username = ?", 386 + ) 387 + .get(username) as 388 + | { 389 + id: number; 390 + status: string; 391 + provisioned_via_ldap: number; 392 + last_ldap_verified_at: number | null; 393 + } 394 + | undefined; 386 395 387 396 if (!user) { 388 397 return Response.json({ error: "Invalid credentials" }, { status: 401 }); ··· 405 414 const existsInLdap = await checkLdapUser(username); 406 415 if (!existsInLdap) { 407 416 // User no longer exists in LDAP - suspend the account 408 - db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run(user.id); 417 + db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run( 418 + user.id, 419 + ); 409 420 return Response.json( 410 421 { error: "Invalid credentials" }, 411 422 { status: 401 },
+3 -1
src/routes/clients.ts
··· 16 16 17 17 function getSessionUser( 18 18 req: Request, 19 - ): { username: string; userId: number; is_admin: boolean; tier: string } | Response { 19 + ): 20 + | { username: string; userId: number; is_admin: boolean; tier: string } 21 + | Response { 20 22 const authHeader = req.headers.get("Authorization"); 21 23 22 24 if (!authHeader || !authHeader.startsWith("Bearer ")) {
+310 -182
src/routes/indieauth.ts
··· 1 1 import crypto from "crypto"; 2 2 import { db } from "../db"; 3 + import { safeFetch, validateExternalURL } from "../lib/ssrf-safe-fetch"; 3 4 4 5 interface SessionUser { 5 6 username: string; ··· 127 128 } 128 129 129 130 // Validate profile URL per IndieAuth spec 130 - export function validateProfileURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } { 131 + export function validateProfileURL(urlString: string): { 132 + valid: boolean; 133 + error?: string; 134 + canonicalUrl?: string; 135 + } { 131 136 let url: URL; 132 137 try { 133 138 url = new URL(urlString); ··· 152 157 153 158 // MUST NOT contain username/password 154 159 if (url.username || url.password) { 155 - return { valid: false, error: "Profile URL must not contain username or password" }; 160 + return { 161 + valid: false, 162 + error: "Profile URL must not contain username or password", 163 + }; 156 164 } 157 165 158 166 // MUST NOT contain ports ··· 164 172 const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; 165 173 const ipv6Regex = /^\[?[0-9a-fA-F:]+\]?$/; 166 174 if (ipv4Regex.test(url.hostname) || ipv6Regex.test(url.hostname)) { 167 - return { valid: false, error: "Profile URL must use domain names, not IP addresses" }; 175 + return { 176 + valid: false, 177 + error: "Profile URL must use domain names, not IP addresses", 178 + }; 168 179 } 169 180 170 181 // MUST NOT contain single-dot or double-dot path segments 171 182 const pathSegments = url.pathname.split("/"); 172 183 if (pathSegments.includes(".") || pathSegments.includes("..")) { 173 - return { valid: false, error: "Profile URL must not contain . or .. path segments" }; 184 + return { 185 + valid: false, 186 + error: "Profile URL must not contain . or .. path segments", 187 + }; 174 188 } 175 189 176 190 return { valid: true, canonicalUrl: canonicalizeURL(urlString) }; 177 191 } 178 192 179 193 // Validate client URL per IndieAuth spec 180 - function validateClientURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } { 194 + function validateClientURL(urlString: string): { 195 + valid: boolean; 196 + error?: string; 197 + canonicalUrl?: string; 198 + } { 181 199 let url: URL; 182 200 try { 183 201 url = new URL(urlString); ··· 202 220 203 221 // MUST NOT contain username/password 204 222 if (url.username || url.password) { 205 - return { valid: false, error: "Client URL must not contain username or password" }; 223 + return { 224 + valid: false, 225 + error: "Client URL must not contain username or password", 226 + }; 206 227 } 207 228 208 229 // MUST NOT contain single-dot or double-dot path segments 209 230 const pathSegments = url.pathname.split("/"); 210 231 if (pathSegments.includes(".") || pathSegments.includes("..")) { 211 - return { valid: false, error: "Client URL must not contain . or .. path segments" }; 232 + return { 233 + valid: false, 234 + error: "Client URL must not contain . or .. path segments", 235 + }; 212 236 } 213 237 214 238 // MAY use loopback interface, but not other IP addresses ··· 217 241 if (ipv4Regex.test(url.hostname)) { 218 242 // Allow 127.0.0.1 (loopback), reject others 219 243 if (!url.hostname.startsWith("127.")) { 220 - return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" }; 244 + return { 245 + valid: false, 246 + error: 247 + "Client URL must use domain names, not IP addresses (except loopback)", 248 + }; 221 249 } 222 250 } else if (ipv6Regex.test(url.hostname)) { 223 251 // Allow ::1 (loopback), reject others 224 252 const ipv6Match = url.hostname.match(ipv6Regex); 225 253 if (ipv6Match && ipv6Match[1] !== "::1") { 226 - return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" }; 254 + return { 255 + valid: false, 256 + error: 257 + "Client URL must use domain names, not IP addresses (except loopback)", 258 + }; 227 259 } 228 260 } 229 261 ··· 234 266 function isLoopbackURL(urlString: string): boolean { 235 267 try { 236 268 const url = new URL(urlString); 237 - return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname.startsWith("127."); 269 + return ( 270 + url.hostname === "localhost" || 271 + url.hostname === "127.0.0.1" || 272 + url.hostname === "[::1]" || 273 + url.hostname.startsWith("127.") 274 + ); 238 275 } catch { 239 276 return false; 240 277 } 241 278 } 242 279 243 - // Fetch client metadata from client_id URL 280 + // Fetch client metadata from client_id URL (with SSRF protection) 244 281 async function fetchClientMetadata(clientId: string): Promise<{ 245 282 success: boolean; 246 283 metadata?: { ··· 252 289 }; 253 290 error?: string; 254 291 }> { 255 - // MUST NOT fetch loopback addresses (security requirement) 292 + // Validate URL is safe to fetch (prevents SSRF attacks) 293 + const urlValidation = validateExternalURL(clientId); 294 + if (!urlValidation.safe) { 295 + return { 296 + success: false, 297 + error: urlValidation.error || "Invalid client_id URL", 298 + }; 299 + } 300 + 301 + // Additional check: MUST NOT fetch loopback addresses (IndieAuth spec requirement) 256 302 if (isLoopbackURL(clientId)) { 257 - return { success: false, error: "Cannot fetch metadata from loopback addresses" }; 303 + return { 304 + success: false, 305 + error: "Cannot fetch metadata from loopback addresses", 306 + }; 258 307 } 259 308 260 - try { 261 - // Set timeout for fetch to prevent hanging 262 - const controller = new AbortController(); 263 - const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout 309 + // Use SSRF-safe fetch with timeout and redirect validation 310 + const fetchResult = await safeFetch(clientId, { timeout: 5000 }); 264 311 265 - const response = await fetch(clientId, { 266 - method: "GET", 267 - headers: { 268 - Accept: "application/json, text/html", 269 - }, 270 - signal: controller.signal, 271 - }); 312 + if (!fetchResult.success) { 313 + return { 314 + success: false, 315 + error: `Failed to fetch client metadata: ${fetchResult.error}`, 316 + }; 317 + } 272 318 273 - clearTimeout(timeoutId); 319 + const response = fetchResult.data; 274 320 275 - if (!response.ok) { 276 - return { success: false, error: `Failed to fetch client metadata: HTTP ${response.status}` }; 277 - } 321 + if (!response.ok) { 322 + return { 323 + success: false, 324 + error: `Failed to fetch client metadata: HTTP ${response.status}`, 325 + }; 326 + } 278 327 279 - const contentType = response.headers.get("content-type") || ""; 328 + const contentType = response.headers.get("content-type") || ""; 280 329 281 - // Try to parse as JSON first 282 - if (contentType.includes("application/json")) { 330 + // Try to parse as JSON first 331 + if (contentType.includes("application/json")) { 332 + try { 283 333 const metadata = await response.json(); 284 334 285 335 // Verify client_id matches 286 336 if (metadata.client_id && metadata.client_id !== clientId) { 287 - return { success: false, error: "client_id in metadata does not match URL" }; 337 + return { 338 + success: false, 339 + error: "client_id in metadata does not match URL", 340 + }; 288 341 } 289 342 290 - return { success: true, metadata }; 291 - } 292 - 293 - // If HTML, look for <link rel="redirect_uri"> tags 294 - if (contentType.includes("text/html")) { 295 - const html = await response.text(); 296 - 297 - // Extract redirect URIs from link tags 298 - const redirectUriRegex = /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 299 - const redirectUris: string[] = []; 300 - let match: RegExpExecArray | null; 301 - 302 - while ((match = redirectUriRegex.exec(html)) !== null) { 303 - redirectUris.push(match[1]); 343 + // Validate any logo_uri or client_uri in metadata (prevent SSRF via metadata fields) 344 + if (metadata.logo_uri) { 345 + const logoValidation = validateExternalURL(metadata.logo_uri); 346 + if (!logoValidation.safe) { 347 + delete metadata.logo_uri; 348 + } 304 349 } 305 350 306 - // Also try reverse order (href before rel) 307 - const redirectUriRegex2 = /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi; 308 - while ((match = redirectUriRegex2.exec(html)) !== null) { 309 - if (!redirectUris.includes(match[1])) { 310 - redirectUris.push(match[1]); 351 + if (metadata.client_uri) { 352 + const clientUriValidation = validateExternalURL(metadata.client_uri); 353 + if (!clientUriValidation.safe) { 354 + delete metadata.client_uri; 311 355 } 312 356 } 313 357 314 - if (redirectUris.length > 0) { 315 - return { 316 - success: true, 317 - metadata: { 318 - client_id: clientId, 319 - redirect_uris: redirectUris, 320 - }, 321 - }; 322 - } 358 + return { success: true, metadata }; 359 + } catch { 360 + return { success: false, error: "Invalid JSON in client metadata" }; 361 + } 362 + } 363 + 364 + // If HTML, look for <link rel="redirect_uri"> tags 365 + if (contentType.includes("text/html")) { 366 + const html = await response.text(); 367 + 368 + // Extract redirect URIs from link tags 369 + const redirectUriRegex = 370 + /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 371 + const redirectUris: string[] = []; 372 + let match: RegExpExecArray | null; 323 373 324 - return { success: false, error: "No client metadata or redirect_uri links found in HTML" }; 374 + while ((match = redirectUriRegex.exec(html)) !== null) { 375 + redirectUris.push(match[1]); 325 376 } 326 377 327 - return { success: false, error: "Unsupported content type" }; 328 - } catch (error) { 329 - if (error instanceof Error) { 330 - if (error.name === "AbortError") { 331 - return { success: false, error: "Timeout fetching client metadata" }; 378 + // Also try reverse order (href before rel) 379 + const redirectUriRegex2 = 380 + /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi; 381 + while ((match = redirectUriRegex2.exec(html)) !== null) { 382 + if (!redirectUris.includes(match[1])) { 383 + redirectUris.push(match[1]); 332 384 } 333 - return { success: false, error: `Failed to fetch client metadata: ${error.message}` }; 385 + } 386 + 387 + if (redirectUris.length > 0) { 388 + return { 389 + success: true, 390 + metadata: { 391 + client_id: clientId, 392 + redirect_uris: redirectUris, 393 + }, 394 + }; 334 395 } 335 - return { success: false, error: "Failed to fetch client metadata" }; 396 + 397 + return { 398 + success: false, 399 + error: "No client metadata or redirect_uri links found in HTML", 400 + }; 336 401 } 402 + 403 + return { success: false, error: "Unsupported content type" }; 337 404 } 338 405 339 - // Verify domain has rel="me" link back to user profile 340 - export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): Promise<{ 406 + // Verify domain has rel="me" link back to user profile (with SSRF protection) 407 + export async function verifyDomain( 408 + domainUrl: string, 409 + indikoProfileUrl: string, 410 + ): Promise<{ 341 411 success: boolean; 342 412 error?: string; 343 413 }> { 344 - try { 345 - // Set timeout for fetch 346 - const controller = new AbortController(); 347 - const timeoutId = setTimeout(() => controller.abort(), 5000); 414 + // Validate URL is safe to fetch (prevents SSRF attacks) 415 + const urlValidation = validateExternalURL(domainUrl); 416 + if (!urlValidation.safe) { 417 + return { 418 + success: false, 419 + error: urlValidation.error || "Invalid domain URL", 420 + }; 421 + } 348 422 349 - const response = await fetch(domainUrl, { 350 - method: "GET", 351 - headers: { 352 - Accept: "text/html", 353 - "User-Agent": "indiko/1.0 (+https://indiko.dunkirk.sh/)", 354 - }, 355 - signal: controller.signal, 356 - }); 423 + // Use SSRF-safe fetch 424 + const fetchResult = await safeFetch(domainUrl, { 425 + timeout: 5000, 426 + headers: { 427 + Accept: "text/html", 428 + "User-Agent": "indiko/1.0 (+https://indiko.dunkirk.sh/)", 429 + }, 430 + }); 357 431 358 - clearTimeout(timeoutId); 432 + if (!fetchResult.success) { 433 + console.error( 434 + `[verifyDomain] Failed to fetch ${domainUrl}: ${fetchResult.error}`, 435 + ); 436 + return { 437 + success: false, 438 + error: `Failed to fetch domain: ${fetchResult.error}`, 439 + }; 440 + } 359 441 360 - if (!response.ok) { 361 - const errorBody = await response.text(); 362 - console.error(`[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, { 442 + const response = fetchResult.data; 443 + 444 + if (!response.ok) { 445 + const errorBody = await response.text(); 446 + console.error( 447 + `[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, 448 + { 363 449 status: response.status, 364 450 contentType: response.headers.get("content-type"), 365 451 bodyPreview: errorBody.substring(0, 200), 366 - }); 367 - return { success: false, error: `Failed to fetch domain: HTTP ${response.status}` }; 368 - } 452 + }, 453 + ); 454 + return { 455 + success: false, 456 + error: `Failed to fetch domain: HTTP ${response.status}`, 457 + }; 458 + } 369 459 370 - const html = await response.text(); 460 + const html = await response.text(); 371 461 372 - // Extract rel="me" links using regex 373 - // Matches both <link> and <a> tags with rel attribute containing "me" 374 - const relMeLinks: string[] = []; 462 + // Extract rel="me" links using regex 463 + // Matches both <link> and <a> tags with rel attribute containing "me" 464 + const relMeLinks: string[] = []; 375 465 376 - // Simpler approach: find all link and a tags, then check if they have rel="me" and href 377 - const linkRegex = /<link\s+[^>]*>/gi; 378 - const aRegex = /<a\s+[^>]*>/gi; 466 + // Simpler approach: find all link and a tags, then check if they have rel="me" and href 467 + const linkRegex = /<link\s+[^>]*>/gi; 468 + const aRegex = /<a\s+[^>]*>/gi; 379 469 380 - const processTag = (tagHtml: string) => { 381 - // Check if has rel containing "me" (handle quoted and unquoted attributes) 382 - const relMatch = tagHtml.match(/rel=["']?([^"'\s>]+)["']?/i); 383 - if (!relMatch) return null; 470 + const processTag = (tagHtml: string) => { 471 + // Check if has rel containing "me" (handle quoted and unquoted attributes) 472 + const relMatch = tagHtml.match(/rel=["']?([^"'\s>]+)["']?/i); 473 + if (!relMatch) return null; 384 474 385 - const relValue = relMatch[1]; 386 - // Check if "me" is a separate word in the rel attribute 387 - if (!relValue.split(/\s+/).includes("me")) return null; 475 + const relValue = relMatch[1]; 476 + // Check if "me" is a separate word in the rel attribute 477 + if (!relValue.split(/\s+/).includes("me")) return null; 388 478 389 - // Extract href (handle quoted and unquoted attributes) 390 - const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i); 391 - if (!hrefMatch) return null; 479 + // Extract href (handle quoted and unquoted attributes) 480 + const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i); 481 + if (!hrefMatch) return null; 392 482 393 - return hrefMatch[1]; 394 - }; 483 + return hrefMatch[1]; 484 + }; 395 485 396 - // Process all link tags 397 - let linkMatch; 398 - while ((linkMatch = linkRegex.exec(html)) !== null) { 399 - const href = processTag(linkMatch[0]); 400 - if (href && !relMeLinks.includes(href)) { 401 - relMeLinks.push(href); 402 - } 486 + // Process all link tags 487 + let linkMatch; 488 + while ((linkMatch = linkRegex.exec(html)) !== null) { 489 + const href = processTag(linkMatch[0]); 490 + if (href && !relMeLinks.includes(href)) { 491 + relMeLinks.push(href); 403 492 } 493 + } 404 494 405 - // Process all a tags 406 - let aMatch; 407 - while ((aMatch = aRegex.exec(html)) !== null) { 408 - const href = processTag(aMatch[0]); 409 - if (href && !relMeLinks.includes(href)) { 410 - relMeLinks.push(href); 411 - } 495 + // Process all a tags 496 + let aMatch; 497 + while ((aMatch = aRegex.exec(html)) !== null) { 498 + const href = processTag(aMatch[0]); 499 + if (href && !relMeLinks.includes(href)) { 500 + relMeLinks.push(href); 412 501 } 502 + } 413 503 414 - // Check if any rel="me" link matches the indiko profile URL 415 - const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl); 416 - const hasRelMe = relMeLinks.some(link => { 417 - try { 418 - const normalizedLink = canonicalizeURL(link); 419 - return normalizedLink === normalizedIndikoUrl; 420 - } catch { 421 - return false; 422 - } 423 - }); 504 + // Check if any rel="me" link matches the indiko profile URL 505 + const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl); 506 + const hasRelMe = relMeLinks.some((link) => { 507 + try { 508 + const normalizedLink = canonicalizeURL(link); 509 + return normalizedLink === normalizedIndikoUrl; 510 + } catch { 511 + return false; 512 + } 513 + }); 424 514 425 - if (!hasRelMe) { 426 - console.error(`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, { 515 + if (!hasRelMe) { 516 + console.error( 517 + `[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, 518 + { 427 519 foundLinks: relMeLinks, 428 520 normalizedTarget: normalizedIndikoUrl, 429 - }); 430 - return { 431 - success: false, 432 - error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`, 433 - }; 434 - } 435 - 436 - return { success: true }; 437 - } catch (error) { 438 - if (error instanceof Error) { 439 - if (error.name === "AbortError") { 440 - console.error(`[verifyDomain] Timeout verifying ${domainUrl}`); 441 - return { success: false, error: "Timeout verifying domain" }; 442 - } 443 - console.error(`[verifyDomain] Error verifying ${domainUrl}: ${error.message}`, { 444 - name: error.name, 445 - stack: error.stack, 446 - }); 447 - return { success: false, error: `Failed to verify domain: ${error.message}` }; 448 - } 449 - console.error(`[verifyDomain] Unknown error verifying ${domainUrl}:`, error); 450 - return { success: false, error: "Failed to verify domain" }; 521 + }, 522 + ); 523 + return { 524 + success: false, 525 + error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`, 526 + }; 451 527 } 528 + 529 + return { success: true }; 452 530 } 453 531 454 532 // Validate and register app with client information discovery ··· 457 535 redirectUri: string, 458 536 ): Promise<{ 459 537 error?: string; 460 - app?: { name: string | null; redirect_uris: string; logo_url?: string | null }; 538 + app?: { 539 + name: string | null; 540 + redirect_uris: string; 541 + logo_url?: string | null; 542 + }; 461 543 }> { 462 544 const existing = db 463 545 .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") ··· 550 632 551 633 // Fetch the newly created app 552 634 const newApp = db 553 - .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") 554 - .get(canonicalClientId) as { name: string | null; redirect_uris: string; logo_url?: string | null }; 635 + .query( 636 + "SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?", 637 + ) 638 + .get(canonicalClientId) as { 639 + name: string | null; 640 + redirect_uris: string; 641 + logo_url?: string | null; 642 + }; 555 643 556 644 return { app: newApp }; 557 645 } ··· 954 1042 ).run(Math.floor(Date.now() / 1000), user.userId, clientId); 955 1043 956 1044 const origin = process.env.ORIGIN || "http://localhost:3000"; 957 - return Response.redirect(`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`); 1045 + return Response.redirect( 1046 + `${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`, 1047 + ); 958 1048 } 959 1049 } 960 1050 ··· 1316 1406 // POST /auth/authorize - Consent form submission 1317 1407 export async function authorizePost(req: Request): Promise<Response> { 1318 1408 const contentType = req.headers.get("Content-Type"); 1319 - 1409 + 1320 1410 // Parse the request body 1321 1411 let body: Record<string, string>; 1322 1412 let formData: FormData; ··· 1334 1424 } 1335 1425 1336 1426 const grantType = body.grant_type; 1337 - 1427 + 1338 1428 // If grant_type is present, this is a token exchange request (IndieAuth profile scope only) 1339 1429 if (grantType === "authorization_code") { 1340 1430 // Create a mock request for token() function 1341 1431 const mockReq = new Request(req.url, { 1342 1432 method: "POST", 1343 1433 headers: req.headers, 1344 - body: contentType?.includes("application/x-www-form-urlencoded") 1434 + body: contentType?.includes("application/x-www-form-urlencoded") 1345 1435 ? new URLSearchParams(body).toString() 1346 1436 : JSON.stringify(body), 1347 1437 }); ··· 1373 1463 clientId = canonicalizeURL(rawClientId); 1374 1464 redirectUri = canonicalizeURL(rawRedirectUri); 1375 1465 } catch { 1376 - return new Response("Invalid client_id or redirect_uri URL format", { status: 400 }); 1466 + return new Response("Invalid client_id or redirect_uri URL format", { 1467 + status: 400, 1468 + }); 1377 1469 } 1378 1470 1379 1471 if (action === "deny") { ··· 1487 1579 let redirect_uri: string | undefined; 1488 1580 try { 1489 1581 client_id = raw_client_id ? canonicalizeURL(raw_client_id) : undefined; 1490 - redirect_uri = raw_redirect_uri ? canonicalizeURL(raw_redirect_uri) : undefined; 1582 + redirect_uri = raw_redirect_uri 1583 + ? canonicalizeURL(raw_redirect_uri) 1584 + : undefined; 1491 1585 } catch { 1492 1586 return Response.json( 1493 1587 { ··· 1502 1596 return Response.json( 1503 1597 { 1504 1598 error: "unsupported_grant_type", 1505 - error_description: "Only authorization_code and refresh_token grant types are supported", 1599 + error_description: 1600 + "Only authorization_code and refresh_token grant types are supported", 1506 1601 }, 1507 1602 { status: 400 }, 1508 1603 ); ··· 1577 1672 const expiresAt = now + expiresIn; 1578 1673 1579 1674 // Update token (rotate access token, keep refresh token) 1580 - db.query( 1581 - "UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?", 1582 - ).run(newAccessToken, expiresAt, tokenData.id); 1675 + db.query("UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?").run( 1676 + newAccessToken, 1677 + expiresAt, 1678 + tokenData.id, 1679 + ); 1583 1680 1584 1681 // Get user profile for me value 1585 1682 const user = db ··· 1614 1711 headers: { 1615 1712 "Content-Type": "application/json", 1616 1713 "Cache-Control": "no-store", 1617 - "Pragma": "no-cache", 1714 + Pragma: "no-cache", 1618 1715 }, 1619 1716 }, 1620 1717 ); ··· 1727 1824 1728 1825 // Check if already used 1729 1826 if (authcode.used) { 1730 - console.error("Token endpoint: authorization code already used", { code }); 1827 + console.error("Token endpoint: authorization code already used", { 1828 + code, 1829 + }); 1731 1830 return Response.json( 1732 1831 { 1733 1832 error: "invalid_grant", ··· 1740 1839 // Check if expired 1741 1840 const now = Math.floor(Date.now() / 1000); 1742 1841 if (authcode.expires_at < now) { 1743 - console.error("Token endpoint: authorization code expired", { code, expires_at: authcode.expires_at, now, diff: now - authcode.expires_at }); 1842 + console.error("Token endpoint: authorization code expired", { 1843 + code, 1844 + expires_at: authcode.expires_at, 1845 + now, 1846 + diff: now - authcode.expires_at, 1847 + }); 1744 1848 return Response.json( 1745 1849 { 1746 1850 error: "invalid_grant", ··· 1752 1856 1753 1857 // Verify client_id matches 1754 1858 if (authcode.client_id !== client_id) { 1755 - console.error("Token endpoint: client_id mismatch", { stored: authcode.client_id, received: client_id }); 1859 + console.error("Token endpoint: client_id mismatch", { 1860 + stored: authcode.client_id, 1861 + received: client_id, 1862 + }); 1756 1863 return Response.json( 1757 1864 { 1758 1865 error: "invalid_grant", ··· 1764 1871 1765 1872 // Verify redirect_uri matches 1766 1873 if (authcode.redirect_uri !== redirect_uri) { 1767 - console.error("Token endpoint: redirect_uri mismatch", { stored: authcode.redirect_uri, received: redirect_uri }); 1874 + console.error("Token endpoint: redirect_uri mismatch", { 1875 + stored: authcode.redirect_uri, 1876 + received: redirect_uri, 1877 + }); 1768 1878 return Response.json( 1769 1879 { 1770 1880 error: "invalid_grant", ··· 1776 1886 1777 1887 // Verify PKCE code_verifier (required for all clients per IndieAuth spec) 1778 1888 if (!verifyPKCE(code_verifier, authcode.code_challenge)) { 1779 - console.error("Token endpoint: PKCE verification failed", { code_verifier, code_challenge: authcode.code_challenge }); 1889 + console.error("Token endpoint: PKCE verification failed", { 1890 + code_verifier, 1891 + code_challenge: authcode.code_challenge, 1892 + }); 1780 1893 return Response.json( 1781 1894 { 1782 1895 error: "invalid_grant", ··· 1839 1952 1840 1953 // Validate that the user controls the requested me parameter 1841 1954 if (authcode.me && authcode.me !== meValue) { 1842 - console.error("Token endpoint: me mismatch", { requested: authcode.me, actual: meValue }); 1955 + console.error("Token endpoint: me mismatch", { 1956 + requested: authcode.me, 1957 + actual: meValue, 1958 + }); 1843 1959 return Response.json( 1844 1960 { 1845 1961 error: "invalid_grant", 1846 - error_description: "The requested identity does not match the user's verified domain", 1962 + error_description: 1963 + "The requested identity does not match the user's verified domain", 1847 1964 }, 1848 1965 { status: 400 }, 1849 1966 ); 1850 1967 } 1851 1968 1852 1969 const origin = process.env.ORIGIN || "http://localhost:3000"; 1853 - 1970 + 1854 1971 // Generate access token 1855 1972 const accessToken = crypto.randomBytes(32).toString("base64url"); 1856 1973 const expiresIn = 3600; // 1 hour ··· 1864 1981 // Store token in database with refresh token 1865 1982 db.query( 1866 1983 "INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", 1867 - ).run(accessToken, authcode.user_id, client_id, scopes.join(" "), expiresAt, refreshToken, refreshExpiresAt); 1984 + ).run( 1985 + accessToken, 1986 + authcode.user_id, 1987 + client_id, 1988 + scopes.join(" "), 1989 + expiresAt, 1990 + refreshToken, 1991 + refreshExpiresAt, 1992 + ); 1868 1993 1869 1994 const response: Record<string, unknown> = { 1870 1995 access_token: accessToken, ··· 1882 2007 response.role = permission.role; 1883 2008 } 1884 2009 1885 - console.log("Token endpoint: success", { me: meValue, scopes: scopes.join(" ") }); 2010 + console.log("Token endpoint: success", { 2011 + me: meValue, 2012 + scopes: scopes.join(" "), 2013 + }); 1886 2014 1887 2015 return Response.json(response, { 1888 2016 headers: { 1889 2017 "Content-Type": "application/json", 1890 2018 "Cache-Control": "no-store", 1891 - "Pragma": "no-cache", 2019 + Pragma: "no-cache", 1892 2020 }, 1893 2021 }); 1894 2022 } catch (error) { ··· 2052 2180 try { 2053 2181 // Get access token from Authorization header 2054 2182 const authHeader = req.headers.get("Authorization"); 2055 - 2183 + 2056 2184 if (!authHeader || !authHeader.startsWith("Bearer ")) { 2057 2185 return Response.json( 2058 2186 {
+6 -2
src/routes/passkeys.ts
··· 1 1 import { 2 - type RegistrationResponseJSON, 3 2 generateRegistrationOptions, 3 + type RegistrationResponseJSON, 4 4 type VerifiedRegistrationResponse, 5 5 verifyRegistrationResponse, 6 6 } from "@simplewebauthn/server"; ··· 133 133 } 134 134 135 135 const body = await req.json(); 136 - const { response, challenge: expectedChallenge, name } = body as { 136 + const { 137 + response, 138 + challenge: expectedChallenge, 139 + name, 140 + } = body as { 137 141 response: RegistrationResponseJSON; 138 142 challenge: string; 139 143 name?: string;