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

fix: type safety and linting improvements #3

closed opened by avycado13.tngl.sh targeting main
  • Fix userId property access (user.id → user.userId) in disableUser
  • Add null coalescing for optional userId path params
  • Replace non-null assertions with fallback values
  • Use node: prefix for crypto imports
  • Fix unused error variable in catch block
  • Add missing checkLdapUser import
  • Add SessionUser.tier property and BunRequest type for userProfile
  • Fix passkey excludeCredentials id encoding
  • Add global.d.ts for Window interface augmentation
  • Format improvements throughout
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:ew7nbkvshgu6lzv6uxl25lsh/sh.tangled.repo.pull/3mclg2sw2ud22
+1092 -438
Diff #0
+10
CRUSH.md
··· 9 9 ## Architecture Patterns 10 10 11 11 ### Route Organization 12 + 12 13 - Use separate route files in `src/routes/` directory 13 14 - Export handler functions that accept `Request` and return `Response` 14 15 - Import handlers in `src/index.ts` and wire them in the `routes` object ··· 17 18 - IndieAuth/OAuth 2.0 endpoints in `src/routes/indieauth.ts` 18 19 19 20 ### Project Structure 21 + 20 22 ``` 21 23 src/ 22 24 ├── db.ts # Database setup and exports ··· 43 45 ### Database Migrations 44 46 45 47 **Migration Versioning:** 48 + 46 49 - SQLite uses `PRAGMA user_version` to track migration state 47 50 - Version starts at 0, increments by 1 for each migration 48 51 - The `bun-sqlite-migrations` package handles version tracking ··· 55 58 - Use descriptive name (e.g., `008_add_auth_tokens.sql`) 56 59 57 60 2. **Write SQL statements**: Add schema changes in the file 61 + 58 62 ```sql 59 63 -- Add new column to users table 60 64 ALTER TABLE users ADD COLUMN new_field TEXT DEFAULT ''; ··· 72 76 - Each migration increments `user_version` by 1 73 77 74 78 **Version Tracking:** 79 + 75 80 - Check current version: `sqlite3 data/indiko.db "PRAGMA user_version;"` 76 81 - The migration system compares `user_version` against migration files 77 82 - No manual version updates needed - handled by `bun-sqlite-migrations` 78 83 79 84 **Best Practices:** 85 + 80 86 - Use `ALTER TABLE` for adding columns to existing tables 81 87 - Use `CREATE TABLE IF NOT EXISTS` for new tables 82 88 - Use `DEFAULT` values when adding non-null columns ··· 84 90 - Test migrations locally before committing 85 91 86 92 ### Client-Side Code 93 + 87 94 - Extract JavaScript from HTML into separate TypeScript modules in `src/client/` 88 95 - Import client modules into HTML with `<script type="module" src="../client/file.ts"></script>` 89 96 - Bun will bundle the imports automatically ··· 91 98 - In HTML files: use paths relative to server root (e.g., `/logo.svg`, `/favicon.svg`) since Bun bundles HTML and resolves paths from server context 92 99 93 100 ### IndieAuth/OAuth 2.0 Implementation 101 + 94 102 - Full IndieAuth server supporting OAuth 2.0 with PKCE 95 103 - Authorization code flow with single-use, short-lived codes (60 seconds) 96 104 - Auto-registration of client apps on first authorization ··· 103 111 - **`me` parameter delegation**: When a client passes `me=https://example.com` in the authorization request and it matches the user's website URL, the token response returns that URL instead of the canonical `/u/{username}` URL 104 112 105 113 ### Database Schema 114 + 106 115 - **users**: username, name, email, photo, url, status, role, tier, is_admin, provisioned_via_ldap, last_ldap_verified_at 107 116 - **tier**: User access level - 'admin' (full access), 'developer' (can create apps), 'user' (can only authenticate with apps) 108 117 - **is_admin**: Legacy flag, automatically synced with tier (1 if tier='admin', 0 otherwise) ··· 117 126 - **invites**: admin-created invite codes, includes `ldap_username` for LDAP-provisioned accounts 118 127 119 128 ### WebAuthn/Passkey Settings 129 + 120 130 - **Registration**: residentKey="required", userVerification="required" 121 131 - **Authentication**: omit allowCredentials to show all passkeys (discoverable credentials) 122 132 - **Credential lookup**: credential_id stored as Buffer, compare using base64url string
+60
SPEC.md
··· 3 3 ## Overview 4 4 5 5 **indiko** is a centralized authentication and user management system for personal projects. It provides: 6 + 6 7 - Passkey-based authentication (WebAuthn) 7 8 - IndieAuth server implementation 8 9 - User profile management ··· 12 13 ## Core Concepts 13 14 14 15 ### Single Source of Truth 16 + 15 17 - Authentication via passkeys 16 18 - User profiles (name, email, picture, URL) 17 19 - Authorization with per-app scoping 18 20 - User management (admin + invite system) 19 21 20 22 ### Trust Model 23 + 21 24 - First user becomes admin 22 25 - Admin can create invite links 23 26 - Apps auto-register on first use ··· 30 33 ## Data Structures 31 34 32 35 ### Users 36 + 33 37 ``` 34 38 user:{username} -> { 35 39 credential: { ··· 49 53 ``` 50 54 51 55 ### Admin Marker 56 + 52 57 ``` 53 58 admin:user -> username // marks first/admin user 54 59 ``` 55 60 56 61 ### Sessions 62 + 57 63 ``` 58 64 session:{token} -> { 59 65 username: string, ··· 67 73 There are two types of OAuth clients in indiko: 68 74 69 75 #### Auto-registered Apps (IndieAuth) 76 + 70 77 ``` 71 78 app:{client_id} -> { 72 79 client_id: string, // e.g. "https://blog.kierank.dev" (any valid URL) ··· 80 87 ``` 81 88 82 89 **Features:** 90 + 83 91 - Client ID is any valid URL per IndieAuth spec 84 92 - No client secret (public client) 85 93 - MUST use PKCE (code_verifier) ··· 88 96 - Cannot use role-based access control 89 97 90 98 #### Pre-registered Apps (OAuth 2.0 with secrets) 99 + 91 100 ``` 92 101 app:{client_id} -> { 93 102 client_id: string, // e.g. "ikc_xxxxxxxxxxxxxxxxxxxxx" (generated ID) ··· 105 114 ``` 106 115 107 116 **Features:** 117 + 108 118 - Client ID format: `ikc_` + 21 character nanoid 109 119 - Client secret format: `iks_` + 43 character nanoid (shown once on creation) 110 120 - MUST use PKCE (code_verifier) AND client_secret ··· 113 123 - Created via admin interface 114 124 115 125 ### User Permissions (Per-App) 126 + 116 127 ``` 117 128 permission:{user_id}:{client_id} -> { 118 129 scopes: string[], // e.g. ["profile", "email"] ··· 123 134 ``` 124 135 125 136 ### Authorization Codes (Short-lived) 137 + 126 138 ``` 127 139 authcode:{code} -> { 128 140 username: string, ··· 138 150 ``` 139 151 140 152 ### Invites 153 + 141 154 ``` 142 155 invite:{code} -> { 143 156 code: string, ··· 150 163 ``` 151 164 152 165 ### Challenges (WebAuthn) 166 + 153 167 ``` 154 168 challenge:{challenge} -> { 155 169 username: string, ··· 170 184 ### Authentication (WebAuthn/Passkey) 171 185 172 186 #### `GET /login` 187 + 173 188 - Login/registration page 174 189 - Shows passkey auth interface 175 190 - First user: admin registration flow 176 191 - With `?invite=CODE`: invite-based registration 177 192 178 193 #### `GET /auth/can-register` 194 + 179 195 - Check if open registration allowed 180 196 - Returns `{ canRegister: boolean }` 181 197 182 198 #### `POST /auth/register/options` 199 + 183 200 - Generate WebAuthn registration options 184 201 - Body: `{ username: string, inviteCode?: string }` 185 202 - Validates invite code if not first user 186 203 - Returns registration options 187 204 188 205 #### `POST /auth/register/verify` 206 + 189 207 - Verify WebAuthn registration response 190 208 - Body: `{ username: string, response: RegistrationResponseJSON, inviteCode?: string }` 191 209 - Creates user, stores credential ··· 193 211 - Returns `{ token: string, username: string }` 194 212 195 213 #### `POST /auth/login/options` 214 + 196 215 - Generate WebAuthn authentication options 197 216 - Body: `{ username: string }` 198 217 - Returns authentication options 199 218 200 219 #### `POST /auth/login/verify` 220 + 201 221 - Verify WebAuthn authentication response 202 222 - Body: `{ username: string, response: AuthenticationResponseJSON }` 203 223 - Creates session 204 224 - Returns `{ token: string, username: string }` 205 225 206 226 #### `POST /auth/logout` 227 + 207 228 - Clear session 208 229 - Requires: `Authorization: Bearer {token}` 209 230 - Returns `{ success: true }` ··· 211 232 ### IndieAuth Endpoints 212 233 213 234 #### `GET /auth/authorize` 235 + 214 236 Authorization request from client app 215 237 216 238 **Query Parameters:** 239 + 217 240 - `response_type=code` (required) 218 241 - `client_id` (required) - App's URL 219 242 - `redirect_uri` (required) - Callback URL ··· 224 247 - `me` (optional) - User's URL (hint) 225 248 226 249 **Flow:** 250 + 227 251 1. Validate parameters 228 252 2. Auto-register app if not exists 229 253 3. If no session → redirect to `/login` ··· 233 257 - If no → show consent screen 234 258 235 259 **Response:** 260 + 236 261 - HTML consent screen 237 262 - Shows: app name, requested scopes 238 263 - Buttons: "Allow" / "Deny" 239 264 240 265 #### `POST /auth/authorize` 266 + 241 267 Consent form submission (CSRF protected) 242 268 243 269 **Body:** 270 + 244 271 - `client_id` (required) 245 272 - `redirect_uri` (required) 246 273 - `state` (required) ··· 249 276 - `action` (required) - "allow" | "deny" 250 277 251 278 **Flow:** 279 + 252 280 1. Validate CSRF token 253 281 2. Validate session 254 282 3. If denied → redirect with error ··· 259 287 - Redirect to redirect_uri with code & state 260 288 261 289 **Success Response:** 290 + 262 291 ``` 263 292 HTTP/1.1 302 Found 264 293 Location: {redirect_uri}?code={authcode}&state={state} 265 294 ``` 266 295 267 296 **Error Response:** 297 + 268 298 ``` 269 299 HTTP/1.1 302 Found 270 300 Location: {redirect_uri}?error=access_denied&state={state} 271 301 ``` 272 302 273 303 #### `POST /auth/token` 304 + 274 305 Exchange authorization code for user identity (NOT CSRF protected) 275 306 276 307 **Headers:** 308 + 277 309 - `Content-Type: application/json` 278 310 279 311 **Body:** 312 + 280 313 ```json 281 314 { 282 315 "grant_type": "authorization_code", ··· 288 321 ``` 289 322 290 323 **Flow:** 324 + 291 325 1. Validate authorization code exists 292 326 2. Verify code not expired 293 327 3. Verify code not already used ··· 298 332 8. Return user identity + profile 299 333 300 334 **Success Response:** 335 + 301 336 ```json 302 337 { 303 338 "me": "https://indiko.yourdomain.com/u/kieran", ··· 311 346 ``` 312 347 313 348 **Error Response:** 349 + 314 350 ```json 315 351 { 316 352 "error": "invalid_grant", ··· 319 355 ``` 320 356 321 357 #### `GET /auth/userinfo` (Optional) 358 + 322 359 Get current user profile with bearer token 323 360 324 361 **Headers:** 362 + 325 363 - `Authorization: Bearer {access_token}` 326 364 327 365 **Response:** 366 + 328 367 ```json 329 368 { 330 369 "sub": "https://indiko.yourdomain.com/u/kieran", ··· 338 377 ### User Profile & Settings 339 378 340 379 #### `GET /settings` 380 + 341 381 User settings page (requires session) 342 382 343 383 **Shows:** 384 + 344 385 - Profile form (name, email, photo, URL) 345 386 - Connected apps list 346 387 - Revoke access buttons 347 388 - (Admin only) Invite generation 348 389 349 390 #### `POST /settings/profile` 391 + 350 392 Update user profile 351 393 352 394 **Body:** 395 + 353 396 ```json 354 397 { 355 398 "name": "Kieran Klukas", ··· 360 403 ``` 361 404 362 405 **Response:** 406 + 363 407 ```json 364 408 { 365 409 "success": true, ··· 368 412 ``` 369 413 370 414 #### `POST /settings/apps/:client_id/revoke` 415 + 371 416 Revoke app access 372 417 373 418 **Response:** 419 + 374 420 ```json 375 421 { 376 422 "success": true ··· 378 424 ``` 379 425 380 426 #### `GET /u/:username` 427 + 381 428 Public user profile page (h-card) 382 429 383 430 **Response:** 384 431 HTML page with microformats h-card: 432 + 385 433 ```html 386 434 <div class="h-card"> 387 435 <img class="u-photo" src="..."> ··· 393 441 ### Admin Endpoints 394 442 395 443 #### `POST /api/invites/create` 444 + 396 445 Create invite link (admin only) 397 446 398 447 **Headers:** 448 + 399 449 - `Authorization: Bearer {token}` 400 450 401 451 **Response:** 452 + 402 453 ```json 403 454 { 404 455 "inviteCode": "abc123xyz" ··· 410 461 ### Dashboard 411 462 412 463 #### `GET /` 464 + 413 465 Main dashboard (requires session) 414 466 415 467 **Shows:** 468 + 416 469 - User info 417 470 - Test API button 418 471 - (Admin only) Admin controls section ··· 420 473 - Invite display 421 474 422 475 #### `GET /api/hello` 476 + 423 477 Test endpoint (requires session) 424 478 425 479 **Headers:** 480 + 426 481 - `Authorization: Bearer {token}` 427 482 428 483 **Response:** 484 + 429 485 ```json 430 486 { 431 487 "message": "Hello kieran! You're authenticated with passkeys.", ··· 437 493 ## Session Behavior 438 494 439 495 ### Single Sign-On 496 + 440 497 - Once logged into indiko (valid session), subsequent app authorization requests: 441 498 - Skip passkey authentication 442 499 - Show consent screen directly ··· 445 502 - Passkey required only when session expires 446 503 447 504 ### Security 505 + 448 506 - PKCE required for all authorization flows 449 507 - Authorization codes: 450 508 - Single-use only ··· 455 513 ## Client Integration Example 456 514 457 515 ### 1. Initiate Authorization 516 + 458 517 ```javascript 459 518 const params = new URLSearchParams({ 460 519 response_type: 'code', ··· 470 529 ``` 471 530 472 531 ### 2. Handle Callback 532 + 473 533 ```javascript 474 534 // At https://blog.kierank.dev/auth/callback?code=...&state=... 475 535 const code = new URLSearchParams(window.location.search).get('code');
+34
biome.json
··· 1 + { 2 + "$schema": "https://biomejs.dev/schemas/2.3.9/schema.json", 3 + "vcs": { 4 + "enabled": true, 5 + "clientKind": "git", 6 + "useIgnoreFile": true 7 + }, 8 + "files": { 9 + "ignoreUnknown": false 10 + }, 11 + "formatter": { 12 + "enabled": true, 13 + "indentStyle": "tab" 14 + }, 15 + "linter": { 16 + "enabled": true, 17 + "rules": { 18 + "recommended": true 19 + } 20 + }, 21 + "javascript": { 22 + "formatter": { 23 + "quoteStyle": "double" 24 + } 25 + }, 26 + "assist": { 27 + "enabled": true, 28 + "actions": { 29 + "source": { 30 + "organizeImports": "on" 31 + } 32 + } 33 + } 34 + }
+2 -1
package.json
··· 6 6 "scripts": { 7 7 "dev": "bun run --hot src/index.ts", 8 8 "start": "bun run src/index.ts", 9 - "format": "bun run --bun biome check --write ." 9 + "forqmat": "bun run --bun biome check --write .", 10 + "lint": "bun run --bun biome check" 10 11 }, 11 12 "devDependencies": { 12 13 "@simplewebauthn/types": "^12.0.0",
+1 -1
scripts/audit-ldap-orphans.ts
··· 62 62 verifyUserExists: true, 63 63 }); 64 64 return !!user; 65 - } catch (error) { 65 + } catch { 66 66 // User not found or invalid credentials (expected for non-existence check) 67 67 return false; 68 68 }
+14 -26
src/client/admin-clients.ts
··· 104 104 lastUsed: number; 105 105 } 106 106 107 - interface AppPermission { 108 - username: string; 109 - name: string; 110 - scopes: string[]; 111 - grantedAt: number; 112 - lastUsed: number; 113 - } 114 - 115 107 async function loadClients() { 116 108 try { 117 109 const response = await fetch("/api/admin/clients", { ··· 191 183 .join(""); 192 184 } 193 185 194 - (window as any).toggleClient = async (clientId: string) => { 186 + window.toggleClient = async (clientId: string) => { 195 187 const card = document.querySelector( 196 188 `[data-client-id="${clientId}"]`, 197 189 ) as HTMLElement; ··· 319 311 } 320 312 }; 321 313 322 - (window as any).setUserRole = async ( 314 + window.setUserRole = async ( 323 315 clientId: string, 324 316 username: string, 325 317 role: string, ··· 348 340 } 349 341 }; 350 342 351 - (window as any).editClient = async (clientId: string) => { 343 + window.editClient = async (clientId: string) => { 352 344 try { 353 345 const response = await fetch( 354 346 `/api/admin/clients/${encodeURIComponent(clientId)}`, ··· 398 390 } 399 391 }; 400 392 401 - (window as any).deleteClient = async (clientId: string, event?: Event) => { 393 + window.deleteClient = async (clientId: string, event?: Event) => { 402 394 const btn = event?.target as HTMLButtonElement | undefined; 403 395 404 396 // Double-click confirmation pattern 405 397 if (btn?.dataset.confirmState === "pending") { 406 398 // Second click - execute delete 407 - delete btn.dataset.confirmState; 399 + btn.dataset.confirmState = undefined; 408 400 btn.disabled = true; 409 401 btn.textContent = "deleting..."; 410 402 ··· 440 432 // Reset after 3 seconds if not confirmed 441 433 setTimeout(() => { 442 434 if (btn.dataset.confirmState === "pending") { 443 - delete btn.dataset.confirmState; 435 + btn.dataset.confirmState = undefined; 444 436 btn.textContent = originalText; 445 437 } 446 438 }, 3000); ··· 479 471 redirectUrisList.appendChild(newItem); 480 472 }); 481 473 482 - (window as any).removeRedirectUri = (btn: HTMLButtonElement) => { 474 + window.removeRedirectUri = (btn: HTMLButtonElement) => { 483 475 const items = redirectUrisList.querySelectorAll(".redirect-uri-item"); 484 476 if (items.length > 1) { 485 477 btn.parentElement?.remove(); ··· 569 561 // If creating a new client, show the credentials in modal 570 562 if (!isEdit) { 571 563 const result = await response.json(); 572 - if ( 573 - result.client && 574 - result.client.clientId && 575 - result.client.clientSecret 576 - ) { 564 + if (result.client?.clientId && result.client.clientSecret) { 577 565 const secretModal = document.getElementById( 578 566 "secretModal", 579 567 ) as HTMLElement; ··· 604 592 } 605 593 }); 606 594 607 - (window as any).regenerateSecret = async (clientId: string, event?: Event) => { 595 + window.regenerateSecret = async (clientId: string, event?: Event) => { 608 596 const btn = event?.target as HTMLButtonElement | undefined; 609 597 610 598 // Double-click confirmation pattern (same as delete) 611 599 if (btn?.dataset.confirmState === "pending") { 612 600 // Second click - execute regenerate 613 - delete btn.dataset.confirmState; 601 + btn.dataset.confirmState = undefined; 614 602 btn.disabled = true; 615 603 btn.textContent = "regenerating..."; 616 604 ··· 667 655 // Reset after 3 seconds if not confirmed 668 656 setTimeout(() => { 669 657 if (btn.dataset.confirmState === "pending") { 670 - delete btn.dataset.confirmState; 658 + btn.dataset.confirmState = undefined; 671 659 btn.textContent = originalText; 672 660 } 673 661 }, 3000); ··· 675 663 } 676 664 }; 677 665 678 - (window as any).revokeUserPermission = async ( 666 + window.revokeUserPermission = async ( 679 667 clientId: string, 680 668 username: string, 681 669 event?: Event, ··· 685 673 // Double-click confirmation pattern 686 674 if (btn?.dataset.confirmState === "pending") { 687 675 // Second click - execute revoke 688 - delete btn.dataset.confirmState; 676 + btn.dataset.confirmState = undefined; 689 677 btn.disabled = true; 690 678 btn.textContent = "revoking..."; 691 679 ··· 736 724 // Reset after 3 seconds if not confirmed 737 725 setTimeout(() => { 738 726 if (btn.dataset.confirmState === "pending") { 739 - delete btn.dataset.confirmState; 727 + btn.dataset.confirmState = undefined; 740 728 btn.textContent = originalText; 741 729 } 742 730 }, 3000);
+13 -13
src/client/admin-invites.ts
··· 60 60 } catch (error) { 61 61 console.error("Auth check failed:", error); 62 62 footer.textContent = "error loading user info"; 63 - usersList.innerHTML = '<div class="error">Failed to load users</div>'; 63 + invitesList.innerHTML = '<div class="error">Failed to load users</div>'; 64 64 } 65 65 } 66 66 ··· 193 193 ) as HTMLSelectElement; 194 194 195 195 let role = ""; 196 - if (roleSelect && roleSelect.value) { 196 + if (roleSelect?.value) { 197 197 role = roleSelect.value; 198 198 } 199 199 ··· 266 266 } 267 267 268 268 // Expose functions to global scope for HTML onclick handlers 269 - (window as any).submitCreateInvite = submitCreateInvite; 270 - (window as any).closeCreateInviteModal = closeCreateInviteModal; 269 + window.submitCreateInvite = submitCreateInvite; 270 + window.closeCreateInviteModal = closeCreateInviteModal; 271 271 272 272 async function loadInvites() { 273 273 try { ··· 408 408 document.addEventListener("keydown", (e) => { 409 409 if (e.key === "Escape") { 410 410 closeCreateInviteModal(); 411 - closeEditInviteModal(); 411 + window.closeEditInviteModal(); 412 412 } 413 413 }); 414 414 ··· 421 421 422 422 document.getElementById("editInviteModal")?.addEventListener("click", (e) => { 423 423 if (e.target === e.currentTarget) { 424 - closeEditInviteModal(); 424 + window.closeEditInviteModal(); 425 425 } 426 426 }); 427 427 428 428 let currentEditInviteId: number | null = null; 429 429 430 430 // Make editInvite globally available for onclick handler 431 - (window as any).editInvite = async (inviteId: number) => { 431 + window.editInvite = async (inviteId: number) => { 432 432 try { 433 433 const response = await fetch("/api/invites", { 434 434 headers: { ··· 488 488 } 489 489 }; 490 490 491 - (window as any).submitEditInvite = async () => { 491 + window.submitEditInvite = async () => { 492 492 if (currentEditInviteId === null) return; 493 493 494 494 const maxUsesInput = document.getElementById( ··· 532 532 } 533 533 534 534 await loadInvites(); 535 - closeEditInviteModal(); 535 + window.closeEditInviteModal(); 536 536 } catch (error) { 537 537 console.error("Failed to update invite:", error); 538 538 alert("Failed to update invite"); ··· 542 542 } 543 543 }; 544 544 545 - (window as any).closeEditInviteModal = () => { 545 + window.closeEditInviteModal = () => { 546 546 const modal = document.getElementById("editInviteModal"); 547 547 if (modal) { 548 548 modal.style.display = "none"; ··· 557 557 } 558 558 }; 559 559 560 - (window as any).deleteInvite = async (inviteId: number, event?: Event) => { 560 + window.deleteInvite = async (inviteId: number, event?: Event) => { 561 561 const btn = event?.target as HTMLButtonElement | undefined; 562 562 563 563 // Double-click confirmation pattern 564 564 if (btn?.dataset.confirmState === "pending") { 565 565 // Second click - execute delete 566 - delete btn.dataset.confirmState; 566 + btn.dataset.confirmState = undefined; 567 567 btn.textContent = "deleting..."; 568 568 btn.disabled = true; 569 569 ··· 596 596 // Reset after 3 seconds if not confirmed 597 597 setTimeout(() => { 598 598 if (btn.dataset.confirmState === "pending") { 599 - delete btn.dataset.confirmState; 599 + btn.dataset.confirmState = undefined; 600 600 btn.textContent = originalText; 601 601 } 602 602 }, 3000);
+3 -3
src/client/apps.ts
··· 73 73 .join(""); 74 74 } 75 75 76 - (window as any).revokeApp = async (clientId: string, event?: Event) => { 76 + window.revokeApp = async (clientId: string, event?: Event) => { 77 77 const btn = event?.target as HTMLButtonElement | undefined; 78 78 79 79 // Double-click confirmation pattern 80 80 if (btn?.dataset.confirmState === "pending") { 81 81 // Second click - execute revoke 82 - delete btn.dataset.confirmState; 82 + btn.dataset.confirmState = undefined; 83 83 btn.disabled = true; 84 84 btn.textContent = "revoking..."; 85 85 ··· 127 127 // Reset after 3 seconds if not confirmed 128 128 setTimeout(() => { 129 129 if (btn.dataset.confirmState === "pending") { 130 - delete btn.dataset.confirmState; 130 + btn.dataset.confirmState = undefined; 131 131 btn.textContent = originalText; 132 132 } 133 133 }, 3000);
+8 -6
src/client/docs.ts
··· 36 36 /&lt;(\/?)([\w-]+)([\s\S]*?)&gt;/g, 37 37 (_match, slash, tag, attrs) => { 38 38 let result = `&lt;${slash}<span class="html-tag">${tag}</span>`; 39 + let replaced_attrs = attrs ?? ""; 39 40 40 - if (attrs) { 41 - attrs = attrs.replace( 41 + if (replaced_attrs) { 42 + replaced_attrs = replaced_attrs.replace( 42 43 /([\w-]+)="([^"]*)"/g, 43 44 '<span class="html-attr">$1</span>="<span class="html-string">$2</span>"', 44 45 ); 45 - attrs = attrs.replace( 46 - /(?<=\s)([\w-]+)(?=\s|$)/g, 47 - '<span class="html-attr">$1</span>', 46 + 47 + replaced_attrs = replaced_attrs.replace( 48 + /(\s)([\w-]+)(?=\s|$)/g, 49 + '$1<span class="html-attr">$2</span>', 48 50 ); 49 51 } 50 52 51 - result += attrs + "&gt;"; 53 + result += `${replaced_attrs}&gt;`; 52 54 return result; 53 55 }, 54 56 );
+31 -16
src/client/index.ts
··· 1 - import { 2 - startRegistration, 3 - } from "@simplewebauthn/browser"; 1 + import { startRegistration } from "@simplewebauthn/browser"; 4 2 5 3 const token = localStorage.getItem("indiko_session"); 6 4 const footer = document.getElementById("footer") as HTMLElement; ··· 8 6 const subtitle = document.getElementById("subtitle") as HTMLElement; 9 7 const recentApps = document.getElementById("recentApps") as HTMLElement; 10 8 const passkeysList = document.getElementById("passkeysList") as HTMLElement; 11 - const addPasskeyBtn = document.getElementById("addPasskeyBtn") as HTMLButtonElement; 9 + const addPasskeyBtn = document.getElementById( 10 + "addPasskeyBtn", 11 + ) as HTMLButtonElement; 12 12 const toast = document.getElementById("toast") as HTMLElement; 13 13 14 14 // Profile form elements ··· 320 320 const passkeys = data.passkeys as Passkey[]; 321 321 322 322 if (passkeys.length === 0) { 323 - passkeysList.innerHTML = '<div class="empty">No passkeys registered</div>'; 323 + passkeysList.innerHTML = 324 + '<div class="empty">No passkeys registered</div>'; 324 325 return; 325 326 } 326 327 327 328 passkeysList.innerHTML = passkeys 328 329 .map((passkey) => { 329 - const createdDate = new Date(passkey.created_at * 1000).toLocaleDateString(); 330 + const createdDate = new Date( 331 + passkey.created_at * 1000, 332 + ).toLocaleDateString(); 330 333 331 334 return ` 332 335 <div class="passkey-item" data-passkey-id="${passkey.id}"> ··· 336 339 </div> 337 340 <div class="passkey-actions"> 338 341 <button type="button" class="rename-passkey-btn" data-passkey-id="${passkey.id}">rename</button> 339 - ${passkeys.length > 1 ? `<button type="button" class="delete-passkey-btn" data-passkey-id="${passkey.id}">delete</button>` : ''} 342 + ${passkeys.length > 1 ? `<button type="button" class="delete-passkey-btn" data-passkey-id="${passkey.id}">delete</button>` : ""} 340 343 </div> 341 344 </div> 342 345 `; ··· 365 368 } 366 369 367 370 function showRenameForm(passkeyId: number) { 368 - const passkeyItem = document.querySelector(`[data-passkey-id="${passkeyId}"]`); 371 + const passkeyItem = document.querySelector( 372 + `[data-passkey-id="${passkeyId}"]`, 373 + ); 369 374 if (!passkeyItem) return; 370 375 371 376 const infoDiv = passkeyItem.querySelector(".passkey-info"); ··· 389 394 input.select(); 390 395 391 396 // Save button 392 - infoDiv.querySelector(".save-rename-btn")?.addEventListener("click", async () => { 393 - await renamePasskeyHandler(passkeyId, input.value); 394 - }); 397 + infoDiv 398 + .querySelector(".save-rename-btn") 399 + ?.addEventListener("click", async () => { 400 + await renamePasskeyHandler(passkeyId, input.value); 401 + }); 395 402 396 403 // Cancel button 397 - infoDiv.querySelector(".cancel-rename-btn")?.addEventListener("click", () => { 398 - loadPasskeys(); 399 - }); 404 + infoDiv 405 + .querySelector(".cancel-rename-btn") 406 + ?.addEventListener("click", () => { 407 + loadPasskeys(); 408 + }); 400 409 401 410 // Enter to save 402 411 input.addEventListener("keypress", async (e) => { ··· 443 452 } 444 453 445 454 async function deletePasskeyHandler(passkeyId: number) { 446 - if (!confirm("Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.")) { 455 + if ( 456 + !confirm( 457 + "Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.", 458 + ) 459 + ) { 447 460 return; 448 461 } 449 462 ··· 496 509 addPasskeyBtn.textContent = "verifying..."; 497 510 498 511 // Ask for a name 499 - const name = prompt("Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):"); 512 + const name = prompt( 513 + "Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):", 514 + ); 500 515 501 516 // Verify registration 502 517 const verifyRes = await fetch("/api/passkeys/add/verify", {
+1 -1
src/client/oauth-test.ts
··· 205 205 resultDiv.className = `result show ${type}`; 206 206 } 207 207 208 - function syntaxHighlightJSON(obj: any): string { 208 + function syntaxHighlightJSON(obj: unknown): string { 209 209 const json = JSON.stringify(obj, null, 2); 210 210 return json.replace( 211 211 /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,
+65 -43
src/html/admin-clients.html
··· 7 7 <title>oauth clients • admin • indiko</title> 8 8 <meta name="description" content="Manage OAuth clients and application registrations" /> 9 9 <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 10 - 10 + 11 11 <!-- Open Graph / Facebook --> 12 12 <meta property="og:type" content="website" /> 13 13 <meta property="og:title" content="OAuth Clients • Indiko Admin" /> 14 14 <meta property="og:description" content="Manage OAuth clients and application registrations" /> 15 - 15 + 16 16 <!-- Twitter --> 17 17 <meta name="twitter:card" content="summary" /> 18 18 <meta name="twitter:title" content="OAuth Clients • Indiko Admin" /> ··· 58 58 align-items: flex-start; 59 59 } 60 60 61 + footer { 62 + width: 100%; 63 + max-width: 56.25rem; 64 + padding: 1rem; 65 + text-align: center; 66 + color: var(--old-rose); 67 + font-size: 0.875rem; 68 + font-weight: 300; 69 + letter-spacing: 0.05rem; 70 + } 71 + 72 + footer a { 73 + color: var(--berry-crush); 74 + text-decoration: none; 75 + transition: color 0.2s; 76 + } 77 + 78 + footer a:hover { 79 + color: var(--rosewood); 80 + text-decoration: underline; 81 + } 82 + 61 83 .header-nav { 62 84 display: flex; 63 85 gap: 1rem; ··· 111 133 letter-spacing: -0.05rem; 112 134 } 113 135 114 - footer { 115 - width: 100%; 116 - max-width: 56.25rem; 117 - padding: 1rem; 118 - text-align: center; 119 - color: var(--old-rose); 120 - font-size: 0.875rem; 121 - font-weight: 300; 122 - letter-spacing: 0.05rem; 123 - } 124 - 125 - footer a { 126 - color: var(--berry-crush); 127 - text-decoration: none; 128 - transition: color 0.2s; 129 - } 130 - 131 - footer a:hover { 132 - color: var(--rosewood); 133 - text-decoration: underline; 134 - } 135 - 136 136 .back-link { 137 137 margin-top: 0.5rem; 138 138 font-size: 0.875rem; ··· 385 385 margin-top: 1rem; 386 386 } 387 387 388 - .btn-edit, .btn-delete, .revoke-btn { 388 + .btn-edit, 389 + .btn-delete, 390 + .revoke-btn { 389 391 padding: 0.5rem 1rem; 390 392 font-family: inherit; 391 393 font-size: 0.875rem; ··· 404 406 background: rgba(188, 141, 160, 0.3); 405 407 } 406 408 407 - .btn-delete, .revoke-btn { 409 + .btn-delete, 410 + .revoke-btn { 408 411 background: rgba(160, 70, 104, 0.2); 409 412 color: var(--lavender); 410 413 border: 2px solid var(--rosewood); 411 414 } 412 415 413 - .btn-delete:hover, .revoke-btn:hover { 416 + .btn-delete:hover, 417 + .revoke-btn:hover { 414 418 background: rgba(160, 70, 104, 0.3); 415 419 } 416 420 417 - .loading, .error, .empty { 421 + .loading, 422 + .error, 423 + .empty { 418 424 text-align: center; 419 425 padding: 2rem; 420 426 color: var(--old-rose); ··· 645 651 </div> 646 652 <div class="form-group"> 647 653 <label class="form-label" for="description">Description</label> 648 - <textarea class="form-input form-textarea" id="description" placeholder="A brief description of your application"></textarea> 654 + <textarea class="form-input form-textarea" id="description" 655 + placeholder="A brief description of your application"></textarea> 649 656 </div> 650 657 <div class="form-group"> 651 658 <label class="form-label">Redirect URIs</label> 652 659 <div id="redirectUrisList" class="redirect-uris-list"> 653 660 <div class="redirect-uri-item"> 654 - <input type="url" class="form-input redirect-uri-input" placeholder="https://example.com/auth/callback" required /> 661 + <input type="url" class="form-input redirect-uri-input" 662 + placeholder="https://example.com/auth/callback" required /> 655 663 <button type="button" class="btn-remove" onclick="removeRedirectUri(this)">remove</button> 656 664 </div> 657 665 </div> ··· 659 667 </div> 660 668 <div class="form-group"> 661 669 <label class="form-label">Available Roles (one per line)</label> 662 - <textarea class="form-input form-textarea" id="availableRoles" placeholder="admin&#10;editor&#10;viewer" style="min-height: 6rem;"></textarea> 663 - <p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Define which roles can be assigned to users for this app. Leave empty to allow free-text roles.</p> 670 + <textarea class="form-input form-textarea" id="availableRoles" 671 + placeholder="admin&#10;editor&#10;viewer" style="min-height: 6rem;"></textarea> 672 + <p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Define which roles can be 673 + assigned to users for this app. Leave empty to allow free-text roles.</p> 664 674 </div> 665 675 <div class="form-group"> 666 676 <label class="form-label" for="defaultRole">Default Role</label> 667 677 <input type="text" class="form-input" id="defaultRole" placeholder="Leave empty for no default" /> 668 - <p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Automatically assigned when users first authorize this app.</p> 678 + <p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Automatically assigned 679 + when users first authorize this app.</p> 669 680 </div> 670 681 <div class="form-actions"> 671 - <button type="button" class="btn" style="background: rgba(188, 141, 160, 0.2);" id="cancelBtn">cancel</button> 682 + <button type="button" class="btn" style="background: rgba(188, 141, 160, 0.2);" 683 + id="cancelBtn">cancel</button> 672 684 <button type="submit" class="btn">save</button> 673 685 </div> 674 686 </form> ··· 686 698 ⚠️ Save these credentials now. You won't be able to see the secret again! 687 699 </p> 688 700 <div style="margin-bottom: 1rem;"> 689 - <label style="display: block; color: var(--old-rose); font-size: 0.875rem; margin-bottom: 0.5rem;">Client ID</label> 690 - <div style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;"> 691 - <code id="generatedClientId" style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code> 701 + <label 702 + style="display: block; color: var(--old-rose); font-size: 0.875rem; margin-bottom: 0.5rem;">Client 703 + ID</label> 704 + <div 705 + style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;"> 706 + <code id="generatedClientId" 707 + style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code> 692 708 </div> 693 - <button class="btn" id="copyClientIdBtn" style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy client id</button> 709 + <button class="btn" id="copyClientIdBtn" 710 + style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy client id</button> 694 711 </div> 695 712 <div style="margin-bottom: 1rem;"> 696 - <label style="display: block; color: var(--old-rose); font-size: 0.875rem; margin-bottom: 0.5rem;">Client Secret</label> 697 - <div style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;"> 698 - <code id="generatedSecret" style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code> 713 + <label 714 + style="display: block; color: var(--old-rose); font-size: 0.875rem; margin-bottom: 0.5rem;">Client 715 + Secret</label> 716 + <div 717 + style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;"> 718 + <code id="generatedSecret" 719 + style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code> 699 720 </div> 700 - <button class="btn" id="copySecretBtn" style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy secret</button> 721 + <button class="btn" id="copySecretBtn" 722 + style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy secret</button> 701 723 </div> 702 724 </div> 703 725 </div> ··· 706 728 <script type="module" src="../client/admin-clients.ts"></script> 707 729 </body> 708 730 709 - </html> 731 + </html>
+1 -1
src/html/login.html
··· 28 28 } 29 29 30 30 main { 31 - max-width: 28rem !important; 31 + max-width: 28rem; 32 32 width: 100%; 33 33 text-align: center; 34 34 }
+16 -7
src/index.ts
··· 199 199 if (req.method === "POST") { 200 200 const url = new URL(req.url); 201 201 const userId = url.pathname.split("/")[4]; 202 - return disableUser(req, userId); 202 + return disableUser(req, userId || ""); 203 203 } 204 204 return new Response("Method not allowed", { status: 405 }); 205 205 }, ··· 207 207 if (req.method === "POST") { 208 208 const url = new URL(req.url); 209 209 const userId = url.pathname.split("/")[4]; 210 - return enableUser(req, userId); 210 + return enableUser(req, userId || ""); 211 211 } 212 212 return new Response("Method not allowed", { status: 405 }); 213 213 }, ··· 215 215 if (req.method === "PUT") { 216 216 const url = new URL(req.url); 217 217 const userId = url.pathname.split("/")[4]; 218 - return updateUserTier(req, userId); 218 + return updateUserTier(req, userId || ""); 219 219 } 220 220 return new Response("Method not allowed", { status: 405 }); 221 221 }, ··· 223 223 if (req.method === "DELETE") { 224 224 const url = new URL(req.url); 225 225 const userId = url.pathname.split("/")[4]; 226 - return deleteUser(req, userId); 226 + return deleteUser(req, userId || ""); 227 227 } 228 228 return new Response("Method not allowed", { status: 405 }); 229 229 }, ··· 365 365 366 366 if (expiredOrphans.length > 0) { 367 367 if (action === "suspend") { 368 - await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "suspend"); 368 + await updateOrphanedAccounts( 369 + { ...result, orphanedUsers: expiredOrphans }, 370 + "suspend", 371 + ); 369 372 } else if (action === "deactivate") { 370 - await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "deactivate"); 373 + await updateOrphanedAccounts( 374 + { ...result, orphanedUsers: expiredOrphans }, 375 + "deactivate", 376 + ); 371 377 } else if (action === "remove") { 372 - await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "remove"); 378 + await updateOrphanedAccounts( 379 + { ...result, orphanedUsers: expiredOrphans }, 380 + "remove", 381 + ); 373 382 } 374 383 console.log( 375 384 `[LDAP Cleanup] ${action === "remove" ? "Removed" : action === "suspend" ? "Suspended" : "Deactivated"} ${expiredOrphans.length} LDAP orphan accounts (grace period: ${gracePeriod}s)`,
+1 -1
src/ldap-cleanup.ts
··· 35 35 verifyUserExists: true, 36 36 }); 37 37 return !!user; 38 - } catch (error) { 38 + } catch { 39 39 // User not found or invalid credentials (expected for non-existence check) 40 40 return false; 41 41 }
+16 -6
src/routes/api.ts
··· 1 1 import { db } from "../db"; 2 - import { verifyDomain, validateProfileURL } from "./indieauth"; 2 + import { validateProfileURL, verifyDomain } from "./indieauth"; 3 3 4 4 function getSessionUser( 5 5 req: Request, 6 - ): { username: string; userId: number; is_admin: boolean; tier: string } | Response { 6 + ): 7 + | { username: string; userId: number; is_admin: boolean; tier: string } 8 + | Response { 7 9 const authHeader = req.headers.get("Authorization"); 8 10 9 11 if (!authHeader || !authHeader.startsWith("Bearer ")) { ··· 193 195 const origin = process.env.ORIGIN || "http://localhost:3000"; 194 196 const indikoProfileUrl = `${origin}/u/${user.username}`; 195 197 196 - const verification = await verifyDomain(validation.canonicalUrl!, indikoProfileUrl); 198 + const verification = await verifyDomain( 199 + validation.canonicalUrl || "", 200 + indikoProfileUrl, 201 + ); 197 202 if (!verification.success) { 198 203 return Response.json( 199 204 { error: verification.error || "Failed to verify domain" }, ··· 456 461 } 457 462 458 463 // Prevent disabling self 459 - if (targetUserId === user.id) { 464 + if (targetUserId === user.userId) { 460 465 return Response.json( 461 466 { error: "Cannot disable your own account" }, 462 467 { status: 400 }, ··· 508 513 return Response.json({ success: true }); 509 514 } 510 515 511 - export async function updateUserTier(req: Request, userId: string): Promise<Response> { 516 + export async function updateUserTier( 517 + req: Request, 518 + userId: string, 519 + ): Promise<Response> { 512 520 const user = getSessionUser(req); 513 521 if (user instanceof Response) { 514 522 return user; ··· 536 544 537 545 const targetUser = db 538 546 .query("SELECT id, username, tier FROM users WHERE id = ?") 539 - .get(targetUserId) as { id: number; username: string; tier: string } | undefined; 547 + .get(targetUserId) as 548 + | { id: number; username: string; tier: string } 549 + | undefined; 540 550 541 551 if (!targetUser) { 542 552 return Response.json({ error: "User not found" }, { status: 404 });
+17 -6
src/routes/auth.ts
··· 1 1 import { 2 2 type AuthenticationResponseJSON, 3 + generateAuthenticationOptions, 4 + generateRegistrationOptions, 3 5 type PublicKeyCredentialCreationOptionsJSON, 4 6 type PublicKeyCredentialRequestOptionsJSON, 5 7 type RegistrationResponseJSON, 6 8 type VerifiedAuthenticationResponse, 7 9 type VerifiedRegistrationResponse, 8 - generateAuthenticationOptions, 9 - generateRegistrationOptions, 10 10 verifyAuthenticationResponse, 11 11 verifyRegistrationResponse, 12 12 } from "@simplewebauthn/server"; 13 13 import { authenticate } from "ldap-authentication"; 14 14 import { db } from "../db"; 15 - import { checkLdapGroupMembership } from "../ldap-cleanup"; 15 + import { checkLdapGroupMembership, checkLdapUser } from "../ldap-cleanup"; 16 16 17 17 const RP_NAME = "Indiko"; 18 18 ··· 381 381 382 382 // Check if user exists and is active 383 383 const user = db 384 - .query("SELECT id, status, provisioned_via_ldap, last_ldap_verified_at FROM users WHERE username = ?") 385 - .get(username) as { id: number; status: string; provisioned_via_ldap: number; last_ldap_verified_at: number | null } | undefined; 384 + .query( 385 + "SELECT id, status, provisioned_via_ldap, last_ldap_verified_at FROM users WHERE username = ?", 386 + ) 387 + .get(username) as 388 + | { 389 + id: number; 390 + status: string; 391 + provisioned_via_ldap: number; 392 + last_ldap_verified_at: number | null; 393 + } 394 + | undefined; 386 395 387 396 if (!user) { 388 397 return Response.json({ error: "Invalid credentials" }, { status: 401 }); ··· 405 414 const existsInLdap = await checkLdapUser(username); 406 415 if (!existsInLdap) { 407 416 // User no longer exists in LDAP - suspend the account 408 - db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run(user.id); 417 + db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run( 418 + user.id, 419 + ); 409 420 return Response.json( 410 421 { error: "Invalid credentials" }, 411 422 { status: 401 },
+5 -3
src/routes/clients.ts
··· 1 - import crypto from "crypto"; 1 + import crypto from "node:crypto"; 2 2 import { nanoid } from "nanoid"; 3 3 import { db } from "../db"; 4 4 ··· 16 16 17 17 function getSessionUser( 18 18 req: Request, 19 - ): { username: string; userId: number; is_admin: boolean; tier: string } | Response { 19 + ): 20 + | { username: string; userId: number; is_admin: boolean; tier: string } 21 + | Response { 20 22 const authHeader = req.headers.get("Authorization"); 21 23 22 24 if (!authHeader || !authHeader.startsWith("Bearer ")) { ··· 119 121 if (!rolesByApp.has(app_id)) { 120 122 rolesByApp.set(app_id, []); 121 123 } 122 - rolesByApp.get(app_id)!.push(role); 124 + rolesByApp.get(app_id)?.push(role); 123 125 } 124 126 125 127 return Response.json({
+210 -79
src/routes/indieauth.ts
··· 1 - import crypto from "crypto"; 1 + import crypto from "node:crypto"; 2 + import type { BunRequest } from "bun"; 2 3 import { db } from "../db"; 3 4 4 5 interface SessionUser { ··· 53 54 username: session.username, 54 55 userId: session.id, 55 56 isAdmin: session.is_admin === 1, 57 + tier: session.tier, 56 58 }; 57 59 } 58 60 ··· 68 70 }), 69 71 ); 70 72 71 - const sessionToken = cookies["indiko_session"]; 73 + const sessionToken = cookies.indiko_session; 72 74 if (!sessionToken) return null; 73 75 74 76 const session = db ··· 127 129 } 128 130 129 131 // Validate profile URL per IndieAuth spec 130 - export function validateProfileURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } { 132 + export function validateProfileURL(urlString: string): { 133 + valid: boolean; 134 + error?: string; 135 + canonicalUrl?: string; 136 + } { 131 137 let url: URL; 132 138 try { 133 139 url = new URL(urlString); ··· 152 158 153 159 // MUST NOT contain username/password 154 160 if (url.username || url.password) { 155 - return { valid: false, error: "Profile URL must not contain username or password" }; 161 + return { 162 + valid: false, 163 + error: "Profile URL must not contain username or password", 164 + }; 156 165 } 157 166 158 167 // MUST NOT contain ports ··· 164 173 const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; 165 174 const ipv6Regex = /^\[?[0-9a-fA-F:]+\]?$/; 166 175 if (ipv4Regex.test(url.hostname) || ipv6Regex.test(url.hostname)) { 167 - return { valid: false, error: "Profile URL must use domain names, not IP addresses" }; 176 + return { 177 + valid: false, 178 + error: "Profile URL must use domain names, not IP addresses", 179 + }; 168 180 } 169 181 170 182 // MUST NOT contain single-dot or double-dot path segments 171 183 const pathSegments = url.pathname.split("/"); 172 184 if (pathSegments.includes(".") || pathSegments.includes("..")) { 173 - return { valid: false, error: "Profile URL must not contain . or .. path segments" }; 185 + return { 186 + valid: false, 187 + error: "Profile URL must not contain . or .. path segments", 188 + }; 174 189 } 175 190 176 191 return { valid: true, canonicalUrl: canonicalizeURL(urlString) }; 177 192 } 178 193 179 194 // Validate client URL per IndieAuth spec 180 - function validateClientURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } { 195 + function validateClientURL(urlString: string): { 196 + valid: boolean; 197 + error?: string; 198 + canonicalUrl?: string; 199 + } { 181 200 let url: URL; 182 201 try { 183 202 url = new URL(urlString); ··· 202 221 203 222 // MUST NOT contain username/password 204 223 if (url.username || url.password) { 205 - return { valid: false, error: "Client URL must not contain username or password" }; 224 + return { 225 + valid: false, 226 + error: "Client URL must not contain username or password", 227 + }; 206 228 } 207 229 208 230 // MUST NOT contain single-dot or double-dot path segments 209 231 const pathSegments = url.pathname.split("/"); 210 232 if (pathSegments.includes(".") || pathSegments.includes("..")) { 211 - return { valid: false, error: "Client URL must not contain . or .. path segments" }; 233 + return { 234 + valid: false, 235 + error: "Client URL must not contain . or .. path segments", 236 + }; 212 237 } 213 238 214 239 // MAY use loopback interface, but not other IP addresses ··· 217 242 if (ipv4Regex.test(url.hostname)) { 218 243 // Allow 127.0.0.1 (loopback), reject others 219 244 if (!url.hostname.startsWith("127.")) { 220 - return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" }; 245 + return { 246 + valid: false, 247 + error: 248 + "Client URL must use domain names, not IP addresses (except loopback)", 249 + }; 221 250 } 222 251 } else if (ipv6Regex.test(url.hostname)) { 223 252 // Allow ::1 (loopback), reject others 224 253 const ipv6Match = url.hostname.match(ipv6Regex); 225 254 if (ipv6Match && ipv6Match[1] !== "::1") { 226 - return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" }; 255 + return { 256 + valid: false, 257 + error: 258 + "Client URL must use domain names, not IP addresses (except loopback)", 259 + }; 227 260 } 228 261 } 229 262 ··· 234 267 function isLoopbackURL(urlString: string): boolean { 235 268 try { 236 269 const url = new URL(urlString); 237 - return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname.startsWith("127."); 270 + return ( 271 + url.hostname === "localhost" || 272 + url.hostname === "127.0.0.1" || 273 + url.hostname === "[::1]" || 274 + url.hostname.startsWith("127.") 275 + ); 238 276 } catch { 239 277 return false; 240 278 } ··· 254 292 }> { 255 293 // MUST NOT fetch loopback addresses (security requirement) 256 294 if (isLoopbackURL(clientId)) { 257 - return { success: false, error: "Cannot fetch metadata from loopback addresses" }; 295 + return { 296 + success: false, 297 + error: "Cannot fetch metadata from loopback addresses", 298 + }; 258 299 } 259 300 260 301 try { ··· 273 314 clearTimeout(timeoutId); 274 315 275 316 if (!response.ok) { 276 - return { success: false, error: `Failed to fetch client metadata: HTTP ${response.status}` }; 317 + return { 318 + success: false, 319 + error: `Failed to fetch client metadata: HTTP ${response.status}`, 320 + }; 277 321 } 278 322 279 323 const contentType = response.headers.get("content-type") || ""; ··· 284 328 285 329 // Verify client_id matches 286 330 if (metadata.client_id && metadata.client_id !== clientId) { 287 - return { success: false, error: "client_id in metadata does not match URL" }; 331 + return { 332 + success: false, 333 + error: "client_id in metadata does not match URL", 334 + }; 288 335 } 289 336 290 337 return { success: true, metadata }; ··· 295 342 const html = await response.text(); 296 343 297 344 // Extract redirect URIs from link tags 298 - const redirectUriRegex = /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 345 + const redirectUriRegex = 346 + /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 299 347 const redirectUris: string[] = []; 300 - let match: RegExpExecArray | null; 348 + let match = redirectUriRegex.exec(html); 301 349 302 - while ((match = redirectUriRegex.exec(html)) !== null) { 303 - redirectUris.push(match[1]); 350 + while (match !== null) { 351 + redirectUris.push(match[1] || ""); 352 + match = redirectUriRegex.exec(html); 304 353 } 305 354 306 355 // 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]); 356 + const redirectUriRegex2 = 357 + /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi; 358 + match = redirectUriRegex2.exec(html); 359 + while (match !== null) { 360 + if (!redirectUris.includes(match[1] || "")) { 361 + redirectUris.push(match[1] || ""); 311 362 } 363 + match = redirectUriRegex2.exec(html); 312 364 } 313 365 314 366 if (redirectUris.length > 0) { ··· 321 373 }; 322 374 } 323 375 324 - return { success: false, error: "No client metadata or redirect_uri links found in HTML" }; 376 + return { 377 + success: false, 378 + error: "No client metadata or redirect_uri links found in HTML", 379 + }; 325 380 } 326 381 327 382 return { success: false, error: "Unsupported content type" }; ··· 330 385 if (error.name === "AbortError") { 331 386 return { success: false, error: "Timeout fetching client metadata" }; 332 387 } 333 - return { success: false, error: `Failed to fetch client metadata: ${error.message}` }; 388 + return { 389 + success: false, 390 + error: `Failed to fetch client metadata: ${error.message}`, 391 + }; 334 392 } 335 393 return { success: false, error: "Failed to fetch client metadata" }; 336 394 } 337 395 } 338 396 339 397 // Verify domain has rel="me" link back to user profile 340 - export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): Promise<{ 398 + export async function verifyDomain( 399 + domainUrl: string, 400 + indikoProfileUrl: string, 401 + ): Promise<{ 341 402 success: boolean; 342 403 error?: string; 343 404 }> { ··· 359 420 360 421 if (!response.ok) { 361 422 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}` }; 423 + console.error( 424 + `[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, 425 + { 426 + status: response.status, 427 + contentType: response.headers.get("content-type"), 428 + bodyPreview: errorBody.substring(0, 200), 429 + }, 430 + ); 431 + return { 432 + success: false, 433 + error: `Failed to fetch domain: HTTP ${response.status}`, 434 + }; 368 435 } 369 436 370 437 const html = await response.text(); ··· 384 451 385 452 const relValue = relMatch[1]; 386 453 // Check if "me" is a separate word in the rel attribute 387 - if (!relValue.split(/\s+/).includes("me")) return null; 454 + if (!relValue?.split(/\s+/).includes("me")) return null; 388 455 389 456 // Extract href (handle quoted and unquoted attributes) 390 457 const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i); ··· 394 461 }; 395 462 396 463 // Process all link tags 397 - let linkMatch; 398 - while ((linkMatch = linkRegex.exec(html)) !== null) { 464 + let linkMatch = linkRegex.exec(html); 465 + while (linkMatch !== null) { 399 466 const href = processTag(linkMatch[0]); 400 467 if (href && !relMeLinks.includes(href)) { 401 468 relMeLinks.push(href); 402 469 } 470 + linkMatch = linkRegex.exec(html); 403 471 } 404 472 405 473 // Process all a tags 406 - let aMatch; 407 - while ((aMatch = aRegex.exec(html)) !== null) { 474 + let aMatch = aRegex.exec(html); 475 + while (aMatch !== null) { 408 476 const href = processTag(aMatch[0]); 409 477 if (href && !relMeLinks.includes(href)) { 410 478 relMeLinks.push(href); 411 479 } 480 + aMatch = aRegex.exec(html); 412 481 } 413 482 414 483 // Check if any rel="me" link matches the indiko profile URL 415 484 const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl); 416 - const hasRelMe = relMeLinks.some(link => { 485 + const hasRelMe = relMeLinks.some((link) => { 417 486 try { 418 487 const normalizedLink = canonicalizeURL(link); 419 488 return normalizedLink === normalizedIndikoUrl; ··· 423 492 }); 424 493 425 494 if (!hasRelMe) { 426 - console.error(`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, { 427 - foundLinks: relMeLinks, 428 - normalizedTarget: normalizedIndikoUrl, 429 - }); 495 + console.error( 496 + `[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, 497 + { 498 + foundLinks: relMeLinks, 499 + normalizedTarget: normalizedIndikoUrl, 500 + }, 501 + ); 430 502 return { 431 503 success: false, 432 504 error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`, ··· 440 512 console.error(`[verifyDomain] Timeout verifying ${domainUrl}`); 441 513 return { success: false, error: "Timeout verifying domain" }; 442 514 } 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}` }; 515 + console.error( 516 + `[verifyDomain] Error verifying ${domainUrl}: ${error.message}`, 517 + { 518 + name: error.name, 519 + stack: error.stack, 520 + }, 521 + ); 522 + return { 523 + success: false, 524 + error: `Failed to verify domain: ${error.message}`, 525 + }; 448 526 } 449 - console.error(`[verifyDomain] Unknown error verifying ${domainUrl}:`, error); 527 + console.error( 528 + `[verifyDomain] Unknown error verifying ${domainUrl}:`, 529 + error, 530 + ); 450 531 return { success: false, error: "Failed to verify domain" }; 451 532 } 452 533 } ··· 457 538 redirectUri: string, 458 539 ): Promise<{ 459 540 error?: string; 460 - app?: { name: string | null; redirect_uris: string; logo_url?: string | null }; 541 + app?: { 542 + name: string | null; 543 + redirect_uris: string; 544 + logo_url?: string | null; 545 + }; 461 546 }> { 462 547 const existing = db 463 548 .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") ··· 474 559 }; 475 560 } 476 561 477 - const canonicalClientId = validation.canonicalUrl!; 562 + const canonicalClientId = validation.canonicalUrl || ""; 478 563 479 564 // Fetch client metadata per IndieAuth spec 480 565 const metadataResult = await fetchClientMetadata(canonicalClientId); ··· 550 635 551 636 // Fetch the newly created app 552 637 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 }; 638 + .query( 639 + "SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?", 640 + ) 641 + .get(canonicalClientId) as { 642 + name: string | null; 643 + redirect_uris: string; 644 + logo_url?: string | null; 645 + }; 555 646 556 647 return { app: newApp }; 557 648 } ··· 800 891 ); 801 892 } 802 893 803 - const app = appResult.app!; 894 + const app = appResult.app as NonNullable<typeof appResult.app>; 804 895 805 896 const allowedRedirects = JSON.parse(app.redirect_uris) as string[]; 806 897 if (!allowedRedirects.includes(redirectUri)) { ··· 954 1045 ).run(Math.floor(Date.now() / 1000), user.userId, clientId); 955 1046 956 1047 const origin = process.env.ORIGIN || "http://localhost:3000"; 957 - return Response.redirect(`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`); 1048 + return Response.redirect( 1049 + `${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`, 1050 + ); 958 1051 } 959 1052 } 960 1053 ··· 1316 1409 // POST /auth/authorize - Consent form submission 1317 1410 export async function authorizePost(req: Request): Promise<Response> { 1318 1411 const contentType = req.headers.get("Content-Type"); 1319 - 1412 + 1320 1413 // Parse the request body 1321 1414 let body: Record<string, string>; 1322 1415 let formData: FormData; ··· 1334 1427 } 1335 1428 1336 1429 const grantType = body.grant_type; 1337 - 1430 + 1338 1431 // If grant_type is present, this is a token exchange request (IndieAuth profile scope only) 1339 1432 if (grantType === "authorization_code") { 1340 1433 // Create a mock request for token() function 1341 1434 const mockReq = new Request(req.url, { 1342 1435 method: "POST", 1343 1436 headers: req.headers, 1344 - body: contentType?.includes("application/x-www-form-urlencoded") 1437 + body: contentType?.includes("application/x-www-form-urlencoded") 1345 1438 ? new URLSearchParams(body).toString() 1346 1439 : JSON.stringify(body), 1347 1440 }); ··· 1373 1466 clientId = canonicalizeURL(rawClientId); 1374 1467 redirectUri = canonicalizeURL(rawRedirectUri); 1375 1468 } catch { 1376 - return new Response("Invalid client_id or redirect_uri URL format", { status: 400 }); 1469 + return new Response("Invalid client_id or redirect_uri URL format", { 1470 + status: 400, 1471 + }); 1377 1472 } 1378 1473 1379 1474 if (action === "deny") { ··· 1487 1582 let redirect_uri: string | undefined; 1488 1583 try { 1489 1584 client_id = raw_client_id ? canonicalizeURL(raw_client_id) : undefined; 1490 - redirect_uri = raw_redirect_uri ? canonicalizeURL(raw_redirect_uri) : undefined; 1585 + redirect_uri = raw_redirect_uri 1586 + ? canonicalizeURL(raw_redirect_uri) 1587 + : undefined; 1491 1588 } catch { 1492 1589 return Response.json( 1493 1590 { ··· 1502 1599 return Response.json( 1503 1600 { 1504 1601 error: "unsupported_grant_type", 1505 - error_description: "Only authorization_code and refresh_token grant types are supported", 1602 + error_description: 1603 + "Only authorization_code and refresh_token grant types are supported", 1506 1604 }, 1507 1605 { status: 400 }, 1508 1606 ); ··· 1577 1675 const expiresAt = now + expiresIn; 1578 1676 1579 1677 // 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); 1678 + db.query("UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?").run( 1679 + newAccessToken, 1680 + expiresAt, 1681 + tokenData.id, 1682 + ); 1583 1683 1584 1684 // Get user profile for me value 1585 1685 const user = db ··· 1614 1714 headers: { 1615 1715 "Content-Type": "application/json", 1616 1716 "Cache-Control": "no-store", 1617 - "Pragma": "no-cache", 1717 + Pragma: "no-cache", 1618 1718 }, 1619 1719 }, 1620 1720 ); ··· 1626 1726 .query( 1627 1727 "SELECT is_preregistered, client_secret_hash FROM apps WHERE client_id = ?", 1628 1728 ) 1629 - .get(client_id) as 1729 + .get(client_id || "") as 1630 1730 | { is_preregistered: number; client_secret_hash: string | null } 1631 1731 | undefined; 1632 1732 ··· 1727 1827 1728 1828 // Check if already used 1729 1829 if (authcode.used) { 1730 - console.error("Token endpoint: authorization code already used", { code }); 1830 + console.error("Token endpoint: authorization code already used", { 1831 + code, 1832 + }); 1731 1833 return Response.json( 1732 1834 { 1733 1835 error: "invalid_grant", ··· 1740 1842 // Check if expired 1741 1843 const now = Math.floor(Date.now() / 1000); 1742 1844 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 }); 1845 + console.error("Token endpoint: authorization code expired", { 1846 + code, 1847 + expires_at: authcode.expires_at, 1848 + now, 1849 + diff: now - authcode.expires_at, 1850 + }); 1744 1851 return Response.json( 1745 1852 { 1746 1853 error: "invalid_grant", ··· 1752 1859 1753 1860 // Verify client_id matches 1754 1861 if (authcode.client_id !== client_id) { 1755 - console.error("Token endpoint: client_id mismatch", { stored: authcode.client_id, received: client_id }); 1862 + console.error("Token endpoint: client_id mismatch", { 1863 + stored: authcode.client_id, 1864 + received: client_id, 1865 + }); 1756 1866 return Response.json( 1757 1867 { 1758 1868 error: "invalid_grant", ··· 1764 1874 1765 1875 // Verify redirect_uri matches 1766 1876 if (authcode.redirect_uri !== redirect_uri) { 1767 - console.error("Token endpoint: redirect_uri mismatch", { stored: authcode.redirect_uri, received: redirect_uri }); 1877 + console.error("Token endpoint: redirect_uri mismatch", { 1878 + stored: authcode.redirect_uri, 1879 + received: redirect_uri, 1880 + }); 1768 1881 return Response.json( 1769 1882 { 1770 1883 error: "invalid_grant", ··· 1776 1889 1777 1890 // Verify PKCE code_verifier (required for all clients per IndieAuth spec) 1778 1891 if (!verifyPKCE(code_verifier, authcode.code_challenge)) { 1779 - console.error("Token endpoint: PKCE verification failed", { code_verifier, code_challenge: authcode.code_challenge }); 1892 + console.error("Token endpoint: PKCE verification failed", { 1893 + code_verifier, 1894 + code_challenge: authcode.code_challenge, 1895 + }); 1780 1896 return Response.json( 1781 1897 { 1782 1898 error: "invalid_grant", ··· 1839 1955 1840 1956 // Validate that the user controls the requested me parameter 1841 1957 if (authcode.me && authcode.me !== meValue) { 1842 - console.error("Token endpoint: me mismatch", { requested: authcode.me, actual: meValue }); 1958 + console.error("Token endpoint: me mismatch", { 1959 + requested: authcode.me, 1960 + actual: meValue, 1961 + }); 1843 1962 return Response.json( 1844 1963 { 1845 1964 error: "invalid_grant", 1846 - error_description: "The requested identity does not match the user's verified domain", 1965 + error_description: 1966 + "The requested identity does not match the user's verified domain", 1847 1967 }, 1848 1968 { status: 400 }, 1849 1969 ); 1850 1970 } 1851 1971 1852 1972 const origin = process.env.ORIGIN || "http://localhost:3000"; 1853 - 1973 + 1854 1974 // Generate access token 1855 1975 const accessToken = crypto.randomBytes(32).toString("base64url"); 1856 1976 const expiresIn = 3600; // 1 hour ··· 1864 1984 // Store token in database with refresh token 1865 1985 db.query( 1866 1986 "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); 1987 + ).run( 1988 + accessToken, 1989 + authcode.user_id, 1990 + client_id, 1991 + scopes.join(" "), 1992 + expiresAt, 1993 + refreshToken, 1994 + refreshExpiresAt, 1995 + ); 1868 1996 1869 1997 const response: Record<string, unknown> = { 1870 1998 access_token: accessToken, ··· 1882 2010 response.role = permission.role; 1883 2011 } 1884 2012 1885 - console.log("Token endpoint: success", { me: meValue, scopes: scopes.join(" ") }); 2013 + console.log("Token endpoint: success", { 2014 + me: meValue, 2015 + scopes: scopes.join(" "), 2016 + }); 1886 2017 1887 2018 return Response.json(response, { 1888 2019 headers: { 1889 2020 "Content-Type": "application/json", 1890 2021 "Cache-Control": "no-store", 1891 - "Pragma": "no-cache", 2022 + Pragma: "no-cache", 1892 2023 }, 1893 2024 }); 1894 2025 } catch (error) { ··· 2052 2183 try { 2053 2184 // Get access token from Authorization header 2054 2185 const authHeader = req.headers.get("Authorization"); 2055 - 2186 + 2056 2187 if (!authHeader || !authHeader.startsWith("Bearer ")) { 2057 2188 return Response.json( 2058 2189 { ··· 2175 2306 } 2176 2307 2177 2308 // GET /u/:username - Public user profile (h-card) 2178 - export function userProfile(req: Request): Response { 2179 - const username = (req as any).params?.username; 2309 + export function userProfile(req: BunRequest): Response { 2310 + const username = req.params?.username; 2180 2311 if (!username) { 2181 2312 return new Response("Username required", { status: 400 }); 2182 2313 }
+10 -6
src/routes/passkeys.ts
··· 1 1 import { 2 - type RegistrationResponseJSON, 3 2 generateRegistrationOptions, 3 + type RegistrationResponseJSON, 4 4 type VerifiedRegistrationResponse, 5 5 verifyRegistrationResponse, 6 6 } from "@simplewebauthn/server"; ··· 75 75 .all(session.user_id) as Array<{ credential_id: Buffer }>; 76 76 77 77 const excludeCredentials = existingCredentials.map((cred) => ({ 78 - id: cred.credential_id, 78 + id: cred.credential_id.toString("base64url"), 79 79 type: "public-key" as const, 80 80 })); 81 81 82 82 // Generate WebAuthn registration options 83 83 const options = await generateRegistrationOptions({ 84 84 rpName: RP_NAME, 85 - rpID: process.env.RP_ID!, 85 + rpID: process.env.RP_ID || "", 86 86 userName: user.username, 87 87 userDisplayName: user.username, 88 88 attestationType: "none", ··· 133 133 } 134 134 135 135 const body = await req.json(); 136 - const { response, challenge: expectedChallenge, name } = body as { 136 + const { 137 + response, 138 + challenge: expectedChallenge, 139 + name, 140 + } = body as { 137 141 response: RegistrationResponseJSON; 138 142 challenge: string; 139 143 name?: string; ··· 167 171 verification = await verifyRegistrationResponse({ 168 172 response, 169 173 expectedChallenge: challenge.challenge, 170 - expectedOrigin: process.env.ORIGIN!, 171 - expectedRPID: process.env.RP_ID!, 174 + expectedOrigin: process.env.ORIGIN || "", 175 + expectedRPID: process.env.RP_ID || "", 172 176 }); 173 177 } catch (error) { 174 178 console.error("WebAuthn verification failed:", error);
+28
types/global.d.ts
··· 1 + export {}; 2 + 3 + declare global { 4 + interface Window { 5 + closeEditInviteModal(): void; 6 + deleteInvite(inviteId: number, event?: Event): Promise<void>; 7 + submitCreateInvite(): Promise<void>; 8 + closeCreateInviteModal(): void; 9 + editInvite(inviteId: number): Promise<void>; 10 + submitEditInvite(): Promise<void>; 11 + toggleClient(clientId: string): Promise<void>; 12 + setUserRole( 13 + clientId: string, 14 + username: string, 15 + role: string, 16 + ): Promise<void>; 17 + editClient(clientId: string): Promise<void>; 18 + deleteClient(clientId: string, event?: Event): Promise<void>; 19 + removeRedirectUri(btn: HTMLButtonElement): void; 20 + regenerateSecret(clientId: string, event?: Event): Promise<void>; 21 + revokeUserPermission( 22 + clientId: string, 23 + username: string, 24 + event?: Event, 25 + ): Promise<void>; 26 + revokeApp(clientId: string, event?: Event): Promise<void>; 27 + } 28 + }
+10
CRUSH.md
··· 9 9 ## Architecture Patterns 10 10 11 11 ### Route Organization 12 + 12 13 - Use separate route files in `src/routes/` directory 13 14 - Export handler functions that accept `Request` and return `Response` 14 15 - Import handlers in `src/index.ts` and wire them in the `routes` object ··· 17 18 - IndieAuth/OAuth 2.0 endpoints in `src/routes/indieauth.ts` 18 19 19 20 ### Project Structure 21 + 20 22 ``` 21 23 src/ 22 24 ├── db.ts # Database setup and exports ··· 43 45 ### Database Migrations 44 46 45 47 **Migration Versioning:** 48 + 46 49 - SQLite uses `PRAGMA user_version` to track migration state 47 50 - Version starts at 0, increments by 1 for each migration 48 51 - The `bun-sqlite-migrations` package handles version tracking ··· 55 58 - Use descriptive name (e.g., `008_add_auth_tokens.sql`) 56 59 57 60 2. **Write SQL statements**: Add schema changes in the file 61 + 58 62 ```sql 59 63 -- Add new column to users table 60 64 ALTER TABLE users ADD COLUMN new_field TEXT DEFAULT ''; ··· 72 76 - Each migration increments `user_version` by 1 73 77 74 78 **Version Tracking:** 79 + 75 80 - Check current version: `sqlite3 data/indiko.db "PRAGMA user_version;"` 76 81 - The migration system compares `user_version` against migration files 77 82 - No manual version updates needed - handled by `bun-sqlite-migrations` 78 83 79 84 **Best Practices:** 85 + 80 86 - Use `ALTER TABLE` for adding columns to existing tables 81 87 - Use `CREATE TABLE IF NOT EXISTS` for new tables 82 88 - Use `DEFAULT` values when adding non-null columns ··· 84 90 - Test migrations locally before committing 85 91 86 92 ### Client-Side Code 93 + 87 94 - Extract JavaScript from HTML into separate TypeScript modules in `src/client/` 88 95 - Import client modules into HTML with `<script type="module" src="../client/file.ts"></script>` 89 96 - Bun will bundle the imports automatically ··· 91 98 - In HTML files: use paths relative to server root (e.g., `/logo.svg`, `/favicon.svg`) since Bun bundles HTML and resolves paths from server context 92 99 93 100 ### IndieAuth/OAuth 2.0 Implementation 101 + 94 102 - Full IndieAuth server supporting OAuth 2.0 with PKCE 95 103 - Authorization code flow with single-use, short-lived codes (60 seconds) 96 104 - Auto-registration of client apps on first authorization ··· 103 111 - **`me` parameter delegation**: When a client passes `me=https://example.com` in the authorization request and it matches the user's website URL, the token response returns that URL instead of the canonical `/u/{username}` URL 104 112 105 113 ### Database Schema 114 + 106 115 - **users**: username, name, email, photo, url, status, role, tier, is_admin, provisioned_via_ldap, last_ldap_verified_at 107 116 - **tier**: User access level - 'admin' (full access), 'developer' (can create apps), 'user' (can only authenticate with apps) 108 117 - **is_admin**: Legacy flag, automatically synced with tier (1 if tier='admin', 0 otherwise) ··· 117 126 - **invites**: admin-created invite codes, includes `ldap_username` for LDAP-provisioned accounts 118 127 119 128 ### WebAuthn/Passkey Settings 129 + 120 130 - **Registration**: residentKey="required", userVerification="required" 121 131 - **Authentication**: omit allowCredentials to show all passkeys (discoverable credentials) 122 132 - **Credential lookup**: credential_id stored as Buffer, compare using base64url string
+60
SPEC.md
··· 3 3 ## Overview 4 4 5 5 **indiko** is a centralized authentication and user management system for personal projects. It provides: 6 + 6 7 - Passkey-based authentication (WebAuthn) 7 8 - IndieAuth server implementation 8 9 - User profile management ··· 12 13 ## Core Concepts 13 14 14 15 ### Single Source of Truth 16 + 15 17 - Authentication via passkeys 16 18 - User profiles (name, email, picture, URL) 17 19 - Authorization with per-app scoping 18 20 - User management (admin + invite system) 19 21 20 22 ### Trust Model 23 + 21 24 - First user becomes admin 22 25 - Admin can create invite links 23 26 - Apps auto-register on first use ··· 30 33 ## Data Structures 31 34 32 35 ### Users 36 + 33 37 ``` 34 38 user:{username} -> { 35 39 credential: { ··· 49 53 ``` 50 54 51 55 ### Admin Marker 56 + 52 57 ``` 53 58 admin:user -> username // marks first/admin user 54 59 ``` 55 60 56 61 ### Sessions 62 + 57 63 ``` 58 64 session:{token} -> { 59 65 username: string, ··· 67 73 There are two types of OAuth clients in indiko: 68 74 69 75 #### Auto-registered Apps (IndieAuth) 76 + 70 77 ``` 71 78 app:{client_id} -> { 72 79 client_id: string, // e.g. "https://blog.kierank.dev" (any valid URL) ··· 80 87 ``` 81 88 82 89 **Features:** 90 + 83 91 - Client ID is any valid URL per IndieAuth spec 84 92 - No client secret (public client) 85 93 - MUST use PKCE (code_verifier) ··· 88 96 - Cannot use role-based access control 89 97 90 98 #### Pre-registered Apps (OAuth 2.0 with secrets) 99 + 91 100 ``` 92 101 app:{client_id} -> { 93 102 client_id: string, // e.g. "ikc_xxxxxxxxxxxxxxxxxxxxx" (generated ID) ··· 105 114 ``` 106 115 107 116 **Features:** 117 + 108 118 - Client ID format: `ikc_` + 21 character nanoid 109 119 - Client secret format: `iks_` + 43 character nanoid (shown once on creation) 110 120 - MUST use PKCE (code_verifier) AND client_secret ··· 113 123 - Created via admin interface 114 124 115 125 ### User Permissions (Per-App) 126 + 116 127 ``` 117 128 permission:{user_id}:{client_id} -> { 118 129 scopes: string[], // e.g. ["profile", "email"] ··· 123 134 ``` 124 135 125 136 ### Authorization Codes (Short-lived) 137 + 126 138 ``` 127 139 authcode:{code} -> { 128 140 username: string, ··· 138 150 ``` 139 151 140 152 ### Invites 153 + 141 154 ``` 142 155 invite:{code} -> { 143 156 code: string, ··· 150 163 ``` 151 164 152 165 ### Challenges (WebAuthn) 166 + 153 167 ``` 154 168 challenge:{challenge} -> { 155 169 username: string, ··· 170 184 ### Authentication (WebAuthn/Passkey) 171 185 172 186 #### `GET /login` 187 + 173 188 - Login/registration page 174 189 - Shows passkey auth interface 175 190 - First user: admin registration flow 176 191 - With `?invite=CODE`: invite-based registration 177 192 178 193 #### `GET /auth/can-register` 194 + 179 195 - Check if open registration allowed 180 196 - Returns `{ canRegister: boolean }` 181 197 182 198 #### `POST /auth/register/options` 199 + 183 200 - Generate WebAuthn registration options 184 201 - Body: `{ username: string, inviteCode?: string }` 185 202 - Validates invite code if not first user 186 203 - Returns registration options 187 204 188 205 #### `POST /auth/register/verify` 206 + 189 207 - Verify WebAuthn registration response 190 208 - Body: `{ username: string, response: RegistrationResponseJSON, inviteCode?: string }` 191 209 - Creates user, stores credential ··· 193 211 - Returns `{ token: string, username: string }` 194 212 195 213 #### `POST /auth/login/options` 214 + 196 215 - Generate WebAuthn authentication options 197 216 - Body: `{ username: string }` 198 217 - Returns authentication options 199 218 200 219 #### `POST /auth/login/verify` 220 + 201 221 - Verify WebAuthn authentication response 202 222 - Body: `{ username: string, response: AuthenticationResponseJSON }` 203 223 - Creates session 204 224 - Returns `{ token: string, username: string }` 205 225 206 226 #### `POST /auth/logout` 227 + 207 228 - Clear session 208 229 - Requires: `Authorization: Bearer {token}` 209 230 - Returns `{ success: true }` ··· 211 232 ### IndieAuth Endpoints 212 233 213 234 #### `GET /auth/authorize` 235 + 214 236 Authorization request from client app 215 237 216 238 **Query Parameters:** 239 + 217 240 - `response_type=code` (required) 218 241 - `client_id` (required) - App's URL 219 242 - `redirect_uri` (required) - Callback URL ··· 224 247 - `me` (optional) - User's URL (hint) 225 248 226 249 **Flow:** 250 + 227 251 1. Validate parameters 228 252 2. Auto-register app if not exists 229 253 3. If no session → redirect to `/login` ··· 233 257 - If no → show consent screen 234 258 235 259 **Response:** 260 + 236 261 - HTML consent screen 237 262 - Shows: app name, requested scopes 238 263 - Buttons: "Allow" / "Deny" 239 264 240 265 #### `POST /auth/authorize` 266 + 241 267 Consent form submission (CSRF protected) 242 268 243 269 **Body:** 270 + 244 271 - `client_id` (required) 245 272 - `redirect_uri` (required) 246 273 - `state` (required) ··· 249 276 - `action` (required) - "allow" | "deny" 250 277 251 278 **Flow:** 279 + 252 280 1. Validate CSRF token 253 281 2. Validate session 254 282 3. If denied → redirect with error ··· 259 287 - Redirect to redirect_uri with code & state 260 288 261 289 **Success Response:** 290 + 262 291 ``` 263 292 HTTP/1.1 302 Found 264 293 Location: {redirect_uri}?code={authcode}&state={state} 265 294 ``` 266 295 267 296 **Error Response:** 297 + 268 298 ``` 269 299 HTTP/1.1 302 Found 270 300 Location: {redirect_uri}?error=access_denied&state={state} 271 301 ``` 272 302 273 303 #### `POST /auth/token` 304 + 274 305 Exchange authorization code for user identity (NOT CSRF protected) 275 306 276 307 **Headers:** 308 + 277 309 - `Content-Type: application/json` 278 310 279 311 **Body:** 312 + 280 313 ```json 281 314 { 282 315 "grant_type": "authorization_code", ··· 288 321 ``` 289 322 290 323 **Flow:** 324 + 291 325 1. Validate authorization code exists 292 326 2. Verify code not expired 293 327 3. Verify code not already used ··· 298 332 8. Return user identity + profile 299 333 300 334 **Success Response:** 335 + 301 336 ```json 302 337 { 303 338 "me": "https://indiko.yourdomain.com/u/kieran", ··· 311 346 ``` 312 347 313 348 **Error Response:** 349 + 314 350 ```json 315 351 { 316 352 "error": "invalid_grant", ··· 319 355 ``` 320 356 321 357 #### `GET /auth/userinfo` (Optional) 358 + 322 359 Get current user profile with bearer token 323 360 324 361 **Headers:** 362 + 325 363 - `Authorization: Bearer {access_token}` 326 364 327 365 **Response:** 366 + 328 367 ```json 329 368 { 330 369 "sub": "https://indiko.yourdomain.com/u/kieran", ··· 338 377 ### User Profile & Settings 339 378 340 379 #### `GET /settings` 380 + 341 381 User settings page (requires session) 342 382 343 383 **Shows:** 384 + 344 385 - Profile form (name, email, photo, URL) 345 386 - Connected apps list 346 387 - Revoke access buttons 347 388 - (Admin only) Invite generation 348 389 349 390 #### `POST /settings/profile` 391 + 350 392 Update user profile 351 393 352 394 **Body:** 395 + 353 396 ```json 354 397 { 355 398 "name": "Kieran Klukas", ··· 360 403 ``` 361 404 362 405 **Response:** 406 + 363 407 ```json 364 408 { 365 409 "success": true, ··· 368 412 ``` 369 413 370 414 #### `POST /settings/apps/:client_id/revoke` 415 + 371 416 Revoke app access 372 417 373 418 **Response:** 419 + 374 420 ```json 375 421 { 376 422 "success": true ··· 378 424 ``` 379 425 380 426 #### `GET /u/:username` 427 + 381 428 Public user profile page (h-card) 382 429 383 430 **Response:** 384 431 HTML page with microformats h-card: 432 + 385 433 ```html 386 434 <div class="h-card"> 387 435 <img class="u-photo" src="..."> ··· 393 441 ### Admin Endpoints 394 442 395 443 #### `POST /api/invites/create` 444 + 396 445 Create invite link (admin only) 397 446 398 447 **Headers:** 448 + 399 449 - `Authorization: Bearer {token}` 400 450 401 451 **Response:** 452 + 402 453 ```json 403 454 { 404 455 "inviteCode": "abc123xyz" ··· 410 461 ### Dashboard 411 462 412 463 #### `GET /` 464 + 413 465 Main dashboard (requires session) 414 466 415 467 **Shows:** 468 + 416 469 - User info 417 470 - Test API button 418 471 - (Admin only) Admin controls section ··· 420 473 - Invite display 421 474 422 475 #### `GET /api/hello` 476 + 423 477 Test endpoint (requires session) 424 478 425 479 **Headers:** 480 + 426 481 - `Authorization: Bearer {token}` 427 482 428 483 **Response:** 484 + 429 485 ```json 430 486 { 431 487 "message": "Hello kieran! You're authenticated with passkeys.", ··· 437 493 ## Session Behavior 438 494 439 495 ### Single Sign-On 496 + 440 497 - Once logged into indiko (valid session), subsequent app authorization requests: 441 498 - Skip passkey authentication 442 499 - Show consent screen directly ··· 445 502 - Passkey required only when session expires 446 503 447 504 ### Security 505 + 448 506 - PKCE required for all authorization flows 449 507 - Authorization codes: 450 508 - Single-use only ··· 455 513 ## Client Integration Example 456 514 457 515 ### 1. Initiate Authorization 516 + 458 517 ```javascript 459 518 const params = new URLSearchParams({ 460 519 response_type: 'code', ··· 470 529 ``` 471 530 472 531 ### 2. Handle Callback 532 + 473 533 ```javascript 474 534 // At https://blog.kierank.dev/auth/callback?code=...&state=... 475 535 const code = new URLSearchParams(window.location.search).get('code');
+34
biome.json
··· 1 + { 2 + "$schema": "https://biomejs.dev/schemas/2.3.9/schema.json", 3 + "vcs": { 4 + "enabled": true, 5 + "clientKind": "git", 6 + "useIgnoreFile": true 7 + }, 8 + "files": { 9 + "ignoreUnknown": false 10 + }, 11 + "formatter": { 12 + "enabled": true, 13 + "indentStyle": "tab" 14 + }, 15 + "linter": { 16 + "enabled": true, 17 + "rules": { 18 + "recommended": true 19 + } 20 + }, 21 + "javascript": { 22 + "formatter": { 23 + "quoteStyle": "double" 24 + } 25 + }, 26 + "assist": { 27 + "enabled": true, 28 + "actions": { 29 + "source": { 30 + "organizeImports": "on" 31 + } 32 + } 33 + } 34 + }
+2 -1
package.json
··· 6 6 "scripts": { 7 7 "dev": "bun run --hot src/index.ts", 8 8 "start": "bun run src/index.ts", 9 - "format": "bun run --bun biome check --write ." 9 + "forqmat": "bun run --bun biome check --write .", 10 + "lint": "bun run --bun biome check" 10 11 }, 11 12 "devDependencies": { 12 13 "@simplewebauthn/types": "^12.0.0",
+1 -1
scripts/audit-ldap-orphans.ts
··· 62 62 verifyUserExists: true, 63 63 }); 64 64 return !!user; 65 - } catch (error) { 65 + } catch { 66 66 // User not found or invalid credentials (expected for non-existence check) 67 67 return false; 68 68 }
+14 -26
src/client/admin-clients.ts
··· 104 104 lastUsed: number; 105 105 } 106 106 107 - interface AppPermission { 108 - username: string; 109 - name: string; 110 - scopes: string[]; 111 - grantedAt: number; 112 - lastUsed: number; 113 - } 114 - 115 107 async function loadClients() { 116 108 try { 117 109 const response = await fetch("/api/admin/clients", { ··· 191 183 .join(""); 192 184 } 193 185 194 - (window as any).toggleClient = async (clientId: string) => { 186 + window.toggleClient = async (clientId: string) => { 195 187 const card = document.querySelector( 196 188 `[data-client-id="${clientId}"]`, 197 189 ) as HTMLElement; ··· 319 311 } 320 312 }; 321 313 322 - (window as any).setUserRole = async ( 314 + window.setUserRole = async ( 323 315 clientId: string, 324 316 username: string, 325 317 role: string, ··· 348 340 } 349 341 }; 350 342 351 - (window as any).editClient = async (clientId: string) => { 343 + window.editClient = async (clientId: string) => { 352 344 try { 353 345 const response = await fetch( 354 346 `/api/admin/clients/${encodeURIComponent(clientId)}`, ··· 398 390 } 399 391 }; 400 392 401 - (window as any).deleteClient = async (clientId: string, event?: Event) => { 393 + window.deleteClient = async (clientId: string, event?: Event) => { 402 394 const btn = event?.target as HTMLButtonElement | undefined; 403 395 404 396 // Double-click confirmation pattern 405 397 if (btn?.dataset.confirmState === "pending") { 406 398 // Second click - execute delete 407 - delete btn.dataset.confirmState; 399 + btn.dataset.confirmState = undefined; 408 400 btn.disabled = true; 409 401 btn.textContent = "deleting..."; 410 402 ··· 440 432 // Reset after 3 seconds if not confirmed 441 433 setTimeout(() => { 442 434 if (btn.dataset.confirmState === "pending") { 443 - delete btn.dataset.confirmState; 435 + btn.dataset.confirmState = undefined; 444 436 btn.textContent = originalText; 445 437 } 446 438 }, 3000); ··· 479 471 redirectUrisList.appendChild(newItem); 480 472 }); 481 473 482 - (window as any).removeRedirectUri = (btn: HTMLButtonElement) => { 474 + window.removeRedirectUri = (btn: HTMLButtonElement) => { 483 475 const items = redirectUrisList.querySelectorAll(".redirect-uri-item"); 484 476 if (items.length > 1) { 485 477 btn.parentElement?.remove(); ··· 569 561 // If creating a new client, show the credentials in modal 570 562 if (!isEdit) { 571 563 const result = await response.json(); 572 - if ( 573 - result.client && 574 - result.client.clientId && 575 - result.client.clientSecret 576 - ) { 564 + if (result.client?.clientId && result.client.clientSecret) { 577 565 const secretModal = document.getElementById( 578 566 "secretModal", 579 567 ) as HTMLElement; ··· 604 592 } 605 593 }); 606 594 607 - (window as any).regenerateSecret = async (clientId: string, event?: Event) => { 595 + window.regenerateSecret = async (clientId: string, event?: Event) => { 608 596 const btn = event?.target as HTMLButtonElement | undefined; 609 597 610 598 // Double-click confirmation pattern (same as delete) 611 599 if (btn?.dataset.confirmState === "pending") { 612 600 // Second click - execute regenerate 613 - delete btn.dataset.confirmState; 601 + btn.dataset.confirmState = undefined; 614 602 btn.disabled = true; 615 603 btn.textContent = "regenerating..."; 616 604 ··· 667 655 // Reset after 3 seconds if not confirmed 668 656 setTimeout(() => { 669 657 if (btn.dataset.confirmState === "pending") { 670 - delete btn.dataset.confirmState; 658 + btn.dataset.confirmState = undefined; 671 659 btn.textContent = originalText; 672 660 } 673 661 }, 3000); ··· 675 663 } 676 664 }; 677 665 678 - (window as any).revokeUserPermission = async ( 666 + window.revokeUserPermission = async ( 679 667 clientId: string, 680 668 username: string, 681 669 event?: Event, ··· 685 673 // Double-click confirmation pattern 686 674 if (btn?.dataset.confirmState === "pending") { 687 675 // Second click - execute revoke 688 - delete btn.dataset.confirmState; 676 + btn.dataset.confirmState = undefined; 689 677 btn.disabled = true; 690 678 btn.textContent = "revoking..."; 691 679 ··· 736 724 // Reset after 3 seconds if not confirmed 737 725 setTimeout(() => { 738 726 if (btn.dataset.confirmState === "pending") { 739 - delete btn.dataset.confirmState; 727 + btn.dataset.confirmState = undefined; 740 728 btn.textContent = originalText; 741 729 } 742 730 }, 3000);
+13 -13
src/client/admin-invites.ts
··· 60 60 } catch (error) { 61 61 console.error("Auth check failed:", error); 62 62 footer.textContent = "error loading user info"; 63 - usersList.innerHTML = '<div class="error">Failed to load users</div>'; 63 + invitesList.innerHTML = '<div class="error">Failed to load users</div>'; 64 64 } 65 65 } 66 66 ··· 193 193 ) as HTMLSelectElement; 194 194 195 195 let role = ""; 196 - if (roleSelect && roleSelect.value) { 196 + if (roleSelect?.value) { 197 197 role = roleSelect.value; 198 198 } 199 199 ··· 266 266 } 267 267 268 268 // Expose functions to global scope for HTML onclick handlers 269 - (window as any).submitCreateInvite = submitCreateInvite; 270 - (window as any).closeCreateInviteModal = closeCreateInviteModal; 269 + window.submitCreateInvite = submitCreateInvite; 270 + window.closeCreateInviteModal = closeCreateInviteModal; 271 271 272 272 async function loadInvites() { 273 273 try { ··· 408 408 document.addEventListener("keydown", (e) => { 409 409 if (e.key === "Escape") { 410 410 closeCreateInviteModal(); 411 - closeEditInviteModal(); 411 + window.closeEditInviteModal(); 412 412 } 413 413 }); 414 414 ··· 421 421 422 422 document.getElementById("editInviteModal")?.addEventListener("click", (e) => { 423 423 if (e.target === e.currentTarget) { 424 - closeEditInviteModal(); 424 + window.closeEditInviteModal(); 425 425 } 426 426 }); 427 427 428 428 let currentEditInviteId: number | null = null; 429 429 430 430 // Make editInvite globally available for onclick handler 431 - (window as any).editInvite = async (inviteId: number) => { 431 + window.editInvite = async (inviteId: number) => { 432 432 try { 433 433 const response = await fetch("/api/invites", { 434 434 headers: { ··· 488 488 } 489 489 }; 490 490 491 - (window as any).submitEditInvite = async () => { 491 + window.submitEditInvite = async () => { 492 492 if (currentEditInviteId === null) return; 493 493 494 494 const maxUsesInput = document.getElementById( ··· 532 532 } 533 533 534 534 await loadInvites(); 535 - closeEditInviteModal(); 535 + window.closeEditInviteModal(); 536 536 } catch (error) { 537 537 console.error("Failed to update invite:", error); 538 538 alert("Failed to update invite"); ··· 542 542 } 543 543 }; 544 544 545 - (window as any).closeEditInviteModal = () => { 545 + window.closeEditInviteModal = () => { 546 546 const modal = document.getElementById("editInviteModal"); 547 547 if (modal) { 548 548 modal.style.display = "none"; ··· 557 557 } 558 558 }; 559 559 560 - (window as any).deleteInvite = async (inviteId: number, event?: Event) => { 560 + window.deleteInvite = async (inviteId: number, event?: Event) => { 561 561 const btn = event?.target as HTMLButtonElement | undefined; 562 562 563 563 // Double-click confirmation pattern 564 564 if (btn?.dataset.confirmState === "pending") { 565 565 // Second click - execute delete 566 - delete btn.dataset.confirmState; 566 + btn.dataset.confirmState = undefined; 567 567 btn.textContent = "deleting..."; 568 568 btn.disabled = true; 569 569 ··· 596 596 // Reset after 3 seconds if not confirmed 597 597 setTimeout(() => { 598 598 if (btn.dataset.confirmState === "pending") { 599 - delete btn.dataset.confirmState; 599 + btn.dataset.confirmState = undefined; 600 600 btn.textContent = originalText; 601 601 } 602 602 }, 3000);
+3 -3
src/client/apps.ts
··· 73 73 .join(""); 74 74 } 75 75 76 - (window as any).revokeApp = async (clientId: string, event?: Event) => { 76 + window.revokeApp = async (clientId: string, event?: Event) => { 77 77 const btn = event?.target as HTMLButtonElement | undefined; 78 78 79 79 // Double-click confirmation pattern 80 80 if (btn?.dataset.confirmState === "pending") { 81 81 // Second click - execute revoke 82 - delete btn.dataset.confirmState; 82 + btn.dataset.confirmState = undefined; 83 83 btn.disabled = true; 84 84 btn.textContent = "revoking..."; 85 85 ··· 127 127 // Reset after 3 seconds if not confirmed 128 128 setTimeout(() => { 129 129 if (btn.dataset.confirmState === "pending") { 130 - delete btn.dataset.confirmState; 130 + btn.dataset.confirmState = undefined; 131 131 btn.textContent = originalText; 132 132 } 133 133 }, 3000);
+8 -6
src/client/docs.ts
··· 36 36 /&lt;(\/?)([\w-]+)([\s\S]*?)&gt;/g, 37 37 (_match, slash, tag, attrs) => { 38 38 let result = `&lt;${slash}<span class="html-tag">${tag}</span>`; 39 + let replaced_attrs = attrs ?? ""; 39 40 40 - if (attrs) { 41 - attrs = attrs.replace( 41 + if (replaced_attrs) { 42 + replaced_attrs = replaced_attrs.replace( 42 43 /([\w-]+)="([^"]*)"/g, 43 44 '<span class="html-attr">$1</span>="<span class="html-string">$2</span>"', 44 45 ); 45 - attrs = attrs.replace( 46 - /(?<=\s)([\w-]+)(?=\s|$)/g, 47 - '<span class="html-attr">$1</span>', 46 + 47 + replaced_attrs = replaced_attrs.replace( 48 + /(\s)([\w-]+)(?=\s|$)/g, 49 + '$1<span class="html-attr">$2</span>', 48 50 ); 49 51 } 50 52 51 - result += attrs + "&gt;"; 53 + result += `${replaced_attrs}&gt;`; 52 54 return result; 53 55 }, 54 56 );
+31 -16
src/client/index.ts
··· 1 - import { 2 - startRegistration, 3 - } from "@simplewebauthn/browser"; 1 + import { startRegistration } from "@simplewebauthn/browser"; 4 2 5 3 const token = localStorage.getItem("indiko_session"); 6 4 const footer = document.getElementById("footer") as HTMLElement; ··· 8 6 const subtitle = document.getElementById("subtitle") as HTMLElement; 9 7 const recentApps = document.getElementById("recentApps") as HTMLElement; 10 8 const passkeysList = document.getElementById("passkeysList") as HTMLElement; 11 - const addPasskeyBtn = document.getElementById("addPasskeyBtn") as HTMLButtonElement; 9 + const addPasskeyBtn = document.getElementById( 10 + "addPasskeyBtn", 11 + ) as HTMLButtonElement; 12 12 const toast = document.getElementById("toast") as HTMLElement; 13 13 14 14 // Profile form elements ··· 320 320 const passkeys = data.passkeys as Passkey[]; 321 321 322 322 if (passkeys.length === 0) { 323 - passkeysList.innerHTML = '<div class="empty">No passkeys registered</div>'; 323 + passkeysList.innerHTML = 324 + '<div class="empty">No passkeys registered</div>'; 324 325 return; 325 326 } 326 327 327 328 passkeysList.innerHTML = passkeys 328 329 .map((passkey) => { 329 - const createdDate = new Date(passkey.created_at * 1000).toLocaleDateString(); 330 + const createdDate = new Date( 331 + passkey.created_at * 1000, 332 + ).toLocaleDateString(); 330 333 331 334 return ` 332 335 <div class="passkey-item" data-passkey-id="${passkey.id}"> ··· 336 339 </div> 337 340 <div class="passkey-actions"> 338 341 <button type="button" class="rename-passkey-btn" data-passkey-id="${passkey.id}">rename</button> 339 - ${passkeys.length > 1 ? `<button type="button" class="delete-passkey-btn" data-passkey-id="${passkey.id}">delete</button>` : ''} 342 + ${passkeys.length > 1 ? `<button type="button" class="delete-passkey-btn" data-passkey-id="${passkey.id}">delete</button>` : ""} 340 343 </div> 341 344 </div> 342 345 `; ··· 365 368 } 366 369 367 370 function showRenameForm(passkeyId: number) { 368 - const passkeyItem = document.querySelector(`[data-passkey-id="${passkeyId}"]`); 371 + const passkeyItem = document.querySelector( 372 + `[data-passkey-id="${passkeyId}"]`, 373 + ); 369 374 if (!passkeyItem) return; 370 375 371 376 const infoDiv = passkeyItem.querySelector(".passkey-info"); ··· 389 394 input.select(); 390 395 391 396 // Save button 392 - infoDiv.querySelector(".save-rename-btn")?.addEventListener("click", async () => { 393 - await renamePasskeyHandler(passkeyId, input.value); 394 - }); 397 + infoDiv 398 + .querySelector(".save-rename-btn") 399 + ?.addEventListener("click", async () => { 400 + await renamePasskeyHandler(passkeyId, input.value); 401 + }); 395 402 396 403 // Cancel button 397 - infoDiv.querySelector(".cancel-rename-btn")?.addEventListener("click", () => { 398 - loadPasskeys(); 399 - }); 404 + infoDiv 405 + .querySelector(".cancel-rename-btn") 406 + ?.addEventListener("click", () => { 407 + loadPasskeys(); 408 + }); 400 409 401 410 // Enter to save 402 411 input.addEventListener("keypress", async (e) => { ··· 443 452 } 444 453 445 454 async function deletePasskeyHandler(passkeyId: number) { 446 - if (!confirm("Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.")) { 455 + if ( 456 + !confirm( 457 + "Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.", 458 + ) 459 + ) { 447 460 return; 448 461 } 449 462 ··· 496 509 addPasskeyBtn.textContent = "verifying..."; 497 510 498 511 // Ask for a name 499 - const name = prompt("Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):"); 512 + const name = prompt( 513 + "Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):", 514 + ); 500 515 501 516 // Verify registration 502 517 const verifyRes = await fetch("/api/passkeys/add/verify", {
+1 -1
src/client/oauth-test.ts
··· 205 205 resultDiv.className = `result show ${type}`; 206 206 } 207 207 208 - function syntaxHighlightJSON(obj: any): string { 208 + function syntaxHighlightJSON(obj: unknown): string { 209 209 const json = JSON.stringify(obj, null, 2); 210 210 return json.replace( 211 211 /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,
+65 -43
src/html/admin-clients.html
··· 7 7 <title>oauth clients • admin • indiko</title> 8 8 <meta name="description" content="Manage OAuth clients and application registrations" /> 9 9 <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 10 - 10 + 11 11 <!-- Open Graph / Facebook --> 12 12 <meta property="og:type" content="website" /> 13 13 <meta property="og:title" content="OAuth Clients • Indiko Admin" /> 14 14 <meta property="og:description" content="Manage OAuth clients and application registrations" /> 15 - 15 + 16 16 <!-- Twitter --> 17 17 <meta name="twitter:card" content="summary" /> 18 18 <meta name="twitter:title" content="OAuth Clients • Indiko Admin" /> ··· 58 58 align-items: flex-start; 59 59 } 60 60 61 + footer { 62 + width: 100%; 63 + max-width: 56.25rem; 64 + padding: 1rem; 65 + text-align: center; 66 + color: var(--old-rose); 67 + font-size: 0.875rem; 68 + font-weight: 300; 69 + letter-spacing: 0.05rem; 70 + } 71 + 72 + footer a { 73 + color: var(--berry-crush); 74 + text-decoration: none; 75 + transition: color 0.2s; 76 + } 77 + 78 + footer a:hover { 79 + color: var(--rosewood); 80 + text-decoration: underline; 81 + } 82 + 61 83 .header-nav { 62 84 display: flex; 63 85 gap: 1rem; ··· 111 133 letter-spacing: -0.05rem; 112 134 } 113 135 114 - footer { 115 - width: 100%; 116 - max-width: 56.25rem; 117 - padding: 1rem; 118 - text-align: center; 119 - color: var(--old-rose); 120 - font-size: 0.875rem; 121 - font-weight: 300; 122 - letter-spacing: 0.05rem; 123 - } 124 - 125 - footer a { 126 - color: var(--berry-crush); 127 - text-decoration: none; 128 - transition: color 0.2s; 129 - } 130 - 131 - footer a:hover { 132 - color: var(--rosewood); 133 - text-decoration: underline; 134 - } 135 - 136 136 .back-link { 137 137 margin-top: 0.5rem; 138 138 font-size: 0.875rem; ··· 385 385 margin-top: 1rem; 386 386 } 387 387 388 - .btn-edit, .btn-delete, .revoke-btn { 388 + .btn-edit, 389 + .btn-delete, 390 + .revoke-btn { 389 391 padding: 0.5rem 1rem; 390 392 font-family: inherit; 391 393 font-size: 0.875rem; ··· 404 406 background: rgba(188, 141, 160, 0.3); 405 407 } 406 408 407 - .btn-delete, .revoke-btn { 409 + .btn-delete, 410 + .revoke-btn { 408 411 background: rgba(160, 70, 104, 0.2); 409 412 color: var(--lavender); 410 413 border: 2px solid var(--rosewood); 411 414 } 412 415 413 - .btn-delete:hover, .revoke-btn:hover { 416 + .btn-delete:hover, 417 + .revoke-btn:hover { 414 418 background: rgba(160, 70, 104, 0.3); 415 419 } 416 420 417 - .loading, .error, .empty { 421 + .loading, 422 + .error, 423 + .empty { 418 424 text-align: center; 419 425 padding: 2rem; 420 426 color: var(--old-rose); ··· 645 651 </div> 646 652 <div class="form-group"> 647 653 <label class="form-label" for="description">Description</label> 648 - <textarea class="form-input form-textarea" id="description" placeholder="A brief description of your application"></textarea> 654 + <textarea class="form-input form-textarea" id="description" 655 + placeholder="A brief description of your application"></textarea> 649 656 </div> 650 657 <div class="form-group"> 651 658 <label class="form-label">Redirect URIs</label> 652 659 <div id="redirectUrisList" class="redirect-uris-list"> 653 660 <div class="redirect-uri-item"> 654 - <input type="url" class="form-input redirect-uri-input" placeholder="https://example.com/auth/callback" required /> 661 + <input type="url" class="form-input redirect-uri-input" 662 + placeholder="https://example.com/auth/callback" required /> 655 663 <button type="button" class="btn-remove" onclick="removeRedirectUri(this)">remove</button> 656 664 </div> 657 665 </div> ··· 659 667 </div> 660 668 <div class="form-group"> 661 669 <label class="form-label">Available Roles (one per line)</label> 662 - <textarea class="form-input form-textarea" id="availableRoles" placeholder="admin&#10;editor&#10;viewer" style="min-height: 6rem;"></textarea> 663 - <p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Define which roles can be assigned to users for this app. Leave empty to allow free-text roles.</p> 670 + <textarea class="form-input form-textarea" id="availableRoles" 671 + placeholder="admin&#10;editor&#10;viewer" style="min-height: 6rem;"></textarea> 672 + <p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Define which roles can be 673 + assigned to users for this app. Leave empty to allow free-text roles.</p> 664 674 </div> 665 675 <div class="form-group"> 666 676 <label class="form-label" for="defaultRole">Default Role</label> 667 677 <input type="text" class="form-input" id="defaultRole" placeholder="Leave empty for no default" /> 668 - <p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Automatically assigned when users first authorize this app.</p> 678 + <p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Automatically assigned 679 + when users first authorize this app.</p> 669 680 </div> 670 681 <div class="form-actions"> 671 - <button type="button" class="btn" style="background: rgba(188, 141, 160, 0.2);" id="cancelBtn">cancel</button> 682 + <button type="button" class="btn" style="background: rgba(188, 141, 160, 0.2);" 683 + id="cancelBtn">cancel</button> 672 684 <button type="submit" class="btn">save</button> 673 685 </div> 674 686 </form> ··· 686 698 ⚠️ Save these credentials now. You won't be able to see the secret again! 687 699 </p> 688 700 <div style="margin-bottom: 1rem;"> 689 - <label style="display: block; color: var(--old-rose); font-size: 0.875rem; margin-bottom: 0.5rem;">Client ID</label> 690 - <div style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;"> 691 - <code id="generatedClientId" style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code> 701 + <label 702 + style="display: block; color: var(--old-rose); font-size: 0.875rem; margin-bottom: 0.5rem;">Client 703 + ID</label> 704 + <div 705 + style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;"> 706 + <code id="generatedClientId" 707 + style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code> 692 708 </div> 693 - <button class="btn" id="copyClientIdBtn" style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy client id</button> 709 + <button class="btn" id="copyClientIdBtn" 710 + style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy client id</button> 694 711 </div> 695 712 <div style="margin-bottom: 1rem;"> 696 - <label style="display: block; color: var(--old-rose); font-size: 0.875rem; margin-bottom: 0.5rem;">Client Secret</label> 697 - <div style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;"> 698 - <code id="generatedSecret" style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code> 713 + <label 714 + style="display: block; color: var(--old-rose); font-size: 0.875rem; margin-bottom: 0.5rem;">Client 715 + Secret</label> 716 + <div 717 + style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;"> 718 + <code id="generatedSecret" 719 + style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code> 699 720 </div> 700 - <button class="btn" id="copySecretBtn" style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy secret</button> 721 + <button class="btn" id="copySecretBtn" 722 + style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy secret</button> 701 723 </div> 702 724 </div> 703 725 </div> ··· 706 728 <script type="module" src="../client/admin-clients.ts"></script> 707 729 </body> 708 730 709 - </html> 731 + </html>
+1 -1
src/html/login.html
··· 28 28 } 29 29 30 30 main { 31 - max-width: 28rem !important; 31 + max-width: 28rem; 32 32 width: 100%; 33 33 text-align: center; 34 34 }
+16 -7
src/index.ts
··· 199 199 if (req.method === "POST") { 200 200 const url = new URL(req.url); 201 201 const userId = url.pathname.split("/")[4]; 202 - return disableUser(req, userId); 202 + return disableUser(req, userId || ""); 203 203 } 204 204 return new Response("Method not allowed", { status: 405 }); 205 205 }, ··· 207 207 if (req.method === "POST") { 208 208 const url = new URL(req.url); 209 209 const userId = url.pathname.split("/")[4]; 210 - return enableUser(req, userId); 210 + return enableUser(req, userId || ""); 211 211 } 212 212 return new Response("Method not allowed", { status: 405 }); 213 213 }, ··· 215 215 if (req.method === "PUT") { 216 216 const url = new URL(req.url); 217 217 const userId = url.pathname.split("/")[4]; 218 - return updateUserTier(req, userId); 218 + return updateUserTier(req, userId || ""); 219 219 } 220 220 return new Response("Method not allowed", { status: 405 }); 221 221 }, ··· 223 223 if (req.method === "DELETE") { 224 224 const url = new URL(req.url); 225 225 const userId = url.pathname.split("/")[4]; 226 - return deleteUser(req, userId); 226 + return deleteUser(req, userId || ""); 227 227 } 228 228 return new Response("Method not allowed", { status: 405 }); 229 229 }, ··· 365 365 366 366 if (expiredOrphans.length > 0) { 367 367 if (action === "suspend") { 368 - await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "suspend"); 368 + await updateOrphanedAccounts( 369 + { ...result, orphanedUsers: expiredOrphans }, 370 + "suspend", 371 + ); 369 372 } else if (action === "deactivate") { 370 - await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "deactivate"); 373 + await updateOrphanedAccounts( 374 + { ...result, orphanedUsers: expiredOrphans }, 375 + "deactivate", 376 + ); 371 377 } else if (action === "remove") { 372 - await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "remove"); 378 + await updateOrphanedAccounts( 379 + { ...result, orphanedUsers: expiredOrphans }, 380 + "remove", 381 + ); 373 382 } 374 383 console.log( 375 384 `[LDAP Cleanup] ${action === "remove" ? "Removed" : action === "suspend" ? "Suspended" : "Deactivated"} ${expiredOrphans.length} LDAP orphan accounts (grace period: ${gracePeriod}s)`,
+1 -1
src/ldap-cleanup.ts
··· 35 35 verifyUserExists: true, 36 36 }); 37 37 return !!user; 38 - } catch (error) { 38 + } catch { 39 39 // User not found or invalid credentials (expected for non-existence check) 40 40 return false; 41 41 }
+16 -6
src/routes/api.ts
··· 1 1 import { db } from "../db"; 2 - import { verifyDomain, validateProfileURL } from "./indieauth"; 2 + import { validateProfileURL, verifyDomain } from "./indieauth"; 3 3 4 4 function getSessionUser( 5 5 req: Request, 6 - ): { username: string; userId: number; is_admin: boolean; tier: string } | Response { 6 + ): 7 + | { username: string; userId: number; is_admin: boolean; tier: string } 8 + | Response { 7 9 const authHeader = req.headers.get("Authorization"); 8 10 9 11 if (!authHeader || !authHeader.startsWith("Bearer ")) { ··· 193 195 const origin = process.env.ORIGIN || "http://localhost:3000"; 194 196 const indikoProfileUrl = `${origin}/u/${user.username}`; 195 197 196 - const verification = await verifyDomain(validation.canonicalUrl!, indikoProfileUrl); 198 + const verification = await verifyDomain( 199 + validation.canonicalUrl || "", 200 + indikoProfileUrl, 201 + ); 197 202 if (!verification.success) { 198 203 return Response.json( 199 204 { error: verification.error || "Failed to verify domain" }, ··· 456 461 } 457 462 458 463 // Prevent disabling self 459 - if (targetUserId === user.id) { 464 + if (targetUserId === user.userId) { 460 465 return Response.json( 461 466 { error: "Cannot disable your own account" }, 462 467 { status: 400 }, ··· 508 513 return Response.json({ success: true }); 509 514 } 510 515 511 - export async function updateUserTier(req: Request, userId: string): Promise<Response> { 516 + export async function updateUserTier( 517 + req: Request, 518 + userId: string, 519 + ): Promise<Response> { 512 520 const user = getSessionUser(req); 513 521 if (user instanceof Response) { 514 522 return user; ··· 536 544 537 545 const targetUser = db 538 546 .query("SELECT id, username, tier FROM users WHERE id = ?") 539 - .get(targetUserId) as { id: number; username: string; tier: string } | undefined; 547 + .get(targetUserId) as 548 + | { id: number; username: string; tier: string } 549 + | undefined; 540 550 541 551 if (!targetUser) { 542 552 return Response.json({ error: "User not found" }, { status: 404 });
+17 -6
src/routes/auth.ts
··· 1 1 import { 2 2 type AuthenticationResponseJSON, 3 + generateAuthenticationOptions, 4 + generateRegistrationOptions, 3 5 type PublicKeyCredentialCreationOptionsJSON, 4 6 type PublicKeyCredentialRequestOptionsJSON, 5 7 type RegistrationResponseJSON, 6 8 type VerifiedAuthenticationResponse, 7 9 type VerifiedRegistrationResponse, 8 - generateAuthenticationOptions, 9 - generateRegistrationOptions, 10 10 verifyAuthenticationResponse, 11 11 verifyRegistrationResponse, 12 12 } from "@simplewebauthn/server"; 13 13 import { authenticate } from "ldap-authentication"; 14 14 import { db } from "../db"; 15 - import { checkLdapGroupMembership } from "../ldap-cleanup"; 15 + import { checkLdapGroupMembership, checkLdapUser } from "../ldap-cleanup"; 16 16 17 17 const RP_NAME = "Indiko"; 18 18 ··· 381 381 382 382 // Check if user exists and is active 383 383 const user = db 384 - .query("SELECT id, status, provisioned_via_ldap, last_ldap_verified_at FROM users WHERE username = ?") 385 - .get(username) as { id: number; status: string; provisioned_via_ldap: number; last_ldap_verified_at: number | null } | undefined; 384 + .query( 385 + "SELECT id, status, provisioned_via_ldap, last_ldap_verified_at FROM users WHERE username = ?", 386 + ) 387 + .get(username) as 388 + | { 389 + id: number; 390 + status: string; 391 + provisioned_via_ldap: number; 392 + last_ldap_verified_at: number | null; 393 + } 394 + | undefined; 386 395 387 396 if (!user) { 388 397 return Response.json({ error: "Invalid credentials" }, { status: 401 }); ··· 405 414 const existsInLdap = await checkLdapUser(username); 406 415 if (!existsInLdap) { 407 416 // User no longer exists in LDAP - suspend the account 408 - db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run(user.id); 417 + db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run( 418 + user.id, 419 + ); 409 420 return Response.json( 410 421 { error: "Invalid credentials" }, 411 422 { status: 401 },
+5 -3
src/routes/clients.ts
··· 1 - import crypto from "crypto"; 1 + import crypto from "node:crypto"; 2 2 import { nanoid } from "nanoid"; 3 3 import { db } from "../db"; 4 4 ··· 16 16 17 17 function getSessionUser( 18 18 req: Request, 19 - ): { username: string; userId: number; is_admin: boolean; tier: string } | Response { 19 + ): 20 + | { username: string; userId: number; is_admin: boolean; tier: string } 21 + | Response { 20 22 const authHeader = req.headers.get("Authorization"); 21 23 22 24 if (!authHeader || !authHeader.startsWith("Bearer ")) { ··· 119 121 if (!rolesByApp.has(app_id)) { 120 122 rolesByApp.set(app_id, []); 121 123 } 122 - rolesByApp.get(app_id)!.push(role); 124 + rolesByApp.get(app_id)?.push(role); 123 125 } 124 126 125 127 return Response.json({
+210 -79
src/routes/indieauth.ts
··· 1 - import crypto from "crypto"; 1 + import crypto from "node:crypto"; 2 + import type { BunRequest } from "bun"; 2 3 import { db } from "../db"; 3 4 4 5 interface SessionUser { ··· 53 54 username: session.username, 54 55 userId: session.id, 55 56 isAdmin: session.is_admin === 1, 57 + tier: session.tier, 56 58 }; 57 59 } 58 60 ··· 68 70 }), 69 71 ); 70 72 71 - const sessionToken = cookies["indiko_session"]; 73 + const sessionToken = cookies.indiko_session; 72 74 if (!sessionToken) return null; 73 75 74 76 const session = db ··· 127 129 } 128 130 129 131 // Validate profile URL per IndieAuth spec 130 - export function validateProfileURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } { 132 + export function validateProfileURL(urlString: string): { 133 + valid: boolean; 134 + error?: string; 135 + canonicalUrl?: string; 136 + } { 131 137 let url: URL; 132 138 try { 133 139 url = new URL(urlString); ··· 152 158 153 159 // MUST NOT contain username/password 154 160 if (url.username || url.password) { 155 - return { valid: false, error: "Profile URL must not contain username or password" }; 161 + return { 162 + valid: false, 163 + error: "Profile URL must not contain username or password", 164 + }; 156 165 } 157 166 158 167 // MUST NOT contain ports ··· 164 173 const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; 165 174 const ipv6Regex = /^\[?[0-9a-fA-F:]+\]?$/; 166 175 if (ipv4Regex.test(url.hostname) || ipv6Regex.test(url.hostname)) { 167 - return { valid: false, error: "Profile URL must use domain names, not IP addresses" }; 176 + return { 177 + valid: false, 178 + error: "Profile URL must use domain names, not IP addresses", 179 + }; 168 180 } 169 181 170 182 // MUST NOT contain single-dot or double-dot path segments 171 183 const pathSegments = url.pathname.split("/"); 172 184 if (pathSegments.includes(".") || pathSegments.includes("..")) { 173 - return { valid: false, error: "Profile URL must not contain . or .. path segments" }; 185 + return { 186 + valid: false, 187 + error: "Profile URL must not contain . or .. path segments", 188 + }; 174 189 } 175 190 176 191 return { valid: true, canonicalUrl: canonicalizeURL(urlString) }; 177 192 } 178 193 179 194 // Validate client URL per IndieAuth spec 180 - function validateClientURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } { 195 + function validateClientURL(urlString: string): { 196 + valid: boolean; 197 + error?: string; 198 + canonicalUrl?: string; 199 + } { 181 200 let url: URL; 182 201 try { 183 202 url = new URL(urlString); ··· 202 221 203 222 // MUST NOT contain username/password 204 223 if (url.username || url.password) { 205 - return { valid: false, error: "Client URL must not contain username or password" }; 224 + return { 225 + valid: false, 226 + error: "Client URL must not contain username or password", 227 + }; 206 228 } 207 229 208 230 // MUST NOT contain single-dot or double-dot path segments 209 231 const pathSegments = url.pathname.split("/"); 210 232 if (pathSegments.includes(".") || pathSegments.includes("..")) { 211 - return { valid: false, error: "Client URL must not contain . or .. path segments" }; 233 + return { 234 + valid: false, 235 + error: "Client URL must not contain . or .. path segments", 236 + }; 212 237 } 213 238 214 239 // MAY use loopback interface, but not other IP addresses ··· 217 242 if (ipv4Regex.test(url.hostname)) { 218 243 // Allow 127.0.0.1 (loopback), reject others 219 244 if (!url.hostname.startsWith("127.")) { 220 - return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" }; 245 + return { 246 + valid: false, 247 + error: 248 + "Client URL must use domain names, not IP addresses (except loopback)", 249 + }; 221 250 } 222 251 } else if (ipv6Regex.test(url.hostname)) { 223 252 // Allow ::1 (loopback), reject others 224 253 const ipv6Match = url.hostname.match(ipv6Regex); 225 254 if (ipv6Match && ipv6Match[1] !== "::1") { 226 - return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" }; 255 + return { 256 + valid: false, 257 + error: 258 + "Client URL must use domain names, not IP addresses (except loopback)", 259 + }; 227 260 } 228 261 } 229 262 ··· 234 267 function isLoopbackURL(urlString: string): boolean { 235 268 try { 236 269 const url = new URL(urlString); 237 - return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname.startsWith("127."); 270 + return ( 271 + url.hostname === "localhost" || 272 + url.hostname === "127.0.0.1" || 273 + url.hostname === "[::1]" || 274 + url.hostname.startsWith("127.") 275 + ); 238 276 } catch { 239 277 return false; 240 278 } ··· 254 292 }> { 255 293 // MUST NOT fetch loopback addresses (security requirement) 256 294 if (isLoopbackURL(clientId)) { 257 - return { success: false, error: "Cannot fetch metadata from loopback addresses" }; 295 + return { 296 + success: false, 297 + error: "Cannot fetch metadata from loopback addresses", 298 + }; 258 299 } 259 300 260 301 try { ··· 273 314 clearTimeout(timeoutId); 274 315 275 316 if (!response.ok) { 276 - return { success: false, error: `Failed to fetch client metadata: HTTP ${response.status}` }; 317 + return { 318 + success: false, 319 + error: `Failed to fetch client metadata: HTTP ${response.status}`, 320 + }; 277 321 } 278 322 279 323 const contentType = response.headers.get("content-type") || ""; ··· 284 328 285 329 // Verify client_id matches 286 330 if (metadata.client_id && metadata.client_id !== clientId) { 287 - return { success: false, error: "client_id in metadata does not match URL" }; 331 + return { 332 + success: false, 333 + error: "client_id in metadata does not match URL", 334 + }; 288 335 } 289 336 290 337 return { success: true, metadata }; ··· 295 342 const html = await response.text(); 296 343 297 344 // Extract redirect URIs from link tags 298 - const redirectUriRegex = /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 345 + const redirectUriRegex = 346 + /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 299 347 const redirectUris: string[] = []; 300 - let match: RegExpExecArray | null; 348 + let match = redirectUriRegex.exec(html); 301 349 302 - while ((match = redirectUriRegex.exec(html)) !== null) { 303 - redirectUris.push(match[1]); 350 + while (match !== null) { 351 + redirectUris.push(match[1] || ""); 352 + match = redirectUriRegex.exec(html); 304 353 } 305 354 306 355 // 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]); 356 + const redirectUriRegex2 = 357 + /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi; 358 + match = redirectUriRegex2.exec(html); 359 + while (match !== null) { 360 + if (!redirectUris.includes(match[1] || "")) { 361 + redirectUris.push(match[1] || ""); 311 362 } 363 + match = redirectUriRegex2.exec(html); 312 364 } 313 365 314 366 if (redirectUris.length > 0) { ··· 321 373 }; 322 374 } 323 375 324 - return { success: false, error: "No client metadata or redirect_uri links found in HTML" }; 376 + return { 377 + success: false, 378 + error: "No client metadata or redirect_uri links found in HTML", 379 + }; 325 380 } 326 381 327 382 return { success: false, error: "Unsupported content type" }; ··· 330 385 if (error.name === "AbortError") { 331 386 return { success: false, error: "Timeout fetching client metadata" }; 332 387 } 333 - return { success: false, error: `Failed to fetch client metadata: ${error.message}` }; 388 + return { 389 + success: false, 390 + error: `Failed to fetch client metadata: ${error.message}`, 391 + }; 334 392 } 335 393 return { success: false, error: "Failed to fetch client metadata" }; 336 394 } 337 395 } 338 396 339 397 // Verify domain has rel="me" link back to user profile 340 - export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): Promise<{ 398 + export async function verifyDomain( 399 + domainUrl: string, 400 + indikoProfileUrl: string, 401 + ): Promise<{ 341 402 success: boolean; 342 403 error?: string; 343 404 }> { ··· 359 420 360 421 if (!response.ok) { 361 422 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}` }; 423 + console.error( 424 + `[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, 425 + { 426 + status: response.status, 427 + contentType: response.headers.get("content-type"), 428 + bodyPreview: errorBody.substring(0, 200), 429 + }, 430 + ); 431 + return { 432 + success: false, 433 + error: `Failed to fetch domain: HTTP ${response.status}`, 434 + }; 368 435 } 369 436 370 437 const html = await response.text(); ··· 384 451 385 452 const relValue = relMatch[1]; 386 453 // Check if "me" is a separate word in the rel attribute 387 - if (!relValue.split(/\s+/).includes("me")) return null; 454 + if (!relValue?.split(/\s+/).includes("me")) return null; 388 455 389 456 // Extract href (handle quoted and unquoted attributes) 390 457 const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i); ··· 394 461 }; 395 462 396 463 // Process all link tags 397 - let linkMatch; 398 - while ((linkMatch = linkRegex.exec(html)) !== null) { 464 + let linkMatch = linkRegex.exec(html); 465 + while (linkMatch !== null) { 399 466 const href = processTag(linkMatch[0]); 400 467 if (href && !relMeLinks.includes(href)) { 401 468 relMeLinks.push(href); 402 469 } 470 + linkMatch = linkRegex.exec(html); 403 471 } 404 472 405 473 // Process all a tags 406 - let aMatch; 407 - while ((aMatch = aRegex.exec(html)) !== null) { 474 + let aMatch = aRegex.exec(html); 475 + while (aMatch !== null) { 408 476 const href = processTag(aMatch[0]); 409 477 if (href && !relMeLinks.includes(href)) { 410 478 relMeLinks.push(href); 411 479 } 480 + aMatch = aRegex.exec(html); 412 481 } 413 482 414 483 // Check if any rel="me" link matches the indiko profile URL 415 484 const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl); 416 - const hasRelMe = relMeLinks.some(link => { 485 + const hasRelMe = relMeLinks.some((link) => { 417 486 try { 418 487 const normalizedLink = canonicalizeURL(link); 419 488 return normalizedLink === normalizedIndikoUrl; ··· 423 492 }); 424 493 425 494 if (!hasRelMe) { 426 - console.error(`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, { 427 - foundLinks: relMeLinks, 428 - normalizedTarget: normalizedIndikoUrl, 429 - }); 495 + console.error( 496 + `[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, 497 + { 498 + foundLinks: relMeLinks, 499 + normalizedTarget: normalizedIndikoUrl, 500 + }, 501 + ); 430 502 return { 431 503 success: false, 432 504 error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`, ··· 440 512 console.error(`[verifyDomain] Timeout verifying ${domainUrl}`); 441 513 return { success: false, error: "Timeout verifying domain" }; 442 514 } 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}` }; 515 + console.error( 516 + `[verifyDomain] Error verifying ${domainUrl}: ${error.message}`, 517 + { 518 + name: error.name, 519 + stack: error.stack, 520 + }, 521 + ); 522 + return { 523 + success: false, 524 + error: `Failed to verify domain: ${error.message}`, 525 + }; 448 526 } 449 - console.error(`[verifyDomain] Unknown error verifying ${domainUrl}:`, error); 527 + console.error( 528 + `[verifyDomain] Unknown error verifying ${domainUrl}:`, 529 + error, 530 + ); 450 531 return { success: false, error: "Failed to verify domain" }; 451 532 } 452 533 } ··· 457 538 redirectUri: string, 458 539 ): Promise<{ 459 540 error?: string; 460 - app?: { name: string | null; redirect_uris: string; logo_url?: string | null }; 541 + app?: { 542 + name: string | null; 543 + redirect_uris: string; 544 + logo_url?: string | null; 545 + }; 461 546 }> { 462 547 const existing = db 463 548 .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") ··· 474 559 }; 475 560 } 476 561 477 - const canonicalClientId = validation.canonicalUrl!; 562 + const canonicalClientId = validation.canonicalUrl || ""; 478 563 479 564 // Fetch client metadata per IndieAuth spec 480 565 const metadataResult = await fetchClientMetadata(canonicalClientId); ··· 550 635 551 636 // Fetch the newly created app 552 637 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 }; 638 + .query( 639 + "SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?", 640 + ) 641 + .get(canonicalClientId) as { 642 + name: string | null; 643 + redirect_uris: string; 644 + logo_url?: string | null; 645 + }; 555 646 556 647 return { app: newApp }; 557 648 } ··· 800 891 ); 801 892 } 802 893 803 - const app = appResult.app!; 894 + const app = appResult.app as NonNullable<typeof appResult.app>; 804 895 805 896 const allowedRedirects = JSON.parse(app.redirect_uris) as string[]; 806 897 if (!allowedRedirects.includes(redirectUri)) { ··· 954 1045 ).run(Math.floor(Date.now() / 1000), user.userId, clientId); 955 1046 956 1047 const origin = process.env.ORIGIN || "http://localhost:3000"; 957 - return Response.redirect(`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`); 1048 + return Response.redirect( 1049 + `${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`, 1050 + ); 958 1051 } 959 1052 } 960 1053 ··· 1316 1409 // POST /auth/authorize - Consent form submission 1317 1410 export async function authorizePost(req: Request): Promise<Response> { 1318 1411 const contentType = req.headers.get("Content-Type"); 1319 - 1412 + 1320 1413 // Parse the request body 1321 1414 let body: Record<string, string>; 1322 1415 let formData: FormData; ··· 1334 1427 } 1335 1428 1336 1429 const grantType = body.grant_type; 1337 - 1430 + 1338 1431 // If grant_type is present, this is a token exchange request (IndieAuth profile scope only) 1339 1432 if (grantType === "authorization_code") { 1340 1433 // Create a mock request for token() function 1341 1434 const mockReq = new Request(req.url, { 1342 1435 method: "POST", 1343 1436 headers: req.headers, 1344 - body: contentType?.includes("application/x-www-form-urlencoded") 1437 + body: contentType?.includes("application/x-www-form-urlencoded") 1345 1438 ? new URLSearchParams(body).toString() 1346 1439 : JSON.stringify(body), 1347 1440 }); ··· 1373 1466 clientId = canonicalizeURL(rawClientId); 1374 1467 redirectUri = canonicalizeURL(rawRedirectUri); 1375 1468 } catch { 1376 - return new Response("Invalid client_id or redirect_uri URL format", { status: 400 }); 1469 + return new Response("Invalid client_id or redirect_uri URL format", { 1470 + status: 400, 1471 + }); 1377 1472 } 1378 1473 1379 1474 if (action === "deny") { ··· 1487 1582 let redirect_uri: string | undefined; 1488 1583 try { 1489 1584 client_id = raw_client_id ? canonicalizeURL(raw_client_id) : undefined; 1490 - redirect_uri = raw_redirect_uri ? canonicalizeURL(raw_redirect_uri) : undefined; 1585 + redirect_uri = raw_redirect_uri 1586 + ? canonicalizeURL(raw_redirect_uri) 1587 + : undefined; 1491 1588 } catch { 1492 1589 return Response.json( 1493 1590 { ··· 1502 1599 return Response.json( 1503 1600 { 1504 1601 error: "unsupported_grant_type", 1505 - error_description: "Only authorization_code and refresh_token grant types are supported", 1602 + error_description: 1603 + "Only authorization_code and refresh_token grant types are supported", 1506 1604 }, 1507 1605 { status: 400 }, 1508 1606 ); ··· 1577 1675 const expiresAt = now + expiresIn; 1578 1676 1579 1677 // 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); 1678 + db.query("UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?").run( 1679 + newAccessToken, 1680 + expiresAt, 1681 + tokenData.id, 1682 + ); 1583 1683 1584 1684 // Get user profile for me value 1585 1685 const user = db ··· 1614 1714 headers: { 1615 1715 "Content-Type": "application/json", 1616 1716 "Cache-Control": "no-store", 1617 - "Pragma": "no-cache", 1717 + Pragma: "no-cache", 1618 1718 }, 1619 1719 }, 1620 1720 ); ··· 1626 1726 .query( 1627 1727 "SELECT is_preregistered, client_secret_hash FROM apps WHERE client_id = ?", 1628 1728 ) 1629 - .get(client_id) as 1729 + .get(client_id || "") as 1630 1730 | { is_preregistered: number; client_secret_hash: string | null } 1631 1731 | undefined; 1632 1732 ··· 1727 1827 1728 1828 // Check if already used 1729 1829 if (authcode.used) { 1730 - console.error("Token endpoint: authorization code already used", { code }); 1830 + console.error("Token endpoint: authorization code already used", { 1831 + code, 1832 + }); 1731 1833 return Response.json( 1732 1834 { 1733 1835 error: "invalid_grant", ··· 1740 1842 // Check if expired 1741 1843 const now = Math.floor(Date.now() / 1000); 1742 1844 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 }); 1845 + console.error("Token endpoint: authorization code expired", { 1846 + code, 1847 + expires_at: authcode.expires_at, 1848 + now, 1849 + diff: now - authcode.expires_at, 1850 + }); 1744 1851 return Response.json( 1745 1852 { 1746 1853 error: "invalid_grant", ··· 1752 1859 1753 1860 // Verify client_id matches 1754 1861 if (authcode.client_id !== client_id) { 1755 - console.error("Token endpoint: client_id mismatch", { stored: authcode.client_id, received: client_id }); 1862 + console.error("Token endpoint: client_id mismatch", { 1863 + stored: authcode.client_id, 1864 + received: client_id, 1865 + }); 1756 1866 return Response.json( 1757 1867 { 1758 1868 error: "invalid_grant", ··· 1764 1874 1765 1875 // Verify redirect_uri matches 1766 1876 if (authcode.redirect_uri !== redirect_uri) { 1767 - console.error("Token endpoint: redirect_uri mismatch", { stored: authcode.redirect_uri, received: redirect_uri }); 1877 + console.error("Token endpoint: redirect_uri mismatch", { 1878 + stored: authcode.redirect_uri, 1879 + received: redirect_uri, 1880 + }); 1768 1881 return Response.json( 1769 1882 { 1770 1883 error: "invalid_grant", ··· 1776 1889 1777 1890 // Verify PKCE code_verifier (required for all clients per IndieAuth spec) 1778 1891 if (!verifyPKCE(code_verifier, authcode.code_challenge)) { 1779 - console.error("Token endpoint: PKCE verification failed", { code_verifier, code_challenge: authcode.code_challenge }); 1892 + console.error("Token endpoint: PKCE verification failed", { 1893 + code_verifier, 1894 + code_challenge: authcode.code_challenge, 1895 + }); 1780 1896 return Response.json( 1781 1897 { 1782 1898 error: "invalid_grant", ··· 1839 1955 1840 1956 // Validate that the user controls the requested me parameter 1841 1957 if (authcode.me && authcode.me !== meValue) { 1842 - console.error("Token endpoint: me mismatch", { requested: authcode.me, actual: meValue }); 1958 + console.error("Token endpoint: me mismatch", { 1959 + requested: authcode.me, 1960 + actual: meValue, 1961 + }); 1843 1962 return Response.json( 1844 1963 { 1845 1964 error: "invalid_grant", 1846 - error_description: "The requested identity does not match the user's verified domain", 1965 + error_description: 1966 + "The requested identity does not match the user's verified domain", 1847 1967 }, 1848 1968 { status: 400 }, 1849 1969 ); 1850 1970 } 1851 1971 1852 1972 const origin = process.env.ORIGIN || "http://localhost:3000"; 1853 - 1973 + 1854 1974 // Generate access token 1855 1975 const accessToken = crypto.randomBytes(32).toString("base64url"); 1856 1976 const expiresIn = 3600; // 1 hour ··· 1864 1984 // Store token in database with refresh token 1865 1985 db.query( 1866 1986 "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); 1987 + ).run( 1988 + accessToken, 1989 + authcode.user_id, 1990 + client_id, 1991 + scopes.join(" "), 1992 + expiresAt, 1993 + refreshToken, 1994 + refreshExpiresAt, 1995 + ); 1868 1996 1869 1997 const response: Record<string, unknown> = { 1870 1998 access_token: accessToken, ··· 1882 2010 response.role = permission.role; 1883 2011 } 1884 2012 1885 - console.log("Token endpoint: success", { me: meValue, scopes: scopes.join(" ") }); 2013 + console.log("Token endpoint: success", { 2014 + me: meValue, 2015 + scopes: scopes.join(" "), 2016 + }); 1886 2017 1887 2018 return Response.json(response, { 1888 2019 headers: { 1889 2020 "Content-Type": "application/json", 1890 2021 "Cache-Control": "no-store", 1891 - "Pragma": "no-cache", 2022 + Pragma: "no-cache", 1892 2023 }, 1893 2024 }); 1894 2025 } catch (error) { ··· 2052 2183 try { 2053 2184 // Get access token from Authorization header 2054 2185 const authHeader = req.headers.get("Authorization"); 2055 - 2186 + 2056 2187 if (!authHeader || !authHeader.startsWith("Bearer ")) { 2057 2188 return Response.json( 2058 2189 { ··· 2175 2306 } 2176 2307 2177 2308 // GET /u/:username - Public user profile (h-card) 2178 - export function userProfile(req: Request): Response { 2179 - const username = (req as any).params?.username; 2309 + export function userProfile(req: BunRequest): Response { 2310 + const username = req.params?.username; 2180 2311 if (!username) { 2181 2312 return new Response("Username required", { status: 400 }); 2182 2313 }
+10 -6
src/routes/passkeys.ts
··· 1 1 import { 2 - type RegistrationResponseJSON, 3 2 generateRegistrationOptions, 3 + type RegistrationResponseJSON, 4 4 type VerifiedRegistrationResponse, 5 5 verifyRegistrationResponse, 6 6 } from "@simplewebauthn/server"; ··· 75 75 .all(session.user_id) as Array<{ credential_id: Buffer }>; 76 76 77 77 const excludeCredentials = existingCredentials.map((cred) => ({ 78 - id: cred.credential_id, 78 + id: cred.credential_id.toString("base64url"), 79 79 type: "public-key" as const, 80 80 })); 81 81 82 82 // Generate WebAuthn registration options 83 83 const options = await generateRegistrationOptions({ 84 84 rpName: RP_NAME, 85 - rpID: process.env.RP_ID!, 85 + rpID: process.env.RP_ID || "", 86 86 userName: user.username, 87 87 userDisplayName: user.username, 88 88 attestationType: "none", ··· 133 133 } 134 134 135 135 const body = await req.json(); 136 - const { response, challenge: expectedChallenge, name } = body as { 136 + const { 137 + response, 138 + challenge: expectedChallenge, 139 + name, 140 + } = body as { 137 141 response: RegistrationResponseJSON; 138 142 challenge: string; 139 143 name?: string; ··· 167 171 verification = await verifyRegistrationResponse({ 168 172 response, 169 173 expectedChallenge: challenge.challenge, 170 - expectedOrigin: process.env.ORIGIN!, 171 - expectedRPID: process.env.RP_ID!, 174 + expectedOrigin: process.env.ORIGIN || "", 175 + expectedRPID: process.env.RP_ID || "", 172 176 }); 173 177 } catch (error) { 174 178 console.error("WebAuthn verification failed:", error);
+28
types/global.d.ts
··· 1 + export {}; 2 + 3 + declare global { 4 + interface Window { 5 + closeEditInviteModal(): void; 6 + deleteInvite(inviteId: number, event?: Event): Promise<void>; 7 + submitCreateInvite(): Promise<void>; 8 + closeCreateInviteModal(): void; 9 + editInvite(inviteId: number): Promise<void>; 10 + submitEditInvite(): Promise<void>; 11 + toggleClient(clientId: string): Promise<void>; 12 + setUserRole( 13 + clientId: string, 14 + username: string, 15 + role: string, 16 + ): Promise<void>; 17 + editClient(clientId: string): Promise<void>; 18 + deleteClient(clientId: string, event?: Event): Promise<void>; 19 + removeRedirectUri(btn: HTMLButtonElement): void; 20 + regenerateSecret(clientId: string, event?: Event): Promise<void>; 21 + revokeUserPermission( 22 + clientId: string, 23 + username: string, 24 + event?: Event, 25 + ): Promise<void>; 26 + revokeApp(clientId: string, event?: Event): Promise<void>; 27 + } 28 + }

History

1 round 0 comments
sign up or login to add to the discussion
1 commit
expand
20c6a038
From 20c6a038d5de182fbf0d5b806bd790b621c8df41 Mon Sep 17 00:00:00 2001
expand 0 comments
closed without merging