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

Compare changes

Choose any two refs to compare.

+24 -3
README.md
··· 130 130 131 131 Now you can sign in to IndieAuth-compatible sites using `https://your-domain.com/` as your identity. 132 132 133 + ### Using as an OpenID Connect (OIDC) Provider 134 + 135 + Indiko also supports OpenID Connect (OIDC) for modern authentication flows: 136 + 137 + **Discovery endpoint:** 138 + ``` 139 + https://your-indiko-domain.com/.well-known/openid-configuration 140 + ``` 141 + 142 + **Key features:** 143 + - Authorization Code Flow with PKCE 144 + - ID Token with RS256 signing 145 + - JWKS endpoint for token verification 146 + - Support for `openid`, `profile`, and `email` scopes 147 + - Userinfo endpoint for retrieving user claims 148 + 149 + Test your OIDC setup using the [OIDC Debugger](https://oidcdebugger.com/). 150 + 133 151 ## API Reference 134 152 135 - ### OAuth 2.0 Endpoints 153 + ### OAuth 2.0 / OpenID Connect Endpoints 136 154 137 - - `GET /auth/authorize` - Authorization endpoint 138 - - `POST /auth/token` - Token exchange endpoint 155 + - `GET /auth/authorize` - Authorization endpoint (OAuth 2.0 / OIDC) 156 + - `POST /auth/token` - Token exchange endpoint (returns access token and ID token for OIDC) 157 + - `GET /userinfo` - OIDC userinfo endpoint (returns user claims) 158 + - `GET /.well-known/openid-configuration` - OIDC discovery document 159 + - `GET /jwks` - JSON Web Key Set for ID token verification 139 160 - `POST /auth/logout` - Session logout 140 161 141 162 ### User Profile
+140
SPEC.md
··· 497 497 // Create session for user 498 498 ``` 499 499 500 + ## OpenID Connect (OIDC) Support 501 + 502 + Indiko implements OpenID Connect Core 1.0 as an identity layer on top of OAuth 2.0, enabling "Sign in with Indiko" for any OIDC-compatible application. 503 + 504 + ### Overview 505 + 506 + OIDC extends the existing OAuth 2.0 authorization flow by: 507 + - Adding the `openid` scope to request identity information 508 + - Returning an **ID Token** (signed JWT) alongside the authorization code exchange 509 + - Providing a standardized `/userinfo` endpoint 510 + - Publishing discovery metadata at `/.well-known/openid-configuration` 511 + 512 + ### Supported Scopes 513 + 514 + | Scope | Claims Returned | 515 + |-------|-----------------| 516 + | `openid` | `sub`, `iss`, `aud`, `exp`, `iat`, `auth_time` | 517 + | `profile` | `name`, `picture`, `website` | 518 + | `email` | `email` | 519 + 520 + ### OIDC Endpoints 521 + 522 + #### `GET /.well-known/openid-configuration` 523 + Discovery document for OIDC clients. 524 + 525 + **Response:** 526 + ```json 527 + { 528 + "issuer": "https://indiko.yourdomain.com", 529 + "authorization_endpoint": "https://indiko.yourdomain.com/auth/authorize", 530 + "token_endpoint": "https://indiko.yourdomain.com/auth/token", 531 + "userinfo_endpoint": "https://indiko.yourdomain.com/auth/userinfo", 532 + "jwks_uri": "https://indiko.yourdomain.com/jwks", 533 + "scopes_supported": ["openid", "profile", "email"], 534 + "response_types_supported": ["code"], 535 + "grant_types_supported": ["authorization_code"], 536 + "subject_types_supported": ["public"], 537 + "id_token_signing_alg_values_supported": ["RS256"], 538 + "token_endpoint_auth_methods_supported": ["none", "client_secret_post"], 539 + "claims_supported": ["sub", "iss", "aud", "exp", "iat", "auth_time", "name", "email", "picture", "website"], 540 + "code_challenge_methods_supported": ["S256"] 541 + } 542 + ``` 543 + 544 + #### `GET /jwks` 545 + JSON Web Key Set containing the public key for ID Token verification. 546 + 547 + **Response:** 548 + ```json 549 + { 550 + "keys": [ 551 + { 552 + "kty": "RSA", 553 + "use": "sig", 554 + "alg": "RS256", 555 + "kid": "indiko-oidc-key-1", 556 + "n": "...", 557 + "e": "AQAB" 558 + } 559 + ] 560 + } 561 + ``` 562 + 563 + ### ID Token 564 + 565 + When the `openid` scope is requested, the token endpoint returns an `id_token` JWT: 566 + 567 + **Token Endpoint Response (with openid scope):** 568 + ```json 569 + { 570 + "me": "https://indiko.yourdomain.com/u/kieran", 571 + "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImluZGlrby1vaWRjLWtleS0xIn0...", 572 + "profile": { 573 + "name": "Kieran Klukas", 574 + "email": "kieran@example.com", 575 + "photo": "https://...", 576 + "url": "https://kierank.dev" 577 + } 578 + } 579 + ``` 580 + 581 + **ID Token Claims:** 582 + ```json 583 + { 584 + "iss": "https://indiko.yourdomain.com", 585 + "sub": "https://indiko.yourdomain.com/u/kieran", 586 + "aud": "https://blog.kierank.dev", 587 + "exp": 1234567890, 588 + "iat": 1234567800, 589 + "auth_time": 1234567700, 590 + "nonce": "abc123", 591 + "name": "Kieran Klukas", 592 + "email": "kieran@example.com", 593 + "picture": "https://...", 594 + "website": "https://kierank.dev" 595 + } 596 + ``` 597 + 598 + ### OIDC Authorization Flow 599 + 600 + 1. Client initiates authorization with `scope=openid profile email` 601 + 2. User authenticates and consents (same as IndieAuth) 602 + 3. Client receives authorization code 603 + 4. Client exchanges code at `/auth/token` with `code_verifier` 604 + 5. Token endpoint returns `id_token` JWT + profile data 605 + 6. Client verifies `id_token` signature using keys from `/jwks` 606 + 607 + ### Key Management 608 + 609 + - RSA 2048-bit key pair generated on first OIDC request 610 + - Private key stored in database (`oidc_keys` table) 611 + - Key rotation: manual via admin interface (future) 612 + - Key ID format: `indiko-oidc-key-{version}` 613 + 614 + ### Data Structures 615 + 616 + #### OIDC Keys 617 + ``` 618 + oidc_keys -> { 619 + id: number, 620 + kid: string, // e.g. "indiko-oidc-key-1" 621 + private_key: string, // PEM-encoded RSA private key 622 + public_key: string, // PEM-encoded RSA public key 623 + created_at: timestamp, 624 + is_active: boolean 625 + } 626 + ``` 627 + 628 + #### Authorization Code (Extended) 629 + ``` 630 + authcode:{code} -> { 631 + ...existing fields..., 632 + nonce?: string, // OIDC nonce for replay protection 633 + auth_time: timestamp // when user authenticated 634 + } 635 + ``` 636 + 500 637 ## Future Enhancements 501 638 502 639 - Token endpoint for longer-lived access tokens ··· 509 646 - Audit log for admin 510 647 - Rate limiting 511 648 - Account recovery flow 649 + - OIDC key rotation via admin interface 512 650 513 651 ## Standards Compliance 514 652 ··· 516 654 - [WebAuthn/FIDO2](https://www.w3.org/TR/webauthn-2/) 517 655 - [OAuth 2.0 PKCE](https://tools.ietf.org/html/rfc7636) 518 656 - [Microformats h-card](http://microformats.org/wiki/h-card) 657 + - [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) 658 + - [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html)
+3
bun.lock
··· 8 8 "@simplewebauthn/browser": "^13.2.2", 9 9 "@simplewebauthn/server": "^13.2.2", 10 10 "bun-sqlite-migrations": "^1.0.2", 11 + "jose": "^6.1.3", 11 12 "ldap-authentication": "^3.3.6", 12 13 "nanoid": "^5.1.6", 13 14 }, ··· 70 71 "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], 71 72 72 73 "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], 74 + 75 + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], 73 76 74 77 "ldap-authentication": ["ldap-authentication@3.3.6", "", { "dependencies": { "ldapts": "^7.3.1" } }, "sha512-j8XxH5wGXhIQ3mMnoRCZTalSLmPhzEaGH4+5RIFP0Higc32fCKDRJBo+9wb5ysy9TnlaNtaf+rgdwYCD15OBpQ=="], 75 78
+1
package.json
··· 19 19 "@simplewebauthn/browser": "^13.2.2", 20 20 "@simplewebauthn/server": "^13.2.2", 21 21 "bun-sqlite-migrations": "^1.0.2", 22 + "jose": "^6.1.3", 22 23 "ldap-authentication": "^3.3.6", 23 24 "nanoid": "^5.1.6" 24 25 }
+240
scripts/reset-passkey.ts
··· 1 + #!/usr/bin/env bun 2 + /** 3 + * Passkey Reset Script 4 + * 5 + * Resets a user's passkey credentials and generates a one-time reset link. 6 + * The user can use this link to register a new passkey while preserving 7 + * their existing account, permissions, and app authorizations. 8 + * 9 + * Usage: bun scripts/reset-passkey.ts <username> 10 + * 11 + * Example: 12 + * bun scripts/reset-passkey.ts kieran 13 + * 14 + * The script will: 15 + * 1. Verify the user exists 16 + * 2. Delete all their existing passkey credentials 17 + * 3. Invalidate all active sessions (logs them out) 18 + * 4. Create a single-use reset invite locked to their username 19 + * 5. Output a reset link 20 + * 21 + * IMPORTANT: This preserves: 22 + * - User account and profile data 23 + * - All app permissions and authorizations 24 + * - Role assignments 25 + * - Admin status 26 + */ 27 + 28 + import { Database } from "bun:sqlite"; 29 + import crypto from "node:crypto"; 30 + import * as path from "node:path"; 31 + 32 + // Load database 33 + const dbPath = path.join(import.meta.dir, "..", "data", "indiko.db"); 34 + const db = new Database(dbPath); 35 + 36 + const ORIGIN = process.env.ORIGIN || "http://localhost:3000"; 37 + 38 + interface User { 39 + id: number; 40 + username: string; 41 + name: string; 42 + email: string | null; 43 + status: string; 44 + is_admin: number; 45 + } 46 + 47 + interface Credential { 48 + id: number; 49 + name: string | null; 50 + created_at: number; 51 + } 52 + 53 + function getUser(username: string): User | null { 54 + return db 55 + .query("SELECT id, username, name, email, status, is_admin FROM users WHERE username = ?") 56 + .get(username) as User | null; 57 + } 58 + 59 + function getCredentials(userId: number): Credential[] { 60 + return db 61 + .query("SELECT id, name, created_at FROM credentials WHERE user_id = ?") 62 + .all(userId) as Credential[]; 63 + } 64 + 65 + function deleteCredentials(userId: number): number { 66 + const result = db 67 + .query("DELETE FROM credentials WHERE user_id = ?") 68 + .run(userId); 69 + return result.changes; 70 + } 71 + 72 + function deleteSessions(userId: number): number { 73 + const result = db 74 + .query("DELETE FROM sessions WHERE user_id = ?") 75 + .run(userId); 76 + return result.changes; 77 + } 78 + 79 + function createResetInvite(adminUserId: number, targetUsername: string): string { 80 + const code = crypto.randomBytes(16).toString("base64url"); 81 + const now = Math.floor(Date.now() / 1000); 82 + const expiresAt = now + 86400; // 24 hours 83 + 84 + // Check if there's a reset_username column, if not we'll use the note field 85 + const hasResetColumn = db 86 + .query("SELECT name FROM pragma_table_info('invites') WHERE name = 'reset_username'") 87 + .get(); 88 + 89 + if (hasResetColumn) { 90 + db.query( 91 + "INSERT INTO invites (code, created_by, max_uses, current_uses, expires_at, note, reset_username) VALUES (?, ?, 1, 0, ?, ?, ?)", 92 + ).run(code, adminUserId, expiresAt, `Passkey reset for ${targetUsername}`, targetUsername); 93 + } else { 94 + // Use a special note format to indicate this is a reset invite 95 + // Format: PASSKEY_RESET:username 96 + db.query( 97 + "INSERT INTO invites (code, created_by, max_uses, current_uses, expires_at, note, message) VALUES (?, ?, 1, 0, ?, ?, ?)", 98 + ).run( 99 + code, 100 + adminUserId, 101 + expiresAt, 102 + `PASSKEY_RESET:${targetUsername}`, 103 + `Your passkey has been reset. Please register a new passkey to regain access to your account.`, 104 + ); 105 + } 106 + 107 + return code; 108 + } 109 + 110 + function getAdminUser(): User | null { 111 + return db 112 + .query("SELECT id, username, name, email, status, is_admin FROM users WHERE is_admin = 1 LIMIT 1") 113 + .get() as User | null; 114 + } 115 + 116 + async function main() { 117 + const args = process.argv.slice(2); 118 + 119 + if (args.length === 0 || args.includes("--help") || args.includes("-h")) { 120 + console.log(` 121 + Passkey Reset Script 122 + 123 + Usage: bun scripts/reset-passkey.ts <username> 124 + 125 + Options: 126 + --help, -h Show this help message 127 + --dry-run Show what would happen without making changes 128 + --force Skip confirmation prompt 129 + 130 + Example: 131 + bun scripts/reset-passkey.ts kieran 132 + bun scripts/reset-passkey.ts kieran --dry-run 133 + `); 134 + process.exit(0); 135 + } 136 + 137 + const username = args.find((arg) => !arg.startsWith("--")); 138 + const dryRun = args.includes("--dry-run"); 139 + const force = args.includes("--force"); 140 + 141 + if (!username) { 142 + console.error("โŒ Error: Username is required"); 143 + process.exit(1); 144 + } 145 + 146 + console.log(`\n๐Ÿ” Passkey Reset for: ${username}`); 147 + console.log("โ”€".repeat(50)); 148 + 149 + // Look up user 150 + const user = getUser(username); 151 + if (!user) { 152 + console.error(`\nโŒ Error: User '${username}' not found`); 153 + process.exit(1); 154 + } 155 + 156 + console.log(`\n๐Ÿ“‹ User Details:`); 157 + console.log(` โ€ข ID: ${user.id}`); 158 + console.log(` โ€ข Name: ${user.name}`); 159 + console.log(` โ€ข Email: ${user.email || "(not set)"}`); 160 + console.log(` โ€ข Status: ${user.status}`); 161 + console.log(` โ€ข Admin: ${user.is_admin ? "Yes" : "No"}`); 162 + 163 + // Get existing credentials 164 + const credentials = getCredentials(user.id); 165 + console.log(`\n๐Ÿ”‘ Existing Passkeys: ${credentials.length}`); 166 + credentials.forEach((cred, idx) => { 167 + const date = new Date(cred.created_at * 1000).toISOString().split("T")[0]; 168 + console.log(` ${idx + 1}. ${cred.name || "(unnamed)"} - created ${date}`); 169 + }); 170 + 171 + if (credentials.length === 0) { 172 + console.log("\nโš ๏ธ User has no passkeys registered. Creating reset link anyway..."); 173 + } 174 + 175 + if (dryRun) { 176 + console.log("\n๐Ÿ”„ DRY RUN - No changes will be made"); 177 + console.log("\nWould perform:"); 178 + console.log(` โ€ข Delete ${credentials.length} passkey(s)`); 179 + console.log(" โ€ข Invalidate all active sessions"); 180 + console.log(" โ€ข Create single-use reset invite"); 181 + process.exit(0); 182 + } 183 + 184 + // Confirmation prompt (unless --force) 185 + if (!force) { 186 + console.log("\nโš ๏ธ This will:"); 187 + console.log(` โ€ข Delete ALL ${credentials.length} passkey(s) for this user`); 188 + console.log(" โ€ข Log them out of all sessions"); 189 + console.log(" โ€ข Generate a 24-hour reset link\n"); 190 + 191 + process.stdout.write("Continue? [y/N] "); 192 + 193 + for await (const line of console) { 194 + const answer = line.trim().toLowerCase(); 195 + if (answer !== "y" && answer !== "yes") { 196 + console.log("Cancelled."); 197 + process.exit(0); 198 + } 199 + break; 200 + } 201 + } 202 + 203 + // Get admin user for creating invite 204 + const admin = getAdminUser(); 205 + if (!admin) { 206 + console.error("\nโŒ Error: No admin user found to create invite"); 207 + process.exit(1); 208 + } 209 + 210 + // Perform reset 211 + console.log("\n๐Ÿ”„ Performing reset..."); 212 + 213 + // Delete credentials 214 + const deletedCreds = deleteCredentials(user.id); 215 + console.log(` โœ… Deleted ${deletedCreds} passkey(s)`); 216 + 217 + // Delete sessions 218 + const deletedSessions = deleteSessions(user.id); 219 + console.log(` โœ… Invalidated ${deletedSessions} session(s)`); 220 + 221 + // Create reset invite 222 + const inviteCode = createResetInvite(admin.id, username); 223 + console.log(" โœ… Created reset invite"); 224 + 225 + // Generate reset URL 226 + const resetUrl = `${ORIGIN}/login?invite=${inviteCode}&username=${encodeURIComponent(username)}`; 227 + 228 + console.log("\n" + "โ•".repeat(50)); 229 + console.log("โœจ PASSKEY RESET COMPLETE"); 230 + console.log("โ•".repeat(50)); 231 + console.log(`\n๐Ÿ“ง Send this link to ${user.name || username}:\n`); 232 + console.log(` ${resetUrl}`); 233 + console.log(`\nโฐ This link expires in 24 hours and can only be used once.`); 234 + console.log(`\n๐Ÿ’ก The user must register with username: ${username}`); 235 + } 236 + 237 + main().catch((error) => { 238 + console.error("\nโŒ Error:", error instanceof Error ? error.message : error); 239 + process.exit(1); 240 + });
+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", {
+76 -1
src/html/docs.html
··· 577 577 <h3>table of contents</h3> 578 578 <ul> 579 579 <li><a href="#overview">overview</a></li> 580 + <li><a href="#oidc">openid connect (oidc)</a></li> 580 581 <li><a href="#getting-started">getting started</a></li> 581 582 <li><a href="#button">sign in button</a></li> 582 583 <li><a href="#endpoints">endpoints</a></li> ··· 612 613 <ul> 613 614 <li>Passwordless authentication via WebAuthn passkeys</li> 614 615 <li>Full IndieAuth and OAuth 2.0 support with PKCE</li> 616 + <li>OpenID Connect (OIDC) support with ID tokens</li> 615 617 <li>Access tokens and refresh tokens for API access</li> 616 618 <li>Token introspection and revocation endpoints</li> 617 619 <li>UserInfo endpoint for profile data</li> ··· 621 623 <li>User profile endpoints with h-card microformats</li> 622 624 <li>Invite-based user registration</li> 623 625 </ul> 626 + </section> 627 + 628 + <section id="oidc" class="section"> 629 + <h2>openid connect (oidc)</h2> 630 + <p> 631 + Indiko supports OpenID Connect (OIDC) for modern authentication flows, enabling "Sign in with Indiko" for any OIDC-compatible application. 632 + </p> 633 + 634 + <h3>oidc endpoints</h3> 635 + <table> 636 + <thead> 637 + <tr> 638 + <th>Endpoint</th> 639 + <th>Description</th> 640 + </tr> 641 + </thead> 642 + <tbody> 643 + <tr> 644 + <td><code>/.well-known/openid-configuration</code></td> 645 + <td>OIDC discovery document</td> 646 + </tr> 647 + <tr> 648 + <td><code>/jwks</code></td> 649 + <td>JSON Web Key Set for ID token verification</td> 650 + </tr> 651 + <tr> 652 + <td><code>/auth/authorize</code></td> 653 + <td>Authorization endpoint (same as OAuth 2.0)</td> 654 + </tr> 655 + <tr> 656 + <td><code>/auth/token</code></td> 657 + <td>Token endpoint (returns ID token when <code>openid</code> scope requested)</td> 658 + </tr> 659 + <tr> 660 + <td><code>/userinfo</code></td> 661 + <td>OIDC userinfo endpoint</td> 662 + </tr> 663 + </tbody> 664 + </table> 665 + 666 + <h3>key features</h3> 667 + <ul> 668 + <li>Authorization Code Flow with PKCE</li> 669 + <li>ID Token with RS256 signing</li> 670 + <li>Support for <code>openid</code>, <code>profile</code>, and <code>email</code> scopes</li> 671 + <li>Automatic key generation and management</li> 672 + <li>Standards-compliant discovery document</li> 673 + </ul> 674 + 675 + <h3>id token claims</h3> 676 + <p> 677 + When the <code>openid</code> scope is requested, the token endpoint returns an ID token (JWT) containing: 678 + </p> 679 + <ul> 680 + <li><code>iss</code> - Issuer (Indiko server URL)</li> 681 + <li><code>sub</code> - Subject (user identifier)</li> 682 + <li><code>aud</code> - Audience (client ID)</li> 683 + <li><code>exp</code> - Expiration time</li> 684 + <li><code>iat</code> - Issued at time</li> 685 + <li><code>auth_time</code> - Authentication time</li> 686 + <li><code>nonce</code> - Nonce (if provided in authorization request)</li> 687 + <li><code>name</code>, <code>email</code>, <code>picture</code>, <code>website</code> - User claims (based on granted scopes)</li> 688 + </ul> 689 + 690 + <div class="info-box"> 691 + <strong>Testing:</strong> 692 + You can test your OIDC setup using the <a href="https://oidcdebugger.com/" target="_blank" rel="noopener noreferrer">OIDC Debugger</a>. Set the discovery endpoint and use PKCE with SHA-256. 693 + </div> 624 694 </section> 625 695 626 696 <section id="getting-started" class="section"> ··· 1032 1102 </thead> 1033 1103 <tbody> 1034 1104 <tr> 1105 + <td><code>openid</code></td> 1106 + <td>OpenID Connect authentication</td> 1107 + <td>Triggers ID token issuance (OIDC only)</td> 1108 + </tr> 1109 + <tr> 1035 1110 <td><code>profile</code></td> 1036 1111 <td>Basic profile information</td> 1037 1112 <td>name, photo, URL</td> ··· 1046 1121 1047 1122 <div class="info-box"> 1048 1123 <strong>Note:</strong> 1049 - Users can selectively approve scopes during authorization. Your app may receive fewer scopes than requested. 1124 + Users can selectively approve scopes during authorization. Your app may receive fewer scopes than requested. The <code>openid</code> scope is only relevant for OIDC flows and enables ID token issuance. 1050 1125 </div> 1051 1126 </section> 1052 1127
+21 -3
src/index.ts
··· 8 8 import indexHTML from "./html/index.html"; 9 9 import loginHTML from "./html/login.html"; 10 10 import { getLdapAccounts, updateOrphanedAccounts } from "./ldap-cleanup"; 11 + import { getDiscoveryDocument, getJWKS } from "./oidc"; 11 12 import { 12 13 deleteSelfAccount, 13 14 deleteUser, ··· 155 156 ); 156 157 }, 157 158 "/.well-known/oauth-authorization-server": indieauthMetadata, 159 + "/.well-known/openid-configuration": () => { 160 + const origin = process.env.ORIGIN as string; 161 + return Response.json(getDiscoveryDocument(origin)); 162 + }, 163 + "/jwks": async () => { 164 + const jwks = await getJWKS(); 165 + return Response.json(jwks); 166 + }, 158 167 // OAuth/IndieAuth endpoints 159 168 "/userinfo": (req: Request) => { 160 169 if (req.method === "GET") return userinfo(req); ··· 365 374 366 375 if (expiredOrphans.length > 0) { 367 376 if (action === "suspend") { 368 - await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "suspend"); 377 + await updateOrphanedAccounts( 378 + { ...result, orphanedUsers: expiredOrphans }, 379 + "suspend", 380 + ); 369 381 } else if (action === "deactivate") { 370 - await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "deactivate"); 382 + await updateOrphanedAccounts( 383 + { ...result, orphanedUsers: expiredOrphans }, 384 + "deactivate", 385 + ); 371 386 } else if (action === "remove") { 372 - await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "remove"); 387 + await updateOrphanedAccounts( 388 + { ...result, orphanedUsers: expiredOrphans }, 389 + "remove", 390 + ); 373 391 } 374 392 console.log( 375 393 `[LDAP Cleanup] ${action === "remove" ? "Removed" : action === "suspend" ? "Suspended" : "Deactivated"} ${expiredOrphans.length} LDAP orphan accounts (grace period: ${gracePeriod}s)`,
+16
src/migrations/008_add_oidc_keys.sql
··· 1 + -- OIDC signing keys for ID Token generation 2 + CREATE TABLE IF NOT EXISTS oidc_keys ( 3 + id INTEGER PRIMARY KEY AUTOINCREMENT, 4 + kid TEXT NOT NULL UNIQUE, 5 + private_key TEXT NOT NULL, 6 + public_key TEXT NOT NULL, 7 + is_active INTEGER NOT NULL DEFAULT 1, 8 + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) 9 + ); 10 + 11 + -- Add nonce and auth_time to authcodes for OIDC 12 + ALTER TABLE authcodes ADD COLUMN nonce TEXT; 13 + ALTER TABLE authcodes ADD COLUMN auth_time INTEGER; 14 + 15 + CREATE INDEX IF NOT EXISTS idx_oidc_keys_kid ON oidc_keys(kid); 16 + CREATE INDEX IF NOT EXISTS idx_oidc_keys_active ON oidc_keys(is_active);
+167
src/oidc.ts
··· 1 + import { exportJWK, generateKeyPair, importPKCS8, SignJWT } from "jose"; 2 + import { db } from "./db"; 3 + 4 + interface OIDCKey { 5 + id: number; 6 + kid: string; 7 + private_key: string; 8 + public_key: string; 9 + is_active: number; 10 + created_at: number; 11 + } 12 + 13 + interface JWK { 14 + kty: string; 15 + use: string; 16 + alg: string; 17 + kid: string; 18 + n: string; 19 + e: string; 20 + } 21 + 22 + async function generateAndStoreKey(): Promise<OIDCKey> { 23 + const { privateKey, publicKey } = await generateKeyPair("RS256", { 24 + modulusLength: 2048, 25 + }); 26 + 27 + const privateKeyPem = await exportKeyToPem(privateKey); 28 + const publicKeyPem = await exportKeyToPem(publicKey); 29 + 30 + const kid = `indiko-oidc-key-${Date.now()}`; 31 + 32 + db.query( 33 + "INSERT INTO oidc_keys (kid, private_key, public_key, is_active) VALUES (?, ?, ?, 1)", 34 + ).run(kid, privateKeyPem, publicKeyPem); 35 + 36 + const key = db 37 + .query("SELECT * FROM oidc_keys WHERE kid = ?") 38 + .get(kid) as OIDCKey; 39 + 40 + return key; 41 + } 42 + 43 + async function exportKeyToPem(key: CryptoKey): Promise<string> { 44 + const format = key.type === "private" ? "pkcs8" : "spki"; 45 + const exported = await crypto.subtle.exportKey(format, key); 46 + const base64 = Buffer.from(exported).toString("base64"); 47 + const type = key.type === "private" ? "PRIVATE KEY" : "PUBLIC KEY"; 48 + 49 + const lines = base64.match(/.{1,64}/g) || []; 50 + return `-----BEGIN ${type}-----\n${lines.join("\n")}\n-----END ${type}-----`; 51 + } 52 + 53 + export async function getActiveKey(): Promise<OIDCKey> { 54 + let key = db 55 + .query( 56 + "SELECT * FROM oidc_keys WHERE is_active = 1 ORDER BY id DESC LIMIT 1", 57 + ) 58 + .get() as OIDCKey | undefined; 59 + 60 + if (!key) { 61 + key = await generateAndStoreKey(); 62 + } 63 + 64 + return key; 65 + } 66 + 67 + export async function getJWKS(): Promise<{ keys: JWK[] }> { 68 + const keys = db 69 + .query("SELECT * FROM oidc_keys WHERE is_active = 1") 70 + .all() as OIDCKey[]; 71 + 72 + const jwks: JWK[] = []; 73 + 74 + for (const key of keys) { 75 + const publicKey = await importPublicKey(key.public_key); 76 + const jwk = await exportJWK(publicKey); 77 + 78 + jwks.push({ 79 + kty: jwk.kty as string, 80 + use: "sig", 81 + alg: "RS256", 82 + kid: key.kid, 83 + n: jwk.n as string, 84 + e: jwk.e as string, 85 + }); 86 + } 87 + 88 + return { keys: jwks }; 89 + } 90 + 91 + async function importPublicKey(pem: string): Promise<CryptoKey> { 92 + const pemContents = pem 93 + .replace("-----BEGIN PUBLIC KEY-----", "") 94 + .replace("-----END PUBLIC KEY-----", "") 95 + .replace(/\n/g, ""); 96 + 97 + const binaryDer = Buffer.from(pemContents, "base64"); 98 + 99 + return await crypto.subtle.importKey( 100 + "spki", 101 + binaryDer, 102 + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, 103 + true, 104 + ["verify"], 105 + ); 106 + } 107 + 108 + interface IDTokenClaims { 109 + sub: string; 110 + aud: string; 111 + nonce?: string; 112 + auth_time?: number; 113 + name?: string; 114 + email?: string; 115 + picture?: string; 116 + website?: string; 117 + } 118 + 119 + export async function signIDToken( 120 + issuer: string, 121 + claims: IDTokenClaims, 122 + ): Promise<string> { 123 + const key = await getActiveKey(); 124 + const privateKey = await importPKCS8(key.private_key, "RS256"); 125 + 126 + const now = Math.floor(Date.now() / 1000); 127 + const expiresIn = 3600; // 1 hour 128 + 129 + const builder = new SignJWT({ 130 + ...claims, 131 + iss: issuer, 132 + iat: now, 133 + exp: now + expiresIn, 134 + }).setProtectedHeader({ alg: "RS256", typ: "JWT", kid: key.kid }); 135 + 136 + return await builder.sign(privateKey); 137 + } 138 + 139 + export function getDiscoveryDocument(origin: string) { 140 + return { 141 + issuer: origin, 142 + authorization_endpoint: `${origin}/auth/authorize`, 143 + token_endpoint: `${origin}/auth/token`, 144 + userinfo_endpoint: `${origin}/userinfo`, 145 + jwks_uri: `${origin}/jwks`, 146 + scopes_supported: ["openid", "profile", "email"], 147 + response_types_supported: ["code"], 148 + grant_types_supported: ["authorization_code", "refresh_token"], 149 + subject_types_supported: ["public"], 150 + id_token_signing_alg_values_supported: ["RS256"], 151 + token_endpoint_auth_methods_supported: ["none", "client_secret_post"], 152 + claims_supported: [ 153 + "sub", 154 + "iss", 155 + "aud", 156 + "exp", 157 + "iat", 158 + "auth_time", 159 + "nonce", 160 + "name", 161 + "email", 162 + "picture", 163 + "website", 164 + ], 165 + code_challenge_methods_supported: ["S256"], 166 + }; 167 + }
+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 });
+78 -38
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"; ··· 39 39 // Check if username already exists 40 40 const existingUser = db 41 41 .query("SELECT id FROM users WHERE username = ?") 42 - .get(username); 42 + .get(username) as { id: number } | undefined; 43 43 44 + // Allow re-registration if user exists but has no credentials (passkey reset case) 45 + let isPasskeyReset = false; 44 46 if (existingUser) { 45 - return Response.json( 46 - { error: "Username already taken" }, 47 - { status: 400 }, 48 - ); 47 + const credCount = db 48 + .query("SELECT COUNT(*) as count FROM credentials WHERE user_id = ?") 49 + .get(existingUser.id) as { count: number }; 50 + 51 + if (credCount.count > 0) { 52 + return Response.json( 53 + { error: "Username already taken" }, 54 + { status: 400 }, 55 + ); 56 + } 57 + // User exists but has no credentials - this is a passkey reset 58 + isPasskeyReset = true; 49 59 } 50 60 51 61 // Check if this is bootstrap (first user) ··· 156 166 157 167 // Check if username already exists 158 168 const existingUser = db 159 - .query("SELECT id FROM users WHERE username = ?") 160 - .get(username); 169 + .query("SELECT id, is_admin FROM users WHERE username = ?") 170 + .get(username) as { id: number; is_admin: number } | undefined; 161 171 172 + // Allow re-registration if user exists but has no credentials (passkey reset case) 173 + let isPasskeyReset = false; 162 174 if (existingUser) { 163 - return Response.json( 164 - { error: "Username already taken" }, 165 - { status: 400 }, 166 - ); 175 + const credCount = db 176 + .query("SELECT COUNT(*) as count FROM credentials WHERE user_id = ?") 177 + .get(existingUser.id) as { count: number }; 178 + 179 + if (credCount.count > 0) { 180 + return Response.json( 181 + { error: "Username already taken" }, 182 + { status: 400 }, 183 + ); 184 + } 185 + // User exists but has no credentials - this is a passkey reset 186 + isPasskeyReset = true; 167 187 } 168 188 169 189 if (!expectedChallenge) { ··· 275 295 invite?.ldap_username !== null && invite?.ldap_username !== undefined; 276 296 } 277 297 278 - // Create user (bootstrap is always admin, invited users are regular users) 279 - const insertUser = db.query( 280 - "INSERT INTO users (username, name, is_admin, tier, role, provisioned_via_ldap) VALUES (?, ?, ?, ?, ?, ?) RETURNING id", 281 - ); 282 - const user = insertUser.get( 283 - username, 284 - username, 285 - isBootstrap ? 1 : 0, 286 - isBootstrap ? "admin" : "user", 287 - isBootstrap ? "admin" : "user", 288 - isLdapProvisioned ? 1 : 0, 289 - ) as { 290 - id: number; 291 - }; 298 + let userId: number; 299 + let userIsAdmin: boolean; 300 + 301 + if (isPasskeyReset && existingUser) { 302 + // Passkey reset: use existing user, just add credential 303 + userId = existingUser.id; 304 + userIsAdmin = existingUser.is_admin === 1; 305 + } else { 306 + // Create new user (bootstrap is always admin, invited users are regular users) 307 + const insertUser = db.query( 308 + "INSERT INTO users (username, name, is_admin, tier, role, provisioned_via_ldap) VALUES (?, ?, ?, ?, ?, ?) RETURNING id", 309 + ); 310 + const user = insertUser.get( 311 + username, 312 + username, 313 + isBootstrap ? 1 : 0, 314 + isBootstrap ? "admin" : "user", 315 + isBootstrap ? "admin" : "user", 316 + isLdapProvisioned ? 1 : 0, 317 + ) as { id: number }; 318 + userId = user.id; 319 + userIsAdmin = isBootstrap; 320 + } 292 321 293 322 // Store credential 294 323 // credential.id is a Uint8Array, convert to Buffer for storage 295 324 db.query( 296 325 "INSERT INTO credentials (user_id, credential_id, public_key, counter, name) VALUES (?, ?, ?, ?, ?)", 297 326 ).run( 298 - user.id, 327 + userId, 299 328 Buffer.from(credential.id), 300 329 Buffer.from(credential.publicKey), 301 330 credential.counter, 302 - "Primary Passkey", 331 + isPasskeyReset ? "Reset Passkey" : "Primary Passkey", 303 332 ); 304 333 305 334 // Mark invite as used if applicable ··· 324 353 // Record this invite use 325 354 db.query( 326 355 "INSERT INTO invite_uses (invite_id, user_id, used_at) VALUES (?, ?, ?)", 327 - ).run(inviteId, user.id, usedAt); 356 + ).run(inviteId, userId, usedAt); 328 357 329 - // Assign app roles to the new user 330 - if (inviteRoles.length > 0) { 358 + // Assign app roles to the new user (skip for passkey reset - they already have roles) 359 + if (inviteRoles.length > 0 && !isPasskeyReset) { 331 360 const insertPermission = db.query( 332 361 "INSERT INTO permissions (user_id, app_id, role) VALUES (?, ?, ?)", 333 362 ); 334 363 for (const { app_id, role } of inviteRoles) { 335 - insertPermission.run(user.id, app_id, role); 364 + insertPermission.run(userId, app_id, role); 336 365 } 337 366 } 338 367 } ··· 347 376 const expiresAt = Math.floor(Date.now() / 1000) + 86400; // 24 hours 348 377 db.query( 349 378 "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)", 350 - ).run(token, user.id, expiresAt); 379 + ).run(token, userId, expiresAt); 351 380 352 381 const isProduction = process.env.NODE_ENV === "production"; 353 382 const secureCookie = isProduction ? "; Secure" : ""; ··· 356 385 { 357 386 token, 358 387 username, 359 - isAdmin: isBootstrap, 388 + isAdmin: userIsAdmin, 360 389 }, 361 390 { 362 391 headers: { ··· 381 410 382 411 // Check if user exists and is active 383 412 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; 413 + .query( 414 + "SELECT id, status, provisioned_via_ldap, last_ldap_verified_at FROM users WHERE username = ?", 415 + ) 416 + .get(username) as 417 + | { 418 + id: number; 419 + status: string; 420 + provisioned_via_ldap: number; 421 + last_ldap_verified_at: number | null; 422 + } 423 + | undefined; 386 424 387 425 if (!user) { 388 426 return Response.json({ error: "Invalid credentials" }, { status: 401 }); ··· 405 443 const existsInLdap = await checkLdapUser(username); 406 444 if (!existsInLdap) { 407 445 // User no longer exists in LDAP - suspend the account 408 - db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run(user.id); 446 + db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run( 447 + user.id, 448 + ); 409 449 return Response.json( 410 450 { error: "Invalid credentials" }, 411 451 { 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 ")) {
+270 -80
src/routes/indieauth.ts
··· 1 1 import crypto from "crypto"; 2 2 import { db } from "../db"; 3 + import { signIDToken } from "../oidc"; 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 } ··· 254 291 }> { 255 292 // MUST NOT fetch loopback addresses (security requirement) 256 293 if (isLoopbackURL(clientId)) { 257 - return { success: false, error: "Cannot fetch metadata from loopback addresses" }; 294 + return { 295 + success: false, 296 + error: "Cannot fetch metadata from loopback addresses", 297 + }; 258 298 } 259 299 260 300 try { ··· 273 313 clearTimeout(timeoutId); 274 314 275 315 if (!response.ok) { 276 - return { success: false, error: `Failed to fetch client metadata: HTTP ${response.status}` }; 316 + return { 317 + success: false, 318 + error: `Failed to fetch client metadata: HTTP ${response.status}`, 319 + }; 277 320 } 278 321 279 322 const contentType = response.headers.get("content-type") || ""; ··· 284 327 285 328 // Verify client_id matches 286 329 if (metadata.client_id && metadata.client_id !== clientId) { 287 - return { success: false, error: "client_id in metadata does not match URL" }; 330 + return { 331 + success: false, 332 + error: "client_id in metadata does not match URL", 333 + }; 288 334 } 289 335 290 336 return { success: true, metadata }; ··· 295 341 const html = await response.text(); 296 342 297 343 // Extract redirect URIs from link tags 298 - const redirectUriRegex = /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 344 + const redirectUriRegex = 345 + /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 299 346 const redirectUris: string[] = []; 300 347 let match: RegExpExecArray | null; 301 348 ··· 304 351 } 305 352 306 353 // Also try reverse order (href before rel) 307 - const redirectUriRegex2 = /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi; 354 + const redirectUriRegex2 = 355 + /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi; 308 356 while ((match = redirectUriRegex2.exec(html)) !== null) { 309 357 if (!redirectUris.includes(match[1])) { 310 358 redirectUris.push(match[1]); ··· 321 369 }; 322 370 } 323 371 324 - return { success: false, error: "No client metadata or redirect_uri links found in HTML" }; 372 + return { 373 + success: false, 374 + error: "No client metadata or redirect_uri links found in HTML", 375 + }; 325 376 } 326 377 327 378 return { success: false, error: "Unsupported content type" }; ··· 330 381 if (error.name === "AbortError") { 331 382 return { success: false, error: "Timeout fetching client metadata" }; 332 383 } 333 - return { success: false, error: `Failed to fetch client metadata: ${error.message}` }; 384 + return { 385 + success: false, 386 + error: `Failed to fetch client metadata: ${error.message}`, 387 + }; 334 388 } 335 389 return { success: false, error: "Failed to fetch client metadata" }; 336 390 } 337 391 } 338 392 339 393 // Verify domain has rel="me" link back to user profile 340 - export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): Promise<{ 394 + export async function verifyDomain( 395 + domainUrl: string, 396 + indikoProfileUrl: string, 397 + ): Promise<{ 341 398 success: boolean; 342 399 error?: string; 343 400 }> { ··· 359 416 360 417 if (!response.ok) { 361 418 const errorBody = await response.text(); 362 - console.error(`[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, { 363 - status: response.status, 364 - contentType: response.headers.get("content-type"), 365 - bodyPreview: errorBody.substring(0, 200), 366 - }); 367 - return { success: false, error: `Failed to fetch domain: HTTP ${response.status}` }; 419 + console.error( 420 + `[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, 421 + { 422 + status: response.status, 423 + contentType: response.headers.get("content-type"), 424 + bodyPreview: errorBody.substring(0, 200), 425 + }, 426 + ); 427 + return { 428 + success: false, 429 + error: `Failed to fetch domain: HTTP ${response.status}`, 430 + }; 368 431 } 369 432 370 433 const html = await response.text(); ··· 413 476 414 477 // Check if any rel="me" link matches the indiko profile URL 415 478 const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl); 416 - const hasRelMe = relMeLinks.some(link => { 479 + const hasRelMe = relMeLinks.some((link) => { 417 480 try { 418 481 const normalizedLink = canonicalizeURL(link); 419 482 return normalizedLink === normalizedIndikoUrl; ··· 423 486 }); 424 487 425 488 if (!hasRelMe) { 426 - console.error(`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, { 427 - foundLinks: relMeLinks, 428 - normalizedTarget: normalizedIndikoUrl, 429 - }); 489 + console.error( 490 + `[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, 491 + { 492 + foundLinks: relMeLinks, 493 + normalizedTarget: normalizedIndikoUrl, 494 + }, 495 + ); 430 496 return { 431 497 success: false, 432 498 error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`, ··· 440 506 console.error(`[verifyDomain] Timeout verifying ${domainUrl}`); 441 507 return { success: false, error: "Timeout verifying domain" }; 442 508 } 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}` }; 509 + console.error( 510 + `[verifyDomain] Error verifying ${domainUrl}: ${error.message}`, 511 + { 512 + name: error.name, 513 + stack: error.stack, 514 + }, 515 + ); 516 + return { 517 + success: false, 518 + error: `Failed to verify domain: ${error.message}`, 519 + }; 448 520 } 449 - console.error(`[verifyDomain] Unknown error verifying ${domainUrl}:`, error); 521 + console.error( 522 + `[verifyDomain] Unknown error verifying ${domainUrl}:`, 523 + error, 524 + ); 450 525 return { success: false, error: "Failed to verify domain" }; 451 526 } 452 527 } ··· 457 532 redirectUri: string, 458 533 ): Promise<{ 459 534 error?: string; 460 - app?: { name: string | null; redirect_uris: string; logo_url?: string | null }; 535 + app?: { 536 + name: string | null; 537 + redirect_uris: string; 538 + logo_url?: string | null; 539 + }; 461 540 }> { 462 541 const existing = db 463 542 .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") ··· 550 629 551 630 // Fetch the newly created app 552 631 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 }; 632 + .query( 633 + "SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?", 634 + ) 635 + .get(canonicalClientId) as { 636 + name: string | null; 637 + redirect_uris: string; 638 + logo_url?: string | null; 639 + }; 555 640 556 641 return { app: newApp }; 557 642 } ··· 579 664 const codeChallengeMethod = params.get("code_challenge_method"); 580 665 const scope = params.get("scope") || "profile"; 581 666 const me = params.get("me"); 667 + const nonce = params.get("nonce"); // OIDC nonce parameter 582 668 583 669 if (responseType !== "code") { 584 670 return new Response("Unsupported response_type", { status: 400 }); ··· 933 1019 if (hasAllScopes) { 934 1020 // Auto-approve - create auth code and redirect 935 1021 const code = crypto.randomBytes(32).toString("base64url"); 936 - const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds 1022 + const now = Math.floor(Date.now() / 1000); 1023 + const expiresAt = now + 60; // 60 seconds 937 1024 938 1025 db.query( 939 - "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 1026 + "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me, nonce, auth_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 940 1027 ).run( 941 1028 code, 942 1029 user.userId, ··· 946 1033 codeChallenge, 947 1034 expiresAt, 948 1035 me, 1036 + nonce, 1037 + now, // auth_time - user already authenticated 949 1038 ); 950 1039 951 1040 // Update permission last_used ··· 954 1043 ).run(Math.floor(Date.now() / 1000), user.userId, clientId); 955 1044 956 1045 const origin = process.env.ORIGIN || "http://localhost:3000"; 957 - return Response.redirect(`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`); 1046 + return Response.redirect( 1047 + `${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`, 1048 + ); 958 1049 } 959 1050 } 960 1051 ··· 967 1058 codeChallenge, 968 1059 requestedScopes, 969 1060 me, 1061 + nonce, 970 1062 ); 971 1063 } 972 1064 ··· 978 1070 codeChallenge: string, 979 1071 scopes: string[], 980 1072 me: string | null, 1073 + nonce: string | null, 981 1074 ): Response { 982 1075 // Load app metadata if pre-registered 983 1076 const appData = db ··· 1296 1389 <input type="hidden" name="state" value="${state}" /> 1297 1390 <input type="hidden" name="code_challenge" value="${codeChallenge}" /> 1298 1391 ${me ? `<input type="hidden" name="me" value="${me}" />` : ""} 1392 + ${nonce ? `<input type="hidden" name="nonce" value="${nonce}" />` : ""} 1299 1393 <!-- Always include profile scope as it's required --> 1300 1394 <input type="hidden" name="scope" value="profile" /> 1301 1395 ··· 1316 1410 // POST /auth/authorize - Consent form submission 1317 1411 export async function authorizePost(req: Request): Promise<Response> { 1318 1412 const contentType = req.headers.get("Content-Type"); 1319 - 1413 + 1320 1414 // Parse the request body 1321 1415 let body: Record<string, string>; 1322 1416 let formData: FormData; ··· 1334 1428 } 1335 1429 1336 1430 const grantType = body.grant_type; 1337 - 1431 + 1338 1432 // If grant_type is present, this is a token exchange request (IndieAuth profile scope only) 1339 1433 if (grantType === "authorization_code") { 1340 1434 // Create a mock request for token() function 1341 1435 const mockReq = new Request(req.url, { 1342 1436 method: "POST", 1343 1437 headers: req.headers, 1344 - body: contentType?.includes("application/x-www-form-urlencoded") 1438 + body: contentType?.includes("application/x-www-form-urlencoded") 1345 1439 ? new URLSearchParams(body).toString() 1346 1440 : JSON.stringify(body), 1347 1441 }); ··· 1361 1455 const state = body.state; 1362 1456 const codeChallenge = body.code_challenge; 1363 1457 const me = body.me || null; 1458 + const nonce = body.nonce || null; // OIDC nonce 1364 1459 1365 1460 if (!rawClientId || !rawRedirectUri || !state || !codeChallenge) { 1366 1461 return new Response("Missing required parameters", { status: 400 }); ··· 1373 1468 clientId = canonicalizeURL(rawClientId); 1374 1469 redirectUri = canonicalizeURL(rawRedirectUri); 1375 1470 } catch { 1376 - return new Response("Invalid client_id or redirect_uri URL format", { status: 400 }); 1471 + return new Response("Invalid client_id or redirect_uri URL format", { 1472 + status: 400, 1473 + }); 1377 1474 } 1378 1475 1379 1476 if (action === "deny") { ··· 1392 1489 1393 1490 // Create authorization code 1394 1491 const code = crypto.randomBytes(32).toString("base64url"); 1395 - const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds 1492 + const now = Math.floor(Date.now() / 1000); 1493 + const expiresAt = now + 60; // 60 seconds 1396 1494 1397 1495 db.query( 1398 - "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 1496 + "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me, nonce, auth_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 1399 1497 ).run( 1400 1498 code, 1401 1499 user.userId, ··· 1405 1503 codeChallenge, 1406 1504 expiresAt, 1407 1505 me, 1506 + nonce, 1507 + now, // auth_time 1408 1508 ); 1409 1509 1410 1510 // Store or update permission grant ··· 1487 1587 let redirect_uri: string | undefined; 1488 1588 try { 1489 1589 client_id = raw_client_id ? canonicalizeURL(raw_client_id) : undefined; 1490 - redirect_uri = raw_redirect_uri ? canonicalizeURL(raw_redirect_uri) : undefined; 1590 + redirect_uri = raw_redirect_uri 1591 + ? canonicalizeURL(raw_redirect_uri) 1592 + : undefined; 1491 1593 } catch { 1492 1594 return Response.json( 1493 1595 { ··· 1502 1604 return Response.json( 1503 1605 { 1504 1606 error: "unsupported_grant_type", 1505 - error_description: "Only authorization_code and refresh_token grant types are supported", 1607 + error_description: 1608 + "Only authorization_code and refresh_token grant types are supported", 1506 1609 }, 1507 1610 { status: 400 }, 1508 1611 ); ··· 1577 1680 const expiresAt = now + expiresIn; 1578 1681 1579 1682 // 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); 1683 + db.query("UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?").run( 1684 + newAccessToken, 1685 + expiresAt, 1686 + tokenData.id, 1687 + ); 1583 1688 1584 1689 // Get user profile for me value 1585 1690 const user = db ··· 1614 1719 headers: { 1615 1720 "Content-Type": "application/json", 1616 1721 "Cache-Control": "no-store", 1617 - "Pragma": "no-cache", 1722 + Pragma: "no-cache", 1618 1723 }, 1619 1724 }, 1620 1725 ); ··· 1670 1775 } 1671 1776 } 1672 1777 1673 - if (!code || !client_id || !redirect_uri) { 1674 - console.error("Token endpoint: missing parameters", { 1778 + if (!code || !client_id) { 1779 + console.error("Token endpoint: missing required parameters", { 1675 1780 code: !!code, 1676 1781 client_id: !!client_id, 1677 - redirect_uri: !!redirect_uri, 1678 1782 }); 1679 1783 return Response.json( 1680 1784 { 1681 1785 error: "invalid_request", 1682 - error_description: "Missing required parameters", 1786 + error_description: "Missing required parameters (code, client_id)", 1683 1787 }, 1684 1788 { status: 400 }, 1685 1789 ); ··· 1699 1803 // Look up authorization code 1700 1804 const authcode = db 1701 1805 .query( 1702 - "SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me FROM authcodes WHERE code = ?", 1806 + "SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me, nonce, auth_time FROM authcodes WHERE code = ?", 1703 1807 ) 1704 1808 .get(code) as 1705 1809 | { ··· 1711 1815 expires_at: number; 1712 1816 used: number; 1713 1817 me: string | null; 1818 + nonce: string | null; 1819 + auth_time: number | null; 1714 1820 } 1715 1821 | undefined; 1716 1822 ··· 1727 1833 1728 1834 // Check if already used 1729 1835 if (authcode.used) { 1730 - console.error("Token endpoint: authorization code already used", { code }); 1836 + console.error("Token endpoint: authorization code already used", { 1837 + code, 1838 + }); 1731 1839 return Response.json( 1732 1840 { 1733 1841 error: "invalid_grant", ··· 1740 1848 // Check if expired 1741 1849 const now = Math.floor(Date.now() / 1000); 1742 1850 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 }); 1851 + console.error("Token endpoint: authorization code expired", { 1852 + code, 1853 + expires_at: authcode.expires_at, 1854 + now, 1855 + diff: now - authcode.expires_at, 1856 + }); 1744 1857 return Response.json( 1745 1858 { 1746 1859 error: "invalid_grant", ··· 1752 1865 1753 1866 // Verify client_id matches 1754 1867 if (authcode.client_id !== client_id) { 1755 - console.error("Token endpoint: client_id mismatch", { stored: authcode.client_id, received: client_id }); 1868 + console.error("Token endpoint: client_id mismatch", { 1869 + stored: authcode.client_id, 1870 + received: client_id, 1871 + }); 1756 1872 return Response.json( 1757 1873 { 1758 1874 error: "invalid_grant", ··· 1762 1878 ); 1763 1879 } 1764 1880 1765 - // Verify redirect_uri matches 1766 - if (authcode.redirect_uri !== redirect_uri) { 1767 - console.error("Token endpoint: redirect_uri mismatch", { stored: authcode.redirect_uri, received: redirect_uri }); 1881 + // Verify redirect_uri matches if provided (per OAuth 2.0 RFC 6749 section 4.1.3) 1882 + // redirect_uri is REQUIRED if it was included in the authorization request 1883 + if (redirect_uri && authcode.redirect_uri !== redirect_uri) { 1884 + console.error("Token endpoint: redirect_uri mismatch", { 1885 + stored: authcode.redirect_uri, 1886 + received: redirect_uri, 1887 + }); 1768 1888 return Response.json( 1769 1889 { 1770 1890 error: "invalid_grant", ··· 1776 1896 1777 1897 // Verify PKCE code_verifier (required for all clients per IndieAuth spec) 1778 1898 if (!verifyPKCE(code_verifier, authcode.code_challenge)) { 1779 - console.error("Token endpoint: PKCE verification failed", { code_verifier, code_challenge: authcode.code_challenge }); 1899 + console.error("Token endpoint: PKCE verification failed", { 1900 + code_verifier, 1901 + code_challenge: authcode.code_challenge, 1902 + }); 1780 1903 return Response.json( 1781 1904 { 1782 1905 error: "invalid_grant", ··· 1839 1962 1840 1963 // Validate that the user controls the requested me parameter 1841 1964 if (authcode.me && authcode.me !== meValue) { 1842 - console.error("Token endpoint: me mismatch", { requested: authcode.me, actual: meValue }); 1965 + console.error("Token endpoint: me mismatch", { 1966 + requested: authcode.me, 1967 + actual: meValue, 1968 + }); 1843 1969 return Response.json( 1844 1970 { 1845 1971 error: "invalid_grant", 1846 - error_description: "The requested identity does not match the user's verified domain", 1972 + error_description: 1973 + "The requested identity does not match the user's verified domain", 1847 1974 }, 1848 1975 { status: 400 }, 1849 1976 ); 1850 1977 } 1851 1978 1852 1979 const origin = process.env.ORIGIN || "http://localhost:3000"; 1853 - 1980 + 1854 1981 // Generate access token 1855 1982 const accessToken = crypto.randomBytes(32).toString("base64url"); 1856 1983 const expiresIn = 3600; // 1 hour ··· 1864 1991 // Store token in database with refresh token 1865 1992 db.query( 1866 1993 "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); 1994 + ).run( 1995 + accessToken, 1996 + authcode.user_id, 1997 + client_id, 1998 + scopes.join(" "), 1999 + expiresAt, 2000 + refreshToken, 2001 + refreshExpiresAt, 2002 + ); 1868 2003 1869 2004 const response: Record<string, unknown> = { 1870 2005 access_token: accessToken, ··· 1882 2017 response.role = permission.role; 1883 2018 } 1884 2019 1885 - console.log("Token endpoint: success", { me: meValue, scopes: scopes.join(" ") }); 2020 + // Generate OIDC id_token if openid scope is requested 2021 + if (scopes.includes("openid")) { 2022 + const idTokenClaims: Record<string, unknown> = { 2023 + sub: meValue, 2024 + aud: client_id, 2025 + }; 2026 + 2027 + // Add nonce if provided (OIDC replay protection) 2028 + if (authcode.nonce) { 2029 + idTokenClaims.nonce = authcode.nonce; 2030 + } 2031 + 2032 + // Add auth_time if available 2033 + if (authcode.auth_time) { 2034 + idTokenClaims.auth_time = authcode.auth_time; 2035 + } 2036 + 2037 + // Add profile claims if profile scope included 2038 + if (scopes.includes("profile")) { 2039 + idTokenClaims.name = user.name; 2040 + if (user.photo) idTokenClaims.picture = user.photo; 2041 + if (user.url) idTokenClaims.website = user.url; 2042 + } 2043 + 2044 + // Add email claim if email scope included 2045 + if (scopes.includes("email") && user.email) { 2046 + idTokenClaims.email = user.email; 2047 + } 2048 + 2049 + const idToken = await signIDToken( 2050 + origin, 2051 + idTokenClaims as { 2052 + sub: string; 2053 + aud: string; 2054 + nonce?: string; 2055 + auth_time?: number; 2056 + name?: string; 2057 + email?: string; 2058 + picture?: string; 2059 + website?: string; 2060 + }, 2061 + ); 2062 + response.id_token = idToken; 2063 + } 2064 + 2065 + console.log("Token endpoint: success", { 2066 + me: meValue, 2067 + scopes: scopes.join(" "), 2068 + }); 1886 2069 1887 2070 return Response.json(response, { 1888 2071 headers: { 1889 2072 "Content-Type": "application/json", 1890 2073 "Cache-Control": "no-store", 1891 - "Pragma": "no-cache", 2074 + Pragma: "no-cache", 1892 2075 }, 1893 2076 }); 1894 2077 } catch (error) { ··· 2052 2235 try { 2053 2236 // Get access token from Authorization header 2054 2237 const authHeader = req.headers.get("Authorization"); 2055 - 2238 + 2056 2239 if (!authHeader || !authHeader.startsWith("Bearer ")) { 2057 2240 return Response.json( 2058 2241 { ··· 2110 2293 // Parse scopes 2111 2294 const scopes = tokenData.scope.split(" "); 2112 2295 2113 - // Build response based on scopes 2296 + // Build response based on scopes (OIDC-compliant claim names) 2297 + const origin = process.env.ORIGIN || "http://localhost:3000"; 2114 2298 const response: Record<string, string> = {}; 2115 2299 2300 + // sub claim is always required for OIDC userinfo 2301 + if (tokenData.url) { 2302 + response.sub = tokenData.url; 2303 + } else { 2304 + response.sub = `${origin}/u/${tokenData.username}`; 2305 + } 2306 + 2116 2307 if (scopes.includes("profile")) { 2117 2308 response.name = tokenData.name; 2118 - if (tokenData.photo) response.photo = tokenData.photo; 2309 + if (tokenData.photo) response.picture = tokenData.photo; // OIDC uses 'picture' 2119 2310 if (tokenData.url) { 2120 - response.url = tokenData.url; 2121 - } else { 2122 - const origin = process.env.ORIGIN || "http://localhost:3000"; 2123 - response.url = `${origin}/u/${tokenData.username}`; 2311 + response.website = tokenData.url; // OIDC uses 'website' 2124 2312 } 2125 2313 } 2126 2314 ··· 2128 2316 response.email = tokenData.email; 2129 2317 } 2130 2318 2131 - // Return empty object if no profile/email scopes 2132 - if (Object.keys(response).length === 0) { 2319 + // For OIDC, we always return at least sub 2320 + // But for IndieAuth compatibility, check if we have meaningful claims 2321 + if (Object.keys(response).length === 1 && !scopes.includes("openid")) { 2322 + // Only sub, no openid scope - this is a pure IndieAuth request without claims 2133 2323 return Response.json( 2134 2324 { 2135 2325 error: "insufficient_scope",
+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;