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

Compare changes

Choose any two refs to compare.

+24 -3
README.md
··· 130 131 Now you can sign in to IndieAuth-compatible sites using `https://your-domain.com/` as your identity. 132 133 ## API Reference 134 135 - ### OAuth 2.0 Endpoints 136 137 - - `GET /auth/authorize` - Authorization endpoint 138 - - `POST /auth/token` - Token exchange endpoint 139 - `POST /auth/logout` - Session logout 140 141 ### User Profile
··· 130 131 Now you can sign in to IndieAuth-compatible sites using `https://your-domain.com/` as your identity. 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 + 151 ## API Reference 152 153 + ### OAuth 2.0 / OpenID Connect Endpoints 154 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 160 - `POST /auth/logout` - Session logout 161 162 ### User Profile
+140
SPEC.md
··· 497 // Create session for user 498 ``` 499 500 ## Future Enhancements 501 502 - Token endpoint for longer-lived access tokens ··· 509 - Audit log for admin 510 - Rate limiting 511 - Account recovery flow 512 513 ## Standards Compliance 514 ··· 516 - [WebAuthn/FIDO2](https://www.w3.org/TR/webauthn-2/) 517 - [OAuth 2.0 PKCE](https://tools.ietf.org/html/rfc7636) 518 - [Microformats h-card](http://microformats.org/wiki/h-card)
··· 497 // Create session for user 498 ``` 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 + 637 ## Future Enhancements 638 639 - Token endpoint for longer-lived access tokens ··· 646 - Audit log for admin 647 - Rate limiting 648 - Account recovery flow 649 + - OIDC key rotation via admin interface 650 651 ## Standards Compliance 652 ··· 654 - [WebAuthn/FIDO2](https://www.w3.org/TR/webauthn-2/) 655 - [OAuth 2.0 PKCE](https://tools.ietf.org/html/rfc7636) 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 "@simplewebauthn/browser": "^13.2.2", 9 "@simplewebauthn/server": "^13.2.2", 10 "bun-sqlite-migrations": "^1.0.2", 11 "ldap-authentication": "^3.3.6", 12 "nanoid": "^5.1.6", 13 }, ··· 70 "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], 71 72 "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], 73 74 "ldap-authentication": ["ldap-authentication@3.3.6", "", { "dependencies": { "ldapts": "^7.3.1" } }, "sha512-j8XxH5wGXhIQ3mMnoRCZTalSLmPhzEaGH4+5RIFP0Higc32fCKDRJBo+9wb5ysy9TnlaNtaf+rgdwYCD15OBpQ=="], 75
··· 8 "@simplewebauthn/browser": "^13.2.2", 9 "@simplewebauthn/server": "^13.2.2", 10 "bun-sqlite-migrations": "^1.0.2", 11 + "jose": "^6.1.3", 12 "ldap-authentication": "^3.3.6", 13 "nanoid": "^5.1.6", 14 }, ··· 71 "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], 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=="], 76 77 "ldap-authentication": ["ldap-authentication@3.3.6", "", { "dependencies": { "ldapts": "^7.3.1" } }, "sha512-j8XxH5wGXhIQ3mMnoRCZTalSLmPhzEaGH4+5RIFP0Higc32fCKDRJBo+9wb5ysy9TnlaNtaf+rgdwYCD15OBpQ=="], 78
+1
package.json
··· 19 "@simplewebauthn/browser": "^13.2.2", 20 "@simplewebauthn/server": "^13.2.2", 21 "bun-sqlite-migrations": "^1.0.2", 22 "ldap-authentication": "^3.3.6", 23 "nanoid": "^5.1.6" 24 }
··· 19 "@simplewebauthn/browser": "^13.2.2", 20 "@simplewebauthn/server": "^13.2.2", 21 "bun-sqlite-migrations": "^1.0.2", 22 + "jose": "^6.1.3", 23 "ldap-authentication": "^3.3.6", 24 "nanoid": "^5.1.6" 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"; 4 5 const token = localStorage.getItem("indiko_session"); 6 const footer = document.getElementById("footer") as HTMLElement; ··· 8 const subtitle = document.getElementById("subtitle") as HTMLElement; 9 const recentApps = document.getElementById("recentApps") as HTMLElement; 10 const passkeysList = document.getElementById("passkeysList") as HTMLElement; 11 - const addPasskeyBtn = document.getElementById("addPasskeyBtn") as HTMLButtonElement; 12 const toast = document.getElementById("toast") as HTMLElement; 13 14 // Profile form elements ··· 320 const passkeys = data.passkeys as Passkey[]; 321 322 if (passkeys.length === 0) { 323 - passkeysList.innerHTML = '<div class="empty">No passkeys registered</div>'; 324 return; 325 } 326 327 passkeysList.innerHTML = passkeys 328 .map((passkey) => { 329 - const createdDate = new Date(passkey.created_at * 1000).toLocaleDateString(); 330 331 return ` 332 <div class="passkey-item" data-passkey-id="${passkey.id}"> ··· 336 </div> 337 <div class="passkey-actions"> 338 <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>` : ''} 340 </div> 341 </div> 342 `; ··· 365 } 366 367 function showRenameForm(passkeyId: number) { 368 - const passkeyItem = document.querySelector(`[data-passkey-id="${passkeyId}"]`); 369 if (!passkeyItem) return; 370 371 const infoDiv = passkeyItem.querySelector(".passkey-info"); ··· 389 input.select(); 390 391 // Save button 392 - infoDiv.querySelector(".save-rename-btn")?.addEventListener("click", async () => { 393 - await renamePasskeyHandler(passkeyId, input.value); 394 - }); 395 396 // Cancel button 397 - infoDiv.querySelector(".cancel-rename-btn")?.addEventListener("click", () => { 398 - loadPasskeys(); 399 - }); 400 401 // Enter to save 402 input.addEventListener("keypress", async (e) => { ··· 443 } 444 445 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.")) { 447 return; 448 } 449 ··· 496 addPasskeyBtn.textContent = "verifying..."; 497 498 // Ask for a name 499 - const name = prompt("Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):"); 500 501 // Verify registration 502 const verifyRes = await fetch("/api/passkeys/add/verify", {
··· 1 + import { startRegistration } from "@simplewebauthn/browser"; 2 3 const token = localStorage.getItem("indiko_session"); 4 const footer = document.getElementById("footer") as HTMLElement; ··· 6 const subtitle = document.getElementById("subtitle") as HTMLElement; 7 const recentApps = document.getElementById("recentApps") as HTMLElement; 8 const passkeysList = document.getElementById("passkeysList") as HTMLElement; 9 + const addPasskeyBtn = document.getElementById( 10 + "addPasskeyBtn", 11 + ) as HTMLButtonElement; 12 const toast = document.getElementById("toast") as HTMLElement; 13 14 // Profile form elements ··· 320 const passkeys = data.passkeys as Passkey[]; 321 322 if (passkeys.length === 0) { 323 + passkeysList.innerHTML = 324 + '<div class="empty">No passkeys registered</div>'; 325 return; 326 } 327 328 passkeysList.innerHTML = passkeys 329 .map((passkey) => { 330 + const createdDate = new Date( 331 + passkey.created_at * 1000, 332 + ).toLocaleDateString(); 333 334 return ` 335 <div class="passkey-item" data-passkey-id="${passkey.id}"> ··· 339 </div> 340 <div class="passkey-actions"> 341 <button type="button" class="rename-passkey-btn" data-passkey-id="${passkey.id}">rename</button> 342 + ${passkeys.length > 1 ? `<button type="button" class="delete-passkey-btn" data-passkey-id="${passkey.id}">delete</button>` : ""} 343 </div> 344 </div> 345 `; ··· 368 } 369 370 function showRenameForm(passkeyId: number) { 371 + const passkeyItem = document.querySelector( 372 + `[data-passkey-id="${passkeyId}"]`, 373 + ); 374 if (!passkeyItem) return; 375 376 const infoDiv = passkeyItem.querySelector(".passkey-info"); ··· 394 input.select(); 395 396 // Save button 397 + infoDiv 398 + .querySelector(".save-rename-btn") 399 + ?.addEventListener("click", async () => { 400 + await renamePasskeyHandler(passkeyId, input.value); 401 + }); 402 403 // Cancel button 404 + infoDiv 405 + .querySelector(".cancel-rename-btn") 406 + ?.addEventListener("click", () => { 407 + loadPasskeys(); 408 + }); 409 410 // Enter to save 411 input.addEventListener("keypress", async (e) => { ··· 452 } 453 454 async function deletePasskeyHandler(passkeyId: number) { 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 + ) { 460 return; 461 } 462 ··· 509 addPasskeyBtn.textContent = "verifying..."; 510 511 // Ask for a name 512 + const name = prompt( 513 + "Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):", 514 + ); 515 516 // Verify registration 517 const verifyRes = await fetch("/api/passkeys/add/verify", {
+76 -1
src/html/docs.html
··· 577 <h3>table of contents</h3> 578 <ul> 579 <li><a href="#overview">overview</a></li> 580 <li><a href="#getting-started">getting started</a></li> 581 <li><a href="#button">sign in button</a></li> 582 <li><a href="#endpoints">endpoints</a></li> ··· 612 <ul> 613 <li>Passwordless authentication via WebAuthn passkeys</li> 614 <li>Full IndieAuth and OAuth 2.0 support with PKCE</li> 615 <li>Access tokens and refresh tokens for API access</li> 616 <li>Token introspection and revocation endpoints</li> 617 <li>UserInfo endpoint for profile data</li> ··· 621 <li>User profile endpoints with h-card microformats</li> 622 <li>Invite-based user registration</li> 623 </ul> 624 </section> 625 626 <section id="getting-started" class="section"> ··· 1032 </thead> 1033 <tbody> 1034 <tr> 1035 <td><code>profile</code></td> 1036 <td>Basic profile information</td> 1037 <td>name, photo, URL</td> ··· 1046 1047 <div class="info-box"> 1048 <strong>Note:</strong> 1049 - Users can selectively approve scopes during authorization. Your app may receive fewer scopes than requested. 1050 </div> 1051 </section> 1052
··· 577 <h3>table of contents</h3> 578 <ul> 579 <li><a href="#overview">overview</a></li> 580 + <li><a href="#oidc">openid connect (oidc)</a></li> 581 <li><a href="#getting-started">getting started</a></li> 582 <li><a href="#button">sign in button</a></li> 583 <li><a href="#endpoints">endpoints</a></li> ··· 613 <ul> 614 <li>Passwordless authentication via WebAuthn passkeys</li> 615 <li>Full IndieAuth and OAuth 2.0 support with PKCE</li> 616 + <li>OpenID Connect (OIDC) support with ID tokens</li> 617 <li>Access tokens and refresh tokens for API access</li> 618 <li>Token introspection and revocation endpoints</li> 619 <li>UserInfo endpoint for profile data</li> ··· 623 <li>User profile endpoints with h-card microformats</li> 624 <li>Invite-based user registration</li> 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> 694 </section> 695 696 <section id="getting-started" class="section"> ··· 1102 </thead> 1103 <tbody> 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> 1110 <td><code>profile</code></td> 1111 <td>Basic profile information</td> 1112 <td>name, photo, URL</td> ··· 1121 1122 <div class="info-box"> 1123 <strong>Note:</strong> 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. 1125 </div> 1126 </section> 1127
+21 -3
src/index.ts
··· 8 import indexHTML from "./html/index.html"; 9 import loginHTML from "./html/login.html"; 10 import { getLdapAccounts, updateOrphanedAccounts } from "./ldap-cleanup"; 11 import { 12 deleteSelfAccount, 13 deleteUser, ··· 155 ); 156 }, 157 "/.well-known/oauth-authorization-server": indieauthMetadata, 158 // OAuth/IndieAuth endpoints 159 "/userinfo": (req: Request) => { 160 if (req.method === "GET") return userinfo(req); ··· 365 366 if (expiredOrphans.length > 0) { 367 if (action === "suspend") { 368 - await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "suspend"); 369 } else if (action === "deactivate") { 370 - await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "deactivate"); 371 } else if (action === "remove") { 372 - await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "remove"); 373 } 374 console.log( 375 `[LDAP Cleanup] ${action === "remove" ? "Removed" : action === "suspend" ? "Suspended" : "Deactivated"} ${expiredOrphans.length} LDAP orphan accounts (grace period: ${gracePeriod}s)`,
··· 8 import indexHTML from "./html/index.html"; 9 import loginHTML from "./html/login.html"; 10 import { getLdapAccounts, updateOrphanedAccounts } from "./ldap-cleanup"; 11 + import { getDiscoveryDocument, getJWKS } from "./oidc"; 12 import { 13 deleteSelfAccount, 14 deleteUser, ··· 156 ); 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 + }, 167 // OAuth/IndieAuth endpoints 168 "/userinfo": (req: Request) => { 169 if (req.method === "GET") return userinfo(req); ··· 374 375 if (expiredOrphans.length > 0) { 376 if (action === "suspend") { 377 + await updateOrphanedAccounts( 378 + { ...result, orphanedUsers: expiredOrphans }, 379 + "suspend", 380 + ); 381 } else if (action === "deactivate") { 382 + await updateOrphanedAccounts( 383 + { ...result, orphanedUsers: expiredOrphans }, 384 + "deactivate", 385 + ); 386 } else if (action === "remove") { 387 + await updateOrphanedAccounts( 388 + { ...result, orphanedUsers: expiredOrphans }, 389 + "remove", 390 + ); 391 } 392 console.log( 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 import { db } from "../db"; 2 - import { verifyDomain, validateProfileURL } from "./indieauth"; 3 4 function getSessionUser( 5 req: Request, 6 - ): { username: string; userId: number; is_admin: boolean; tier: string } | Response { 7 const authHeader = req.headers.get("Authorization"); 8 9 if (!authHeader || !authHeader.startsWith("Bearer ")) { ··· 193 const origin = process.env.ORIGIN || "http://localhost:3000"; 194 const indikoProfileUrl = `${origin}/u/${user.username}`; 195 196 - const verification = await verifyDomain(validation.canonicalUrl!, indikoProfileUrl); 197 if (!verification.success) { 198 return Response.json( 199 { error: verification.error || "Failed to verify domain" }, ··· 508 return Response.json({ success: true }); 509 } 510 511 - export async function updateUserTier(req: Request, userId: string): Promise<Response> { 512 const user = getSessionUser(req); 513 if (user instanceof Response) { 514 return user; ··· 536 537 const targetUser = db 538 .query("SELECT id, username, tier FROM users WHERE id = ?") 539 - .get(targetUserId) as { id: number; username: string; tier: string } | undefined; 540 541 if (!targetUser) { 542 return Response.json({ error: "User not found" }, { status: 404 });
··· 1 import { db } from "../db"; 2 + import { validateProfileURL, verifyDomain } from "./indieauth"; 3 4 function getSessionUser( 5 req: Request, 6 + ): 7 + | { username: string; userId: number; is_admin: boolean; tier: string } 8 + | Response { 9 const authHeader = req.headers.get("Authorization"); 10 11 if (!authHeader || !authHeader.startsWith("Bearer ")) { ··· 195 const origin = process.env.ORIGIN || "http://localhost:3000"; 196 const indikoProfileUrl = `${origin}/u/${user.username}`; 197 198 + const verification = await verifyDomain( 199 + validation.canonicalUrl!, 200 + indikoProfileUrl, 201 + ); 202 if (!verification.success) { 203 return Response.json( 204 { error: verification.error || "Failed to verify domain" }, ··· 513 return Response.json({ success: true }); 514 } 515 516 + export async function updateUserTier( 517 + req: Request, 518 + userId: string, 519 + ): Promise<Response> { 520 const user = getSessionUser(req); 521 if (user instanceof Response) { 522 return user; ··· 544 545 const targetUser = db 546 .query("SELECT id, username, tier FROM users WHERE id = ?") 547 + .get(targetUserId) as 548 + | { id: number; username: string; tier: string } 549 + | undefined; 550 551 if (!targetUser) { 552 return Response.json({ error: "User not found" }, { status: 404 });
+78 -38
src/routes/auth.ts
··· 1 import { 2 type AuthenticationResponseJSON, 3 type PublicKeyCredentialCreationOptionsJSON, 4 type PublicKeyCredentialRequestOptionsJSON, 5 type RegistrationResponseJSON, 6 type VerifiedAuthenticationResponse, 7 type VerifiedRegistrationResponse, 8 - generateAuthenticationOptions, 9 - generateRegistrationOptions, 10 verifyAuthenticationResponse, 11 verifyRegistrationResponse, 12 } from "@simplewebauthn/server"; ··· 39 // Check if username already exists 40 const existingUser = db 41 .query("SELECT id FROM users WHERE username = ?") 42 - .get(username); 43 44 if (existingUser) { 45 - return Response.json( 46 - { error: "Username already taken" }, 47 - { status: 400 }, 48 - ); 49 } 50 51 // Check if this is bootstrap (first user) ··· 156 157 // Check if username already exists 158 const existingUser = db 159 - .query("SELECT id FROM users WHERE username = ?") 160 - .get(username); 161 162 if (existingUser) { 163 - return Response.json( 164 - { error: "Username already taken" }, 165 - { status: 400 }, 166 - ); 167 } 168 169 if (!expectedChallenge) { ··· 275 invite?.ldap_username !== null && invite?.ldap_username !== undefined; 276 } 277 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 - }; 292 293 // Store credential 294 // credential.id is a Uint8Array, convert to Buffer for storage 295 db.query( 296 "INSERT INTO credentials (user_id, credential_id, public_key, counter, name) VALUES (?, ?, ?, ?, ?)", 297 ).run( 298 - user.id, 299 Buffer.from(credential.id), 300 Buffer.from(credential.publicKey), 301 credential.counter, 302 - "Primary Passkey", 303 ); 304 305 // Mark invite as used if applicable ··· 324 // Record this invite use 325 db.query( 326 "INSERT INTO invite_uses (invite_id, user_id, used_at) VALUES (?, ?, ?)", 327 - ).run(inviteId, user.id, usedAt); 328 329 - // Assign app roles to the new user 330 - if (inviteRoles.length > 0) { 331 const insertPermission = db.query( 332 "INSERT INTO permissions (user_id, app_id, role) VALUES (?, ?, ?)", 333 ); 334 for (const { app_id, role } of inviteRoles) { 335 - insertPermission.run(user.id, app_id, role); 336 } 337 } 338 } ··· 347 const expiresAt = Math.floor(Date.now() / 1000) + 86400; // 24 hours 348 db.query( 349 "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)", 350 - ).run(token, user.id, expiresAt); 351 352 const isProduction = process.env.NODE_ENV === "production"; 353 const secureCookie = isProduction ? "; Secure" : ""; ··· 356 { 357 token, 358 username, 359 - isAdmin: isBootstrap, 360 }, 361 { 362 headers: { ··· 381 382 // Check if user exists and is active 383 const user = db 384 - .query("SELECT id, status, provisioned_via_ldap, last_ldap_verified_at FROM users WHERE username = ?") 385 - .get(username) as { id: number; status: string; provisioned_via_ldap: number; last_ldap_verified_at: number | null } | undefined; 386 387 if (!user) { 388 return Response.json({ error: "Invalid credentials" }, { status: 401 }); ··· 405 const existsInLdap = await checkLdapUser(username); 406 if (!existsInLdap) { 407 // User no longer exists in LDAP - suspend the account 408 - db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run(user.id); 409 return Response.json( 410 { error: "Invalid credentials" }, 411 { status: 401 },
··· 1 import { 2 type AuthenticationResponseJSON, 3 + generateAuthenticationOptions, 4 + generateRegistrationOptions, 5 type PublicKeyCredentialCreationOptionsJSON, 6 type PublicKeyCredentialRequestOptionsJSON, 7 type RegistrationResponseJSON, 8 type VerifiedAuthenticationResponse, 9 type VerifiedRegistrationResponse, 10 verifyAuthenticationResponse, 11 verifyRegistrationResponse, 12 } from "@simplewebauthn/server"; ··· 39 // Check if username already exists 40 const existingUser = db 41 .query("SELECT id FROM users WHERE username = ?") 42 + .get(username) as { id: number } | undefined; 43 44 + // Allow re-registration if user exists but has no credentials (passkey reset case) 45 + let isPasskeyReset = false; 46 if (existingUser) { 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; 59 } 60 61 // Check if this is bootstrap (first user) ··· 166 167 // Check if username already exists 168 const existingUser = db 169 + .query("SELECT id, is_admin FROM users WHERE username = ?") 170 + .get(username) as { id: number; is_admin: number } | undefined; 171 172 + // Allow re-registration if user exists but has no credentials (passkey reset case) 173 + let isPasskeyReset = false; 174 if (existingUser) { 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; 187 } 188 189 if (!expectedChallenge) { ··· 295 invite?.ldap_username !== null && invite?.ldap_username !== undefined; 296 } 297 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 + } 321 322 // Store credential 323 // credential.id is a Uint8Array, convert to Buffer for storage 324 db.query( 325 "INSERT INTO credentials (user_id, credential_id, public_key, counter, name) VALUES (?, ?, ?, ?, ?)", 326 ).run( 327 + userId, 328 Buffer.from(credential.id), 329 Buffer.from(credential.publicKey), 330 credential.counter, 331 + isPasskeyReset ? "Reset Passkey" : "Primary Passkey", 332 ); 333 334 // Mark invite as used if applicable ··· 353 // Record this invite use 354 db.query( 355 "INSERT INTO invite_uses (invite_id, user_id, used_at) VALUES (?, ?, ?)", 356 + ).run(inviteId, userId, usedAt); 357 358 + // Assign app roles to the new user (skip for passkey reset - they already have roles) 359 + if (inviteRoles.length > 0 && !isPasskeyReset) { 360 const insertPermission = db.query( 361 "INSERT INTO permissions (user_id, app_id, role) VALUES (?, ?, ?)", 362 ); 363 for (const { app_id, role } of inviteRoles) { 364 + insertPermission.run(userId, app_id, role); 365 } 366 } 367 } ··· 376 const expiresAt = Math.floor(Date.now() / 1000) + 86400; // 24 hours 377 db.query( 378 "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)", 379 + ).run(token, userId, expiresAt); 380 381 const isProduction = process.env.NODE_ENV === "production"; 382 const secureCookie = isProduction ? "; Secure" : ""; ··· 385 { 386 token, 387 username, 388 + isAdmin: userIsAdmin, 389 }, 390 { 391 headers: { ··· 410 411 // Check if user exists and is active 412 const user = db 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; 424 425 if (!user) { 426 return Response.json({ error: "Invalid credentials" }, { status: 401 }); ··· 443 const existsInLdap = await checkLdapUser(username); 444 if (!existsInLdap) { 445 // User no longer exists in LDAP - suspend the account 446 + db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run( 447 + user.id, 448 + ); 449 return Response.json( 450 { error: "Invalid credentials" }, 451 { status: 401 },
+3 -1
src/routes/clients.ts
··· 16 17 function getSessionUser( 18 req: Request, 19 - ): { username: string; userId: number; is_admin: boolean; tier: string } | Response { 20 const authHeader = req.headers.get("Authorization"); 21 22 if (!authHeader || !authHeader.startsWith("Bearer ")) {
··· 16 17 function getSessionUser( 18 req: Request, 19 + ): 20 + | { username: string; userId: number; is_admin: boolean; tier: string } 21 + | Response { 22 const authHeader = req.headers.get("Authorization"); 23 24 if (!authHeader || !authHeader.startsWith("Bearer ")) {
+270 -80
src/routes/indieauth.ts
··· 1 import crypto from "crypto"; 2 import { db } from "../db"; 3 4 interface SessionUser { 5 username: string; ··· 127 } 128 129 // Validate profile URL per IndieAuth spec 130 - export function validateProfileURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } { 131 let url: URL; 132 try { 133 url = new URL(urlString); ··· 152 153 // MUST NOT contain username/password 154 if (url.username || url.password) { 155 - return { valid: false, error: "Profile URL must not contain username or password" }; 156 } 157 158 // MUST NOT contain ports ··· 164 const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; 165 const ipv6Regex = /^\[?[0-9a-fA-F:]+\]?$/; 166 if (ipv4Regex.test(url.hostname) || ipv6Regex.test(url.hostname)) { 167 - return { valid: false, error: "Profile URL must use domain names, not IP addresses" }; 168 } 169 170 // MUST NOT contain single-dot or double-dot path segments 171 const pathSegments = url.pathname.split("/"); 172 if (pathSegments.includes(".") || pathSegments.includes("..")) { 173 - return { valid: false, error: "Profile URL must not contain . or .. path segments" }; 174 } 175 176 return { valid: true, canonicalUrl: canonicalizeURL(urlString) }; 177 } 178 179 // Validate client URL per IndieAuth spec 180 - function validateClientURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } { 181 let url: URL; 182 try { 183 url = new URL(urlString); ··· 202 203 // MUST NOT contain username/password 204 if (url.username || url.password) { 205 - return { valid: false, error: "Client URL must not contain username or password" }; 206 } 207 208 // MUST NOT contain single-dot or double-dot path segments 209 const pathSegments = url.pathname.split("/"); 210 if (pathSegments.includes(".") || pathSegments.includes("..")) { 211 - return { valid: false, error: "Client URL must not contain . or .. path segments" }; 212 } 213 214 // MAY use loopback interface, but not other IP addresses ··· 217 if (ipv4Regex.test(url.hostname)) { 218 // Allow 127.0.0.1 (loopback), reject others 219 if (!url.hostname.startsWith("127.")) { 220 - return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" }; 221 } 222 } else if (ipv6Regex.test(url.hostname)) { 223 // Allow ::1 (loopback), reject others 224 const ipv6Match = url.hostname.match(ipv6Regex); 225 if (ipv6Match && ipv6Match[1] !== "::1") { 226 - return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" }; 227 } 228 } 229 ··· 234 function isLoopbackURL(urlString: string): boolean { 235 try { 236 const url = new URL(urlString); 237 - return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname.startsWith("127."); 238 } catch { 239 return false; 240 } ··· 254 }> { 255 // MUST NOT fetch loopback addresses (security requirement) 256 if (isLoopbackURL(clientId)) { 257 - return { success: false, error: "Cannot fetch metadata from loopback addresses" }; 258 } 259 260 try { ··· 273 clearTimeout(timeoutId); 274 275 if (!response.ok) { 276 - return { success: false, error: `Failed to fetch client metadata: HTTP ${response.status}` }; 277 } 278 279 const contentType = response.headers.get("content-type") || ""; ··· 284 285 // Verify client_id matches 286 if (metadata.client_id && metadata.client_id !== clientId) { 287 - return { success: false, error: "client_id in metadata does not match URL" }; 288 } 289 290 return { success: true, metadata }; ··· 295 const html = await response.text(); 296 297 // Extract redirect URIs from link tags 298 - const redirectUriRegex = /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 299 const redirectUris: string[] = []; 300 let match: RegExpExecArray | null; 301 ··· 304 } 305 306 // Also try reverse order (href before rel) 307 - const redirectUriRegex2 = /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi; 308 while ((match = redirectUriRegex2.exec(html)) !== null) { 309 if (!redirectUris.includes(match[1])) { 310 redirectUris.push(match[1]); ··· 321 }; 322 } 323 324 - return { success: false, error: "No client metadata or redirect_uri links found in HTML" }; 325 } 326 327 return { success: false, error: "Unsupported content type" }; ··· 330 if (error.name === "AbortError") { 331 return { success: false, error: "Timeout fetching client metadata" }; 332 } 333 - return { success: false, error: `Failed to fetch client metadata: ${error.message}` }; 334 } 335 return { success: false, error: "Failed to fetch client metadata" }; 336 } 337 } 338 339 // Verify domain has rel="me" link back to user profile 340 - export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): Promise<{ 341 success: boolean; 342 error?: string; 343 }> { ··· 359 360 if (!response.ok) { 361 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}` }; 368 } 369 370 const html = await response.text(); ··· 413 414 // Check if any rel="me" link matches the indiko profile URL 415 const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl); 416 - const hasRelMe = relMeLinks.some(link => { 417 try { 418 const normalizedLink = canonicalizeURL(link); 419 return normalizedLink === normalizedIndikoUrl; ··· 423 }); 424 425 if (!hasRelMe) { 426 - console.error(`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, { 427 - foundLinks: relMeLinks, 428 - normalizedTarget: normalizedIndikoUrl, 429 - }); 430 return { 431 success: false, 432 error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`, ··· 440 console.error(`[verifyDomain] Timeout verifying ${domainUrl}`); 441 return { success: false, error: "Timeout verifying domain" }; 442 } 443 - console.error(`[verifyDomain] Error verifying ${domainUrl}: ${error.message}`, { 444 - name: error.name, 445 - stack: error.stack, 446 - }); 447 - return { success: false, error: `Failed to verify domain: ${error.message}` }; 448 } 449 - console.error(`[verifyDomain] Unknown error verifying ${domainUrl}:`, error); 450 return { success: false, error: "Failed to verify domain" }; 451 } 452 } ··· 457 redirectUri: string, 458 ): Promise<{ 459 error?: string; 460 - app?: { name: string | null; redirect_uris: string; logo_url?: string | null }; 461 }> { 462 const existing = db 463 .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") ··· 550 551 // Fetch the newly created app 552 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 }; 555 556 return { app: newApp }; 557 } ··· 579 const codeChallengeMethod = params.get("code_challenge_method"); 580 const scope = params.get("scope") || "profile"; 581 const me = params.get("me"); 582 583 if (responseType !== "code") { 584 return new Response("Unsupported response_type", { status: 400 }); ··· 933 if (hasAllScopes) { 934 // Auto-approve - create auth code and redirect 935 const code = crypto.randomBytes(32).toString("base64url"); 936 - const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds 937 938 db.query( 939 - "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 940 ).run( 941 code, 942 user.userId, ··· 946 codeChallenge, 947 expiresAt, 948 me, 949 ); 950 951 // Update permission last_used ··· 954 ).run(Math.floor(Date.now() / 1000), user.userId, clientId); 955 956 const origin = process.env.ORIGIN || "http://localhost:3000"; 957 - return Response.redirect(`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`); 958 } 959 } 960 ··· 967 codeChallenge, 968 requestedScopes, 969 me, 970 ); 971 } 972 ··· 978 codeChallenge: string, 979 scopes: string[], 980 me: string | null, 981 ): Response { 982 // Load app metadata if pre-registered 983 const appData = db ··· 1296 <input type="hidden" name="state" value="${state}" /> 1297 <input type="hidden" name="code_challenge" value="${codeChallenge}" /> 1298 ${me ? `<input type="hidden" name="me" value="${me}" />` : ""} 1299 <!-- Always include profile scope as it's required --> 1300 <input type="hidden" name="scope" value="profile" /> 1301 ··· 1316 // POST /auth/authorize - Consent form submission 1317 export async function authorizePost(req: Request): Promise<Response> { 1318 const contentType = req.headers.get("Content-Type"); 1319 - 1320 // Parse the request body 1321 let body: Record<string, string>; 1322 let formData: FormData; ··· 1334 } 1335 1336 const grantType = body.grant_type; 1337 - 1338 // If grant_type is present, this is a token exchange request (IndieAuth profile scope only) 1339 if (grantType === "authorization_code") { 1340 // Create a mock request for token() function 1341 const mockReq = new Request(req.url, { 1342 method: "POST", 1343 headers: req.headers, 1344 - body: contentType?.includes("application/x-www-form-urlencoded") 1345 ? new URLSearchParams(body).toString() 1346 : JSON.stringify(body), 1347 }); ··· 1361 const state = body.state; 1362 const codeChallenge = body.code_challenge; 1363 const me = body.me || null; 1364 1365 if (!rawClientId || !rawRedirectUri || !state || !codeChallenge) { 1366 return new Response("Missing required parameters", { status: 400 }); ··· 1373 clientId = canonicalizeURL(rawClientId); 1374 redirectUri = canonicalizeURL(rawRedirectUri); 1375 } catch { 1376 - return new Response("Invalid client_id or redirect_uri URL format", { status: 400 }); 1377 } 1378 1379 if (action === "deny") { ··· 1392 1393 // Create authorization code 1394 const code = crypto.randomBytes(32).toString("base64url"); 1395 - const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds 1396 1397 db.query( 1398 - "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 1399 ).run( 1400 code, 1401 user.userId, ··· 1405 codeChallenge, 1406 expiresAt, 1407 me, 1408 ); 1409 1410 // Store or update permission grant ··· 1487 let redirect_uri: string | undefined; 1488 try { 1489 client_id = raw_client_id ? canonicalizeURL(raw_client_id) : undefined; 1490 - redirect_uri = raw_redirect_uri ? canonicalizeURL(raw_redirect_uri) : undefined; 1491 } catch { 1492 return Response.json( 1493 { ··· 1502 return Response.json( 1503 { 1504 error: "unsupported_grant_type", 1505 - error_description: "Only authorization_code and refresh_token grant types are supported", 1506 }, 1507 { status: 400 }, 1508 ); ··· 1577 const expiresAt = now + expiresIn; 1578 1579 // 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); 1583 1584 // Get user profile for me value 1585 const user = db ··· 1614 headers: { 1615 "Content-Type": "application/json", 1616 "Cache-Control": "no-store", 1617 - "Pragma": "no-cache", 1618 }, 1619 }, 1620 ); ··· 1670 } 1671 } 1672 1673 - if (!code || !client_id || !redirect_uri) { 1674 - console.error("Token endpoint: missing parameters", { 1675 code: !!code, 1676 client_id: !!client_id, 1677 - redirect_uri: !!redirect_uri, 1678 }); 1679 return Response.json( 1680 { 1681 error: "invalid_request", 1682 - error_description: "Missing required parameters", 1683 }, 1684 { status: 400 }, 1685 ); ··· 1699 // Look up authorization code 1700 const authcode = db 1701 .query( 1702 - "SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me FROM authcodes WHERE code = ?", 1703 ) 1704 .get(code) as 1705 | { ··· 1711 expires_at: number; 1712 used: number; 1713 me: string | null; 1714 } 1715 | undefined; 1716 ··· 1727 1728 // Check if already used 1729 if (authcode.used) { 1730 - console.error("Token endpoint: authorization code already used", { code }); 1731 return Response.json( 1732 { 1733 error: "invalid_grant", ··· 1740 // Check if expired 1741 const now = Math.floor(Date.now() / 1000); 1742 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 }); 1744 return Response.json( 1745 { 1746 error: "invalid_grant", ··· 1752 1753 // Verify client_id matches 1754 if (authcode.client_id !== client_id) { 1755 - console.error("Token endpoint: client_id mismatch", { stored: authcode.client_id, received: client_id }); 1756 return Response.json( 1757 { 1758 error: "invalid_grant", ··· 1762 ); 1763 } 1764 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 }); 1768 return Response.json( 1769 { 1770 error: "invalid_grant", ··· 1776 1777 // Verify PKCE code_verifier (required for all clients per IndieAuth spec) 1778 if (!verifyPKCE(code_verifier, authcode.code_challenge)) { 1779 - console.error("Token endpoint: PKCE verification failed", { code_verifier, code_challenge: authcode.code_challenge }); 1780 return Response.json( 1781 { 1782 error: "invalid_grant", ··· 1839 1840 // Validate that the user controls the requested me parameter 1841 if (authcode.me && authcode.me !== meValue) { 1842 - console.error("Token endpoint: me mismatch", { requested: authcode.me, actual: meValue }); 1843 return Response.json( 1844 { 1845 error: "invalid_grant", 1846 - error_description: "The requested identity does not match the user's verified domain", 1847 }, 1848 { status: 400 }, 1849 ); 1850 } 1851 1852 const origin = process.env.ORIGIN || "http://localhost:3000"; 1853 - 1854 // Generate access token 1855 const accessToken = crypto.randomBytes(32).toString("base64url"); 1856 const expiresIn = 3600; // 1 hour ··· 1864 // Store token in database with refresh token 1865 db.query( 1866 "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); 1868 1869 const response: Record<string, unknown> = { 1870 access_token: accessToken, ··· 1882 response.role = permission.role; 1883 } 1884 1885 - console.log("Token endpoint: success", { me: meValue, scopes: scopes.join(" ") }); 1886 1887 return Response.json(response, { 1888 headers: { 1889 "Content-Type": "application/json", 1890 "Cache-Control": "no-store", 1891 - "Pragma": "no-cache", 1892 }, 1893 }); 1894 } catch (error) { ··· 2052 try { 2053 // Get access token from Authorization header 2054 const authHeader = req.headers.get("Authorization"); 2055 - 2056 if (!authHeader || !authHeader.startsWith("Bearer ")) { 2057 return Response.json( 2058 { ··· 2110 // Parse scopes 2111 const scopes = tokenData.scope.split(" "); 2112 2113 - // Build response based on scopes 2114 const response: Record<string, string> = {}; 2115 2116 if (scopes.includes("profile")) { 2117 response.name = tokenData.name; 2118 - if (tokenData.photo) response.photo = tokenData.photo; 2119 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}`; 2124 } 2125 } 2126 ··· 2128 response.email = tokenData.email; 2129 } 2130 2131 - // Return empty object if no profile/email scopes 2132 - if (Object.keys(response).length === 0) { 2133 return Response.json( 2134 { 2135 error: "insufficient_scope",
··· 1 import crypto from "crypto"; 2 import { db } from "../db"; 3 + import { signIDToken } from "../oidc"; 4 5 interface SessionUser { 6 username: string; ··· 128 } 129 130 // Validate profile URL per IndieAuth spec 131 + export function validateProfileURL(urlString: string): { 132 + valid: boolean; 133 + error?: string; 134 + canonicalUrl?: string; 135 + } { 136 let url: URL; 137 try { 138 url = new URL(urlString); ··· 157 158 // MUST NOT contain username/password 159 if (url.username || url.password) { 160 + return { 161 + valid: false, 162 + error: "Profile URL must not contain username or password", 163 + }; 164 } 165 166 // MUST NOT contain ports ··· 172 const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; 173 const ipv6Regex = /^\[?[0-9a-fA-F:]+\]?$/; 174 if (ipv4Regex.test(url.hostname) || ipv6Regex.test(url.hostname)) { 175 + return { 176 + valid: false, 177 + error: "Profile URL must use domain names, not IP addresses", 178 + }; 179 } 180 181 // MUST NOT contain single-dot or double-dot path segments 182 const pathSegments = url.pathname.split("/"); 183 if (pathSegments.includes(".") || pathSegments.includes("..")) { 184 + return { 185 + valid: false, 186 + error: "Profile URL must not contain . or .. path segments", 187 + }; 188 } 189 190 return { valid: true, canonicalUrl: canonicalizeURL(urlString) }; 191 } 192 193 // Validate client URL per IndieAuth spec 194 + function validateClientURL(urlString: string): { 195 + valid: boolean; 196 + error?: string; 197 + canonicalUrl?: string; 198 + } { 199 let url: URL; 200 try { 201 url = new URL(urlString); ··· 220 221 // MUST NOT contain username/password 222 if (url.username || url.password) { 223 + return { 224 + valid: false, 225 + error: "Client URL must not contain username or password", 226 + }; 227 } 228 229 // MUST NOT contain single-dot or double-dot path segments 230 const pathSegments = url.pathname.split("/"); 231 if (pathSegments.includes(".") || pathSegments.includes("..")) { 232 + return { 233 + valid: false, 234 + error: "Client URL must not contain . or .. path segments", 235 + }; 236 } 237 238 // MAY use loopback interface, but not other IP addresses ··· 241 if (ipv4Regex.test(url.hostname)) { 242 // Allow 127.0.0.1 (loopback), reject others 243 if (!url.hostname.startsWith("127.")) { 244 + return { 245 + valid: false, 246 + error: 247 + "Client URL must use domain names, not IP addresses (except loopback)", 248 + }; 249 } 250 } else if (ipv6Regex.test(url.hostname)) { 251 // Allow ::1 (loopback), reject others 252 const ipv6Match = url.hostname.match(ipv6Regex); 253 if (ipv6Match && ipv6Match[1] !== "::1") { 254 + return { 255 + valid: false, 256 + error: 257 + "Client URL must use domain names, not IP addresses (except loopback)", 258 + }; 259 } 260 } 261 ··· 266 function isLoopbackURL(urlString: string): boolean { 267 try { 268 const url = new URL(urlString); 269 + return ( 270 + url.hostname === "localhost" || 271 + url.hostname === "127.0.0.1" || 272 + url.hostname === "[::1]" || 273 + url.hostname.startsWith("127.") 274 + ); 275 } catch { 276 return false; 277 } ··· 291 }> { 292 // MUST NOT fetch loopback addresses (security requirement) 293 if (isLoopbackURL(clientId)) { 294 + return { 295 + success: false, 296 + error: "Cannot fetch metadata from loopback addresses", 297 + }; 298 } 299 300 try { ··· 313 clearTimeout(timeoutId); 314 315 if (!response.ok) { 316 + return { 317 + success: false, 318 + error: `Failed to fetch client metadata: HTTP ${response.status}`, 319 + }; 320 } 321 322 const contentType = response.headers.get("content-type") || ""; ··· 327 328 // Verify client_id matches 329 if (metadata.client_id && metadata.client_id !== clientId) { 330 + return { 331 + success: false, 332 + error: "client_id in metadata does not match URL", 333 + }; 334 } 335 336 return { success: true, metadata }; ··· 341 const html = await response.text(); 342 343 // Extract redirect URIs from link tags 344 + const redirectUriRegex = 345 + /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 346 const redirectUris: string[] = []; 347 let match: RegExpExecArray | null; 348 ··· 351 } 352 353 // Also try reverse order (href before rel) 354 + const redirectUriRegex2 = 355 + /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi; 356 while ((match = redirectUriRegex2.exec(html)) !== null) { 357 if (!redirectUris.includes(match[1])) { 358 redirectUris.push(match[1]); ··· 369 }; 370 } 371 372 + return { 373 + success: false, 374 + error: "No client metadata or redirect_uri links found in HTML", 375 + }; 376 } 377 378 return { success: false, error: "Unsupported content type" }; ··· 381 if (error.name === "AbortError") { 382 return { success: false, error: "Timeout fetching client metadata" }; 383 } 384 + return { 385 + success: false, 386 + error: `Failed to fetch client metadata: ${error.message}`, 387 + }; 388 } 389 return { success: false, error: "Failed to fetch client metadata" }; 390 } 391 } 392 393 // Verify domain has rel="me" link back to user profile 394 + export async function verifyDomain( 395 + domainUrl: string, 396 + indikoProfileUrl: string, 397 + ): Promise<{ 398 success: boolean; 399 error?: string; 400 }> { ··· 416 417 if (!response.ok) { 418 const errorBody = await response.text(); 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 + }; 431 } 432 433 const html = await response.text(); ··· 476 477 // Check if any rel="me" link matches the indiko profile URL 478 const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl); 479 + const hasRelMe = relMeLinks.some((link) => { 480 try { 481 const normalizedLink = canonicalizeURL(link); 482 return normalizedLink === normalizedIndikoUrl; ··· 486 }); 487 488 if (!hasRelMe) { 489 + console.error( 490 + `[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, 491 + { 492 + foundLinks: relMeLinks, 493 + normalizedTarget: normalizedIndikoUrl, 494 + }, 495 + ); 496 return { 497 success: false, 498 error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`, ··· 506 console.error(`[verifyDomain] Timeout verifying ${domainUrl}`); 507 return { success: false, error: "Timeout verifying domain" }; 508 } 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 + }; 520 } 521 + console.error( 522 + `[verifyDomain] Unknown error verifying ${domainUrl}:`, 523 + error, 524 + ); 525 return { success: false, error: "Failed to verify domain" }; 526 } 527 } ··· 532 redirectUri: string, 533 ): Promise<{ 534 error?: string; 535 + app?: { 536 + name: string | null; 537 + redirect_uris: string; 538 + logo_url?: string | null; 539 + }; 540 }> { 541 const existing = db 542 .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") ··· 629 630 // Fetch the newly created app 631 const newApp = db 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 + }; 640 641 return { app: newApp }; 642 } ··· 664 const codeChallengeMethod = params.get("code_challenge_method"); 665 const scope = params.get("scope") || "profile"; 666 const me = params.get("me"); 667 + const nonce = params.get("nonce"); // OIDC nonce parameter 668 669 if (responseType !== "code") { 670 return new Response("Unsupported response_type", { status: 400 }); ··· 1019 if (hasAllScopes) { 1020 // Auto-approve - create auth code and redirect 1021 const code = crypto.randomBytes(32).toString("base64url"); 1022 + const now = Math.floor(Date.now() / 1000); 1023 + const expiresAt = now + 60; // 60 seconds 1024 1025 db.query( 1026 + "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me, nonce, auth_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 1027 ).run( 1028 code, 1029 user.userId, ··· 1033 codeChallenge, 1034 expiresAt, 1035 me, 1036 + nonce, 1037 + now, // auth_time - user already authenticated 1038 ); 1039 1040 // Update permission last_used ··· 1043 ).run(Math.floor(Date.now() / 1000), user.userId, clientId); 1044 1045 const origin = process.env.ORIGIN || "http://localhost:3000"; 1046 + return Response.redirect( 1047 + `${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`, 1048 + ); 1049 } 1050 } 1051 ··· 1058 codeChallenge, 1059 requestedScopes, 1060 me, 1061 + nonce, 1062 ); 1063 } 1064 ··· 1070 codeChallenge: string, 1071 scopes: string[], 1072 me: string | null, 1073 + nonce: string | null, 1074 ): Response { 1075 // Load app metadata if pre-registered 1076 const appData = db ··· 1389 <input type="hidden" name="state" value="${state}" /> 1390 <input type="hidden" name="code_challenge" value="${codeChallenge}" /> 1391 ${me ? `<input type="hidden" name="me" value="${me}" />` : ""} 1392 + ${nonce ? `<input type="hidden" name="nonce" value="${nonce}" />` : ""} 1393 <!-- Always include profile scope as it's required --> 1394 <input type="hidden" name="scope" value="profile" /> 1395 ··· 1410 // POST /auth/authorize - Consent form submission 1411 export async function authorizePost(req: Request): Promise<Response> { 1412 const contentType = req.headers.get("Content-Type"); 1413 + 1414 // Parse the request body 1415 let body: Record<string, string>; 1416 let formData: FormData; ··· 1428 } 1429 1430 const grantType = body.grant_type; 1431 + 1432 // If grant_type is present, this is a token exchange request (IndieAuth profile scope only) 1433 if (grantType === "authorization_code") { 1434 // Create a mock request for token() function 1435 const mockReq = new Request(req.url, { 1436 method: "POST", 1437 headers: req.headers, 1438 + body: contentType?.includes("application/x-www-form-urlencoded") 1439 ? new URLSearchParams(body).toString() 1440 : JSON.stringify(body), 1441 }); ··· 1455 const state = body.state; 1456 const codeChallenge = body.code_challenge; 1457 const me = body.me || null; 1458 + const nonce = body.nonce || null; // OIDC nonce 1459 1460 if (!rawClientId || !rawRedirectUri || !state || !codeChallenge) { 1461 return new Response("Missing required parameters", { status: 400 }); ··· 1468 clientId = canonicalizeURL(rawClientId); 1469 redirectUri = canonicalizeURL(rawRedirectUri); 1470 } catch { 1471 + return new Response("Invalid client_id or redirect_uri URL format", { 1472 + status: 400, 1473 + }); 1474 } 1475 1476 if (action === "deny") { ··· 1489 1490 // Create authorization code 1491 const code = crypto.randomBytes(32).toString("base64url"); 1492 + const now = Math.floor(Date.now() / 1000); 1493 + const expiresAt = now + 60; // 60 seconds 1494 1495 db.query( 1496 + "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me, nonce, auth_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 1497 ).run( 1498 code, 1499 user.userId, ··· 1503 codeChallenge, 1504 expiresAt, 1505 me, 1506 + nonce, 1507 + now, // auth_time 1508 ); 1509 1510 // Store or update permission grant ··· 1587 let redirect_uri: string | undefined; 1588 try { 1589 client_id = raw_client_id ? canonicalizeURL(raw_client_id) : undefined; 1590 + redirect_uri = raw_redirect_uri 1591 + ? canonicalizeURL(raw_redirect_uri) 1592 + : undefined; 1593 } catch { 1594 return Response.json( 1595 { ··· 1604 return Response.json( 1605 { 1606 error: "unsupported_grant_type", 1607 + error_description: 1608 + "Only authorization_code and refresh_token grant types are supported", 1609 }, 1610 { status: 400 }, 1611 ); ··· 1680 const expiresAt = now + expiresIn; 1681 1682 // Update token (rotate access token, keep refresh token) 1683 + db.query("UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?").run( 1684 + newAccessToken, 1685 + expiresAt, 1686 + tokenData.id, 1687 + ); 1688 1689 // Get user profile for me value 1690 const user = db ··· 1719 headers: { 1720 "Content-Type": "application/json", 1721 "Cache-Control": "no-store", 1722 + Pragma: "no-cache", 1723 }, 1724 }, 1725 ); ··· 1775 } 1776 } 1777 1778 + if (!code || !client_id) { 1779 + console.error("Token endpoint: missing required parameters", { 1780 code: !!code, 1781 client_id: !!client_id, 1782 }); 1783 return Response.json( 1784 { 1785 error: "invalid_request", 1786 + error_description: "Missing required parameters (code, client_id)", 1787 }, 1788 { status: 400 }, 1789 ); ··· 1803 // Look up authorization code 1804 const authcode = db 1805 .query( 1806 + "SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me, nonce, auth_time FROM authcodes WHERE code = ?", 1807 ) 1808 .get(code) as 1809 | { ··· 1815 expires_at: number; 1816 used: number; 1817 me: string | null; 1818 + nonce: string | null; 1819 + auth_time: number | null; 1820 } 1821 | undefined; 1822 ··· 1833 1834 // Check if already used 1835 if (authcode.used) { 1836 + console.error("Token endpoint: authorization code already used", { 1837 + code, 1838 + }); 1839 return Response.json( 1840 { 1841 error: "invalid_grant", ··· 1848 // Check if expired 1849 const now = Math.floor(Date.now() / 1000); 1850 if (authcode.expires_at < now) { 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 + }); 1857 return Response.json( 1858 { 1859 error: "invalid_grant", ··· 1865 1866 // Verify client_id matches 1867 if (authcode.client_id !== client_id) { 1868 + console.error("Token endpoint: client_id mismatch", { 1869 + stored: authcode.client_id, 1870 + received: client_id, 1871 + }); 1872 return Response.json( 1873 { 1874 error: "invalid_grant", ··· 1878 ); 1879 } 1880 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 + }); 1888 return Response.json( 1889 { 1890 error: "invalid_grant", ··· 1896 1897 // Verify PKCE code_verifier (required for all clients per IndieAuth spec) 1898 if (!verifyPKCE(code_verifier, authcode.code_challenge)) { 1899 + console.error("Token endpoint: PKCE verification failed", { 1900 + code_verifier, 1901 + code_challenge: authcode.code_challenge, 1902 + }); 1903 return Response.json( 1904 { 1905 error: "invalid_grant", ··· 1962 1963 // Validate that the user controls the requested me parameter 1964 if (authcode.me && authcode.me !== meValue) { 1965 + console.error("Token endpoint: me mismatch", { 1966 + requested: authcode.me, 1967 + actual: meValue, 1968 + }); 1969 return Response.json( 1970 { 1971 error: "invalid_grant", 1972 + error_description: 1973 + "The requested identity does not match the user's verified domain", 1974 }, 1975 { status: 400 }, 1976 ); 1977 } 1978 1979 const origin = process.env.ORIGIN || "http://localhost:3000"; 1980 + 1981 // Generate access token 1982 const accessToken = crypto.randomBytes(32).toString("base64url"); 1983 const expiresIn = 3600; // 1 hour ··· 1991 // Store token in database with refresh token 1992 db.query( 1993 "INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", 1994 + ).run( 1995 + accessToken, 1996 + authcode.user_id, 1997 + client_id, 1998 + scopes.join(" "), 1999 + expiresAt, 2000 + refreshToken, 2001 + refreshExpiresAt, 2002 + ); 2003 2004 const response: Record<string, unknown> = { 2005 access_token: accessToken, ··· 2017 response.role = permission.role; 2018 } 2019 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 + }); 2069 2070 return Response.json(response, { 2071 headers: { 2072 "Content-Type": "application/json", 2073 "Cache-Control": "no-store", 2074 + Pragma: "no-cache", 2075 }, 2076 }); 2077 } catch (error) { ··· 2235 try { 2236 // Get access token from Authorization header 2237 const authHeader = req.headers.get("Authorization"); 2238 + 2239 if (!authHeader || !authHeader.startsWith("Bearer ")) { 2240 return Response.json( 2241 { ··· 2293 // Parse scopes 2294 const scopes = tokenData.scope.split(" "); 2295 2296 + // Build response based on scopes (OIDC-compliant claim names) 2297 + const origin = process.env.ORIGIN || "http://localhost:3000"; 2298 const response: Record<string, string> = {}; 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 + 2307 if (scopes.includes("profile")) { 2308 response.name = tokenData.name; 2309 + if (tokenData.photo) response.picture = tokenData.photo; // OIDC uses 'picture' 2310 if (tokenData.url) { 2311 + response.website = tokenData.url; // OIDC uses 'website' 2312 } 2313 } 2314 ··· 2316 response.email = tokenData.email; 2317 } 2318 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 2323 return Response.json( 2324 { 2325 error: "insufficient_scope",
+6 -2
src/routes/passkeys.ts
··· 1 import { 2 - type RegistrationResponseJSON, 3 generateRegistrationOptions, 4 type VerifiedRegistrationResponse, 5 verifyRegistrationResponse, 6 } from "@simplewebauthn/server"; ··· 133 } 134 135 const body = await req.json(); 136 - const { response, challenge: expectedChallenge, name } = body as { 137 response: RegistrationResponseJSON; 138 challenge: string; 139 name?: string;
··· 1 import { 2 generateRegistrationOptions, 3 + type RegistrationResponseJSON, 4 type VerifiedRegistrationResponse, 5 verifyRegistrationResponse, 6 } from "@simplewebauthn/server"; ··· 133 } 134 135 const body = await req.json(); 136 + const { 137 + response, 138 + challenge: expectedChallenge, 139 + name, 140 + } = body as { 141 response: RegistrationResponseJSON; 142 challenge: string; 143 name?: string;