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

+14
.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 14 + LDAP_ORPHAN_ACTION=false 15 + 16 + # LDAP Group verification (optional) 17 + LDAP_GROUP_DN=cn=allowed-users,ou=groups,dc=example,dc=com 18 + LDAP_GROUP_CLASS=groupOfUniqueNames 19 + LDAP_GROUP_MEMBER_ATTRIBUTE=uniqueMember
+1
.gitignore
··· 8 8 *.db 9 9 *.db-shm 10 10 *.db-wal 11 + .DS_Store
+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
+25 -3
SECURITY.md
··· 4 4 5 5 If you discover a security vulnerability in Indiko, please report it privately: 6 6 7 - - **Email:** security@dunkirk.sh 7 + - **Email:** <security@dunkirk.sh> 8 8 - **Do not** open public issues for security vulnerabilities 9 9 - You will receive a response within 48 hours 10 10 ··· 50 50 --- 51 51 52 52 ## Known Security Considerations 53 + 54 + ### LDAP Account Provisioning ⚠️ 55 + 56 + When using LDAP authentication, accounts are provisioned on first successful LDAP login. **Important:** If a user is subsequently deleted from LDAP, their Indiko account **remains active**. This is by design—account lifecycle is managed independently from LDAP. 57 + 58 + **Admin responsibilities:** 59 + 60 + - **Audit provisioned accounts:** Query the `provisioned_via_ldap` column to identify LDAP-provisioned users 61 + - **Manual deprovisioning:** Suspended or delete accounts in Indiko when users are removed from LDAP 62 + - **Document policy:** Establish clear procedures for account deletion when LDAP users are removed 63 + 64 + **Example audit query:** 65 + 66 + ```sql 67 + SELECT username, created_at, status FROM users WHERE provisioned_via_ldap = 1; 68 + ``` 69 + 70 + To suspend an LDAP account: 71 + 72 + ```sql 73 + UPDATE users SET status = 'suspended' WHERE username = 'username_here'; 74 + ``` 53 75 54 76 ### Rate Limiting ⚠️ 55 77 ··· 177 199 178 200 ## Contact 179 201 180 - - **Security Issues:** security@dunkirk.sh 181 - - **General Support:** https://tangled.org/@dunkirk.sh/indiko 202 + - **Security Issues:** <security@dunkirk.sh> 203 + - **General Support:** <https://tangled.org/@dunkirk.sh/indiko> 182 204 - **Maintainer:** Kieran Klukas (@taciturnaxolotl) 183 205 184 206 ---
+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=="], 63 + 64 + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], 60 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 ··· 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=="], 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=="], 74 95 75 96 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 76 97 ··· 79 100 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 80 101 81 102 "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 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=="], 82 109 83 110 "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], 84 111 }
+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 }
+217
scripts/audit-ldap-orphans.ts
··· 1 + /** 2 + * LDAP Orphan Account Audit Script 3 + * 4 + * This script identifies Indiko accounts provisioned via LDAP that no longer exist in LDAP. 5 + * Useful for detecting when users have been removed from LDAP but their Indiko accounts remain active. 6 + * 7 + * Usage: bun scripts/audit-ldap-orphans.ts [--suspend | --deactivate | --dry-run] 8 + * 9 + * Flags: 10 + * --dry-run Show what would be done without making changes (default) 11 + * --suspend Set status to 'suspended' for orphaned accounts 12 + * --deactivate Set status to 'inactive' for orphaned accounts 13 + */ 14 + 15 + import { Database } from "bun:sqlite"; 16 + import * as path from "node:path"; 17 + import { authenticate } from "ldap-authentication"; 18 + 19 + // Load database 20 + const dbPath = path.join(import.meta.dir, "..", "data", "indiko.db"); 21 + const db = new Database(dbPath); 22 + 23 + // Configuration from environment 24 + const LDAP_URL = process.env.LDAP_URL || "ldap://localhost:389"; 25 + const LDAP_ADMIN_DN = process.env.LDAP_ADMIN_DN; 26 + const LDAP_ADMIN_PASSWORD = process.env.LDAP_ADMIN_PASSWORD; 27 + const LDAP_USER_SEARCH_BASE = 28 + process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com"; 29 + const LDAP_USERNAME_ATTRIBUTE = process.env.LDAP_USERNAME_ATTRIBUTE || "uid"; 30 + 31 + interface LdapUser { 32 + username: string; 33 + id: number; 34 + status: string; 35 + created_at: number; 36 + } 37 + 38 + interface AuditResult { 39 + total: number; 40 + active: number; 41 + orphaned: number; 42 + errors: number; 43 + orphanedUsers: Array<{ 44 + username: string; 45 + id: number; 46 + status: string; 47 + createdDate: string | undefined; 48 + }>; 49 + } 50 + 51 + async function checkLdapUser(username: string): Promise<boolean> { 52 + try { 53 + const user = await authenticate({ 54 + ldapOpts: { 55 + url: LDAP_URL, 56 + }, 57 + adminDn: LDAP_ADMIN_DN, 58 + adminPassword: LDAP_ADMIN_PASSWORD, 59 + userSearchBase: LDAP_USER_SEARCH_BASE, 60 + usernameAttribute: LDAP_USERNAME_ATTRIBUTE, 61 + username: username, 62 + verifyUserExists: true, 63 + }); 64 + return !!user; 65 + } catch (error) { 66 + // User not found or invalid credentials (expected for non-existence check) 67 + return false; 68 + } 69 + } 70 + 71 + async function auditLdapAccounts(): Promise<AuditResult> { 72 + console.log("🔍 Starting LDAP orphan account audit...\n"); 73 + 74 + // Get all LDAP-provisioned users 75 + const ldapUsers = db 76 + .query( 77 + "SELECT id, username, status, created_at FROM users WHERE provisioned_via_ldap = 1", 78 + ) 79 + .all() as LdapUser[]; 80 + 81 + const result: AuditResult = { 82 + total: ldapUsers.length, 83 + active: 0, 84 + orphaned: 0, 85 + errors: 0, 86 + orphanedUsers: [], 87 + }; 88 + 89 + console.log(`Found ${result.total} LDAP-provisioned accounts\n`); 90 + 91 + // Check each user against LDAP 92 + for (const user of ldapUsers) { 93 + process.stdout.write(`Checking ${user.username}... `); 94 + 95 + try { 96 + const existsInLdap = await checkLdapUser(user.username); 97 + 98 + if (existsInLdap) { 99 + console.log("✅ Found in LDAP"); 100 + result.active++; 101 + } else { 102 + console.log("❌ NOT FOUND in LDAP"); 103 + result.orphaned++; 104 + result.orphanedUsers.push({ 105 + username: user.username, 106 + id: user.id, 107 + status: user.status, 108 + createdDate: new Date(user.created_at * 1000) 109 + .toISOString() 110 + .split("T")[0], 111 + }); 112 + } 113 + } catch (error) { 114 + console.log("⚠️ Error checking LDAP"); 115 + result.errors++; 116 + console.error( 117 + ` Error: ${error instanceof Error ? error.message : String(error)}`, 118 + ); 119 + } 120 + } 121 + 122 + return result; 123 + } 124 + 125 + function printReport(result: AuditResult): void { 126 + console.log(`\n${"=".repeat(60)}`); 127 + console.log("LDAP ORPHAN ACCOUNT AUDIT REPORT"); 128 + console.log(`${"=".repeat(60)}\n`); 129 + 130 + console.log(`Total LDAP-provisioned accounts: ${result.total}`); 131 + console.log(`Active in LDAP: ${result.active}`); 132 + console.log(`Orphaned (missing from LDAP): ${result.orphaned}`); 133 + console.log(`Check errors: ${result.errors}`); 134 + 135 + if (result.orphaned === 0) { 136 + console.log("\n✅ No orphaned accounts found!"); 137 + return; 138 + } 139 + 140 + console.log(`\n${"-".repeat(60)}`); 141 + console.log("ORPHANED ACCOUNTS:"); 142 + console.log(`${"-".repeat(60)}\n`); 143 + 144 + result.orphanedUsers.forEach((user, idx) => { 145 + console.log(`${idx + 1}. ${user.username}`); 146 + console.log( 147 + ` ID: ${user.id} | Status: ${user.status} | Created: ${user.createdDate}`, 148 + ); 149 + }); 150 + } 151 + 152 + async function updateOrphanedAccounts( 153 + result: AuditResult, 154 + action: "suspend" | "deactivate", 155 + ): Promise<void> { 156 + const newStatus = action === "suspend" ? "suspended" : "inactive"; 157 + 158 + console.log( 159 + `\n📝 Updating ${result.orphaned} orphaned account(s) to status: '${newStatus}'`, 160 + ); 161 + 162 + for (const user of result.orphanedUsers) { 163 + db.query("UPDATE users SET status = ? WHERE id = ?").run( 164 + newStatus, 165 + user.id, 166 + ); 167 + console.log(` Updated: ${user.username}`); 168 + } 169 + 170 + console.log(`\n✅ Updated ${result.orphaned} account(s)`); 171 + } 172 + 173 + async function main() { 174 + // Validate LDAP configuration 175 + if (!LDAP_ADMIN_DN || !LDAP_ADMIN_PASSWORD) { 176 + console.error( 177 + "❌ Error: LDAP_ADMIN_DN and LDAP_ADMIN_PASSWORD environment variables are required", 178 + ); 179 + process.exit(1); 180 + } 181 + 182 + const args = process.argv.slice(2); 183 + const dryRun = args.includes("--dry-run") || args.length === 0; 184 + const shouldSuspend = args.includes("--suspend"); 185 + const shouldDeactivate = args.includes("--deactivate"); 186 + 187 + if (dryRun) { 188 + console.log("🔄 Running in DRY-RUN mode (no changes will be made)\n"); 189 + } 190 + 191 + try { 192 + const result = await auditLdapAccounts(); 193 + printReport(result); 194 + 195 + if (!dryRun && result.orphaned > 0) { 196 + if (shouldSuspend) { 197 + await updateOrphanedAccounts(result, "suspend"); 198 + } else if (shouldDeactivate) { 199 + await updateOrphanedAccounts(result, "deactivate"); 200 + } else { 201 + console.log( 202 + "\n⚠️ No action specified. Use --suspend or --deactivate to update accounts.", 203 + ); 204 + } 205 + } 206 + 207 + process.exit(0); 208 + } catch (error) { 209 + console.error( 210 + "\n❌ Audit failed:", 211 + error instanceof Error ? error.message : String(error), 212 + ); 213 + process.exit(1); 214 + } 215 + } 216 + 217 + main();
+1 -5
src/client/admin-clients.ts
··· 569 569 // If creating a new client, show the credentials in modal 570 570 if (!isEdit) { 571 571 const result = await response.json(); 572 - if ( 573 - result.client && 574 - result.client.clientId && 575 - result.client.clientSecret 576 - ) { 572 + if (result.client?.clientId && result.client.clientSecret) { 577 573 const secretModal = document.getElementById( 578 574 "secretModal", 579 575 ) as HTMLElement;
+8 -4
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; 194 196 195 197 let role = ""; 196 - if (roleSelect && roleSelect.value) { 198 + if (roleSelect?.value) { 197 199 role = roleSelect.value; 198 200 } 199 201 ··· 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;
+50 -51
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 ); 55 - } else { 56 - // Process CSS (inside <style> tags) 57 - return ( 58 - part 59 - .replace( 60 - /&lt;style&gt;/g, 61 - '&lt;<span class="html-tag">style</span>&gt;', 62 - ) 63 - .replace( 64 - /&lt;\/style&gt;/g, 65 - '&lt;/<span class="html-tag">style</span>&gt;', 66 - ) 67 - // CSS selectors (anything before { including pseudo-selectors) 68 - .replace( 69 - /^(\s*)([\w.-]+(?::+[\w-]+(?:\([^)]*\))?)*)\s*\{/gm, 70 - '$1<span class="css-selector">$2</span> {', 71 - ) 72 - // CSS properties (word followed by colon, but not :: for pseudo-elements) 73 - .replace( 74 - /^(\s+)([\w-]+):\s+/gm, 75 - '$1<span class="css-property">$2</span>: ', 76 - ) 77 - // CSS values (everything between property: and ;) 78 - .replace( 79 - /(<span class="css-property">[\w-]+<\/span>:\s+)([^;]+);/g, 80 - (_match, prop, value) => { 81 - const highlightedValue = value 82 - .replace( 83 - /(#[0-9a-fA-F]{3,6})/g, 84 - '<span class="css-value">$1</span>', 85 - ) 86 - .replace( 87 - /([\d.]+(?:px|rem|em|s|%))/g, 88 - '<span class="css-value">$1</span>', 89 - ) 90 - .replace(/('.*?')/g, '<span class="css-value">$1</span>') 91 - .replace( 92 - /([\w-]+\([^)]*\))/g, 93 - '<span class="css-value">$1</span>', 94 - ); 95 - return `${prop}${highlightedValue};`; 96 - }, 97 - ) 98 - ); 99 55 } 56 + // Process CSS (inside <style> tags) 57 + return ( 58 + part 59 + .replace( 60 + /&lt;style&gt;/g, 61 + '&lt;<span class="html-tag">style</span>&gt;', 62 + ) 63 + .replace( 64 + /&lt;\/style&gt;/g, 65 + '&lt;/<span class="html-tag">style</span>&gt;', 66 + ) 67 + // CSS selectors (anything before { including pseudo-selectors) 68 + .replace( 69 + /^(\s*)([\w.-]+(?::+[\w-]+(?:\([^)]*\))?)*)\s*\{/gm, 70 + '$1<span class="css-selector">$2</span> {', 71 + ) 72 + // CSS properties (word followed by colon, but not :: for pseudo-elements) 73 + .replace( 74 + /^(\s+)([\w-]+):\s+/gm, 75 + '$1<span class="css-property">$2</span>: ', 76 + ) 77 + // CSS values (everything between property: and ;) 78 + .replace( 79 + /(<span class="css-property">[\w-]+<\/span>:\s+)([^;]+);/g, 80 + (_match, prop, value) => { 81 + const highlightedValue = value 82 + .replace( 83 + /(#[0-9a-fA-F]{3,6})/g, 84 + '<span class="css-value">$1</span>', 85 + ) 86 + .replace( 87 + /([\d.]+(?:px|rem|em|s|%))/g, 88 + '<span class="css-value">$1</span>', 89 + ) 90 + .replace(/('.*?')/g, '<span class="css-value">$1</span>') 91 + .replace( 92 + /([\w-]+\([^)]*\))/g, 93 + '<span class="css-value">$1</span>', 94 + ); 95 + return `${prop}${highlightedValue};`; 96 + }, 97 + ) 98 + ); 100 99 }) 101 100 .join(""); 102 101 ··· 462 461 const rows: string[][] = []; 463 462 464 463 // Get headers 465 - el.querySelectorAll("thead th").forEach((th) => { 464 + for (const th of el.querySelectorAll("thead th")) { 466 465 headers.push(th.textContent?.trim() || ""); 467 - }); 466 + } 468 467 469 468 // Get rows 470 469 el.querySelectorAll("tbody tr").forEach((tr) => { 471 470 const row: string[] = []; 472 - tr.querySelectorAll("td").forEach((td) => { 471 + for (const td of tr.querySelectorAll("td")) { 473 472 row.push(td.textContent?.trim() || ""); 474 - }); 473 + } 475 474 rows.push(row); 476 475 }); 477 476 ··· 479 478 if (headers.length > 0) { 480 479 lines.push(`| ${headers.join(" | ")} |`); 481 480 lines.push(`|${headers.map(() => "-------").join("|")}|`); 482 - rows.forEach((row) => { 481 + for (const row of rows) { 483 482 lines.push(`| ${row.join(" | ")} |`); 484 - }); 483 + } 485 484 lines.push(""); 486 485 } 487 486 }
+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; 10 + 11 + let pendingLdapUsername: string | null = null; 9 12 10 13 // Check if registration is allowed on page load 11 14 async function checkRegistrationAllowed() { ··· 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"; ··· 111 129 } 112 130 113 131 const options = await optionsRes.json(); 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 + } 114 140 115 141 loginBtn.textContent = "use your passkey..."; 116 142 ··· 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; 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; 54 76 } 55 77 56 78 button { ··· 94 116 <input type="text" id="registerUsername" placeholder="create username" required 95 117 autocomplete="username webauthn" /> 96 118 <button type="submit" class="secondary-btn" id="registerBtn">create passkey</button> 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> 97 128 </form> 98 129 </div> 99 130
+32 -6
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"; 9 9 import loginHTML from "./html/login.html"; 10 + import { getLdapAccounts, updateOrphanedAccounts } from "./ldap-cleanup"; 10 11 import { 11 12 deleteSelfAccount, 12 13 deleteUser, ··· 25 26 } from "./routes/api"; 26 27 import { 27 28 canRegister, 29 + ldapVerify, 28 30 loginOptions, 29 31 loginVerify, 30 32 registerOptions, ··· 51 53 tokenIntrospect, 52 54 tokenRevoke, 53 55 updateInvite, 54 - userinfo, 55 56 userProfile, 57 + userinfo, 56 58 } from "./routes/indieauth"; 57 59 import { 58 60 addPasskeyOptions, ··· 197 199 if (req.method === "POST") { 198 200 const url = new URL(req.url); 199 201 const userId = url.pathname.split("/")[4]; 200 - return disableUser(req, userId); 202 + return disableUser(req, userId ? userId : ""); 201 203 } 202 204 return new Response("Method not allowed", { status: 405 }); 203 205 }, ··· 205 207 if (req.method === "POST") { 206 208 const url = new URL(req.url); 207 209 const userId = url.pathname.split("/")[4]; 208 - return enableUser(req, userId); 210 + return enableUser(req, userId ? userId : ""); 209 211 } 210 212 return new Response("Method not allowed", { status: 405 }); 211 213 }, ··· 213 215 if (req.method === "PUT") { 214 216 const url = new URL(req.url); 215 217 const userId = url.pathname.split("/")[4]; 216 - return updateUserTier(req, userId); 218 + return updateUserTier(req, userId ? userId : ""); 217 219 } 218 220 return new Response("Method not allowed", { status: 405 }); 219 221 }, ··· 221 223 if (req.method === "DELETE") { 222 224 const url = new URL(req.url); 223 225 const userId = url.pathname.split("/")[4]; 224 - return deleteUser(req, userId); 226 + return deleteUser(req, userId ? userId : ""); 225 227 } 226 228 return new Response("Method not allowed", { status: 405 }); 227 229 }, ··· 253 255 "/auth/register/verify": registerVerify, 254 256 "/auth/login/options": loginOptions, 255 257 "/auth/login/verify": loginVerify, 258 + // LDAP verification endpoint 259 + "/api/ldap-verify": (req: Request) => { 260 + if (req.method === "POST") return ldapVerify(req); 261 + return new Response("Method not allowed", { status: 405 }); 262 + }, 256 263 // Passkey management endpoints 257 264 "/api/passkeys": (req: Request) => { 258 265 if (req.method === "GET") return listPasskeys(req); ··· 339 346 } 340 347 }, 3600000); // 1 hour in milliseconds 341 348 349 + const ldapCleanupJob = 350 + process.env.LDAP_ADMIN_DN && process.env.LDAP_ADMIN_PASSWORD 351 + ? setInterval(async () => { 352 + const result = await getLdapAccounts(); 353 + const action = process.env.LDAP_ORPHAN_ACTION || "deactivate"; 354 + if (action === "suspend") { 355 + await updateOrphanedAccounts(result, "suspend"); 356 + } else if (action === "deactivate") { 357 + await updateOrphanedAccounts(result, "deactivate"); 358 + } else if (action === "remove") { 359 + await updateOrphanedAccounts(result, "remove"); 360 + } 361 + console.log( 362 + `[LDAP Cleanup] ${action === "remove" ? "Removed" : action === "suspend" ? "Suspended" : "Deactivated"} LDAP orphan accounts: ${result.total} total, ${result.active} active, ${result.orphaned} orphaned, ${result.errors} errors.`, 363 + ); 364 + }, 43200000) 365 + : null; // 12 hours in milliseconds 366 + 342 367 let is_shutting_down = false; 343 368 function shutdown(sig: string) { 344 369 if (is_shutting_down) return; ··· 347 372 console.log(`[Shutdown] triggering shutdown due to ${sig}`); 348 373 349 374 clearInterval(cleanupJob); 375 + if (ldapCleanupJob) clearInterval(ldapCleanupJob); 350 376 console.log("[Shutdown] stopped cleanup job"); 351 377 352 378 server.stop();
+158
src/ldap-cleanup.ts
··· 1 + import { authenticate } from "ldap-authentication"; 2 + import { db } from "./db"; 3 + 4 + interface LdapUser { 5 + username: string; 6 + id: number; 7 + status: string; 8 + created_at: number; 9 + } 10 + 11 + interface AuditResult { 12 + total: number; 13 + active: number; 14 + orphaned: number; 15 + errors: number; 16 + orphanedUsers: Array<{ 17 + username: string; 18 + id: number; 19 + status: string; 20 + createdDate: string | undefined; 21 + }>; 22 + } 23 + 24 + export async function checkLdapUser(username: string): Promise<boolean> { 25 + try { 26 + const user = await authenticate({ 27 + ldapOpts: { 28 + url: process.env.LDAP_URL || "ldap://localhost:389", 29 + }, 30 + adminDn: process.env.LDAP_ADMIN_DN || "", 31 + adminPassword: process.env.LDAP_ADMIN_PASSWORD || "", 32 + userSearchBase: process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com", 33 + usernameAttribute: process.env.LDAP_USERNAME_ATTRIBUTE || "uid", 34 + username: username, 35 + verifyUserExists: true, 36 + }); 37 + return !!user; 38 + } catch (error) { 39 + // User not found or invalid credentials (expected for non-existence check) 40 + return false; 41 + } 42 + } 43 + 44 + export async function checkLdapGroupMembership( 45 + username: string, 46 + userDn: string, 47 + ): Promise<boolean> { 48 + if (!process.env.LDAP_GROUP_DN) { 49 + return true; // No group restriction configured 50 + } 51 + 52 + try { 53 + const groupDn = process.env.LDAP_GROUP_DN; 54 + const groupClass = process.env.LDAP_GROUP_CLASS || "groupOfUniqueNames"; 55 + const memberAttribute = 56 + process.env.LDAP_GROUP_MEMBER_ATTRIBUTE || "uniqueMember"; 57 + 58 + const user = await authenticate({ 59 + ldapOpts: { 60 + url: process.env.LDAP_URL || "ldap://localhost:389", 61 + }, 62 + userDn: userDn, 63 + userSearchBase: process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com", 64 + usernameAttribute: process.env.LDAP_USERNAME_ATTRIBUTE || "uid", 65 + username: username, 66 + verifyUserExists: true, 67 + groupsSearchBase: groupDn, 68 + groupClass: groupClass, 69 + groupMemberAttribute: memberAttribute, 70 + }); 71 + 72 + // If user was found and authenticate returns it, groups are available 73 + return !!user; 74 + } catch (error) { 75 + console.error("LDAP group membership check failed:", error); 76 + return false; 77 + } 78 + } 79 + 80 + export async function getLdapAccounts(): Promise<AuditResult> { 81 + console.log("🔍 Starting LDAP orphan account audit...\n"); 82 + 83 + // Get all LDAP-provisioned users 84 + const ldapUsers = db 85 + .query( 86 + "SELECT id, username, status, created_at FROM users WHERE provisioned_via_ldap = 1", 87 + ) 88 + .all() as LdapUser[]; 89 + 90 + const result: AuditResult = { 91 + total: ldapUsers.length, 92 + active: 0, 93 + orphaned: 0, 94 + errors: 0, 95 + orphanedUsers: [], 96 + }; 97 + 98 + console.log(`Found ${result.total} LDAP-provisioned accounts\n`); 99 + 100 + // Check each user against LDAP 101 + for (const user of ldapUsers) { 102 + process.stdout.write(`Checking ${user.username}... `); 103 + 104 + try { 105 + const existsInLdap = await checkLdapUser(user.username); 106 + 107 + if (existsInLdap) { 108 + console.log("✅ Found in LDAP"); 109 + result.active++; 110 + } else { 111 + console.log("❌ NOT FOUND in LDAP"); 112 + result.orphaned++; 113 + result.orphanedUsers.push({ 114 + username: user.username, 115 + id: user.id, 116 + status: user.status, 117 + createdDate: new Date(user.created_at * 1000) 118 + .toISOString() 119 + .split("T")[0], 120 + }); 121 + } 122 + } catch (error) { 123 + console.log("⚠️ Error checking LDAP"); 124 + result.errors++; 125 + console.error( 126 + ` Error: ${error instanceof Error ? error.message : String(error)}`, 127 + ); 128 + } 129 + } 130 + 131 + return result; 132 + } 133 + 134 + export async function updateOrphanedAccounts( 135 + result: AuditResult, 136 + action: "suspend" | "deactivate" | "remove", 137 + ): Promise<void> { 138 + const newStatus = action === "suspend" ? "suspended" : "inactive"; 139 + 140 + console.log( 141 + `\n📝 Updating ${result.orphaned} orphaned account(s) to status: '${newStatus}'`, 142 + ); 143 + 144 + for (const user of result.orphanedUsers) { 145 + if (action === "remove") { 146 + db.query("DELETE FROM users WHERE id = ?").run(user.id); 147 + console.log(` Removed: ${user.username}`); 148 + continue; 149 + } 150 + db.query("UPDATE users SET status = ? WHERE id = ?").run( 151 + newStatus, 152 + user.id, 153 + ); 154 + console.log(` Updated: ${user.username}`); 155 + } 156 + 157 + console.log(`\n✅ Updated ${result.orphaned} account(s)`); 158 + }
+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;
+4
src/migrations/009_add_ldap_provisioned_flag.sql
··· 1 + -- Add provisioned_via_ldap flag for audit purposes 2 + -- Allows admins to identify LDAP-provisioned accounts 3 + -- Important: If user is deleted from LDAP, the account remains active but this flag tracks its origin 4 + ALTER TABLE users ADD COLUMN provisioned_via_ldap INTEGER NOT NULL DEFAULT 0;
+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 });
+204 -24
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"; 15 + import { checkLdapGroupMembership } from "../ldap-cleanup"; 14 16 15 17 const RP_NAME = "Indiko"; 16 18 ··· 66 68 // Validate invite code 67 69 const invite = db 68 70 .query( 69 - "SELECT id, max_uses, current_uses, expires_at, message FROM invites WHERE code = ?", 71 + "SELECT id, max_uses, current_uses, expires_at, message, ldap_username FROM invites WHERE code = ?", 70 72 ) 71 73 .get(inviteCode) as 72 74 | { ··· 75 77 current_uses: number; 76 78 expires_at: number | null; 77 79 message: string | null; 80 + ldap_username: string | null; 78 81 } 79 82 | undefined; 80 83 ··· 87 90 return Response.json({ error: "Invite code expired" }, { status: 403 }); 88 91 } 89 92 90 - if (invite.current_uses >= invite.max_uses) { 93 + // Will check usage limit atomically during update 94 + 95 + // If invite is locked to an LDAP username, enforce it 96 + if (invite.ldap_username && invite.ldap_username !== username) { 91 97 return Response.json( 92 - { error: "Invite code fully used" }, 93 - { status: 403 }, 98 + { error: "Username must match LDAP account" }, 99 + { status: 400 }, 94 100 ); 95 101 } 96 102 ··· 160 166 ); 161 167 } 162 168 163 - // Verify challenge exists and is valid 169 + if (!expectedChallenge) { 170 + return Response.json({ error: "Invalid challenge" }, { status: 400 }); 171 + } 172 + 164 173 const challenge = db 165 174 .query( 166 175 "SELECT challenge, expires_at FROM challenges WHERE challenge = ? AND username = ? AND type = 'registration'", ··· 198 207 199 208 const invite = db 200 209 .query( 201 - "SELECT id, max_uses, current_uses, expires_at FROM invites WHERE code = ?", 210 + "SELECT id, max_uses, current_uses, expires_at, ldap_username FROM invites WHERE code = ?", 202 211 ) 203 212 .get(inviteCode) as 204 213 | { ··· 206 215 max_uses: number; 207 216 current_uses: number; 208 217 expires_at: number | null; 218 + ldap_username: string | null; 209 219 } 210 220 | undefined; 211 221 ··· 218 228 return Response.json({ error: "Invite code expired" }, { status: 403 }); 219 229 } 220 230 221 - if (invite.current_uses >= invite.max_uses) { 231 + // If invite is locked to an LDAP username, enforce it 232 + if (invite.ldap_username && invite.ldap_username !== username) { 222 233 return Response.json( 223 - { error: "Invite code fully used" }, 224 - { status: 403 }, 234 + { error: "Username must match LDAP account" }, 235 + { status: 400 }, 225 236 ); 226 237 } 227 238 ··· 239 250 verification = await verifyRegistrationResponse({ 240 251 response, 241 252 expectedChallenge: challenge.challenge, 242 - expectedOrigin: process.env.ORIGIN!, 243 - expectedRPID: process.env.RP_ID!, 253 + expectedOrigin: process.env.ORIGIN ? process.env.ORIGIN : "", 254 + expectedRPID: process.env.RP_ID ? process.env.RP_ID : "", 244 255 }); 245 256 } catch (error) { 246 257 console.error("WebAuthn verification failed:", error); ··· 253 264 254 265 const { credential } = verification.registrationInfo; 255 266 267 + // Check if this user is being provisioned via LDAP 268 + let isLdapProvisioned = false; 269 + if (inviteId) { 270 + const invite = db 271 + .query("SELECT ldap_username FROM invites WHERE id = ?") 272 + .get(inviteId) as { ldap_username: string | null } | undefined; 273 + isLdapProvisioned = 274 + invite?.ldap_username !== null && invite?.ldap_username !== undefined; 275 + } 276 + 256 277 // Create user (bootstrap is always admin, invited users are regular users) 257 278 const insertUser = db.query( 258 - "INSERT INTO users (username, name, is_admin, tier, role) VALUES (?, ?, ?, ?, ?) RETURNING id", 279 + "INSERT INTO users (username, name, is_admin, tier, role, provisioned_via_ldap) VALUES (?, ?, ?, ?, ?, ?) RETURNING id", 259 280 ); 260 281 const user = insertUser.get( 261 282 username, ··· 263 284 isBootstrap ? 1 : 0, 264 285 isBootstrap ? "admin" : "user", 265 286 isBootstrap ? "admin" : "user", 287 + isLdapProvisioned ? 1 : 0, 266 288 ) as { 267 289 id: number; 268 290 }; ··· 283 305 if (inviteId) { 284 306 const usedAt = Math.floor(Date.now() / 1000); 285 307 286 - // Increment invite usage counter 287 - db.query( 288 - "UPDATE invites SET current_uses = current_uses + 1 WHERE id = ?", 289 - ).run(inviteId); 308 + // Atomically increment invite usage counter while checking max_uses limit 309 + const result = db 310 + .query( 311 + "UPDATE invites SET current_uses = current_uses + 1 WHERE id = ? AND current_uses < max_uses", 312 + ) 313 + .run(inviteId); 314 + 315 + // Check if update was successful (0 rows affected means invite was already fully used) 316 + if (result.changes === 0) { 317 + return Response.json( 318 + { error: "Invite code fully used" }, 319 + { status: 403 }, 320 + ); 321 + } 290 322 291 323 // Record this invite use 292 324 db.query( ··· 352 384 .get(username) as { id: number; status: string } | undefined; 353 385 354 386 if (!user) { 355 - return Response.json({ error: "User not found" }, { status: 404 }); 387 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 356 388 } 357 389 358 390 if (user.status !== "active") { ··· 365 397 .all(user.id) as { credential_id: Buffer }[]; 366 398 367 399 if (credentials.length === 0) { 368 - return Response.json({ error: "No credentials found" }, { status: 404 }); 400 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 369 401 } 370 402 371 403 // Generate authentication options ··· 382 414 "INSERT INTO challenges (challenge, username, type, expires_at) VALUES (?, ?, 'authentication', ?)", 383 415 ).run(options.challenge, username, expiresAt); 384 416 385 - return Response.json(options); 417 + // Local user always uses passkey login, no LDAP verification needed 418 + return Response.json({ 419 + ...options, 420 + ldapVerificationRequired: false, 421 + }); 386 422 } catch (error) { 387 423 console.error("Login options error:", error); 388 424 return Response.json({ error: "Internal server error" }, { status: 500 }); ··· 428 464 429 465 // Check if user account is active 430 466 if (credentialWithUser.status !== "active") { 431 - return Response.json({ error: "Account is suspended" }, { status: 403 }); 467 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 432 468 } 433 469 434 470 // Verify the username matches ··· 471 507 expectedOrigin: process.env.ORIGIN!, 472 508 expectedRPID: process.env.RP_ID!, 473 509 credential: { 474 - id: credential.credential_id, 475 - publicKey: credential.public_key, 510 + id: credential.credential_id.toString(), 511 + publicKey: new Uint8Array(credential.public_key), 476 512 counter: credential.counter, 477 513 }, 478 514 }); ··· 525 561 return Response.json({ error: "Internal server error" }, { status: 500 }); 526 562 } 527 563 } 564 + 565 + export async function ldapVerify(req: Request): Promise<Response> { 566 + try { 567 + const body = await req.json(); 568 + const { username, password, returnUrl } = body as { 569 + username: string; 570 + password: string; 571 + returnUrl?: string; 572 + }; 573 + 574 + // Check if LDAP is configured 575 + if (!process.env.LDAP_ADMIN_DN || !process.env.LDAP_ADMIN_PASSWORD) { 576 + return Response.json( 577 + { error: "LDAP is not configured" }, 578 + { status: 400 }, 579 + ); 580 + } 581 + 582 + if ( 583 + !username || 584 + username.length > 128 || 585 + !/^[A-Za-z0-9._@-]+$/.test(username) 586 + ) { 587 + return Response.json( 588 + { error: "Invalid username format" }, 589 + { status: 400 }, 590 + ); 591 + } 592 + 593 + // Verify user doesn't already exist locally (race condition check) 594 + const existingUser = db 595 + .query("SELECT id FROM users WHERE username = ?") 596 + .get(username); 597 + 598 + if (existingUser) { 599 + return Response.json( 600 + { error: "Account already exists. Please use passkey login." }, 601 + { status: 400 }, 602 + ); 603 + } 604 + 605 + // Attempt LDAP bind WITH password verification 606 + let ldapUser: unknown; 607 + let userDn: string | null = null; 608 + try { 609 + ldapUser = await authenticate({ 610 + ldapOpts: { 611 + url: process.env.LDAP_URL || "ldap://localhost:389", 612 + }, 613 + adminDn: process.env.LDAP_ADMIN_DN || "", 614 + adminPassword: process.env.LDAP_ADMIN_PASSWORD || "", 615 + userSearchBase: 616 + process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com", 617 + usernameAttribute: process.env.LDAP_USERNAME_ATTRIBUTE || "uid", 618 + username: username, 619 + userPassword: password, 620 + }); 621 + 622 + // Extract userDn from the returned user object 623 + if (ldapUser && typeof ldapUser === "object" && "dn" in ldapUser) { 624 + userDn = (ldapUser as { dn: string }).dn; 625 + } 626 + } catch (ldapError) { 627 + console.error("LDAP verification failed:", ldapError); 628 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 629 + } 630 + 631 + if (!ldapUser) { 632 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 633 + } 634 + 635 + // Check group membership if configured 636 + if (userDn) { 637 + const isInGroup = await checkLdapGroupMembership(username, userDn); 638 + if (!isInGroup) { 639 + return Response.json( 640 + { error: "User is not a member of the required group" }, 641 + { status: 403 }, 642 + ); 643 + } 644 + } 645 + 646 + // LDAP auth succeeded - create single-use invite locked to this username 647 + const inviteCode = crypto.randomUUID(); 648 + const expiresAt = Math.floor(Date.now() / 1000) + 600; // 10 minutes 649 + 650 + // Get an admin user to be the creator (required by NOT NULL constraint) 651 + const adminUser = db 652 + .query("SELECT id FROM users WHERE is_admin = 1 LIMIT 1") 653 + .get() as { id: number } | undefined; 654 + 655 + if (!adminUser) { 656 + return Response.json( 657 + { error: "System not configured for LDAP provisioning" }, 658 + { status: 500 }, 659 + ); 660 + } 661 + 662 + // Create the LDAP invite (max_uses=1, tied to username) 663 + db.query( 664 + "INSERT INTO invites (code, max_uses, current_uses, expires_at, created_by, message, ldap_username) VALUES (?, 1, 0, ?, ?, ?, ?)", 665 + ).run( 666 + inviteCode, 667 + expiresAt, 668 + adminUser.id, 669 + "LDAP-verified account", 670 + username, 671 + ); 672 + 673 + const newInviteId = db 674 + .query("SELECT id FROM invites WHERE code = ?") 675 + .get(inviteCode) as { id: number }; 676 + 677 + // Copy roles from most recent admin-created invite if exists 678 + const defaultInvite = db 679 + .query( 680 + "SELECT id FROM invites WHERE created_by IN (SELECT id FROM users WHERE is_admin = 1) ORDER BY created_at DESC LIMIT 1", 681 + ) 682 + .get() as { id: number } | undefined; 683 + 684 + if (defaultInvite) { 685 + const inviteRoles = db 686 + .query("SELECT app_id, role FROM invite_roles WHERE invite_id = ?") 687 + .all(defaultInvite.id) as Array<{ app_id: number; role: string }>; 688 + 689 + const insertRole = db.query( 690 + "INSERT INTO invite_roles (invite_id, app_id, role) VALUES (?, ?, ?)", 691 + ); 692 + for (const { app_id, role } of inviteRoles) { 693 + insertRole.run(newInviteId.id, app_id, role); 694 + } 695 + } 696 + 697 + return Response.json({ 698 + success: true, 699 + inviteCode: inviteCode, 700 + username: username, 701 + returnUrl: returnUrl || null, 702 + }); 703 + } catch (error) { 704 + console.error("LDAP verify error:", error); 705 + return Response.json({ error: "Internal server error" }, { status: 500 }); 706 + } 707 + }
+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({
+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 {
+14 -6
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"; ··· 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: Buffer.from(cred.credential_id) 79 + .toString("base64") 80 + .replace(/\+/g, "-") 81 + .replace(/\//g, "_") 82 + .replace(/=+$/, ""), 79 83 type: "public-key" as const, 80 84 })); 81 85 82 86 // Generate WebAuthn registration options 83 87 const options = await generateRegistrationOptions({ 84 88 rpName: RP_NAME, 85 - rpID: process.env.RP_ID!, 89 + rpID: process.env.RP_ID || "", 86 90 userName: user.username, 87 91 userDisplayName: user.username, 88 92 attestationType: "none", ··· 133 137 } 134 138 135 139 const body = await req.json(); 136 - const { response, challenge: expectedChallenge, name } = body as { 140 + const { 141 + response, 142 + challenge: expectedChallenge, 143 + name, 144 + } = body as { 137 145 response: RegistrationResponseJSON; 138 146 challenge: string; 139 147 name?: string; ··· 167 175 verification = await verifyRegistrationResponse({ 168 176 response, 169 177 expectedChallenge: challenge.challenge, 170 - expectedOrigin: process.env.ORIGIN!, 171 - expectedRPID: process.env.RP_ID!, 178 + expectedOrigin: process.env.ORIGIN || "", 179 + expectedRPID: process.env.RP_ID || "", 172 180 }); 173 181 } catch (error) { 174 182 console.error("WebAuthn verification failed:", error);
+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%;