Add ldap support #1

closed
opened by avycado13.tngl.sh targeting main from [deleted fork]: main

LDAP support to create user if it does not exist in indiko but exists in linked ldap dir. Currently does not support deletion from ldap dir. if the user is deleted in ldap they will not be deleted in indiko

+8
.env.example
··· 3 3 PORT=3000 4 4 NODE_ENV="production" 5 5 DATABASE_URL=data/indiko.db 6 + 7 + # LDAP Configuration (optional) 8 + LDAP_ENABLED=false 9 + LDAP_URL=ldap://localhost:389 10 + LDAP_ADMIN_DN=cn=admin,dc=example,dc=com 11 + LDAP_ADMIN_PASSWORD=your_admin_password 12 + LDAP_USER_SEARCH_BASE=dc=example,dc=com 13 + LDAP_USERNAME_ATTRIBUTE=uid
+6
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 ··· 40 42 ``` 41 43 42 44 ### Client-Side Code 45 + 43 46 - Extract JavaScript from HTML into separate TypeScript modules in `src/client/` 44 47 - Import client modules into HTML with `<script type="module" src="../client/file.ts"></script>` 45 48 - Bun will bundle the imports automatically ··· 47 50 - 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 48 51 49 52 ### IndieAuth/OAuth 2.0 Implementation 53 + 50 54 - Full IndieAuth server supporting OAuth 2.0 with PKCE 51 55 - Authorization code flow with single-use, short-lived codes (60 seconds) 52 56 - Auto-registration of client apps on first authorization ··· 59 63 - **`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 60 64 61 65 ### Database Schema 66 + 62 67 - **users**: username, name, email, photo, url, status, role, tier, is_admin 63 68 - **tier**: User access level - 'admin' (full access), 'developer' (can create apps), 'user' (can only authenticate with apps) 64 69 - **is_admin**: Legacy flag, automatically synced with tier (1 if tier='admin', 0 otherwise) ··· 71 76 - **invites**: admin-created invite codes 72 77 73 78 ### WebAuthn/Passkey Settings 79 + 74 80 - **Registration**: residentKey="required", userVerification="required" 75 81 - **Authentication**: omit allowCredentials to show all passkeys (discoverable credentials) 76 82 - **Credential lookup**: credential_id stored as Buffer, compare using base64url string
+57
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, ··· 63 69 ``` 64 70 65 71 ### Apps (Auto-registered) 72 + 66 73 ``` 67 74 app:{client_id} -> { 68 75 client_id: string, // e.g. "https://blog.kierank.dev" ··· 74 81 ``` 75 82 76 83 ### User Permissions (Per-App) 84 + 77 85 ``` 78 86 permission:{username}:{client_id} -> { 79 87 scopes: string[], // e.g. ["profile", "email"] ··· 83 91 ``` 84 92 85 93 ### Authorization Codes (Short-lived) 94 + 86 95 ``` 87 96 authcode:{code} -> { 88 97 username: string, ··· 98 107 ``` 99 108 100 109 ### Invites 110 + 101 111 ``` 102 112 invite:{code} -> { 103 113 code: string, ··· 110 120 ``` 111 121 112 122 ### Challenges (WebAuthn) 123 + 113 124 ``` 114 125 challenge:{challenge} -> { 115 126 username: string, ··· 130 141 ### Authentication (WebAuthn/Passkey) 131 142 132 143 #### `GET /login` 144 + 133 145 - Login/registration page 134 146 - Shows passkey auth interface 135 147 - First user: admin registration flow 136 148 - With `?invite=CODE`: invite-based registration 137 149 138 150 #### `GET /auth/can-register` 151 + 139 152 - Check if open registration allowed 140 153 - Returns `{ canRegister: boolean }` 141 154 142 155 #### `POST /auth/register/options` 156 + 143 157 - Generate WebAuthn registration options 144 158 - Body: `{ username: string, inviteCode?: string }` 145 159 - Validates invite code if not first user 146 160 - Returns registration options 147 161 148 162 #### `POST /auth/register/verify` 163 + 149 164 - Verify WebAuthn registration response 150 165 - Body: `{ username: string, response: RegistrationResponseJSON, inviteCode?: string }` 151 166 - Creates user, stores credential ··· 153 168 - Returns `{ token: string, username: string }` 154 169 155 170 #### `POST /auth/login/options` 171 + 156 172 - Generate WebAuthn authentication options 157 173 - Body: `{ username: string }` 158 174 - Returns authentication options 159 175 160 176 #### `POST /auth/login/verify` 177 + 161 178 - Verify WebAuthn authentication response 162 179 - Body: `{ username: string, response: AuthenticationResponseJSON }` 163 180 - Creates session 164 181 - Returns `{ token: string, username: string }` 165 182 166 183 #### `POST /auth/logout` 184 + 167 185 - Clear session 168 186 - Requires: `Authorization: Bearer {token}` 169 187 - Returns `{ success: true }` ··· 171 189 ### IndieAuth Endpoints 172 190 173 191 #### `GET /auth/authorize` 192 + 174 193 Authorization request from client app 175 194 176 195 **Query Parameters:** 196 + 177 197 - `response_type=code` (required) 178 198 - `client_id` (required) - App's URL 179 199 - `redirect_uri` (required) - Callback URL ··· 184 204 - `me` (optional) - User's URL (hint) 185 205 186 206 **Flow:** 207 + 187 208 1. Validate parameters 188 209 2. Auto-register app if not exists 189 210 3. If no session → redirect to `/login` ··· 193 214 - If no → show consent screen 194 215 195 216 **Response:** 217 + 196 218 - HTML consent screen 197 219 - Shows: app name, requested scopes 198 220 - Buttons: "Allow" / "Deny" 199 221 200 222 #### `POST /auth/authorize` 223 + 201 224 Consent form submission (CSRF protected) 202 225 203 226 **Body:** 227 + 204 228 - `client_id` (required) 205 229 - `redirect_uri` (required) 206 230 - `state` (required) ··· 209 233 - `action` (required) - "allow" | "deny" 210 234 211 235 **Flow:** 236 + 212 237 1. Validate CSRF token 213 238 2. Validate session 214 239 3. If denied → redirect with error ··· 219 244 - Redirect to redirect_uri with code & state 220 245 221 246 **Success Response:** 247 + 222 248 ``` 223 249 HTTP/1.1 302 Found 224 250 Location: {redirect_uri}?code={authcode}&state={state} 225 251 ``` 226 252 227 253 **Error Response:** 254 + 228 255 ``` 229 256 HTTP/1.1 302 Found 230 257 Location: {redirect_uri}?error=access_denied&state={state} 231 258 ``` 232 259 233 260 #### `POST /auth/token` 261 + 234 262 Exchange authorization code for user identity (NOT CSRF protected) 235 263 236 264 **Headers:** 265 + 237 266 - `Content-Type: application/json` 238 267 239 268 **Body:** 269 + 240 270 ```json 241 271 { 242 272 "grant_type": "authorization_code", ··· 248 278 ``` 249 279 250 280 **Flow:** 281 + 251 282 1. Validate authorization code exists 252 283 2. Verify code not expired 253 284 3. Verify code not already used ··· 258 289 8. Return user identity + profile 259 290 260 291 **Success Response:** 292 + 261 293 ```json 262 294 { 263 295 "me": "https://indiko.yourdomain.com/u/kieran", ··· 271 303 ``` 272 304 273 305 **Error Response:** 306 + 274 307 ```json 275 308 { 276 309 "error": "invalid_grant", ··· 279 312 ``` 280 313 281 314 #### `GET /auth/userinfo` (Optional) 315 + 282 316 Get current user profile with bearer token 283 317 284 318 **Headers:** 319 + 285 320 - `Authorization: Bearer {access_token}` 286 321 287 322 **Response:** 323 + 288 324 ```json 289 325 { 290 326 "sub": "https://indiko.yourdomain.com/u/kieran", ··· 298 334 ### User Profile & Settings 299 335 300 336 #### `GET /settings` 337 + 301 338 User settings page (requires session) 302 339 303 340 **Shows:** 341 + 304 342 - Profile form (name, email, photo, URL) 305 343 - Connected apps list 306 344 - Revoke access buttons 307 345 - (Admin only) Invite generation 308 346 309 347 #### `POST /settings/profile` 348 + 310 349 Update user profile 311 350 312 351 **Body:** 352 + 313 353 ```json 314 354 { 315 355 "name": "Kieran Klukas", ··· 320 360 ``` 321 361 322 362 **Response:** 363 + 323 364 ```json 324 365 { 325 366 "success": true, ··· 328 369 ``` 329 370 330 371 #### `POST /settings/apps/:client_id/revoke` 372 + 331 373 Revoke app access 332 374 333 375 **Response:** 376 + 334 377 ```json 335 378 { 336 379 "success": true ··· 338 381 ``` 339 382 340 383 #### `GET /u/:username` 384 + 341 385 Public user profile page (h-card) 342 386 343 387 **Response:** 344 388 HTML page with microformats h-card: 389 + 345 390 ```html 346 391 <div class="h-card"> 347 392 <img class="u-photo" src="..."> ··· 353 398 ### Admin Endpoints 354 399 355 400 #### `POST /api/invites/create` 401 + 356 402 Create invite link (admin only) 357 403 358 404 **Headers:** 405 + 359 406 - `Authorization: Bearer {token}` 360 407 361 408 **Response:** 409 + 362 410 ```json 363 411 { 364 412 "inviteCode": "abc123xyz" ··· 370 418 ### Dashboard 371 419 372 420 #### `GET /` 421 + 373 422 Main dashboard (requires session) 374 423 375 424 **Shows:** 425 + 376 426 - User info 377 427 - Test API button 378 428 - (Admin only) Admin controls section ··· 380 430 - Invite display 381 431 382 432 #### `GET /api/hello` 433 + 383 434 Test endpoint (requires session) 384 435 385 436 **Headers:** 437 + 386 438 - `Authorization: Bearer {token}` 387 439 388 440 **Response:** 441 + 389 442 ```json 390 443 { 391 444 "message": "Hello kieran! You're authenticated with passkeys.", ··· 397 450 ## Session Behavior 398 451 399 452 ### Single Sign-On 453 + 400 454 - Once logged into indiko (valid session), subsequent app authorization requests: 401 455 - Skip passkey authentication 402 456 - Show consent screen directly ··· 405 459 - Passkey required only when session expires 406 460 407 461 ### Security 462 + 408 463 - PKCE required for all authorization flows 409 464 - Authorization codes: 410 465 - Single-use only ··· 415 470 ## Client Integration Example 416 471 417 472 ### 1. Initiate Authorization 473 + 418 474 ```javascript 419 475 const params = new URLSearchParams({ 420 476 response_type: 'code', ··· 430 486 ``` 431 487 432 488 ### 2. Handle Callback 489 + 433 490 ```javascript 434 491 // At https://blog.kierank.dev/auth/callback?code=...&state=... 435 492 const code = new URLSearchParams(window.location.search).get('code');
+27
bun.lock
··· 8 8 "@simplewebauthn/browser": "^13.2.2", 9 9 "@simplewebauthn/server": "^13.2.2", 10 10 "bun-sqlite-migrations": "^1.0.2", 11 + "ldap-authentication": "^3.3.6", 11 12 "nanoid": "^5.1.6", 12 13 }, 13 14 "devDependencies": { ··· 54 55 55 56 "@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="], 56 57 58 + "@types/asn1": ["@types/asn1@0.2.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA=="], 59 + 57 60 "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], 58 61 59 62 "@types/node": ["@types/node@25.0.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew=="], 60 63 64 + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], 65 + 61 66 "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="], 62 67 63 68 "bun-sqlite-migrations": ["bun-sqlite-migrations@1.0.2", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-WLw8q67KM+1RN7o4DqVVhmJASypuBp8fygrfA8QD5HZEjiP+E5hD1SV2dpyB7A4tFqLdUF8cdln7+Ptj5+Hz1Q=="], 64 69 65 70 "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], 66 71 72 + "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], 73 + 74 + "ldap-authentication": ["ldap-authentication@3.3.6", "", { "dependencies": { "ldapts": "^7.3.1" } }, "sha512-j8XxH5wGXhIQ3mMnoRCZTalSLmPhzEaGH4+5RIFP0Higc32fCKDRJBo+9wb5ysy9TnlaNtaf+rgdwYCD15OBpQ=="], 75 + 76 + "ldapts": ["ldapts@7.4.0", "", { "dependencies": { "@types/asn1": ">=0.2.4", "asn1": "0.2.6", "debug": "4.4.0", "strict-event-emitter-types": "2.0.0", "uuid": "11.1.0", "whatwg-url": "14.2.0" } }, "sha512-QLgx2pLvxMXY1nCc85Fx+cwVJDvC0sQ3l4CJZSl1FJ/iV8Ypfl6m+5xz4lm1lhoXcUlvhPqxEoyIj/8LR6ut+A=="], 77 + 78 + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 79 + 67 80 "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], 68 81 82 + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], 83 + 69 84 "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], 70 85 71 86 "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], 72 87 73 88 "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], 74 89 90 + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], 91 + 92 + "strict-event-emitter-types": ["strict-event-emitter-types@2.0.0", "", {}, "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA=="], 93 + 94 + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], 95 + 75 96 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 76 97 77 98 "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], ··· 80 101 81 102 "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 82 103 104 + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], 105 + 106 + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], 107 + 108 + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], 109 + 83 110 "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], 84 111 } 85 112 }
+1
package.json
··· 19 19 "@simplewebauthn/browser": "^13.2.2", 20 20 "@simplewebauthn/server": "^13.2.2", 21 21 "bun-sqlite-migrations": "^1.0.2", 22 + "ldap-authentication": "^3.3.6", 22 23 "nanoid": "^5.1.6" 23 24 } 24 25 }
+7 -3
src/client/admin-invites.ts
··· 171 171 "submitInviteBtn", 172 172 ) as HTMLButtonElement; 173 173 174 - const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : 1; 174 + const maxUses = maxUsesInput.value 175 + ? Number.parseInt(maxUsesInput.value, 10) 176 + : 1; 175 177 const expiresAt = expiresAtInput.value 176 178 ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000) 177 179 : null; ··· 187 189 'input[name="appRole"]:checked', 188 190 ); 189 191 checkedBoxes.forEach((checkbox) => { 190 - const appId = parseInt((checkbox as HTMLInputElement).value, 10); 192 + const appId = Number.parseInt((checkbox as HTMLInputElement).value, 10); 191 193 const roleSelect = appRolesContainer.querySelector( 192 194 `select.role-select[data-app-id="${appId}"]`, 193 195 ) as HTMLSelectElement; ··· 507 509 "submitEditInviteBtn", 508 510 ) as HTMLButtonElement; 509 511 510 - const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : null; 512 + const maxUses = maxUsesInput.value 513 + ? Number.parseInt(maxUsesInput.value, 10) 514 + : null; 511 515 const expiresAt = expiresAtInput.value 512 516 ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000) 513 517 : null;
+7 -7
src/client/docs.ts
··· 48 48 ); 49 49 } 50 50 51 - result += attrs + "&gt;"; 51 + result += `${attrs}&gt;`; 52 52 return result; 53 53 }, 54 54 ); ··· 462 462 const rows: string[][] = []; 463 463 464 464 // Get headers 465 - el.querySelectorAll("thead th").forEach((th) => { 465 + for (const th of el.querySelectorAll("thead th")) { 466 466 headers.push(th.textContent?.trim() || ""); 467 - }); 467 + } 468 468 469 469 // Get rows 470 470 el.querySelectorAll("tbody tr").forEach((tr) => { 471 471 const row: string[] = []; 472 - tr.querySelectorAll("td").forEach((td) => { 472 + for (const td of tr.querySelectorAll("td")) { 473 473 row.push(td.textContent?.trim() || ""); 474 - }); 474 + } 475 475 rows.push(row); 476 476 }); 477 477 ··· 479 479 if (headers.length > 0) { 480 480 lines.push(`| ${headers.join(" | ")} |`); 481 481 lines.push(`|${headers.map(() => "-------").join("|")}|`); 482 - rows.forEach((row) => { 482 + for (const row of rows) { 483 483 lines.push(`| ${row.join(" | ")} |`); 484 - }); 484 + } 485 485 lines.push(""); 486 486 } 487 487 }
+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", {
+129 -6
src/client/login.ts
··· 5 5 6 6 const loginForm = document.getElementById("loginForm") as HTMLFormElement; 7 7 const registerForm = document.getElementById("registerForm") as HTMLFormElement; 8 + const ldapForm = document.getElementById("ldapForm") as HTMLFormElement; 8 9 const message = document.getElementById("message") as HTMLDivElement; 9 10 11 + let pendingLdapUsername: string | null = null; 12 + 10 13 // Check if registration is allowed on page load 11 14 async function checkRegistrationAllowed() { 12 15 try { ··· 15 18 const inviteCode = urlParams.get("invite"); 16 19 17 20 if (inviteCode) { 21 + // Check if username is locked (from LDAP flow) 22 + const lockedUsername = urlParams.get("username"); 23 + const registerUsernameInput = document.getElementById( 24 + "registerUsername", 25 + ) as HTMLInputElement; 26 + 18 27 // Fetch invite details to show message 19 28 try { 29 + const testUsername = lockedUsername || "temp"; 20 30 const response = await fetch("/auth/register/options", { 21 31 method: "POST", 22 32 headers: { "Content-Type": "application/json" }, 23 - body: JSON.stringify({ username: "temp", inviteCode }), 33 + body: JSON.stringify({ username: testUsername, inviteCode }), 24 34 }); 25 35 26 36 if (response.ok) { ··· 38 48 if (subtitleElement) { 39 49 subtitleElement.textContent = "create your account"; 40 50 } 41 - ( 42 - document.getElementById("registerUsername") as HTMLInputElement 43 - ).placeholder = "choose username"; 51 + 52 + // If username is locked from LDAP, pre-fill and disable 53 + if (lockedUsername) { 54 + registerUsernameInput.value = lockedUsername; 55 + registerUsernameInput.readOnly = true; 56 + registerUsernameInput.style.opacity = "0.7"; 57 + registerUsernameInput.style.cursor = "not-allowed"; 58 + } else { 59 + registerUsernameInput.placeholder = "choose username"; 60 + } 61 + 44 62 ( 45 63 document.getElementById("registerBtn") as HTMLButtonElement 46 64 ).textContent = "create account"; ··· 112 130 113 131 const options = await optionsRes.json(); 114 132 133 + // Check if LDAP verification is required (user exists in LDAP but not locally) 134 + if (options.ldapVerificationRequired) { 135 + showLdapPasswordPrompt(options.username); 136 + loginBtn.disabled = false; 137 + loginBtn.textContent = "sign in"; 138 + return; 139 + } 140 + 115 141 loginBtn.textContent = "use your passkey..."; 116 142 117 143 // Start authentication ··· 212 238 213 239 showMessage("Registration successful!", "success"); 214 240 215 - // Check for return URL parameter 216 - const returnUrl = urlParams.get("return") || "/"; 241 + // Check for return URL: first sessionStorage (from LDAP flow), then URL param, fallback to / 242 + const storedRedirect = sessionStorage.getItem("postRegistrationRedirect"); 243 + const returnUrl = storedRedirect || urlParams.get("return") || "/"; 244 + 245 + // Clear the stored redirect after use 246 + if (storedRedirect) { 247 + sessionStorage.removeItem("postRegistrationRedirect"); 248 + } 217 249 218 250 const redirectTimer = setTimeout(() => { 219 251 window.location.href = returnUrl; ··· 225 257 registerBtn.textContent = "register passkey"; 226 258 } 227 259 }); 260 + 261 + // LDAP verification flow 262 + function showLdapPasswordPrompt(username: string) { 263 + pendingLdapUsername = username; 264 + 265 + // Update UI to show LDAP form 266 + const subtitleElement = document.querySelector(".subtitle"); 267 + if (subtitleElement) { 268 + subtitleElement.textContent = "verify your LDAP password"; 269 + } 270 + 271 + // Update LDAP form username display 272 + const ldapUsernameSpan = document.getElementById("ldapUsername"); 273 + if (ldapUsernameSpan) { 274 + ldapUsernameSpan.textContent = username; 275 + } 276 + 277 + // Show LDAP form, hide others 278 + loginForm.style.display = "none"; 279 + registerForm.style.display = "none"; 280 + ldapForm.style.display = "block"; 281 + 282 + showMessage( 283 + "This username exists in the linked LDAP directory. Enter your LDAP password to create your account.", 284 + "success", 285 + true, 286 + ); 287 + } 288 + 289 + ldapForm.addEventListener("submit", async (e) => { 290 + e.preventDefault(); 291 + 292 + if (!pendingLdapUsername) { 293 + showMessage("No username pending for LDAP verification"); 294 + return; 295 + } 296 + 297 + const password = (document.getElementById("ldapPassword") as HTMLInputElement) 298 + .value; 299 + const ldapBtn = document.getElementById("ldapBtn") as HTMLButtonElement; 300 + 301 + try { 302 + ldapBtn.disabled = true; 303 + ldapBtn.textContent = "verifying..."; 304 + 305 + // Get return URL for after registration 306 + const urlParams = new URLSearchParams(window.location.search); 307 + const returnUrl = urlParams.get("return") || "/"; 308 + 309 + // Verify LDAP credentials 310 + const verifyRes = await fetch("/api/ldap-verify", { 311 + method: "POST", 312 + headers: { "Content-Type": "application/json" }, 313 + body: JSON.stringify({ 314 + username: pendingLdapUsername, 315 + password: password, 316 + returnUrl: returnUrl, 317 + }), 318 + }); 319 + 320 + if (!verifyRes.ok) { 321 + const error = await verifyRes.json(); 322 + throw new Error(error.error || "LDAP verification failed"); 323 + } 324 + 325 + const result = await verifyRes.json(); 326 + 327 + if (result.success) { 328 + showMessage( 329 + "LDAP verification successful! Redirecting to setup...", 330 + "success", 331 + ); 332 + 333 + // Store return URL for after registration completes 334 + if (result.returnUrl) { 335 + sessionStorage.setItem("postRegistrationRedirect", result.returnUrl); 336 + } 337 + 338 + // Redirect to registration with the invite code and locked username 339 + const registerUrl = `/login?invite=${encodeURIComponent(result.inviteCode)}&username=${encodeURIComponent(result.username)}`; 340 + 341 + setTimeout(() => { 342 + window.location.href = registerUrl; 343 + }, 1000); 344 + } 345 + } catch (error) { 346 + showMessage((error as Error).message || "LDAP verification failed"); 347 + ldapBtn.disabled = false; 348 + ldapBtn.textContent = "verify & continue"; 349 + } 350 + });
+32 -1
src/html/login.html
··· 49 49 margin-bottom: 1rem; 50 50 } 51 51 52 - input[type="text"] { 52 + input[type="text"], 53 + input[type="password"] { 53 54 margin-bottom: 1rem; 54 55 } 55 56 57 + .ldap-user-display { 58 + background: rgba(188, 141, 160, 0.1); 59 + border-left: 3px solid var(--berry-crush); 60 + padding: 0.75rem 1rem; 61 + margin-bottom: 1rem; 62 + text-align: left; 63 + font-size: 0.875rem; 64 + } 65 + 66 + .ldap-user-display .label { 67 + color: var(--old-rose); 68 + font-size: 0.75rem; 69 + text-transform: uppercase; 70 + letter-spacing: 0.05rem; 71 + } 72 + 73 + .ldap-user-display .username { 74 + color: var(--lavender); 75 + font-weight: 600; 76 + } 77 + 56 78 button { 57 79 width: 100%; 58 80 padding: 1.25rem 2rem; ··· 95 117 autocomplete="username webauthn" /> 96 118 <button type="submit" class="secondary-btn" id="registerBtn">create passkey</button> 97 119 </form> 120 + 121 + <form id="ldapForm" style="display: none;"> 122 + <div class="ldap-user-display"> 123 + <div class="label">username</div> 124 + <div class="username" id="ldapUsername"></div> 125 + </div> 126 + <input type="password" id="ldapPassword" placeholder="LDAP password" required autocomplete="current-password" /> 127 + <button type="submit" id="ldapBtn">verify & continue</button> 128 + </form> 98 129 </div> 99 130 100 131 <div class="info">
+18 -12
src/index.ts
··· 1 1 import { env } from "bun"; 2 2 import { db } from "./db"; 3 - import adminHTML from "./html/admin.html"; 4 3 import adminClientsHTML from "./html/admin-clients.html"; 5 4 import adminInvitesHTML from "./html/admin-invites.html"; 5 + import adminHTML from "./html/admin.html"; 6 6 import appsHTML from "./html/apps.html"; 7 7 import docsHTML from "./html/docs.html"; 8 8 import indexHTML from "./html/index.html"; ··· 25 25 } from "./routes/api"; 26 26 import { 27 27 canRegister, 28 + ldapVerify, 28 29 loginOptions, 29 30 loginVerify, 30 31 registerOptions, 31 32 registerVerify, 32 33 } from "./routes/auth"; 33 - import { 34 - addPasskeyOptions, 35 - addPasskeyVerify, 36 - deletePasskey, 37 - listPasskeys, 38 - renamePasskey, 39 - } from "./routes/passkeys"; 40 34 import { 41 35 createClient, 42 36 deleteClient, ··· 61 55 userProfile, 62 56 userinfo, 63 57 } from "./routes/indieauth"; 58 + import { 59 + addPasskeyOptions, 60 + addPasskeyVerify, 61 + deletePasskey, 62 + listPasskeys, 63 + renamePasskey, 64 + } from "./routes/passkeys"; 64 65 65 66 (() => { 66 67 const required = ["ORIGIN", "RP_ID"]; ··· 197 198 if (req.method === "POST") { 198 199 const url = new URL(req.url); 199 200 const userId = url.pathname.split("/")[4]; 200 - return disableUser(req, userId); 201 + return disableUser(req, userId ? userId : ""); 201 202 } 202 203 return new Response("Method not allowed", { status: 405 }); 203 204 }, ··· 205 206 if (req.method === "POST") { 206 207 const url = new URL(req.url); 207 208 const userId = url.pathname.split("/")[4]; 208 - return enableUser(req, userId); 209 + return enableUser(req, userId ? userId : ""); 209 210 } 210 211 return new Response("Method not allowed", { status: 405 }); 211 212 }, ··· 213 214 if (req.method === "PUT") { 214 215 const url = new URL(req.url); 215 216 const userId = url.pathname.split("/")[4]; 216 - return updateUserTier(req, userId); 217 + return updateUserTier(req, userId ? userId : ""); 217 218 } 218 219 return new Response("Method not allowed", { status: 405 }); 219 220 }, ··· 221 222 if (req.method === "DELETE") { 222 223 const url = new URL(req.url); 223 224 const userId = url.pathname.split("/")[4]; 224 - return deleteUser(req, userId); 225 + return deleteUser(req, userId ? userId : ""); 225 226 } 226 227 return new Response("Method not allowed", { status: 405 }); 227 228 }, ··· 253 254 "/auth/register/verify": registerVerify, 254 255 "/auth/login/options": loginOptions, 255 256 "/auth/login/verify": loginVerify, 257 + // LDAP verification endpoint 258 + "/api/ldap-verify": (req: Request) => { 259 + if (req.method === "POST") return ldapVerify(req); 260 + return new Response("Method not allowed", { status: 405 }); 261 + }, 256 262 // Passkey management endpoints 257 263 "/api/passkeys": (req: Request) => { 258 264 if (req.method === "GET") return listPasskeys(req);
+2
src/migrations/007_add_username_to_authcodes.sql
··· 1 + -- Add username column to authcodes table for direct access without user_id lookup 2 + ALTER TABLE authcodes ADD COLUMN username TEXT NOT NULL DEFAULT '';
+4
src/migrations/008_add_ldap_username_to_invites.sql
··· 1 + -- Add ldap_username column to invites table 2 + -- When set, the invite can only be used by a user with that exact username 3 + -- Used for LDAP-verified user provisioning flow 4 + ALTER TABLE invites ADD COLUMN ldap_username TEXT DEFAULT NULL;
+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 });
+147 -9
src/routes/auth.ts
··· 1 1 import { 2 2 type AuthenticationResponseJSON, 3 - generateAuthenticationOptions, 4 - generateRegistrationOptions, 5 3 type PublicKeyCredentialCreationOptionsJSON, 6 4 type PublicKeyCredentialRequestOptionsJSON, 7 5 type RegistrationResponseJSON, 8 6 type VerifiedAuthenticationResponse, 9 7 type VerifiedRegistrationResponse, 8 + generateAuthenticationOptions, 9 + generateRegistrationOptions, 10 10 verifyAuthenticationResponse, 11 11 verifyRegistrationResponse, 12 12 } from "@simplewebauthn/server"; 13 + import { authenticate } from "ldap-authentication"; 13 14 import { db } from "../db"; 14 15 15 16 const RP_NAME = "Indiko"; ··· 66 67 // Validate invite code 67 68 const invite = db 68 69 .query( 69 - "SELECT id, max_uses, current_uses, expires_at, message FROM invites WHERE code = ?", 70 + "SELECT id, max_uses, current_uses, expires_at, message, ldap_username FROM invites WHERE code = ?", 70 71 ) 71 72 .get(inviteCode) as 72 73 | { ··· 75 76 current_uses: number; 76 77 expires_at: number | null; 77 78 message: string | null; 79 + ldap_username: string | null; 78 80 } 79 81 | undefined; 80 82 ··· 94 96 ); 95 97 } 96 98 99 + // If invite is locked to an LDAP username, enforce it 100 + if (invite.ldap_username && invite.ldap_username !== username) { 101 + return Response.json( 102 + { error: "Username must match LDAP account" }, 103 + { status: 400 }, 104 + ); 105 + } 106 + 97 107 // Store invite message to return with options 98 108 inviteMessage = invite.message; 99 109 } ··· 160 170 ); 161 171 } 162 172 163 - // Verify challenge exists and is valid 173 + if (!expectedChallenge) { 174 + return Response.json({ error: "Invalid challenge" }, { status: 400 }); 175 + } 176 + 164 177 const challenge = db 165 178 .query( 166 179 "SELECT challenge, expires_at FROM challenges WHERE challenge = ? AND username = ? AND type = 'registration'", ··· 198 211 199 212 const invite = db 200 213 .query( 201 - "SELECT id, max_uses, current_uses, expires_at FROM invites WHERE code = ?", 214 + "SELECT id, max_uses, current_uses, expires_at, ldap_username FROM invites WHERE code = ?", 202 215 ) 203 216 .get(inviteCode) as 204 217 | { ··· 206 219 max_uses: number; 207 220 current_uses: number; 208 221 expires_at: number | null; 222 + ldap_username: string | null; 209 223 } 210 224 | undefined; 211 225 ··· 225 239 ); 226 240 } 227 241 242 + // If invite is locked to an LDAP username, enforce it 243 + if (invite.ldap_username && invite.ldap_username !== username) { 244 + return Response.json( 245 + { error: "Username must match LDAP account" }, 246 + { status: 400 }, 247 + ); 248 + } 249 + 228 250 inviteId = invite.id; 229 251 230 252 // Get app role assignments for this invite ··· 239 261 verification = await verifyRegistrationResponse({ 240 262 response, 241 263 expectedChallenge: challenge.challenge, 242 - expectedOrigin: process.env.ORIGIN!, 243 - expectedRPID: process.env.RP_ID!, 264 + expectedOrigin: process.env.ORIGIN ? process.env.ORIGIN : "", 265 + expectedRPID: process.env.RP_ID ? process.env.RP_ID : "", 244 266 }); 245 267 } catch (error) { 246 268 console.error("WebAuthn verification failed:", error); ··· 352 374 .get(username) as { id: number; status: string } | undefined; 353 375 354 376 if (!user) { 377 + // Check if LDAP is enabled - if so, user may exist in LDAP and need to register 378 + if (process.env.LDAP_ENABLED === "true") { 379 + return Response.json({ 380 + ldapVerificationRequired: true, 381 + username: username, 382 + }); 383 + } 355 384 return Response.json({ error: "User not found" }, { status: 404 }); 356 385 } 357 386 ··· 471 500 expectedOrigin: process.env.ORIGIN!, 472 501 expectedRPID: process.env.RP_ID!, 473 502 credential: { 474 - id: credential.credential_id, 475 - publicKey: credential.public_key, 503 + id: credential.credential_id.toString(), 504 + publicKey: new Uint8Array(credential.public_key), 476 505 counter: credential.counter, 477 506 }, 478 507 }); ··· 525 554 return Response.json({ error: "Internal server error" }, { status: 500 }); 526 555 } 527 556 } 557 + 558 + export async function ldapVerify(req: Request): Promise<Response> { 559 + try { 560 + const body = await req.json(); 561 + const { username, password, returnUrl } = body as { 562 + username: string; 563 + password: string; 564 + returnUrl?: string; 565 + }; 566 + 567 + if (!username || !password) { 568 + return Response.json( 569 + { error: "Username and password required" }, 570 + { status: 400 }, 571 + ); 572 + } 573 + 574 + // Verify user doesn't already exist locally (race condition check) 575 + const existingUser = db 576 + .query("SELECT id FROM users WHERE username = ?") 577 + .get(username); 578 + 579 + if (existingUser) { 580 + return Response.json( 581 + { error: "Account already exists. Please use passkey login." }, 582 + { status: 400 }, 583 + ); 584 + } 585 + 586 + // Attempt LDAP bind WITH password verification 587 + let ldapUser: unknown; 588 + try { 589 + ldapUser = await authenticate({ 590 + ldapOpts: { 591 + url: process.env.LDAP_URL || "ldap://localhost:389", 592 + }, 593 + adminDn: process.env.LDAP_ADMIN_DN, 594 + adminPassword: process.env.LDAP_ADMIN_PASSWORD, 595 + userSearchBase: 596 + process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com", 597 + usernameAttribute: process.env.LDAP_USERNAME_ATTRIBUTE || "uid", 598 + username: username, 599 + userPassword: password, 600 + }); 601 + } catch (ldapError) { 602 + console.error("LDAP verification failed:", ldapError); 603 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 604 + } 605 + 606 + if (!ldapUser) { 607 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 608 + } 609 + 610 + // LDAP auth succeeded - create single-use invite locked to this username 611 + const inviteCode = crypto.randomUUID(); 612 + const expiresAt = Math.floor(Date.now() / 1000) + 600; // 10 minutes 613 + 614 + // Get an admin user to be the creator (required by NOT NULL constraint) 615 + const adminUser = db 616 + .query("SELECT id FROM users WHERE is_admin = 1 LIMIT 1") 617 + .get() as { id: number } | undefined; 618 + 619 + if (!adminUser) { 620 + return Response.json( 621 + { error: "System not configured for LDAP provisioning" }, 622 + { status: 500 }, 623 + ); 624 + } 625 + 626 + // Create the LDAP invite (max_uses=1, tied to username) 627 + db.query( 628 + "INSERT INTO invites (code, max_uses, current_uses, expires_at, created_by, message, ldap_username) VALUES (?, 1, 0, ?, ?, ?, ?)", 629 + ).run(inviteCode, expiresAt, adminUser.id, "LDAP-verified account", username); 630 + 631 + const newInviteId = db 632 + .query("SELECT id FROM invites WHERE code = ?") 633 + .get(inviteCode) as { id: number }; 634 + 635 + // Copy roles from most recent admin-created invite if exists 636 + const defaultInvite = db 637 + .query( 638 + "SELECT id FROM invites WHERE created_by IN (SELECT id FROM users WHERE is_admin = 1) ORDER BY created_at DESC LIMIT 1", 639 + ) 640 + .get() as { id: number } | undefined; 641 + 642 + if (defaultInvite) { 643 + const inviteRoles = db 644 + .query("SELECT app_id, role FROM invite_roles WHERE invite_id = ?") 645 + .all(defaultInvite.id) as Array<{ app_id: number; role: string }>; 646 + 647 + const insertRole = db.query( 648 + "INSERT INTO invite_roles (invite_id, app_id, role) VALUES (?, ?, ?)", 649 + ); 650 + for (const { app_id, role } of inviteRoles) { 651 + insertRole.run(newInviteId.id, app_id, role); 652 + } 653 + } 654 + 655 + return Response.json({ 656 + success: true, 657 + inviteCode: inviteCode, 658 + username: username, 659 + returnUrl: returnUrl || null, 660 + }); 661 + } catch (error) { 662 + console.error("LDAP verify error:", error); 663 + return Response.json({ error: "Internal server error" }, { status: 500 }); 664 + } 665 + }
+3 -1
src/routes/clients.ts
··· 16 16 17 17 function getSessionUser( 18 18 req: Request, 19 - ): { username: string; userId: number; is_admin: boolean; tier: string } | Response { 19 + ): 20 + | { username: string; userId: number; is_admin: boolean; tier: string } 21 + | Response { 20 22 const authHeader = req.headers.get("Authorization"); 21 23 22 24 if (!authHeader || !authHeader.startsWith("Bearer ")) {
+209 -72
src/routes/indieauth.ts
··· 1 - import crypto from "crypto"; 1 + import crypto from "node:crypto"; 2 2 import { db } from "../db"; 3 3 4 4 interface SessionUser { ··· 53 53 username: session.username, 54 54 userId: session.id, 55 55 isAdmin: session.is_admin === 1, 56 + tier: session.tier, 56 57 }; 57 58 } 58 59 ··· 68 69 }), 69 70 ); 70 71 71 - const sessionToken = cookies["indiko_session"]; 72 + const sessionToken = cookies.indiko_session; 72 73 if (!sessionToken) return null; 73 74 74 75 const session = db ··· 127 128 } 128 129 129 130 // Validate profile URL per IndieAuth spec 130 - export function validateProfileURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } { 131 + export function validateProfileURL(urlString: string): { 132 + valid: boolean; 133 + error?: string; 134 + canonicalUrl?: string; 135 + } { 131 136 let url: URL; 132 137 try { 133 138 url = new URL(urlString); ··· 152 157 153 158 // MUST NOT contain username/password 154 159 if (url.username || url.password) { 155 - return { valid: false, error: "Profile URL must not contain username or password" }; 160 + return { 161 + valid: false, 162 + error: "Profile URL must not contain username or password", 163 + }; 156 164 } 157 165 158 166 // MUST NOT contain ports ··· 164 172 const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; 165 173 const ipv6Regex = /^\[?[0-9a-fA-F:]+\]?$/; 166 174 if (ipv4Regex.test(url.hostname) || ipv6Regex.test(url.hostname)) { 167 - return { valid: false, error: "Profile URL must use domain names, not IP addresses" }; 175 + return { 176 + valid: false, 177 + error: "Profile URL must use domain names, not IP addresses", 178 + }; 168 179 } 169 180 170 181 // MUST NOT contain single-dot or double-dot path segments 171 182 const pathSegments = url.pathname.split("/"); 172 183 if (pathSegments.includes(".") || pathSegments.includes("..")) { 173 - return { valid: false, error: "Profile URL must not contain . or .. path segments" }; 184 + return { 185 + valid: false, 186 + error: "Profile URL must not contain . or .. path segments", 187 + }; 174 188 } 175 189 176 190 return { valid: true, canonicalUrl: canonicalizeURL(urlString) }; 177 191 } 178 192 179 193 // Validate client URL per IndieAuth spec 180 - function validateClientURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } { 194 + function validateClientURL(urlString: string): { 195 + valid: boolean; 196 + error?: string; 197 + canonicalUrl?: string; 198 + } { 181 199 let url: URL; 182 200 try { 183 201 url = new URL(urlString); ··· 202 220 203 221 // MUST NOT contain username/password 204 222 if (url.username || url.password) { 205 - return { valid: false, error: "Client URL must not contain username or password" }; 223 + return { 224 + valid: false, 225 + error: "Client URL must not contain username or password", 226 + }; 206 227 } 207 228 208 229 // MUST NOT contain single-dot or double-dot path segments 209 230 const pathSegments = url.pathname.split("/"); 210 231 if (pathSegments.includes(".") || pathSegments.includes("..")) { 211 - return { valid: false, error: "Client URL must not contain . or .. path segments" }; 232 + return { 233 + valid: false, 234 + error: "Client URL must not contain . or .. path segments", 235 + }; 212 236 } 213 237 214 238 // MAY use loopback interface, but not other IP addresses ··· 217 241 if (ipv4Regex.test(url.hostname)) { 218 242 // Allow 127.0.0.1 (loopback), reject others 219 243 if (!url.hostname.startsWith("127.")) { 220 - return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" }; 244 + return { 245 + valid: false, 246 + error: 247 + "Client URL must use domain names, not IP addresses (except loopback)", 248 + }; 221 249 } 222 250 } else if (ipv6Regex.test(url.hostname)) { 223 251 // Allow ::1 (loopback), reject others 224 252 const ipv6Match = url.hostname.match(ipv6Regex); 225 253 if (ipv6Match && ipv6Match[1] !== "::1") { 226 - return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" }; 254 + return { 255 + valid: false, 256 + error: 257 + "Client URL must use domain names, not IP addresses (except loopback)", 258 + }; 227 259 } 228 260 } 229 261 ··· 234 266 function isLoopbackURL(urlString: string): boolean { 235 267 try { 236 268 const url = new URL(urlString); 237 - return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname.startsWith("127."); 269 + return ( 270 + url.hostname === "localhost" || 271 + url.hostname === "127.0.0.1" || 272 + url.hostname === "[::1]" || 273 + url.hostname.startsWith("127.") 274 + ); 238 275 } catch { 239 276 return false; 240 277 } ··· 254 291 }> { 255 292 // MUST NOT fetch loopback addresses (security requirement) 256 293 if (isLoopbackURL(clientId)) { 257 - return { success: false, error: "Cannot fetch metadata from loopback addresses" }; 294 + return { 295 + success: false, 296 + error: "Cannot fetch metadata from loopback addresses", 297 + }; 258 298 } 259 299 260 300 try { ··· 273 313 clearTimeout(timeoutId); 274 314 275 315 if (!response.ok) { 276 - return { success: false, error: `Failed to fetch client metadata: HTTP ${response.status}` }; 316 + return { 317 + success: false, 318 + error: `Failed to fetch client metadata: HTTP ${response.status}`, 319 + }; 277 320 } 278 321 279 322 const contentType = response.headers.get("content-type") || ""; ··· 284 327 285 328 // Verify client_id matches 286 329 if (metadata.client_id && metadata.client_id !== clientId) { 287 - return { success: false, error: "client_id in metadata does not match URL" }; 330 + return { 331 + success: false, 332 + error: "client_id in metadata does not match URL", 333 + }; 288 334 } 289 335 290 336 return { success: true, metadata }; ··· 295 341 const html = await response.text(); 296 342 297 343 // Extract redirect URIs from link tags 298 - const redirectUriRegex = /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 344 + const redirectUriRegex = 345 + /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 299 346 const redirectUris: string[] = []; 300 347 let match: RegExpExecArray | null; 301 348 302 349 while ((match = redirectUriRegex.exec(html)) !== null) { 303 - redirectUris.push(match[1]); 350 + redirectUris.push(match[1] ? match[1] : ""); 304 351 } 305 352 306 353 // Also try reverse order (href before rel) 307 - const redirectUriRegex2 = /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi; 354 + const redirectUriRegex2 = 355 + /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi; 308 356 while ((match = redirectUriRegex2.exec(html)) !== null) { 309 - if (!redirectUris.includes(match[1])) { 310 - redirectUris.push(match[1]); 357 + if (!redirectUris.includes(match[1] ? match[1] : "")) { 358 + redirectUris.push(match[1] ? match[1] : ""); 311 359 } 312 360 } 313 361 ··· 321 369 }; 322 370 } 323 371 324 - return { success: false, error: "No client metadata or redirect_uri links found in HTML" }; 372 + return { 373 + success: false, 374 + error: "No client metadata or redirect_uri links found in HTML", 375 + }; 325 376 } 326 377 327 378 return { success: false, error: "Unsupported content type" }; ··· 330 381 if (error.name === "AbortError") { 331 382 return { success: false, error: "Timeout fetching client metadata" }; 332 383 } 333 - return { success: false, error: `Failed to fetch client metadata: ${error.message}` }; 384 + return { 385 + success: false, 386 + error: `Failed to fetch client metadata: ${error.message}`, 387 + }; 334 388 } 335 389 return { success: false, error: "Failed to fetch client metadata" }; 336 390 } 337 391 } 338 392 339 393 // Verify domain has rel="me" link back to user profile 340 - export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): Promise<{ 394 + export async function verifyDomain( 395 + domainUrl: string, 396 + indikoProfileUrl: string, 397 + ): Promise<{ 341 398 success: boolean; 342 399 error?: string; 343 400 }> { ··· 359 416 360 417 if (!response.ok) { 361 418 const errorBody = await response.text(); 362 - console.error(`[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, { 363 - status: response.status, 364 - contentType: response.headers.get("content-type"), 365 - bodyPreview: errorBody.substring(0, 200), 366 - }); 367 - return { success: false, error: `Failed to fetch domain: HTTP ${response.status}` }; 419 + console.error( 420 + `[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, 421 + { 422 + status: response.status, 423 + contentType: response.headers.get("content-type"), 424 + bodyPreview: errorBody.substring(0, 200), 425 + }, 426 + ); 427 + return { 428 + success: false, 429 + error: `Failed to fetch domain: HTTP ${response.status}`, 430 + }; 368 431 } 369 432 370 433 const html = await response.text(); ··· 384 447 385 448 const relValue = relMatch[1]; 386 449 // Check if "me" is a separate word in the rel attribute 387 - if (!relValue.split(/\s+/).includes("me")) return null; 450 + if (!relValue?.split(/\s+/).includes("me")) return null; 388 451 389 452 // Extract href (handle quoted and unquoted attributes) 390 453 const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i); ··· 413 476 414 477 // Check if any rel="me" link matches the indiko profile URL 415 478 const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl); 416 - const hasRelMe = relMeLinks.some(link => { 479 + const hasRelMe = relMeLinks.some((link) => { 417 480 try { 418 481 const normalizedLink = canonicalizeURL(link); 419 482 return normalizedLink === normalizedIndikoUrl; ··· 423 486 }); 424 487 425 488 if (!hasRelMe) { 426 - console.error(`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, { 427 - foundLinks: relMeLinks, 428 - normalizedTarget: normalizedIndikoUrl, 429 - }); 489 + console.error( 490 + `[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, 491 + { 492 + foundLinks: relMeLinks, 493 + normalizedTarget: normalizedIndikoUrl, 494 + }, 495 + ); 430 496 return { 431 497 success: false, 432 498 error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`, ··· 440 506 console.error(`[verifyDomain] Timeout verifying ${domainUrl}`); 441 507 return { success: false, error: "Timeout verifying domain" }; 442 508 } 443 - console.error(`[verifyDomain] Error verifying ${domainUrl}: ${error.message}`, { 444 - name: error.name, 445 - stack: error.stack, 446 - }); 447 - return { success: false, error: `Failed to verify domain: ${error.message}` }; 509 + console.error( 510 + `[verifyDomain] Error verifying ${domainUrl}: ${error.message}`, 511 + { 512 + name: error.name, 513 + stack: error.stack, 514 + }, 515 + ); 516 + return { 517 + success: false, 518 + error: `Failed to verify domain: ${error.message}`, 519 + }; 448 520 } 449 - console.error(`[verifyDomain] Unknown error verifying ${domainUrl}:`, error); 521 + console.error( 522 + `[verifyDomain] Unknown error verifying ${domainUrl}:`, 523 + error, 524 + ); 450 525 return { success: false, error: "Failed to verify domain" }; 451 526 } 452 527 } ··· 457 532 redirectUri: string, 458 533 ): Promise<{ 459 534 error?: string; 460 - app?: { name: string | null; redirect_uris: string; logo_url?: string | null }; 535 + app?: { 536 + name: string | null; 537 + redirect_uris: string; 538 + logo_url?: string | null; 539 + }; 461 540 }> { 462 541 const existing = db 463 542 .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") ··· 550 629 551 630 // Fetch the newly created app 552 631 const newApp = db 553 - .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") 554 - .get(canonicalClientId) as { name: string | null; redirect_uris: string; logo_url?: string | null }; 632 + .query( 633 + "SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?", 634 + ) 635 + .get(canonicalClientId) as { 636 + name: string | null; 637 + redirect_uris: string; 638 + logo_url?: string | null; 639 + }; 555 640 556 641 return { app: newApp }; 557 642 } ··· 936 1021 const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds 937 1022 938 1023 db.query( 939 - "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 1024 + "INSERT INTO authcodes (code, user_id, username, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", 940 1025 ).run( 941 1026 code, 942 1027 user.userId, 1028 + user.username, 943 1029 clientId, 944 1030 redirectUri, 945 1031 JSON.stringify(requestedScopes), ··· 954 1040 ).run(Math.floor(Date.now() / 1000), user.userId, clientId); 955 1041 956 1042 const origin = process.env.ORIGIN || "http://localhost:3000"; 957 - return Response.redirect(`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`); 1043 + return Response.redirect( 1044 + `${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`, 1045 + ); 958 1046 } 959 1047 } 960 1048 ··· 1316 1404 // POST /auth/authorize - Consent form submission 1317 1405 export async function authorizePost(req: Request): Promise<Response> { 1318 1406 const contentType = req.headers.get("Content-Type"); 1319 - 1407 + 1320 1408 // Parse the request body 1321 1409 let body: Record<string, string>; 1322 1410 let formData: FormData; ··· 1328 1416 body = await req.json(); 1329 1417 // Create a fake FormData for JSON requests 1330 1418 formData = new FormData(); 1331 - Object.entries(body).forEach(([key, value]) => { 1419 + for (const [key, value] of Object.entries(body)) { 1332 1420 formData.append(key, value); 1333 - }); 1421 + } 1334 1422 } 1335 1423 1336 1424 const grantType = body.grant_type; 1337 - 1425 + 1338 1426 // If grant_type is present, this is a token exchange request (IndieAuth profile scope only) 1339 1427 if (grantType === "authorization_code") { 1340 1428 // Create a mock request for token() function 1341 1429 const mockReq = new Request(req.url, { 1342 1430 method: "POST", 1343 1431 headers: req.headers, 1344 - body: contentType?.includes("application/x-www-form-urlencoded") 1432 + body: contentType?.includes("application/x-www-form-urlencoded") 1345 1433 ? new URLSearchParams(body).toString() 1346 1434 : JSON.stringify(body), 1347 1435 }); ··· 1373 1461 clientId = canonicalizeURL(rawClientId); 1374 1462 redirectUri = canonicalizeURL(rawRedirectUri); 1375 1463 } catch { 1376 - return new Response("Invalid client_id or redirect_uri URL format", { status: 400 }); 1464 + return new Response("Invalid client_id or redirect_uri URL format", { 1465 + status: 400, 1466 + }); 1377 1467 } 1378 1468 1379 1469 if (action === "deny") { ··· 1395 1485 const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds 1396 1486 1397 1487 db.query( 1398 - "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 1488 + "INSERT INTO authcodes (code, user_id, username, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", 1399 1489 ).run( 1400 1490 code, 1401 1491 user.userId, 1492 + user.username, 1402 1493 clientId, 1403 1494 redirectUri, 1404 1495 JSON.stringify(approvedScopes), ··· 1487 1578 let redirect_uri: string | undefined; 1488 1579 try { 1489 1580 client_id = raw_client_id ? canonicalizeURL(raw_client_id) : undefined; 1490 - redirect_uri = raw_redirect_uri ? canonicalizeURL(raw_redirect_uri) : undefined; 1581 + redirect_uri = raw_redirect_uri 1582 + ? canonicalizeURL(raw_redirect_uri) 1583 + : undefined; 1491 1584 } catch { 1492 1585 return Response.json( 1493 1586 { ··· 1502 1595 return Response.json( 1503 1596 { 1504 1597 error: "unsupported_grant_type", 1505 - error_description: "Only authorization_code and refresh_token grant types are supported", 1598 + error_description: 1599 + "Only authorization_code and refresh_token grant types are supported", 1506 1600 }, 1507 1601 { status: 400 }, 1508 1602 ); ··· 1577 1671 const expiresAt = now + expiresIn; 1578 1672 1579 1673 // 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); 1674 + db.query("UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?").run( 1675 + newAccessToken, 1676 + expiresAt, 1677 + tokenData.id, 1678 + ); 1583 1679 1584 1680 // Get user profile for me value 1585 1681 const user = db ··· 1614 1710 headers: { 1615 1711 "Content-Type": "application/json", 1616 1712 "Cache-Control": "no-store", 1617 - "Pragma": "no-cache", 1713 + Pragma: "no-cache", 1618 1714 }, 1619 1715 }, 1620 1716 ); ··· 1622 1718 1623 1719 // Handle authorization_code grant (existing flow) 1624 1720 // Check if client is pre-registered and requires secret 1721 + if (!client_id) { 1722 + return Response.json( 1723 + { 1724 + error: "invalid_request", 1725 + error_description: "client_id is required", 1726 + }, 1727 + { status: 400 }, 1728 + ); 1729 + } 1625 1730 const app = db 1626 1731 .query( 1627 1732 "SELECT is_preregistered, client_secret_hash FROM apps WHERE client_id = ?", ··· 1699 1804 // Look up authorization code 1700 1805 const authcode = db 1701 1806 .query( 1702 - "SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me FROM authcodes WHERE code = ?", 1807 + "SELECT user_id, username, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me FROM authcodes WHERE code = ?", 1703 1808 ) 1704 1809 .get(code) as 1705 1810 | { 1706 1811 user_id: number; 1812 + username: string; 1707 1813 client_id: string; 1708 1814 redirect_uri: string; 1709 1815 scopes: string; ··· 1727 1833 1728 1834 // Check if already used 1729 1835 if (authcode.used) { 1730 - console.error("Token endpoint: authorization code already used", { code }); 1836 + console.error("Token endpoint: authorization code already used", { 1837 + code, 1838 + }); 1731 1839 return Response.json( 1732 1840 { 1733 1841 error: "invalid_grant", ··· 1740 1848 // Check if expired 1741 1849 const now = Math.floor(Date.now() / 1000); 1742 1850 if (authcode.expires_at < now) { 1743 - console.error("Token endpoint: authorization code expired", { code, expires_at: authcode.expires_at, now, diff: now - authcode.expires_at }); 1851 + console.error("Token endpoint: authorization code expired", { 1852 + code, 1853 + expires_at: authcode.expires_at, 1854 + now, 1855 + diff: now - authcode.expires_at, 1856 + }); 1744 1857 return Response.json( 1745 1858 { 1746 1859 error: "invalid_grant", ··· 1752 1865 1753 1866 // Verify client_id matches 1754 1867 if (authcode.client_id !== client_id) { 1755 - console.error("Token endpoint: client_id mismatch", { stored: authcode.client_id, received: client_id }); 1868 + console.error("Token endpoint: client_id mismatch", { 1869 + stored: authcode.client_id, 1870 + received: client_id, 1871 + }); 1756 1872 return Response.json( 1757 1873 { 1758 1874 error: "invalid_grant", ··· 1764 1880 1765 1881 // Verify redirect_uri matches 1766 1882 if (authcode.redirect_uri !== redirect_uri) { 1767 - console.error("Token endpoint: redirect_uri mismatch", { stored: authcode.redirect_uri, received: redirect_uri }); 1883 + console.error("Token endpoint: redirect_uri mismatch", { 1884 + stored: authcode.redirect_uri, 1885 + received: redirect_uri, 1886 + }); 1768 1887 return Response.json( 1769 1888 { 1770 1889 error: "invalid_grant", ··· 1776 1895 1777 1896 // Verify PKCE code_verifier (required for all clients per IndieAuth spec) 1778 1897 if (!verifyPKCE(code_verifier, authcode.code_challenge)) { 1779 - console.error("Token endpoint: PKCE verification failed", { code_verifier, code_challenge: authcode.code_challenge }); 1898 + console.error("Token endpoint: PKCE verification failed", { 1899 + code_verifier, 1900 + code_challenge: authcode.code_challenge, 1901 + }); 1780 1902 return Response.json( 1781 1903 { 1782 1904 error: "invalid_grant", ··· 1839 1961 1840 1962 // Validate that the user controls the requested me parameter 1841 1963 if (authcode.me && authcode.me !== meValue) { 1842 - console.error("Token endpoint: me mismatch", { requested: authcode.me, actual: meValue }); 1964 + console.error("Token endpoint: me mismatch", { 1965 + requested: authcode.me, 1966 + actual: meValue, 1967 + }); 1843 1968 return Response.json( 1844 1969 { 1845 1970 error: "invalid_grant", 1846 - error_description: "The requested identity does not match the user's verified domain", 1971 + error_description: 1972 + "The requested identity does not match the user's verified domain", 1847 1973 }, 1848 1974 { status: 400 }, 1849 1975 ); 1850 1976 } 1851 1977 1852 1978 const origin = process.env.ORIGIN || "http://localhost:3000"; 1853 - 1979 + 1854 1980 // Generate access token 1855 1981 const accessToken = crypto.randomBytes(32).toString("base64url"); 1856 1982 const expiresIn = 3600; // 1 hour ··· 1864 1990 // Store token in database with refresh token 1865 1991 db.query( 1866 1992 "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); 1993 + ).run( 1994 + accessToken, 1995 + authcode.user_id, 1996 + client_id, 1997 + scopes.join(" "), 1998 + expiresAt, 1999 + refreshToken, 2000 + refreshExpiresAt, 2001 + ); 1868 2002 1869 2003 const response: Record<string, unknown> = { 1870 2004 access_token: accessToken, ··· 1882 2016 response.role = permission.role; 1883 2017 } 1884 2018 1885 - console.log("Token endpoint: success", { me: meValue, scopes: scopes.join(" ") }); 2019 + console.log("Token endpoint: success", { 2020 + me: meValue, 2021 + scopes: scopes.join(" "), 2022 + }); 1886 2023 1887 2024 return Response.json(response, { 1888 2025 headers: { 1889 2026 "Content-Type": "application/json", 1890 2027 "Cache-Control": "no-store", 1891 - "Pragma": "no-cache", 2028 + Pragma: "no-cache", 1892 2029 }, 1893 2030 }); 1894 2031 } catch (error) { ··· 2052 2189 try { 2053 2190 // Get access token from Authorization header 2054 2191 const authHeader = req.headers.get("Authorization"); 2055 - 2192 + 2056 2193 if (!authHeader || !authHeader.startsWith("Bearer ")) { 2057 2194 return Response.json( 2058 2195 {
+6 -2
src/routes/passkeys.ts
··· 1 1 import { 2 2 type RegistrationResponseJSON, 3 - generateRegistrationOptions, 4 3 type VerifiedRegistrationResponse, 4 + generateRegistrationOptions, 5 5 verifyRegistrationResponse, 6 6 } from "@simplewebauthn/server"; 7 7 import { db } from "../db"; ··· 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;
+1
src/styles.css
··· 86 86 input[type="email"], 87 87 input[type="url"], 88 88 input[type="number"], 89 + input[type="password"], 89 90 input[type="datetime-local"], 90 91 textarea { 91 92 width: 100%;