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

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement IndieAuth client discovery and URL validation

- Add IndieAuth metadata endpoint (/.well-known/oauth-authorization-server)
- Implement client information discovery (fetches metadata from client_id)
- Add URL validation per IndieAuth spec (profile & client URLs)
- Add redirect_uri validation against client's published metadata
- Add SSRF protection (blocks loopback address fetching)
- Add iss parameter to authorization responses
- Enforce PKCE for all clients (public and pre-registered)
- Update user profile instructions to include indieauth-metadata link
- Update documentation with discovery flow and security requirements

This fixes sign-in issues with modern IndieAuth clients and prevents
open redirect vulnerabilities by validating redirect URIs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

dunkirk.sh 365d88b0 28e1e8e9

verified
+385 -35
+78 -14
src/html/docs.html
··· 580 580 <li><a href="#getting-started">getting started</a></li> 581 581 <li><a href="#button">sign in button</a></li> 582 582 <li><a href="#endpoints">endpoints</a></li> 583 - <li><a href="#authorization">authorization flow</a></li> 583 + <li><a href="#authorization">authorization flow</a> 584 + <ul style="margin-top: 0.5rem; margin-left: 1.5rem;"> 585 + <li><a href="#authorization" style="font-size: 0.9rem;">discovery</a></li> 586 + </ul> 587 + </li> 584 588 <li><a href="#scopes">scopes</a></li> 585 589 <li><a href="#roles">roles</a></li> 586 590 <li><a href="#clients">client types</a></li> ··· 624 628 <div class="info-box"> 625 629 <strong>Auto-registration:</strong> 626 630 Apps are automatically registered on first use. You don't need admin approval to get started. 627 - For advanced features like client secrets and role assignment, contact your Indiko admin to pre-register your 628 - app. 631 + During registration, Indiko fetches your client metadata from your <code>client_id</code> URL to validate redirect URIs and display your app name/logo. 632 + For advanced features like client secrets and role assignment, contact your Indiko admin to pre-register your app. 633 + </div> 634 + 635 + <h3>publishing client metadata (recommended)</h3> 636 + <p> 637 + To help Indiko verify your app and display proper branding, publish client metadata as JSON at your <code>client_id</code> URL: 638 + </p> 639 + <pre><code>{ 640 + "client_id": "https://myapp.example.com/", 641 + "client_name": "My App", 642 + "logo_uri": "https://myapp.example.com/logo.png", 643 + "redirect_uris": [ 644 + "https://myapp.example.com/callback", 645 + "https://myapp.example.com/auth/callback" 646 + ] 647 + }</code></pre> 648 + <p> 649 + Alternatively, you can publish redirect URIs as HTML <code>&lt;link&gt;</code> tags: 650 + </p> 651 + <pre><code>&lt;link rel="redirect_uri" href="https://myapp.example.com/callback" /&gt;</code></pre> 652 + 653 + <div class="info-box"> 654 + <strong>Security:</strong> 655 + If your <code>redirect_uri</code> uses a different host than your <code>client_id</code>, you MUST publish <code>redirect_uris</code> in your client metadata. This prevents unauthorized apps from hijacking your client_id. 629 656 </div> 630 657 631 658 <h3>for users</h3> ··· 675 702 </thead> 676 703 <tbody> 677 704 <tr> 705 + <td><code>/.well-known/oauth-authorization-server</code></td> 706 + <td>GET</td> 707 + <td>IndieAuth server metadata (discovery endpoint)</td> 708 + </tr> 709 + <tr> 678 710 <td><code>/auth/authorize</code></td> 679 711 <td>GET</td> 680 712 <td>Start OAuth authorization flow</td> ··· 692 724 <tr> 693 725 <td><code>/u/:username</code></td> 694 726 <td>GET</td> 695 - <td>Public user profile (h-card)</td> 727 + <td>Public user profile (h-card with discovery links)</td> 696 728 </tr> 697 729 </tbody> 698 730 </table> ··· 744 776 <section id="authorization" class="section"> 745 777 <h2>authorization flow</h2> 746 778 779 + <h3>0. discovery (recommended)</h3> 780 + <p> 781 + Before starting authorization, clients should discover the authorization server's endpoints from the user's profile URL: 782 + </p> 783 + <ol> 784 + <li>Fetch the user's profile URL (e.g., <code id="discoveryUrl">http://localhost:3000/u/username</code>)</li> 785 + <li>Look for <code>&lt;link rel="indieauth-metadata"&gt;</code> tag or HTTP <code>Link:</code> header</li> 786 + <li>Fetch the metadata endpoint to get <code>authorization_endpoint</code> and <code>token_endpoint</code></li> 787 + </ol> 788 + <p> 789 + The metadata endpoint returns: 790 + </p> 791 + <pre><code>{ 792 + <span class="json-key">"issuer"</span>: <span class="json-string" id="metadataIssuer">"http://localhost:3000"</span>, 793 + <span class="json-key">"authorization_endpoint"</span>: <span class="json-string" id="metadataAuthEndpoint">"http://localhost:3000/auth/authorize"</span>, 794 + <span class="json-key">"token_endpoint"</span>: <span class="json-string" id="metadataTokenEndpoint">"http://localhost:3000/auth/token"</span>, 795 + <span class="json-key">"code_challenge_methods_supported"</span>: [<span class="json-string">"S256"</span>], 796 + <span class="json-key">"scopes_supported"</span>: [<span class="json-string">"profile"</span>, <span class="json-string">"email"</span>] 797 + }</code></pre> 798 + 747 799 <h3>1. redirect to authorization endpoint</h3> 748 800 <pre><code><span class="http-method">GET</span> <span class="http-url" id="authUrl">http://localhost:3000/auth/authorize</span>?<span 749 801 class="http-param">response_type</span>=code ··· 773 825 <h3>3. redirect back with code</h3> 774 826 <pre><code><span class="http-url">https://myapp.example.com/callback</span>?<span 775 827 class="http-param">code</span>=short_lived_authorization_code 776 - &<span class="http-param">state</span>=random_state_string</code></pre> 828 + &<span class="http-param">state</span>=random_state_string 829 + &<span class="http-param">iss</span>=<span class="http-url" id="issuerUrl">http://localhost:3000</span></code></pre> 830 + 831 + <div class="info-box"> 832 + <strong>Security:</strong> 833 + The <code>iss</code> (issuer) parameter allows you to verify the response came from the expected authorization server. Compare it to the <code>issuer</code> from the metadata endpoint. 834 + </div> 777 835 778 836 <h3>4. exchange code for token</h3> 779 837 <pre><code><span class="http-method">POST</span> <span class="http-url" id="tokenUrl">http://localhost:3000/auth/token</span> ··· 788 846 789 847 <div class="info-box"> 790 848 <strong>Client authentication:</strong> 791 - Public clients (auto-registered) use PKCE for security. Pre-registered confidential clients must include <code>client_secret</code> in the token request. 849 + All clients MUST use PKCE (code_verifier) per the IndieAuth specification. Pre-registered confidential clients should also include <code>client_secret</code> in the token request for additional security. 792 850 </div> 793 851 794 852 <h3>5. receive user profile</h3> ··· 901 959 902 960 <h3>auto-registered clients</h3> 903 961 <p> 904 - Any app can use Indiko without pre-registration. On first authorization, the client is automatically registered 905 - with: 962 + Any app can use Indiko without pre-registration. On first authorization, Indiko will: 906 963 </p> 907 964 <ul> 908 - <li>Client ID (must be a valid URL)</li> 909 - <li>Redirect URIs</li> 910 - <li>Last used timestamp</li> 965 + <li>Validate the client ID (must be a valid URL per IndieAuth spec)</li> 966 + <li>Fetch client metadata from the client_id URL (if available)</li> 967 + <li>Validate redirect_uri against published redirect_uris (if different host)</li> 968 + <li>Extract and store client name and logo (if provided)</li> 969 + <li>Automatically register the client for future use</li> 911 970 </ul> 912 971 <p> 913 - Auto-registered clients <strong>cannot</strong> use client secrets or role assignment. They must use PKCE for security. 972 + Auto-registered clients <strong>must</strong> use PKCE for security and <strong>cannot</strong> use client secrets or role assignment. 914 973 </p> 915 974 975 + <div class="info-box"> 976 + <strong>Security:</strong> 977 + For redirect URIs on different hosts than your client_id, you must publish redirect_uris in your client metadata. See <a href="#getting-started">getting started</a> for details. 978 + </div> 979 + 916 980 <h3>pre-registered clients</h3> 917 981 <p> 918 - Admins can pre-register clients for advanced features. <strong>All pre-registered clients require a client secret.</strong> 982 + Admins can pre-register clients for advanced features. <strong>All pre-registered clients require a client secret and must also use PKCE.</strong> 919 983 </p> 920 984 <ul> 921 - <li><strong>Client secret:</strong> Required for all pre-registered clients (used in token exchange)</li> 985 + <li><strong>Client secret:</strong> Required for all pre-registered clients (used in token exchange alongside PKCE)</li> 922 986 <li><strong>Role assignment:</strong> Admins can assign per-user roles for RBAC</li> 923 987 <li><strong>Available roles:</strong> Define which roles can be assigned (enforces dropdown selection)</li> 924 988 <li><strong>Default role:</strong> Automatically assigned to users on first authorization</li>
+307 -21
src/routes/indieauth.ts
··· 106 106 return hash === challenge; 107 107 } 108 108 109 - // Auto-register app if it doesn't exist (only for valid URLs, not generated client_ids) 110 - function ensureApp( 109 + // Canonicalize URL per IndieAuth spec 110 + function canonicalizeURL(urlString: string): string { 111 + const url = new URL(urlString); 112 + // Lowercase hostname per spec 113 + url.hostname = url.hostname.toLowerCase(); 114 + // Add / path if missing 115 + if (!url.pathname || url.pathname === "") { 116 + url.pathname = "/"; 117 + } 118 + return url.toString(); 119 + } 120 + 121 + // Validate profile URL per IndieAuth spec 122 + function validateProfileURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } { 123 + let url: URL; 124 + try { 125 + url = new URL(urlString); 126 + } catch { 127 + return { valid: false, error: "Invalid URL format" }; 128 + } 129 + 130 + // MUST use http or https scheme 131 + if (url.protocol !== "http:" && url.protocol !== "https:") { 132 + return { valid: false, error: "Profile URL must use http or https scheme" }; 133 + } 134 + 135 + // MUST contain path component (/ is valid) 136 + if (!url.pathname) { 137 + url.pathname = "/"; 138 + } 139 + 140 + // MUST NOT contain fragments 141 + if (url.hash) { 142 + return { valid: false, error: "Profile URL must not contain fragments" }; 143 + } 144 + 145 + // MUST NOT contain username/password 146 + if (url.username || url.password) { 147 + return { valid: false, error: "Profile URL must not contain username or password" }; 148 + } 149 + 150 + // MUST NOT contain ports 151 + if (url.port) { 152 + return { valid: false, error: "Profile URL must not contain ports" }; 153 + } 154 + 155 + // MUST NOT use IP addresses 156 + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; 157 + const ipv6Regex = /^\[?[0-9a-fA-F:]+\]?$/; 158 + if (ipv4Regex.test(url.hostname) || ipv6Regex.test(url.hostname)) { 159 + return { valid: false, error: "Profile URL must use domain names, not IP addresses" }; 160 + } 161 + 162 + // MUST NOT contain single-dot or double-dot path segments 163 + const pathSegments = url.pathname.split("/"); 164 + if (pathSegments.includes(".") || pathSegments.includes("..")) { 165 + return { valid: false, error: "Profile URL must not contain . or .. path segments" }; 166 + } 167 + 168 + return { valid: true, canonicalUrl: canonicalizeURL(urlString) }; 169 + } 170 + 171 + // Validate client URL per IndieAuth spec 172 + function validateClientURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } { 173 + let url: URL; 174 + try { 175 + url = new URL(urlString); 176 + } catch { 177 + return { valid: false, error: "Invalid URL format" }; 178 + } 179 + 180 + // MUST use http or https scheme 181 + if (url.protocol !== "http:" && url.protocol !== "https:") { 182 + return { valid: false, error: "Client URL must use http or https scheme" }; 183 + } 184 + 185 + // MUST contain path component (/ is valid) 186 + if (!url.pathname) { 187 + url.pathname = "/"; 188 + } 189 + 190 + // MUST NOT contain fragments 191 + if (url.hash) { 192 + return { valid: false, error: "Client URL must not contain fragments" }; 193 + } 194 + 195 + // MUST NOT contain username/password 196 + if (url.username || url.password) { 197 + return { valid: false, error: "Client URL must not contain username or password" }; 198 + } 199 + 200 + // MUST NOT contain single-dot or double-dot path segments 201 + const pathSegments = url.pathname.split("/"); 202 + if (pathSegments.includes(".") || pathSegments.includes("..")) { 203 + return { valid: false, error: "Client URL must not contain . or .. path segments" }; 204 + } 205 + 206 + // MAY use loopback interface, but not other IP addresses 207 + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; 208 + const ipv6Regex = /^\[?([0-9a-fA-F:]+)\]?$/; 209 + if (ipv4Regex.test(url.hostname)) { 210 + // Allow 127.0.0.1 (loopback), reject others 211 + if (!url.hostname.startsWith("127.")) { 212 + return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" }; 213 + } 214 + } else if (ipv6Regex.test(url.hostname)) { 215 + // Allow ::1 (loopback), reject others 216 + const ipv6Match = url.hostname.match(ipv6Regex); 217 + if (ipv6Match && ipv6Match[1] !== "::1") { 218 + return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" }; 219 + } 220 + } 221 + 222 + return { valid: true, canonicalUrl: canonicalizeURL(urlString) }; 223 + } 224 + 225 + // Check if URL is a loopback address 226 + function isLoopbackURL(urlString: string): boolean { 227 + try { 228 + const url = new URL(urlString); 229 + return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname.startsWith("127."); 230 + } catch { 231 + return false; 232 + } 233 + } 234 + 235 + // Fetch client metadata from client_id URL 236 + async function fetchClientMetadata(clientId: string): Promise<{ 237 + success: boolean; 238 + metadata?: { 239 + client_id: string; 240 + client_name?: string; 241 + client_uri?: string; 242 + logo_uri?: string; 243 + redirect_uris?: string[]; 244 + }; 245 + error?: string; 246 + }> { 247 + // MUST NOT fetch loopback addresses (security requirement) 248 + if (isLoopbackURL(clientId)) { 249 + return { success: false, error: "Cannot fetch metadata from loopback addresses" }; 250 + } 251 + 252 + try { 253 + // Set timeout for fetch to prevent hanging 254 + const controller = new AbortController(); 255 + const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout 256 + 257 + const response = await fetch(clientId, { 258 + method: "GET", 259 + headers: { 260 + Accept: "application/json, text/html", 261 + }, 262 + signal: controller.signal, 263 + }); 264 + 265 + clearTimeout(timeoutId); 266 + 267 + if (!response.ok) { 268 + return { success: false, error: `Failed to fetch client metadata: HTTP ${response.status}` }; 269 + } 270 + 271 + const contentType = response.headers.get("content-type") || ""; 272 + 273 + // Try to parse as JSON first 274 + if (contentType.includes("application/json")) { 275 + const metadata = await response.json(); 276 + 277 + // Verify client_id matches 278 + if (metadata.client_id && metadata.client_id !== clientId) { 279 + return { success: false, error: "client_id in metadata does not match URL" }; 280 + } 281 + 282 + return { success: true, metadata }; 283 + } 284 + 285 + // If HTML, look for <link rel="redirect_uri"> tags 286 + if (contentType.includes("text/html")) { 287 + const html = await response.text(); 288 + 289 + // Extract redirect URIs from link tags 290 + const redirectUriRegex = /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 291 + const redirectUris: string[] = []; 292 + let match: RegExpExecArray | null; 293 + 294 + while ((match = redirectUriRegex.exec(html)) !== null) { 295 + redirectUris.push(match[1]); 296 + } 297 + 298 + // Also try reverse order (href before rel) 299 + const redirectUriRegex2 = /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi; 300 + while ((match = redirectUriRegex2.exec(html)) !== null) { 301 + if (!redirectUris.includes(match[1])) { 302 + redirectUris.push(match[1]); 303 + } 304 + } 305 + 306 + if (redirectUris.length > 0) { 307 + return { 308 + success: true, 309 + metadata: { 310 + client_id: clientId, 311 + redirect_uris: redirectUris, 312 + }, 313 + }; 314 + } 315 + 316 + return { success: false, error: "No client metadata or redirect_uri links found in HTML" }; 317 + } 318 + 319 + return { success: false, error: "Unsupported content type" }; 320 + } catch (error) { 321 + if (error instanceof Error) { 322 + if (error.name === "AbortError") { 323 + return { success: false, error: "Timeout fetching client metadata" }; 324 + } 325 + return { success: false, error: `Failed to fetch client metadata: ${error.message}` }; 326 + } 327 + return { success: false, error: "Failed to fetch client metadata" }; 328 + } 329 + } 330 + 331 + // Validate and register app with client information discovery 332 + async function ensureApp( 111 333 clientId: string, 112 334 redirectUri: string, 113 - ): { error?: string; app?: { name: string | null; redirect_uris: string } } { 335 + ): Promise<{ 336 + error?: string; 337 + app?: { name: string | null; redirect_uris: string; logo_url?: string | null }; 338 + }> { 114 339 const existing = db 115 - .query("SELECT name, redirect_uris FROM apps WHERE client_id = ?") 340 + .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") 116 341 .get(clientId) as 117 - | { name: string | null; redirect_uris: string } 342 + | { name: string | null; redirect_uris: string; logo_url?: string | null } 118 343 | undefined; 119 344 120 345 if (!existing) { 121 - // Only allow auto-registration for valid URLs (IndieAuth standard) 122 - // Reject generated client_ids like "ikc_xxxxx" 123 - try { 124 - new URL(clientId); 125 - } catch { 346 + // Validate client URL per IndieAuth spec 347 + const validation = validateClientURL(clientId); 348 + if (!validation.valid) { 126 349 return { 127 - error: 128 - "Client ID must be a valid URL for auto-registration. Non-URL clients must be pre-registered by an admin.", 350 + error: validation.error || "Invalid client URL", 129 351 }; 130 352 } 131 353 132 - // New app - auto-register (without pre-registration, no client secret or role) 354 + const canonicalClientId = validation.canonicalUrl!; 355 + 356 + // Fetch client metadata per IndieAuth spec 357 + const metadataResult = await fetchClientMetadata(canonicalClientId); 358 + 359 + let clientName: string | null = null; 360 + let logoUrl: string | null = null; 361 + let allowedRedirectUris: string[] = []; 362 + 363 + if (metadataResult.success && metadataResult.metadata) { 364 + // Use metadata from client 365 + clientName = metadataResult.metadata.client_name || null; 366 + logoUrl = metadataResult.metadata.logo_uri || null; 367 + allowedRedirectUris = metadataResult.metadata.redirect_uris || []; 368 + 369 + // Validate redirect_uri if client published redirect_uris 370 + if (allowedRedirectUris.length > 0) { 371 + // Check if redirect_uri host differs from client_id host 372 + const clientUrl = new URL(canonicalClientId); 373 + const redirectUrl = new URL(redirectUri); 374 + 375 + const hostsDiffer = 376 + clientUrl.protocol !== redirectUrl.protocol || 377 + clientUrl.hostname !== redirectUrl.hostname || 378 + clientUrl.port !== redirectUrl.port; 379 + 380 + if (hostsDiffer) { 381 + // MUST verify redirect_uri is in published list 382 + if (!allowedRedirectUris.includes(redirectUri)) { 383 + return { 384 + error: `redirect_uri not registered in client metadata. The client published a list of allowed redirect URIs, but ${redirectUri} is not in that list.`, 385 + }; 386 + } 387 + } else { 388 + // Same host - add to allowed list if not present 389 + if (!allowedRedirectUris.includes(redirectUri)) { 390 + allowedRedirectUris.push(redirectUri); 391 + } 392 + } 393 + } else { 394 + // No redirect_uris published - allow this one 395 + allowedRedirectUris = [redirectUri]; 396 + } 397 + } else { 398 + // Could not fetch metadata - allow for now but only same-host redirects 399 + const clientUrl = new URL(canonicalClientId); 400 + const redirectUrl = new URL(redirectUri); 401 + 402 + const hostsDiffer = 403 + clientUrl.protocol !== redirectUrl.protocol || 404 + clientUrl.hostname !== redirectUrl.hostname || 405 + clientUrl.port !== redirectUrl.port; 406 + 407 + if (hostsDiffer) { 408 + return { 409 + error: `Could not fetch client metadata to verify redirect_uri. For security, redirect_uri must have same host as client_id, or client must publish redirect_uris. Error: ${metadataResult.error}`, 410 + }; 411 + } 412 + 413 + allowedRedirectUris = [redirectUri]; 414 + } 415 + 416 + // New app - auto-register 133 417 db.query( 134 - "INSERT INTO apps (client_id, redirect_uris, is_preregistered, first_seen, last_used) VALUES (?, ?, 0, ?, ?)", 418 + "INSERT INTO apps (client_id, redirect_uris, name, logo_url, is_preregistered, first_seen, last_used) VALUES (?, ?, ?, ?, 0, ?, ?)", 135 419 ).run( 136 - clientId, 137 - JSON.stringify([redirectUri]), 420 + canonicalClientId, 421 + JSON.stringify(allowedRedirectUris), 422 + clientName, 423 + logoUrl, 138 424 Math.floor(Date.now() / 1000), 139 425 Math.floor(Date.now() / 1000), 140 426 ); 141 427 142 428 // Fetch the newly created app 143 429 const newApp = db 144 - .query("SELECT name, redirect_uris FROM apps WHERE client_id = ?") 145 - .get(clientId) as { name: string | null; redirect_uris: string }; 430 + .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") 431 + .get(canonicalClientId) as { name: string | null; redirect_uris: string; logo_url?: string | null }; 146 432 147 433 return { app: newApp }; 148 434 } ··· 157 443 } 158 444 159 445 // GET /auth/authorize - Authorization request 160 - export function authorizeGet(req: Request): Response { 446 + export async function authorizeGet(req: Request): Promise<Response> { 161 447 const url = new URL(req.url); 162 448 const params = url.searchParams; 163 449 ··· 285 571 } 286 572 287 573 // Verify app is registered 288 - const appResult = ensureApp(clientId, redirectUri); 574 + const appResult = await ensureApp(clientId, redirectUri); 289 575 290 576 if (appResult.error) { 291 577 return new Response( ··· 498 784 } 499 785 500 786 // Verify app is registered 501 - const appCheckResult = ensureApp(clientId, redirectUri); 787 + const appCheckResult = await ensureApp(clientId, redirectUri); 502 788 503 789 if (appCheckResult.error) { 504 790 return new Response(appCheckResult.error, { status: 400 });