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

feat: implement oidc #2

closed opened by dunkirk.sh targeting main from feat/oidc
Labels

None yet.

assignee

None yet.

Participants 1
Referenced by
AT URI
at://did:plc:krxbvxvis5skq7jj6eot23ul/sh.tangled.repo.pull/3mc67kl5gbp22
+877 -118
Diff #2
+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 + });
+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
+9
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);
+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 + }
+62 -33
src/routes/auth.ts
··· 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: {
+139 -81
src/routes/indieauth.ts
··· 1 1 import crypto from "crypto"; 2 2 import { db } from "../db"; 3 + import { signIDToken } from "../oidc"; 3 4 import { safeFetch, validateExternalURL } from "../lib/ssrf-safe-fetch"; 4 5 5 6 interface SessionUser { ··· 414 415 // Validate URL is safe to fetch (prevents SSRF attacks) 415 416 const urlValidation = validateExternalURL(domainUrl); 416 417 if (!urlValidation.safe) { 417 - return { 418 - success: false, 419 - error: urlValidation.error || "Invalid domain URL", 420 - }; 418 + return { success: false, error: urlValidation.error || "Invalid domain URL" }; 421 419 } 422 420 423 421 // Use SSRF-safe fetch ··· 430 428 }); 431 429 432 430 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 - }; 431 + console.error(`[verifyDomain] Failed to fetch ${domainUrl}: ${fetchResult.error}`); 432 + return { success: false, error: `Failed to fetch domain: ${fetchResult.error}` }; 440 433 } 441 434 442 435 const response = fetchResult.data; ··· 457 450 }; 458 451 } 459 452 460 - const html = await response.text(); 453 + const html = await response.text(); 461 454 462 - // Extract rel="me" links using regex 463 - // Matches both <link> and <a> tags with rel attribute containing "me" 464 - const relMeLinks: string[] = []; 455 + // Extract rel="me" links using regex 456 + // Matches both <link> and <a> tags with rel attribute containing "me" 457 + const relMeLinks: string[] = []; 465 458 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; 459 + // Simpler approach: find all link and a tags, then check if they have rel="me" and href 460 + const linkRegex = /<link\s+[^>]*>/gi; 461 + const aRegex = /<a\s+[^>]*>/gi; 469 462 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; 463 + const processTag = (tagHtml: string) => { 464 + // Check if has rel containing "me" (handle quoted and unquoted attributes) 465 + const relMatch = tagHtml.match(/rel=["']?([^"'\s>]+)["']?/i); 466 + if (!relMatch) return null; 474 467 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; 468 + const relValue = relMatch[1]; 469 + // Check if "me" is a separate word in the rel attribute 470 + if (!relValue.split(/\s+/).includes("me")) return null; 478 471 479 - // Extract href (handle quoted and unquoted attributes) 480 - const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i); 481 - if (!hrefMatch) return null; 472 + // Extract href (handle quoted and unquoted attributes) 473 + const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i); 474 + if (!hrefMatch) return null; 482 475 483 - return hrefMatch[1]; 484 - }; 476 + return hrefMatch[1]; 477 + }; 485 478 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); 479 + // Process all link tags 480 + let linkMatch; 481 + while ((linkMatch = linkRegex.exec(html)) !== null) { 482 + const href = processTag(linkMatch[0]); 483 + if (href && !relMeLinks.includes(href)) { 484 + relMeLinks.push(href); 485 + } 492 486 } 493 - } 494 487 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); 488 + // Process all a tags 489 + let aMatch; 490 + while ((aMatch = aRegex.exec(html)) !== null) { 491 + const href = processTag(aMatch[0]); 492 + if (href && !relMeLinks.includes(href)) { 493 + relMeLinks.push(href); 494 + } 501 495 } 502 - } 503 496 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 - }); 497 + // Check if any rel="me" link matches the indiko profile URL 498 + const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl); 499 + const hasRelMe = relMeLinks.some((link) => { 500 + try { 501 + const normalizedLink = canonicalizeURL(link); 502 + return normalizedLink === normalizedIndikoUrl; 503 + } catch { 504 + return false; 505 + } 506 + }); 514 507 515 - if (!hasRelMe) { 516 - console.error( 517 - `[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, 518 - { 519 - foundLinks: relMeLinks, 520 - normalizedTarget: normalizedIndikoUrl, 521 - }, 522 - ); 508 + if (!hasRelMe) { 509 + console.error( 510 + `[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, 511 + { 512 + foundLinks: relMeLinks, 513 + normalizedTarget: normalizedIndikoUrl, 514 + }, 515 + ); 523 516 return { 524 517 success: false, 525 518 error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`, ··· 667 660 const codeChallengeMethod = params.get("code_challenge_method"); 668 661 const scope = params.get("scope") || "profile"; 669 662 const me = params.get("me"); 663 + const nonce = params.get("nonce"); // OIDC nonce parameter 670 664 671 665 if (responseType !== "code") { 672 666 return new Response("Unsupported response_type", { status: 400 }); ··· 1021 1015 if (hasAllScopes) { 1022 1016 // Auto-approve - create auth code and redirect 1023 1017 const code = crypto.randomBytes(32).toString("base64url"); 1024 - const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds 1018 + const now = Math.floor(Date.now() / 1000); 1019 + const expiresAt = now + 60; // 60 seconds 1025 1020 1026 1021 db.query( 1027 - "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 1022 + "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me, nonce, auth_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 1028 1023 ).run( 1029 1024 code, 1030 1025 user.userId, ··· 1034 1029 codeChallenge, 1035 1030 expiresAt, 1036 1031 me, 1032 + nonce, 1033 + now, // auth_time - user already authenticated 1037 1034 ); 1038 1035 1039 1036 // Update permission last_used ··· 1057 1054 codeChallenge, 1058 1055 requestedScopes, 1059 1056 me, 1057 + nonce, 1060 1058 ); 1061 1059 } 1062 1060 ··· 1068 1066 codeChallenge: string, 1069 1067 scopes: string[], 1070 1068 me: string | null, 1069 + nonce: string | null, 1071 1070 ): Response { 1072 1071 // Load app metadata if pre-registered 1073 1072 const appData = db ··· 1386 1385 <input type="hidden" name="state" value="${state}" /> 1387 1386 <input type="hidden" name="code_challenge" value="${codeChallenge}" /> 1388 1387 ${me ? `<input type="hidden" name="me" value="${me}" />` : ""} 1388 + ${nonce ? `<input type="hidden" name="nonce" value="${nonce}" />` : ""} 1389 1389 <!-- Always include profile scope as it's required --> 1390 1390 <input type="hidden" name="scope" value="profile" /> 1391 1391 ··· 1451 1451 const state = body.state; 1452 1452 const codeChallenge = body.code_challenge; 1453 1453 const me = body.me || null; 1454 + const nonce = body.nonce || null; // OIDC nonce 1454 1455 1455 1456 if (!rawClientId || !rawRedirectUri || !state || !codeChallenge) { 1456 1457 return new Response("Missing required parameters", { status: 400 }); ··· 1484 1485 1485 1486 // Create authorization code 1486 1487 const code = crypto.randomBytes(32).toString("base64url"); 1487 - const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds 1488 + const now = Math.floor(Date.now() / 1000); 1489 + const expiresAt = now + 60; // 60 seconds 1488 1490 1489 1491 db.query( 1490 - "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 1492 + "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me, nonce, auth_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 1491 1493 ).run( 1492 1494 code, 1493 1495 user.userId, ··· 1497 1499 codeChallenge, 1498 1500 expiresAt, 1499 1501 me, 1502 + nonce, 1503 + now, // auth_time 1500 1504 ); 1501 1505 1502 1506 // Store or update permission grant ··· 1767 1771 } 1768 1772 } 1769 1773 1770 - if (!code || !client_id || !redirect_uri) { 1771 - console.error("Token endpoint: missing parameters", { 1774 + if (!code || !client_id) { 1775 + console.error("Token endpoint: missing required parameters", { 1772 1776 code: !!code, 1773 1777 client_id: !!client_id, 1774 - redirect_uri: !!redirect_uri, 1775 1778 }); 1776 1779 return Response.json( 1777 1780 { 1778 1781 error: "invalid_request", 1779 - error_description: "Missing required parameters", 1782 + error_description: "Missing required parameters (code, client_id)", 1780 1783 }, 1781 1784 { status: 400 }, 1782 1785 ); ··· 1796 1799 // Look up authorization code 1797 1800 const authcode = db 1798 1801 .query( 1799 - "SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me FROM authcodes WHERE code = ?", 1802 + "SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me, nonce, auth_time FROM authcodes WHERE code = ?", 1800 1803 ) 1801 1804 .get(code) as 1802 1805 | { ··· 1808 1811 expires_at: number; 1809 1812 used: number; 1810 1813 me: string | null; 1814 + nonce: string | null; 1815 + auth_time: number | null; 1811 1816 } 1812 1817 | undefined; 1813 1818 ··· 1869 1874 ); 1870 1875 } 1871 1876 1872 - // Verify redirect_uri matches 1873 - if (authcode.redirect_uri !== redirect_uri) { 1877 + // Verify redirect_uri matches if provided (per OAuth 2.0 RFC 6749 section 4.1.3) 1878 + // redirect_uri is REQUIRED if it was included in the authorization request 1879 + if (redirect_uri && authcode.redirect_uri !== redirect_uri) { 1874 1880 console.error("Token endpoint: redirect_uri mismatch", { 1875 1881 stored: authcode.redirect_uri, 1876 1882 received: redirect_uri, ··· 2007 2013 response.role = permission.role; 2008 2014 } 2009 2015 2016 + // Generate OIDC id_token if openid scope is requested 2017 + if (scopes.includes("openid")) { 2018 + const idTokenClaims: Record<string, unknown> = { 2019 + sub: meValue, 2020 + aud: client_id, 2021 + }; 2022 + 2023 + // Add nonce if provided (OIDC replay protection) 2024 + if (authcode.nonce) { 2025 + idTokenClaims.nonce = authcode.nonce; 2026 + } 2027 + 2028 + // Add auth_time if available 2029 + if (authcode.auth_time) { 2030 + idTokenClaims.auth_time = authcode.auth_time; 2031 + } 2032 + 2033 + // Add profile claims if profile scope included 2034 + if (scopes.includes("profile")) { 2035 + idTokenClaims.name = user.name; 2036 + if (user.photo) idTokenClaims.picture = user.photo; 2037 + if (user.url) idTokenClaims.website = user.url; 2038 + } 2039 + 2040 + // Add email claim if email scope included 2041 + if (scopes.includes("email") && user.email) { 2042 + idTokenClaims.email = user.email; 2043 + } 2044 + 2045 + const idToken = await signIDToken( 2046 + origin, 2047 + idTokenClaims as { 2048 + sub: string; 2049 + aud: string; 2050 + nonce?: string; 2051 + auth_time?: number; 2052 + name?: string; 2053 + email?: string; 2054 + picture?: string; 2055 + website?: string; 2056 + }, 2057 + ); 2058 + response.id_token = idToken; 2059 + } 2060 + 2010 2061 console.log("Token endpoint: success", { 2011 2062 me: meValue, 2012 2063 scopes: scopes.join(" "), ··· 2238 2289 // Parse scopes 2239 2290 const scopes = tokenData.scope.split(" "); 2240 2291 2241 - // Build response based on scopes 2292 + // Build response based on scopes (OIDC-compliant claim names) 2293 + const origin = process.env.ORIGIN || "http://localhost:3000"; 2242 2294 const response: Record<string, string> = {}; 2243 2295 2296 + // sub claim is always required for OIDC userinfo 2297 + if (tokenData.url) { 2298 + response.sub = tokenData.url; 2299 + } else { 2300 + response.sub = `${origin}/u/${tokenData.username}`; 2301 + } 2302 + 2244 2303 if (scopes.includes("profile")) { 2245 2304 response.name = tokenData.name; 2246 - if (tokenData.photo) response.photo = tokenData.photo; 2305 + if (tokenData.photo) response.picture = tokenData.photo; // OIDC uses 'picture' 2247 2306 if (tokenData.url) { 2248 - response.url = tokenData.url; 2249 - } else { 2250 - const origin = process.env.ORIGIN || "http://localhost:3000"; 2251 - response.url = `${origin}/u/${tokenData.username}`; 2307 + response.website = tokenData.url; // OIDC uses 'website' 2252 2308 } 2253 2309 } 2254 2310 ··· 2256 2312 response.email = tokenData.email; 2257 2313 } 2258 2314 2259 - // Return empty object if no profile/email scopes 2260 - if (Object.keys(response).length === 0) { 2315 + // For OIDC, we always return at least sub 2316 + // But for IndieAuth compatibility, check if we have meaningful claims 2317 + if (Object.keys(response).length === 1 && !scopes.includes("openid")) { 2318 + // Only sub, no openid scope - this is a pure IndieAuth request without claims 2261 2319 return Response.json( 2262 2320 { 2263 2321 error: "insufficient_scope",

History

3 rounds 0 comments
sign up or login to add to the discussion
6 commits
expand
feat: implement oidc
feat: allow re-registration to reset passkey
chore: fix userinfo endpoint
docs: add oidc
bug: allow not sending redirect url
security: add SSRF protection for client metadata and domain verification fetches
expand 0 comments
closed without merging
5 commits
expand
feat: implement oidc
feat: allow re-registration to reset passkey
chore: fix userinfo endpoint
docs: add oidc
bug: allow not sending redirect url
expand 0 comments
2 commits
expand
feat: implement oidc
feat: allow re-registration to reset passkey
expand 0 comments