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 PORT=3000 4 NODE_ENV="production" 5 DATABASE_URL=data/indiko.db
··· 3 PORT=3000 4 NODE_ENV="production" 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 *.db 9 *.db-shm 10 *.db-wal
··· 8 *.db 9 *.db-shm 10 *.db-wal 11 + .DS_Store
+6
CRUSH.md
··· 9 ## Architecture Patterns 10 11 ### Route Organization 12 - Use separate route files in `src/routes/` directory 13 - Export handler functions that accept `Request` and return `Response` 14 - Import handlers in `src/index.ts` and wire them in the `routes` object ··· 17 - IndieAuth/OAuth 2.0 endpoints in `src/routes/indieauth.ts` 18 19 ### Project Structure 20 ``` 21 src/ 22 ├── db.ts # Database setup and exports ··· 40 ``` 41 42 ### Client-Side Code 43 - Extract JavaScript from HTML into separate TypeScript modules in `src/client/` 44 - Import client modules into HTML with `<script type="module" src="../client/file.ts"></script>` 45 - Bun will bundle the imports automatically ··· 47 - 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 49 ### IndieAuth/OAuth 2.0 Implementation 50 - Full IndieAuth server supporting OAuth 2.0 with PKCE 51 - Authorization code flow with single-use, short-lived codes (60 seconds) 52 - Auto-registration of client apps on first authorization ··· 59 - **`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 61 ### Database Schema 62 - **users**: username, name, email, photo, url, status, role, tier, is_admin 63 - **tier**: User access level - 'admin' (full access), 'developer' (can create apps), 'user' (can only authenticate with apps) 64 - **is_admin**: Legacy flag, automatically synced with tier (1 if tier='admin', 0 otherwise) ··· 71 - **invites**: admin-created invite codes 72 73 ### WebAuthn/Passkey Settings 74 - **Registration**: residentKey="required", userVerification="required" 75 - **Authentication**: omit allowCredentials to show all passkeys (discoverable credentials) 76 - **Credential lookup**: credential_id stored as Buffer, compare using base64url string
··· 9 ## Architecture Patterns 10 11 ### Route Organization 12 + 13 - Use separate route files in `src/routes/` directory 14 - Export handler functions that accept `Request` and return `Response` 15 - Import handlers in `src/index.ts` and wire them in the `routes` object ··· 18 - IndieAuth/OAuth 2.0 endpoints in `src/routes/indieauth.ts` 19 20 ### Project Structure 21 + 22 ``` 23 src/ 24 ├── db.ts # Database setup and exports ··· 42 ``` 43 44 ### Client-Side Code 45 + 46 - Extract JavaScript from HTML into separate TypeScript modules in `src/client/` 47 - Import client modules into HTML with `<script type="module" src="../client/file.ts"></script>` 48 - Bun will bundle the imports automatically ··· 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 51 52 ### IndieAuth/OAuth 2.0 Implementation 53 + 54 - Full IndieAuth server supporting OAuth 2.0 with PKCE 55 - Authorization code flow with single-use, short-lived codes (60 seconds) 56 - Auto-registration of client apps on first authorization ··· 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 64 65 ### Database Schema 66 + 67 - **users**: username, name, email, photo, url, status, role, tier, is_admin 68 - **tier**: User access level - 'admin' (full access), 'developer' (can create apps), 'user' (can only authenticate with apps) 69 - **is_admin**: Legacy flag, automatically synced with tier (1 if tier='admin', 0 otherwise) ··· 76 - **invites**: admin-created invite codes 77 78 ### WebAuthn/Passkey Settings 79 + 80 - **Registration**: residentKey="required", userVerification="required" 81 - **Authentication**: omit allowCredentials to show all passkeys (discoverable credentials) 82 - **Credential lookup**: credential_id stored as Buffer, compare using base64url string
+25 -3
SECURITY.md
··· 4 5 If you discover a security vulnerability in Indiko, please report it privately: 6 7 - - **Email:** security@dunkirk.sh 8 - **Do not** open public issues for security vulnerabilities 9 - You will receive a response within 48 hours 10 ··· 50 --- 51 52 ## Known Security Considerations 53 54 ### Rate Limiting ⚠️ 55 ··· 177 178 ## Contact 179 180 - - **Security Issues:** security@dunkirk.sh 181 - - **General Support:** https://tangled.org/@dunkirk.sh/indiko 182 - **Maintainer:** Kieran Klukas (@taciturnaxolotl) 183 184 ---
··· 4 5 If you discover a security vulnerability in Indiko, please report it privately: 6 7 + - **Email:** <security@dunkirk.sh> 8 - **Do not** open public issues for security vulnerabilities 9 - You will receive a response within 48 hours 10 ··· 50 --- 51 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 + ``` 75 76 ### Rate Limiting ⚠️ 77 ··· 199 200 ## Contact 201 202 + - **Security Issues:** <security@dunkirk.sh> 203 + - **General Support:** <https://tangled.org/@dunkirk.sh/indiko> 204 - **Maintainer:** Kieran Klukas (@taciturnaxolotl) 205 206 ---
+57
SPEC.md
··· 3 ## Overview 4 5 **indiko** is a centralized authentication and user management system for personal projects. It provides: 6 - Passkey-based authentication (WebAuthn) 7 - IndieAuth server implementation 8 - User profile management ··· 12 ## Core Concepts 13 14 ### Single Source of Truth 15 - Authentication via passkeys 16 - User profiles (name, email, picture, URL) 17 - Authorization with per-app scoping 18 - User management (admin + invite system) 19 20 ### Trust Model 21 - First user becomes admin 22 - Admin can create invite links 23 - Apps auto-register on first use ··· 30 ## Data Structures 31 32 ### Users 33 ``` 34 user:{username} -> { 35 credential: { ··· 49 ``` 50 51 ### Admin Marker 52 ``` 53 admin:user -> username // marks first/admin user 54 ``` 55 56 ### Sessions 57 ``` 58 session:{token} -> { 59 username: string, ··· 63 ``` 64 65 ### Apps (Auto-registered) 66 ``` 67 app:{client_id} -> { 68 client_id: string, // e.g. "https://blog.kierank.dev" ··· 74 ``` 75 76 ### User Permissions (Per-App) 77 ``` 78 permission:{username}:{client_id} -> { 79 scopes: string[], // e.g. ["profile", "email"] ··· 83 ``` 84 85 ### Authorization Codes (Short-lived) 86 ``` 87 authcode:{code} -> { 88 username: string, ··· 98 ``` 99 100 ### Invites 101 ``` 102 invite:{code} -> { 103 code: string, ··· 110 ``` 111 112 ### Challenges (WebAuthn) 113 ``` 114 challenge:{challenge} -> { 115 username: string, ··· 130 ### Authentication (WebAuthn/Passkey) 131 132 #### `GET /login` 133 - Login/registration page 134 - Shows passkey auth interface 135 - First user: admin registration flow 136 - With `?invite=CODE`: invite-based registration 137 138 #### `GET /auth/can-register` 139 - Check if open registration allowed 140 - Returns `{ canRegister: boolean }` 141 142 #### `POST /auth/register/options` 143 - Generate WebAuthn registration options 144 - Body: `{ username: string, inviteCode?: string }` 145 - Validates invite code if not first user 146 - Returns registration options 147 148 #### `POST /auth/register/verify` 149 - Verify WebAuthn registration response 150 - Body: `{ username: string, response: RegistrationResponseJSON, inviteCode?: string }` 151 - Creates user, stores credential ··· 153 - Returns `{ token: string, username: string }` 154 155 #### `POST /auth/login/options` 156 - Generate WebAuthn authentication options 157 - Body: `{ username: string }` 158 - Returns authentication options 159 160 #### `POST /auth/login/verify` 161 - Verify WebAuthn authentication response 162 - Body: `{ username: string, response: AuthenticationResponseJSON }` 163 - Creates session 164 - Returns `{ token: string, username: string }` 165 166 #### `POST /auth/logout` 167 - Clear session 168 - Requires: `Authorization: Bearer {token}` 169 - Returns `{ success: true }` ··· 171 ### IndieAuth Endpoints 172 173 #### `GET /auth/authorize` 174 Authorization request from client app 175 176 **Query Parameters:** 177 - `response_type=code` (required) 178 - `client_id` (required) - App's URL 179 - `redirect_uri` (required) - Callback URL ··· 184 - `me` (optional) - User's URL (hint) 185 186 **Flow:** 187 1. Validate parameters 188 2. Auto-register app if not exists 189 3. If no session → redirect to `/login` ··· 193 - If no → show consent screen 194 195 **Response:** 196 - HTML consent screen 197 - Shows: app name, requested scopes 198 - Buttons: "Allow" / "Deny" 199 200 #### `POST /auth/authorize` 201 Consent form submission (CSRF protected) 202 203 **Body:** 204 - `client_id` (required) 205 - `redirect_uri` (required) 206 - `state` (required) ··· 209 - `action` (required) - "allow" | "deny" 210 211 **Flow:** 212 1. Validate CSRF token 213 2. Validate session 214 3. If denied → redirect with error ··· 219 - Redirect to redirect_uri with code & state 220 221 **Success Response:** 222 ``` 223 HTTP/1.1 302 Found 224 Location: {redirect_uri}?code={authcode}&state={state} 225 ``` 226 227 **Error Response:** 228 ``` 229 HTTP/1.1 302 Found 230 Location: {redirect_uri}?error=access_denied&state={state} 231 ``` 232 233 #### `POST /auth/token` 234 Exchange authorization code for user identity (NOT CSRF protected) 235 236 **Headers:** 237 - `Content-Type: application/json` 238 239 **Body:** 240 ```json 241 { 242 "grant_type": "authorization_code", ··· 248 ``` 249 250 **Flow:** 251 1. Validate authorization code exists 252 2. Verify code not expired 253 3. Verify code not already used ··· 258 8. Return user identity + profile 259 260 **Success Response:** 261 ```json 262 { 263 "me": "https://indiko.yourdomain.com/u/kieran", ··· 271 ``` 272 273 **Error Response:** 274 ```json 275 { 276 "error": "invalid_grant", ··· 279 ``` 280 281 #### `GET /auth/userinfo` (Optional) 282 Get current user profile with bearer token 283 284 **Headers:** 285 - `Authorization: Bearer {access_token}` 286 287 **Response:** 288 ```json 289 { 290 "sub": "https://indiko.yourdomain.com/u/kieran", ··· 298 ### User Profile & Settings 299 300 #### `GET /settings` 301 User settings page (requires session) 302 303 **Shows:** 304 - Profile form (name, email, photo, URL) 305 - Connected apps list 306 - Revoke access buttons 307 - (Admin only) Invite generation 308 309 #### `POST /settings/profile` 310 Update user profile 311 312 **Body:** 313 ```json 314 { 315 "name": "Kieran Klukas", ··· 320 ``` 321 322 **Response:** 323 ```json 324 { 325 "success": true, ··· 328 ``` 329 330 #### `POST /settings/apps/:client_id/revoke` 331 Revoke app access 332 333 **Response:** 334 ```json 335 { 336 "success": true ··· 338 ``` 339 340 #### `GET /u/:username` 341 Public user profile page (h-card) 342 343 **Response:** 344 HTML page with microformats h-card: 345 ```html 346 <div class="h-card"> 347 <img class="u-photo" src="..."> ··· 353 ### Admin Endpoints 354 355 #### `POST /api/invites/create` 356 Create invite link (admin only) 357 358 **Headers:** 359 - `Authorization: Bearer {token}` 360 361 **Response:** 362 ```json 363 { 364 "inviteCode": "abc123xyz" ··· 370 ### Dashboard 371 372 #### `GET /` 373 Main dashboard (requires session) 374 375 **Shows:** 376 - User info 377 - Test API button 378 - (Admin only) Admin controls section ··· 380 - Invite display 381 382 #### `GET /api/hello` 383 Test endpoint (requires session) 384 385 **Headers:** 386 - `Authorization: Bearer {token}` 387 388 **Response:** 389 ```json 390 { 391 "message": "Hello kieran! You're authenticated with passkeys.", ··· 397 ## Session Behavior 398 399 ### Single Sign-On 400 - Once logged into indiko (valid session), subsequent app authorization requests: 401 - Skip passkey authentication 402 - Show consent screen directly ··· 405 - Passkey required only when session expires 406 407 ### Security 408 - PKCE required for all authorization flows 409 - Authorization codes: 410 - Single-use only ··· 415 ## Client Integration Example 416 417 ### 1. Initiate Authorization 418 ```javascript 419 const params = new URLSearchParams({ 420 response_type: 'code', ··· 430 ``` 431 432 ### 2. Handle Callback 433 ```javascript 434 // At https://blog.kierank.dev/auth/callback?code=...&state=... 435 const code = new URLSearchParams(window.location.search).get('code');
··· 3 ## Overview 4 5 **indiko** is a centralized authentication and user management system for personal projects. It provides: 6 + 7 - Passkey-based authentication (WebAuthn) 8 - IndieAuth server implementation 9 - User profile management ··· 13 ## Core Concepts 14 15 ### Single Source of Truth 16 + 17 - Authentication via passkeys 18 - User profiles (name, email, picture, URL) 19 - Authorization with per-app scoping 20 - User management (admin + invite system) 21 22 ### Trust Model 23 + 24 - First user becomes admin 25 - Admin can create invite links 26 - Apps auto-register on first use ··· 33 ## Data Structures 34 35 ### Users 36 + 37 ``` 38 user:{username} -> { 39 credential: { ··· 53 ``` 54 55 ### Admin Marker 56 + 57 ``` 58 admin:user -> username // marks first/admin user 59 ``` 60 61 ### Sessions 62 + 63 ``` 64 session:{token} -> { 65 username: string, ··· 69 ``` 70 71 ### Apps (Auto-registered) 72 + 73 ``` 74 app:{client_id} -> { 75 client_id: string, // e.g. "https://blog.kierank.dev" ··· 81 ``` 82 83 ### User Permissions (Per-App) 84 + 85 ``` 86 permission:{username}:{client_id} -> { 87 scopes: string[], // e.g. ["profile", "email"] ··· 91 ``` 92 93 ### Authorization Codes (Short-lived) 94 + 95 ``` 96 authcode:{code} -> { 97 username: string, ··· 107 ``` 108 109 ### Invites 110 + 111 ``` 112 invite:{code} -> { 113 code: string, ··· 120 ``` 121 122 ### Challenges (WebAuthn) 123 + 124 ``` 125 challenge:{challenge} -> { 126 username: string, ··· 141 ### Authentication (WebAuthn/Passkey) 142 143 #### `GET /login` 144 + 145 - Login/registration page 146 - Shows passkey auth interface 147 - First user: admin registration flow 148 - With `?invite=CODE`: invite-based registration 149 150 #### `GET /auth/can-register` 151 + 152 - Check if open registration allowed 153 - Returns `{ canRegister: boolean }` 154 155 #### `POST /auth/register/options` 156 + 157 - Generate WebAuthn registration options 158 - Body: `{ username: string, inviteCode?: string }` 159 - Validates invite code if not first user 160 - Returns registration options 161 162 #### `POST /auth/register/verify` 163 + 164 - Verify WebAuthn registration response 165 - Body: `{ username: string, response: RegistrationResponseJSON, inviteCode?: string }` 166 - Creates user, stores credential ··· 168 - Returns `{ token: string, username: string }` 169 170 #### `POST /auth/login/options` 171 + 172 - Generate WebAuthn authentication options 173 - Body: `{ username: string }` 174 - Returns authentication options 175 176 #### `POST /auth/login/verify` 177 + 178 - Verify WebAuthn authentication response 179 - Body: `{ username: string, response: AuthenticationResponseJSON }` 180 - Creates session 181 - Returns `{ token: string, username: string }` 182 183 #### `POST /auth/logout` 184 + 185 - Clear session 186 - Requires: `Authorization: Bearer {token}` 187 - Returns `{ success: true }` ··· 189 ### IndieAuth Endpoints 190 191 #### `GET /auth/authorize` 192 + 193 Authorization request from client app 194 195 **Query Parameters:** 196 + 197 - `response_type=code` (required) 198 - `client_id` (required) - App's URL 199 - `redirect_uri` (required) - Callback URL ··· 204 - `me` (optional) - User's URL (hint) 205 206 **Flow:** 207 + 208 1. Validate parameters 209 2. Auto-register app if not exists 210 3. If no session → redirect to `/login` ··· 214 - If no → show consent screen 215 216 **Response:** 217 + 218 - HTML consent screen 219 - Shows: app name, requested scopes 220 - Buttons: "Allow" / "Deny" 221 222 #### `POST /auth/authorize` 223 + 224 Consent form submission (CSRF protected) 225 226 **Body:** 227 + 228 - `client_id` (required) 229 - `redirect_uri` (required) 230 - `state` (required) ··· 233 - `action` (required) - "allow" | "deny" 234 235 **Flow:** 236 + 237 1. Validate CSRF token 238 2. Validate session 239 3. If denied → redirect with error ··· 244 - Redirect to redirect_uri with code & state 245 246 **Success Response:** 247 + 248 ``` 249 HTTP/1.1 302 Found 250 Location: {redirect_uri}?code={authcode}&state={state} 251 ``` 252 253 **Error Response:** 254 + 255 ``` 256 HTTP/1.1 302 Found 257 Location: {redirect_uri}?error=access_denied&state={state} 258 ``` 259 260 #### `POST /auth/token` 261 + 262 Exchange authorization code for user identity (NOT CSRF protected) 263 264 **Headers:** 265 + 266 - `Content-Type: application/json` 267 268 **Body:** 269 + 270 ```json 271 { 272 "grant_type": "authorization_code", ··· 278 ``` 279 280 **Flow:** 281 + 282 1. Validate authorization code exists 283 2. Verify code not expired 284 3. Verify code not already used ··· 289 8. Return user identity + profile 290 291 **Success Response:** 292 + 293 ```json 294 { 295 "me": "https://indiko.yourdomain.com/u/kieran", ··· 303 ``` 304 305 **Error Response:** 306 + 307 ```json 308 { 309 "error": "invalid_grant", ··· 312 ``` 313 314 #### `GET /auth/userinfo` (Optional) 315 + 316 Get current user profile with bearer token 317 318 **Headers:** 319 + 320 - `Authorization: Bearer {access_token}` 321 322 **Response:** 323 + 324 ```json 325 { 326 "sub": "https://indiko.yourdomain.com/u/kieran", ··· 334 ### User Profile & Settings 335 336 #### `GET /settings` 337 + 338 User settings page (requires session) 339 340 **Shows:** 341 + 342 - Profile form (name, email, photo, URL) 343 - Connected apps list 344 - Revoke access buttons 345 - (Admin only) Invite generation 346 347 #### `POST /settings/profile` 348 + 349 Update user profile 350 351 **Body:** 352 + 353 ```json 354 { 355 "name": "Kieran Klukas", ··· 360 ``` 361 362 **Response:** 363 + 364 ```json 365 { 366 "success": true, ··· 369 ``` 370 371 #### `POST /settings/apps/:client_id/revoke` 372 + 373 Revoke app access 374 375 **Response:** 376 + 377 ```json 378 { 379 "success": true ··· 381 ``` 382 383 #### `GET /u/:username` 384 + 385 Public user profile page (h-card) 386 387 **Response:** 388 HTML page with microformats h-card: 389 + 390 ```html 391 <div class="h-card"> 392 <img class="u-photo" src="..."> ··· 398 ### Admin Endpoints 399 400 #### `POST /api/invites/create` 401 + 402 Create invite link (admin only) 403 404 **Headers:** 405 + 406 - `Authorization: Bearer {token}` 407 408 **Response:** 409 + 410 ```json 411 { 412 "inviteCode": "abc123xyz" ··· 418 ### Dashboard 419 420 #### `GET /` 421 + 422 Main dashboard (requires session) 423 424 **Shows:** 425 + 426 - User info 427 - Test API button 428 - (Admin only) Admin controls section ··· 430 - Invite display 431 432 #### `GET /api/hello` 433 + 434 Test endpoint (requires session) 435 436 **Headers:** 437 + 438 - `Authorization: Bearer {token}` 439 440 **Response:** 441 + 442 ```json 443 { 444 "message": "Hello kieran! You're authenticated with passkeys.", ··· 450 ## Session Behavior 451 452 ### Single Sign-On 453 + 454 - Once logged into indiko (valid session), subsequent app authorization requests: 455 - Skip passkey authentication 456 - Show consent screen directly ··· 459 - Passkey required only when session expires 460 461 ### Security 462 + 463 - PKCE required for all authorization flows 464 - Authorization codes: 465 - Single-use only ··· 470 ## Client Integration Example 471 472 ### 1. Initiate Authorization 473 + 474 ```javascript 475 const params = new URLSearchParams({ 476 response_type: 'code', ··· 486 ``` 487 488 ### 2. Handle Callback 489 + 490 ```javascript 491 // At https://blog.kierank.dev/auth/callback?code=...&state=... 492 const code = new URLSearchParams(window.location.search).get('code');
+27
bun.lock
··· 8 "@simplewebauthn/browser": "^13.2.2", 9 "@simplewebauthn/server": "^13.2.2", 10 "bun-sqlite-migrations": "^1.0.2", 11 "nanoid": "^5.1.6", 12 }, 13 "devDependencies": { ··· 54 55 "@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="], 56 57 "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], 58 59 "@types/node": ["@types/node@25.0.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew=="], 60 61 "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="], 62 ··· 64 65 "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], 66 67 "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], 68 69 "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], 70 71 "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], 72 73 "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], 74 75 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 76 ··· 79 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 80 81 "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 82 83 "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], 84 }
··· 8 "@simplewebauthn/browser": "^13.2.2", 9 "@simplewebauthn/server": "^13.2.2", 10 "bun-sqlite-migrations": "^1.0.2", 11 + "ldap-authentication": "^3.3.6", 12 "nanoid": "^5.1.6", 13 }, 14 "devDependencies": { ··· 55 56 "@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="], 57 58 + "@types/asn1": ["@types/asn1@0.2.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA=="], 59 + 60 "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], 61 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=="], 65 66 "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="], 67 ··· 69 70 "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], 71 72 + "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], 73 + 74 + "ldap-authentication": ["ldap-authentication@3.3.6", "", { "dependencies": { "ldapts": "^7.3.1" } }, "sha512-j8XxH5wGXhIQ3mMnoRCZTalSLmPhzEaGH4+5RIFP0Higc32fCKDRJBo+9wb5ysy9TnlaNtaf+rgdwYCD15OBpQ=="], 75 + 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 + 80 "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], 81 82 + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], 83 + 84 "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], 85 86 "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], 87 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=="], 95 96 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 97 ··· 100 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 101 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=="], 109 110 "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], 111 }
+1
package.json
··· 19 "@simplewebauthn/browser": "^13.2.2", 20 "@simplewebauthn/server": "^13.2.2", 21 "bun-sqlite-migrations": "^1.0.2", 22 "nanoid": "^5.1.6" 23 } 24 }
··· 19 "@simplewebauthn/browser": "^13.2.2", 20 "@simplewebauthn/server": "^13.2.2", 21 "bun-sqlite-migrations": "^1.0.2", 22 + "ldap-authentication": "^3.3.6", 23 "nanoid": "^5.1.6" 24 } 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 // If creating a new client, show the credentials in modal 570 if (!isEdit) { 571 const result = await response.json(); 572 - if ( 573 - result.client && 574 - result.client.clientId && 575 - result.client.clientSecret 576 - ) { 577 const secretModal = document.getElementById( 578 "secretModal", 579 ) as HTMLElement;
··· 569 // If creating a new client, show the credentials in modal 570 if (!isEdit) { 571 const result = await response.json(); 572 + if (result.client?.clientId && result.client.clientSecret) { 573 const secretModal = document.getElementById( 574 "secretModal", 575 ) as HTMLElement;
+8 -4
src/client/admin-invites.ts
··· 171 "submitInviteBtn", 172 ) as HTMLButtonElement; 173 174 - const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : 1; 175 const expiresAt = expiresAtInput.value 176 ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000) 177 : null; ··· 187 'input[name="appRole"]:checked', 188 ); 189 checkedBoxes.forEach((checkbox) => { 190 - const appId = parseInt((checkbox as HTMLInputElement).value, 10); 191 const roleSelect = appRolesContainer.querySelector( 192 `select.role-select[data-app-id="${appId}"]`, 193 ) as HTMLSelectElement; 194 195 let role = ""; 196 - if (roleSelect && roleSelect.value) { 197 role = roleSelect.value; 198 } 199 ··· 507 "submitEditInviteBtn", 508 ) as HTMLButtonElement; 509 510 - const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : null; 511 const expiresAt = expiresAtInput.value 512 ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000) 513 : null;
··· 171 "submitInviteBtn", 172 ) as HTMLButtonElement; 173 174 + const maxUses = maxUsesInput.value 175 + ? Number.parseInt(maxUsesInput.value, 10) 176 + : 1; 177 const expiresAt = expiresAtInput.value 178 ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000) 179 : null; ··· 189 'input[name="appRole"]:checked', 190 ); 191 checkedBoxes.forEach((checkbox) => { 192 + const appId = Number.parseInt((checkbox as HTMLInputElement).value, 10); 193 const roleSelect = appRolesContainer.querySelector( 194 `select.role-select[data-app-id="${appId}"]`, 195 ) as HTMLSelectElement; 196 197 let role = ""; 198 + if (roleSelect?.value) { 199 role = roleSelect.value; 200 } 201 ··· 509 "submitEditInviteBtn", 510 ) as HTMLButtonElement; 511 512 + const maxUses = maxUsesInput.value 513 + ? Number.parseInt(maxUsesInput.value, 10) 514 + : null; 515 const expiresAt = expiresAtInput.value 516 ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000) 517 : null;
+50 -51
src/client/docs.ts
··· 48 ); 49 } 50 51 - result += attrs + "&gt;"; 52 return result; 53 }, 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 } 100 }) 101 .join(""); 102 ··· 462 const rows: string[][] = []; 463 464 // Get headers 465 - el.querySelectorAll("thead th").forEach((th) => { 466 headers.push(th.textContent?.trim() || ""); 467 - }); 468 469 // Get rows 470 el.querySelectorAll("tbody tr").forEach((tr) => { 471 const row: string[] = []; 472 - tr.querySelectorAll("td").forEach((td) => { 473 row.push(td.textContent?.trim() || ""); 474 - }); 475 rows.push(row); 476 }); 477 ··· 479 if (headers.length > 0) { 480 lines.push(`| ${headers.join(" | ")} |`); 481 lines.push(`|${headers.map(() => "-------").join("|")}|`); 482 - rows.forEach((row) => { 483 lines.push(`| ${row.join(" | ")} |`); 484 - }); 485 lines.push(""); 486 } 487 }
··· 48 ); 49 } 50 51 + result += `${attrs}&gt;`; 52 return result; 53 }, 54 ); 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 + ); 99 }) 100 .join(""); 101 ··· 461 const rows: string[][] = []; 462 463 // Get headers 464 + for (const th of el.querySelectorAll("thead th")) { 465 headers.push(th.textContent?.trim() || ""); 466 + } 467 468 // Get rows 469 el.querySelectorAll("tbody tr").forEach((tr) => { 470 const row: string[] = []; 471 + for (const td of tr.querySelectorAll("td")) { 472 row.push(td.textContent?.trim() || ""); 473 + } 474 rows.push(row); 475 }); 476 ··· 478 if (headers.length > 0) { 479 lines.push(`| ${headers.join(" | ")} |`); 480 lines.push(`|${headers.map(() => "-------").join("|")}|`); 481 + for (const row of rows) { 482 lines.push(`| ${row.join(" | ")} |`); 483 + } 484 lines.push(""); 485 } 486 }
+31 -16
src/client/index.ts
··· 1 - import { 2 - startRegistration, 3 - } from "@simplewebauthn/browser"; 4 5 const token = localStorage.getItem("indiko_session"); 6 const footer = document.getElementById("footer") as HTMLElement; ··· 8 const subtitle = document.getElementById("subtitle") as HTMLElement; 9 const recentApps = document.getElementById("recentApps") as HTMLElement; 10 const passkeysList = document.getElementById("passkeysList") as HTMLElement; 11 - const addPasskeyBtn = document.getElementById("addPasskeyBtn") as HTMLButtonElement; 12 const toast = document.getElementById("toast") as HTMLElement; 13 14 // Profile form elements ··· 320 const passkeys = data.passkeys as Passkey[]; 321 322 if (passkeys.length === 0) { 323 - passkeysList.innerHTML = '<div class="empty">No passkeys registered</div>'; 324 return; 325 } 326 327 passkeysList.innerHTML = passkeys 328 .map((passkey) => { 329 - const createdDate = new Date(passkey.created_at * 1000).toLocaleDateString(); 330 331 return ` 332 <div class="passkey-item" data-passkey-id="${passkey.id}"> ··· 336 </div> 337 <div class="passkey-actions"> 338 <button type="button" class="rename-passkey-btn" data-passkey-id="${passkey.id}">rename</button> 339 - ${passkeys.length > 1 ? `<button type="button" class="delete-passkey-btn" data-passkey-id="${passkey.id}">delete</button>` : ''} 340 </div> 341 </div> 342 `; ··· 365 } 366 367 function showRenameForm(passkeyId: number) { 368 - const passkeyItem = document.querySelector(`[data-passkey-id="${passkeyId}"]`); 369 if (!passkeyItem) return; 370 371 const infoDiv = passkeyItem.querySelector(".passkey-info"); ··· 389 input.select(); 390 391 // Save button 392 - infoDiv.querySelector(".save-rename-btn")?.addEventListener("click", async () => { 393 - await renamePasskeyHandler(passkeyId, input.value); 394 - }); 395 396 // Cancel button 397 - infoDiv.querySelector(".cancel-rename-btn")?.addEventListener("click", () => { 398 - loadPasskeys(); 399 - }); 400 401 // Enter to save 402 input.addEventListener("keypress", async (e) => { ··· 443 } 444 445 async function deletePasskeyHandler(passkeyId: number) { 446 - if (!confirm("Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.")) { 447 return; 448 } 449 ··· 496 addPasskeyBtn.textContent = "verifying..."; 497 498 // Ask for a name 499 - const name = prompt("Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):"); 500 501 // Verify registration 502 const verifyRes = await fetch("/api/passkeys/add/verify", {
··· 1 + import { startRegistration } from "@simplewebauthn/browser"; 2 3 const token = localStorage.getItem("indiko_session"); 4 const footer = document.getElementById("footer") as HTMLElement; ··· 6 const subtitle = document.getElementById("subtitle") as HTMLElement; 7 const recentApps = document.getElementById("recentApps") as HTMLElement; 8 const passkeysList = document.getElementById("passkeysList") as HTMLElement; 9 + const addPasskeyBtn = document.getElementById( 10 + "addPasskeyBtn", 11 + ) as HTMLButtonElement; 12 const toast = document.getElementById("toast") as HTMLElement; 13 14 // Profile form elements ··· 320 const passkeys = data.passkeys as Passkey[]; 321 322 if (passkeys.length === 0) { 323 + passkeysList.innerHTML = 324 + '<div class="empty">No passkeys registered</div>'; 325 return; 326 } 327 328 passkeysList.innerHTML = passkeys 329 .map((passkey) => { 330 + const createdDate = new Date( 331 + passkey.created_at * 1000, 332 + ).toLocaleDateString(); 333 334 return ` 335 <div class="passkey-item" data-passkey-id="${passkey.id}"> ··· 339 </div> 340 <div class="passkey-actions"> 341 <button type="button" class="rename-passkey-btn" data-passkey-id="${passkey.id}">rename</button> 342 + ${passkeys.length > 1 ? `<button type="button" class="delete-passkey-btn" data-passkey-id="${passkey.id}">delete</button>` : ""} 343 </div> 344 </div> 345 `; ··· 368 } 369 370 function showRenameForm(passkeyId: number) { 371 + const passkeyItem = document.querySelector( 372 + `[data-passkey-id="${passkeyId}"]`, 373 + ); 374 if (!passkeyItem) return; 375 376 const infoDiv = passkeyItem.querySelector(".passkey-info"); ··· 394 input.select(); 395 396 // Save button 397 + infoDiv 398 + .querySelector(".save-rename-btn") 399 + ?.addEventListener("click", async () => { 400 + await renamePasskeyHandler(passkeyId, input.value); 401 + }); 402 403 // Cancel button 404 + infoDiv 405 + .querySelector(".cancel-rename-btn") 406 + ?.addEventListener("click", () => { 407 + loadPasskeys(); 408 + }); 409 410 // Enter to save 411 input.addEventListener("keypress", async (e) => { ··· 452 } 453 454 async function deletePasskeyHandler(passkeyId: number) { 455 + if ( 456 + !confirm( 457 + "Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.", 458 + ) 459 + ) { 460 return; 461 } 462 ··· 509 addPasskeyBtn.textContent = "verifying..."; 510 511 // Ask for a name 512 + const name = prompt( 513 + "Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):", 514 + ); 515 516 // Verify registration 517 const verifyRes = await fetch("/api/passkeys/add/verify", {
+129 -6
src/client/login.ts
··· 5 6 const loginForm = document.getElementById("loginForm") as HTMLFormElement; 7 const registerForm = document.getElementById("registerForm") as HTMLFormElement; 8 const message = document.getElementById("message") as HTMLDivElement; 9 10 // Check if registration is allowed on page load 11 async function checkRegistrationAllowed() { ··· 15 const inviteCode = urlParams.get("invite"); 16 17 if (inviteCode) { 18 // Fetch invite details to show message 19 try { 20 const response = await fetch("/auth/register/options", { 21 method: "POST", 22 headers: { "Content-Type": "application/json" }, 23 - body: JSON.stringify({ username: "temp", inviteCode }), 24 }); 25 26 if (response.ok) { ··· 38 if (subtitleElement) { 39 subtitleElement.textContent = "create your account"; 40 } 41 - ( 42 - document.getElementById("registerUsername") as HTMLInputElement 43 - ).placeholder = "choose username"; 44 ( 45 document.getElementById("registerBtn") as HTMLButtonElement 46 ).textContent = "create account"; ··· 111 } 112 113 const options = await optionsRes.json(); 114 115 loginBtn.textContent = "use your passkey..."; 116 ··· 212 213 showMessage("Registration successful!", "success"); 214 215 - // Check for return URL parameter 216 - const returnUrl = urlParams.get("return") || "/"; 217 218 const redirectTimer = setTimeout(() => { 219 window.location.href = returnUrl; ··· 225 registerBtn.textContent = "register passkey"; 226 } 227 });
··· 5 6 const loginForm = document.getElementById("loginForm") as HTMLFormElement; 7 const registerForm = document.getElementById("registerForm") as HTMLFormElement; 8 + const ldapForm = document.getElementById("ldapForm") as HTMLFormElement; 9 const message = document.getElementById("message") as HTMLDivElement; 10 + 11 + let pendingLdapUsername: string | null = null; 12 13 // Check if registration is allowed on page load 14 async function checkRegistrationAllowed() { ··· 18 const inviteCode = urlParams.get("invite"); 19 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 + 27 // Fetch invite details to show message 28 try { 29 + const testUsername = lockedUsername || "temp"; 30 const response = await fetch("/auth/register/options", { 31 method: "POST", 32 headers: { "Content-Type": "application/json" }, 33 + body: JSON.stringify({ username: testUsername, inviteCode }), 34 }); 35 36 if (response.ok) { ··· 48 if (subtitleElement) { 49 subtitleElement.textContent = "create your account"; 50 } 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 + 62 ( 63 document.getElementById("registerBtn") as HTMLButtonElement 64 ).textContent = "create account"; ··· 129 } 130 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 + } 140 141 loginBtn.textContent = "use your passkey..."; 142 ··· 238 239 showMessage("Registration successful!", "success"); 240 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 + } 249 250 const redirectTimer = setTimeout(() => { 251 window.location.href = returnUrl; ··· 257 registerBtn.textContent = "register passkey"; 258 } 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 margin-bottom: 1rem; 50 } 51 52 - input[type="text"] { 53 margin-bottom: 1rem; 54 } 55 56 button { ··· 94 <input type="text" id="registerUsername" placeholder="create username" required 95 autocomplete="username webauthn" /> 96 <button type="submit" class="secondary-btn" id="registerBtn">create passkey</button> 97 </form> 98 </div> 99
··· 49 margin-bottom: 1rem; 50 } 51 52 + input[type="text"], 53 + input[type="password"] { 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; 76 } 77 78 button { ··· 116 <input type="text" id="registerUsername" placeholder="create username" required 117 autocomplete="username webauthn" /> 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> 128 </form> 129 </div> 130
+32 -6
src/index.ts
··· 1 import { env } from "bun"; 2 import { db } from "./db"; 3 - import adminHTML from "./html/admin.html"; 4 import adminClientsHTML from "./html/admin-clients.html"; 5 import adminInvitesHTML from "./html/admin-invites.html"; 6 import appsHTML from "./html/apps.html"; 7 import docsHTML from "./html/docs.html"; 8 import indexHTML from "./html/index.html"; 9 import loginHTML from "./html/login.html"; 10 import { 11 deleteSelfAccount, 12 deleteUser, ··· 25 } from "./routes/api"; 26 import { 27 canRegister, 28 loginOptions, 29 loginVerify, 30 registerOptions, ··· 51 tokenIntrospect, 52 tokenRevoke, 53 updateInvite, 54 - userinfo, 55 userProfile, 56 } from "./routes/indieauth"; 57 import { 58 addPasskeyOptions, ··· 197 if (req.method === "POST") { 198 const url = new URL(req.url); 199 const userId = url.pathname.split("/")[4]; 200 - return disableUser(req, userId); 201 } 202 return new Response("Method not allowed", { status: 405 }); 203 }, ··· 205 if (req.method === "POST") { 206 const url = new URL(req.url); 207 const userId = url.pathname.split("/")[4]; 208 - return enableUser(req, userId); 209 } 210 return new Response("Method not allowed", { status: 405 }); 211 }, ··· 213 if (req.method === "PUT") { 214 const url = new URL(req.url); 215 const userId = url.pathname.split("/")[4]; 216 - return updateUserTier(req, userId); 217 } 218 return new Response("Method not allowed", { status: 405 }); 219 }, ··· 221 if (req.method === "DELETE") { 222 const url = new URL(req.url); 223 const userId = url.pathname.split("/")[4]; 224 - return deleteUser(req, userId); 225 } 226 return new Response("Method not allowed", { status: 405 }); 227 }, ··· 253 "/auth/register/verify": registerVerify, 254 "/auth/login/options": loginOptions, 255 "/auth/login/verify": loginVerify, 256 // Passkey management endpoints 257 "/api/passkeys": (req: Request) => { 258 if (req.method === "GET") return listPasskeys(req); ··· 339 } 340 }, 3600000); // 1 hour in milliseconds 341 342 let is_shutting_down = false; 343 function shutdown(sig: string) { 344 if (is_shutting_down) return; ··· 347 console.log(`[Shutdown] triggering shutdown due to ${sig}`); 348 349 clearInterval(cleanupJob); 350 console.log("[Shutdown] stopped cleanup job"); 351 352 server.stop();
··· 1 import { env } from "bun"; 2 import { db } from "./db"; 3 import adminClientsHTML from "./html/admin-clients.html"; 4 import adminInvitesHTML from "./html/admin-invites.html"; 5 + import adminHTML from "./html/admin.html"; 6 import appsHTML from "./html/apps.html"; 7 import docsHTML from "./html/docs.html"; 8 import indexHTML from "./html/index.html"; 9 import loginHTML from "./html/login.html"; 10 + import { getLdapAccounts, updateOrphanedAccounts } from "./ldap-cleanup"; 11 import { 12 deleteSelfAccount, 13 deleteUser, ··· 26 } from "./routes/api"; 27 import { 28 canRegister, 29 + ldapVerify, 30 loginOptions, 31 loginVerify, 32 registerOptions, ··· 53 tokenIntrospect, 54 tokenRevoke, 55 updateInvite, 56 userProfile, 57 + userinfo, 58 } from "./routes/indieauth"; 59 import { 60 addPasskeyOptions, ··· 199 if (req.method === "POST") { 200 const url = new URL(req.url); 201 const userId = url.pathname.split("/")[4]; 202 + return disableUser(req, userId ? userId : ""); 203 } 204 return new Response("Method not allowed", { status: 405 }); 205 }, ··· 207 if (req.method === "POST") { 208 const url = new URL(req.url); 209 const userId = url.pathname.split("/")[4]; 210 + return enableUser(req, userId ? userId : ""); 211 } 212 return new Response("Method not allowed", { status: 405 }); 213 }, ··· 215 if (req.method === "PUT") { 216 const url = new URL(req.url); 217 const userId = url.pathname.split("/")[4]; 218 + return updateUserTier(req, userId ? userId : ""); 219 } 220 return new Response("Method not allowed", { status: 405 }); 221 }, ··· 223 if (req.method === "DELETE") { 224 const url = new URL(req.url); 225 const userId = url.pathname.split("/")[4]; 226 + return deleteUser(req, userId ? userId : ""); 227 } 228 return new Response("Method not allowed", { status: 405 }); 229 }, ··· 255 "/auth/register/verify": registerVerify, 256 "/auth/login/options": loginOptions, 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 + }, 263 // Passkey management endpoints 264 "/api/passkeys": (req: Request) => { 265 if (req.method === "GET") return listPasskeys(req); ··· 346 } 347 }, 3600000); // 1 hour in milliseconds 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 + 367 let is_shutting_down = false; 368 function shutdown(sig: string) { 369 if (is_shutting_down) return; ··· 372 console.log(`[Shutdown] triggering shutdown due to ${sig}`); 373 374 clearInterval(cleanupJob); 375 + if (ldapCleanupJob) clearInterval(ldapCleanupJob); 376 console.log("[Shutdown] stopped cleanup job"); 377 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 + userPassword: "", // We only check groups, not authenticate 64 + userSearchBase: process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com", 65 + usernameAttribute: process.env.LDAP_USERNAME_ATTRIBUTE || "uid", 66 + username: username, 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 import { db } from "../db"; 2 - import { verifyDomain, validateProfileURL } from "./indieauth"; 3 4 function getSessionUser( 5 req: Request, 6 - ): { username: string; userId: number; is_admin: boolean; tier: string } | Response { 7 const authHeader = req.headers.get("Authorization"); 8 9 if (!authHeader || !authHeader.startsWith("Bearer ")) { ··· 193 const origin = process.env.ORIGIN || "http://localhost:3000"; 194 const indikoProfileUrl = `${origin}/u/${user.username}`; 195 196 - const verification = await verifyDomain(validation.canonicalUrl!, indikoProfileUrl); 197 if (!verification.success) { 198 return Response.json( 199 { error: verification.error || "Failed to verify domain" }, ··· 456 } 457 458 // Prevent disabling self 459 - if (targetUserId === user.id) { 460 return Response.json( 461 { error: "Cannot disable your own account" }, 462 { status: 400 }, ··· 508 return Response.json({ success: true }); 509 } 510 511 - export async function updateUserTier(req: Request, userId: string): Promise<Response> { 512 const user = getSessionUser(req); 513 if (user instanceof Response) { 514 return user; ··· 536 537 const targetUser = db 538 .query("SELECT id, username, tier FROM users WHERE id = ?") 539 - .get(targetUserId) as { id: number; username: string; tier: string } | undefined; 540 541 if (!targetUser) { 542 return Response.json({ error: "User not found" }, { status: 404 });
··· 1 import { db } from "../db"; 2 + import { validateProfileURL, verifyDomain } from "./indieauth"; 3 4 function getSessionUser( 5 req: Request, 6 + ): 7 + | { username: string; userId: number; is_admin: boolean; tier: string } 8 + | Response { 9 const authHeader = req.headers.get("Authorization"); 10 11 if (!authHeader || !authHeader.startsWith("Bearer ")) { ··· 195 const origin = process.env.ORIGIN || "http://localhost:3000"; 196 const indikoProfileUrl = `${origin}/u/${user.username}`; 197 198 + const verification = await verifyDomain( 199 + validation.canonicalUrl!, 200 + indikoProfileUrl, 201 + ); 202 if (!verification.success) { 203 return Response.json( 204 { error: verification.error || "Failed to verify domain" }, ··· 461 } 462 463 // Prevent disabling self 464 + if (targetUserId === user.userId) { 465 return Response.json( 466 { error: "Cannot disable your own account" }, 467 { status: 400 }, ··· 513 return Response.json({ success: true }); 514 } 515 516 + export async function updateUserTier( 517 + req: Request, 518 + userId: string, 519 + ): Promise<Response> { 520 const user = getSessionUser(req); 521 if (user instanceof Response) { 522 return user; ··· 544 545 const targetUser = db 546 .query("SELECT id, username, tier FROM users WHERE id = ?") 547 + .get(targetUserId) as 548 + | { id: number; username: string; tier: string } 549 + | undefined; 550 551 if (!targetUser) { 552 return Response.json({ error: "User not found" }, { status: 404 });
+204 -24
src/routes/auth.ts
··· 1 import { 2 type AuthenticationResponseJSON, 3 - generateAuthenticationOptions, 4 - generateRegistrationOptions, 5 type PublicKeyCredentialCreationOptionsJSON, 6 type PublicKeyCredentialRequestOptionsJSON, 7 type RegistrationResponseJSON, 8 type VerifiedAuthenticationResponse, 9 type VerifiedRegistrationResponse, 10 verifyAuthenticationResponse, 11 verifyRegistrationResponse, 12 } from "@simplewebauthn/server"; 13 import { db } from "../db"; 14 15 const RP_NAME = "Indiko"; 16 ··· 66 // Validate invite code 67 const invite = db 68 .query( 69 - "SELECT id, max_uses, current_uses, expires_at, message FROM invites WHERE code = ?", 70 ) 71 .get(inviteCode) as 72 | { ··· 75 current_uses: number; 76 expires_at: number | null; 77 message: string | null; 78 } 79 | undefined; 80 ··· 87 return Response.json({ error: "Invite code expired" }, { status: 403 }); 88 } 89 90 - if (invite.current_uses >= invite.max_uses) { 91 return Response.json( 92 - { error: "Invite code fully used" }, 93 - { status: 403 }, 94 ); 95 } 96 ··· 160 ); 161 } 162 163 - // Verify challenge exists and is valid 164 const challenge = db 165 .query( 166 "SELECT challenge, expires_at FROM challenges WHERE challenge = ? AND username = ? AND type = 'registration'", ··· 198 199 const invite = db 200 .query( 201 - "SELECT id, max_uses, current_uses, expires_at FROM invites WHERE code = ?", 202 ) 203 .get(inviteCode) as 204 | { ··· 206 max_uses: number; 207 current_uses: number; 208 expires_at: number | null; 209 } 210 | undefined; 211 ··· 218 return Response.json({ error: "Invite code expired" }, { status: 403 }); 219 } 220 221 - if (invite.current_uses >= invite.max_uses) { 222 return Response.json( 223 - { error: "Invite code fully used" }, 224 - { status: 403 }, 225 ); 226 } 227 ··· 239 verification = await verifyRegistrationResponse({ 240 response, 241 expectedChallenge: challenge.challenge, 242 - expectedOrigin: process.env.ORIGIN!, 243 - expectedRPID: process.env.RP_ID!, 244 }); 245 } catch (error) { 246 console.error("WebAuthn verification failed:", error); ··· 253 254 const { credential } = verification.registrationInfo; 255 256 // Create user (bootstrap is always admin, invited users are regular users) 257 const insertUser = db.query( 258 - "INSERT INTO users (username, name, is_admin, tier, role) VALUES (?, ?, ?, ?, ?) RETURNING id", 259 ); 260 const user = insertUser.get( 261 username, ··· 263 isBootstrap ? 1 : 0, 264 isBootstrap ? "admin" : "user", 265 isBootstrap ? "admin" : "user", 266 ) as { 267 id: number; 268 }; ··· 283 if (inviteId) { 284 const usedAt = Math.floor(Date.now() / 1000); 285 286 - // Increment invite usage counter 287 - db.query( 288 - "UPDATE invites SET current_uses = current_uses + 1 WHERE id = ?", 289 - ).run(inviteId); 290 291 // Record this invite use 292 db.query( ··· 352 .get(username) as { id: number; status: string } | undefined; 353 354 if (!user) { 355 - return Response.json({ error: "User not found" }, { status: 404 }); 356 } 357 358 if (user.status !== "active") { ··· 365 .all(user.id) as { credential_id: Buffer }[]; 366 367 if (credentials.length === 0) { 368 - return Response.json({ error: "No credentials found" }, { status: 404 }); 369 } 370 371 // Generate authentication options ··· 382 "INSERT INTO challenges (challenge, username, type, expires_at) VALUES (?, ?, 'authentication', ?)", 383 ).run(options.challenge, username, expiresAt); 384 385 - return Response.json(options); 386 } catch (error) { 387 console.error("Login options error:", error); 388 return Response.json({ error: "Internal server error" }, { status: 500 }); ··· 428 429 // Check if user account is active 430 if (credentialWithUser.status !== "active") { 431 - return Response.json({ error: "Account is suspended" }, { status: 403 }); 432 } 433 434 // Verify the username matches ··· 471 expectedOrigin: process.env.ORIGIN!, 472 expectedRPID: process.env.RP_ID!, 473 credential: { 474 - id: credential.credential_id, 475 - publicKey: credential.public_key, 476 counter: credential.counter, 477 }, 478 }); ··· 525 return Response.json({ error: "Internal server error" }, { status: 500 }); 526 } 527 }
··· 1 import { 2 type AuthenticationResponseJSON, 3 type PublicKeyCredentialCreationOptionsJSON, 4 type PublicKeyCredentialRequestOptionsJSON, 5 type RegistrationResponseJSON, 6 type VerifiedAuthenticationResponse, 7 type VerifiedRegistrationResponse, 8 + generateAuthenticationOptions, 9 + generateRegistrationOptions, 10 verifyAuthenticationResponse, 11 verifyRegistrationResponse, 12 } from "@simplewebauthn/server"; 13 + import { authenticate } from "ldap-authentication"; 14 import { db } from "../db"; 15 + import { checkLdapGroupMembership } from "../ldap-cleanup"; 16 17 const RP_NAME = "Indiko"; 18 ··· 68 // Validate invite code 69 const invite = db 70 .query( 71 + "SELECT id, max_uses, current_uses, expires_at, message, ldap_username FROM invites WHERE code = ?", 72 ) 73 .get(inviteCode) as 74 | { ··· 77 current_uses: number; 78 expires_at: number | null; 79 message: string | null; 80 + ldap_username: string | null; 81 } 82 | undefined; 83 ··· 90 return Response.json({ error: "Invite code expired" }, { status: 403 }); 91 } 92 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) { 97 return Response.json( 98 + { error: "Username must match LDAP account" }, 99 + { status: 400 }, 100 ); 101 } 102 ··· 166 ); 167 } 168 169 + if (!expectedChallenge) { 170 + return Response.json({ error: "Invalid challenge" }, { status: 400 }); 171 + } 172 + 173 const challenge = db 174 .query( 175 "SELECT challenge, expires_at FROM challenges WHERE challenge = ? AND username = ? AND type = 'registration'", ··· 207 208 const invite = db 209 .query( 210 + "SELECT id, max_uses, current_uses, expires_at, ldap_username FROM invites WHERE code = ?", 211 ) 212 .get(inviteCode) as 213 | { ··· 215 max_uses: number; 216 current_uses: number; 217 expires_at: number | null; 218 + ldap_username: string | null; 219 } 220 | undefined; 221 ··· 228 return Response.json({ error: "Invite code expired" }, { status: 403 }); 229 } 230 231 + // If invite is locked to an LDAP username, enforce it 232 + if (invite.ldap_username && invite.ldap_username !== username) { 233 return Response.json( 234 + { error: "Username must match LDAP account" }, 235 + { status: 400 }, 236 ); 237 } 238 ··· 250 verification = await verifyRegistrationResponse({ 251 response, 252 expectedChallenge: challenge.challenge, 253 + expectedOrigin: process.env.ORIGIN ? process.env.ORIGIN : "", 254 + expectedRPID: process.env.RP_ID ? process.env.RP_ID : "", 255 }); 256 } catch (error) { 257 console.error("WebAuthn verification failed:", error); ··· 264 265 const { credential } = verification.registrationInfo; 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 + 277 // Create user (bootstrap is always admin, invited users are regular users) 278 const insertUser = db.query( 279 + "INSERT INTO users (username, name, is_admin, tier, role, provisioned_via_ldap) VALUES (?, ?, ?, ?, ?, ?) RETURNING id", 280 ); 281 const user = insertUser.get( 282 username, ··· 284 isBootstrap ? 1 : 0, 285 isBootstrap ? "admin" : "user", 286 isBootstrap ? "admin" : "user", 287 + isLdapProvisioned ? 1 : 0, 288 ) as { 289 id: number; 290 }; ··· 305 if (inviteId) { 306 const usedAt = Math.floor(Date.now() / 1000); 307 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 + } 322 323 // Record this invite use 324 db.query( ··· 384 .get(username) as { id: number; status: string } | undefined; 385 386 if (!user) { 387 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 388 } 389 390 if (user.status !== "active") { ··· 397 .all(user.id) as { credential_id: Buffer }[]; 398 399 if (credentials.length === 0) { 400 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 401 } 402 403 // Generate authentication options ··· 414 "INSERT INTO challenges (challenge, username, type, expires_at) VALUES (?, ?, 'authentication', ?)", 415 ).run(options.challenge, username, expiresAt); 416 417 + // Local user always uses passkey login, no LDAP verification needed 418 + return Response.json({ 419 + ...options, 420 + ldapVerificationRequired: false, 421 + }); 422 } catch (error) { 423 console.error("Login options error:", error); 424 return Response.json({ error: "Internal server error" }, { status: 500 }); ··· 464 465 // Check if user account is active 466 if (credentialWithUser.status !== "active") { 467 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 468 } 469 470 // Verify the username matches ··· 507 expectedOrigin: process.env.ORIGIN!, 508 expectedRPID: process.env.RP_ID!, 509 credential: { 510 + id: credential.credential_id.toString(), 511 + publicKey: new Uint8Array(credential.public_key), 512 counter: credential.counter, 513 }, 514 }); ··· 561 return Response.json({ error: "Internal server error" }, { status: 500 }); 562 } 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"; 2 import { nanoid } from "nanoid"; 3 import { db } from "../db"; 4 ··· 16 17 function getSessionUser( 18 req: Request, 19 - ): { username: string; userId: number; is_admin: boolean; tier: string } | Response { 20 const authHeader = req.headers.get("Authorization"); 21 22 if (!authHeader || !authHeader.startsWith("Bearer ")) { ··· 119 if (!rolesByApp.has(app_id)) { 120 rolesByApp.set(app_id, []); 121 } 122 - rolesByApp.get(app_id)!.push(role); 123 } 124 125 return Response.json({
··· 1 + import crypto from "node:crypto"; 2 import { nanoid } from "nanoid"; 3 import { db } from "../db"; 4 ··· 16 17 function getSessionUser( 18 req: Request, 19 + ): 20 + | { username: string; userId: number; is_admin: boolean; tier: string } 21 + | Response { 22 const authHeader = req.headers.get("Authorization"); 23 24 if (!authHeader || !authHeader.startsWith("Bearer ")) { ··· 121 if (!rolesByApp.has(app_id)) { 122 rolesByApp.set(app_id, []); 123 } 124 + rolesByApp.get(app_id)?.push(role); 125 } 126 127 return Response.json({
+209 -72
src/routes/indieauth.ts
··· 1 - import crypto from "crypto"; 2 import { db } from "../db"; 3 4 interface SessionUser { ··· 53 username: session.username, 54 userId: session.id, 55 isAdmin: session.is_admin === 1, 56 }; 57 } 58 ··· 68 }), 69 ); 70 71 - const sessionToken = cookies["indiko_session"]; 72 if (!sessionToken) return null; 73 74 const session = db ··· 127 } 128 129 // Validate profile URL per IndieAuth spec 130 - export function validateProfileURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } { 131 let url: URL; 132 try { 133 url = new URL(urlString); ··· 152 153 // MUST NOT contain username/password 154 if (url.username || url.password) { 155 - return { valid: false, error: "Profile URL must not contain username or password" }; 156 } 157 158 // MUST NOT contain ports ··· 164 const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; 165 const ipv6Regex = /^\[?[0-9a-fA-F:]+\]?$/; 166 if (ipv4Regex.test(url.hostname) || ipv6Regex.test(url.hostname)) { 167 - return { valid: false, error: "Profile URL must use domain names, not IP addresses" }; 168 } 169 170 // MUST NOT contain single-dot or double-dot path segments 171 const pathSegments = url.pathname.split("/"); 172 if (pathSegments.includes(".") || pathSegments.includes("..")) { 173 - return { valid: false, error: "Profile URL must not contain . or .. path segments" }; 174 } 175 176 return { valid: true, canonicalUrl: canonicalizeURL(urlString) }; 177 } 178 179 // Validate client URL per IndieAuth spec 180 - function validateClientURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } { 181 let url: URL; 182 try { 183 url = new URL(urlString); ··· 202 203 // MUST NOT contain username/password 204 if (url.username || url.password) { 205 - return { valid: false, error: "Client URL must not contain username or password" }; 206 } 207 208 // MUST NOT contain single-dot or double-dot path segments 209 const pathSegments = url.pathname.split("/"); 210 if (pathSegments.includes(".") || pathSegments.includes("..")) { 211 - return { valid: false, error: "Client URL must not contain . or .. path segments" }; 212 } 213 214 // MAY use loopback interface, but not other IP addresses ··· 217 if (ipv4Regex.test(url.hostname)) { 218 // Allow 127.0.0.1 (loopback), reject others 219 if (!url.hostname.startsWith("127.")) { 220 - return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" }; 221 } 222 } else if (ipv6Regex.test(url.hostname)) { 223 // Allow ::1 (loopback), reject others 224 const ipv6Match = url.hostname.match(ipv6Regex); 225 if (ipv6Match && ipv6Match[1] !== "::1") { 226 - return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" }; 227 } 228 } 229 ··· 234 function isLoopbackURL(urlString: string): boolean { 235 try { 236 const url = new URL(urlString); 237 - return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname.startsWith("127."); 238 } catch { 239 return false; 240 } ··· 254 }> { 255 // MUST NOT fetch loopback addresses (security requirement) 256 if (isLoopbackURL(clientId)) { 257 - return { success: false, error: "Cannot fetch metadata from loopback addresses" }; 258 } 259 260 try { ··· 273 clearTimeout(timeoutId); 274 275 if (!response.ok) { 276 - return { success: false, error: `Failed to fetch client metadata: HTTP ${response.status}` }; 277 } 278 279 const contentType = response.headers.get("content-type") || ""; ··· 284 285 // Verify client_id matches 286 if (metadata.client_id && metadata.client_id !== clientId) { 287 - return { success: false, error: "client_id in metadata does not match URL" }; 288 } 289 290 return { success: true, metadata }; ··· 295 const html = await response.text(); 296 297 // Extract redirect URIs from link tags 298 - const redirectUriRegex = /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 299 const redirectUris: string[] = []; 300 let match: RegExpExecArray | null; 301 302 while ((match = redirectUriRegex.exec(html)) !== null) { 303 - redirectUris.push(match[1]); 304 } 305 306 // Also try reverse order (href before rel) 307 - const redirectUriRegex2 = /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi; 308 while ((match = redirectUriRegex2.exec(html)) !== null) { 309 - if (!redirectUris.includes(match[1])) { 310 - redirectUris.push(match[1]); 311 } 312 } 313 ··· 321 }; 322 } 323 324 - return { success: false, error: "No client metadata or redirect_uri links found in HTML" }; 325 } 326 327 return { success: false, error: "Unsupported content type" }; ··· 330 if (error.name === "AbortError") { 331 return { success: false, error: "Timeout fetching client metadata" }; 332 } 333 - return { success: false, error: `Failed to fetch client metadata: ${error.message}` }; 334 } 335 return { success: false, error: "Failed to fetch client metadata" }; 336 } 337 } 338 339 // Verify domain has rel="me" link back to user profile 340 - export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): Promise<{ 341 success: boolean; 342 error?: string; 343 }> { ··· 359 360 if (!response.ok) { 361 const errorBody = await response.text(); 362 - console.error(`[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, { 363 - status: response.status, 364 - contentType: response.headers.get("content-type"), 365 - bodyPreview: errorBody.substring(0, 200), 366 - }); 367 - return { success: false, error: `Failed to fetch domain: HTTP ${response.status}` }; 368 } 369 370 const html = await response.text(); ··· 384 385 const relValue = relMatch[1]; 386 // Check if "me" is a separate word in the rel attribute 387 - if (!relValue.split(/\s+/).includes("me")) return null; 388 389 // Extract href (handle quoted and unquoted attributes) 390 const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i); ··· 413 414 // Check if any rel="me" link matches the indiko profile URL 415 const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl); 416 - const hasRelMe = relMeLinks.some(link => { 417 try { 418 const normalizedLink = canonicalizeURL(link); 419 return normalizedLink === normalizedIndikoUrl; ··· 423 }); 424 425 if (!hasRelMe) { 426 - console.error(`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, { 427 - foundLinks: relMeLinks, 428 - normalizedTarget: normalizedIndikoUrl, 429 - }); 430 return { 431 success: false, 432 error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`, ··· 440 console.error(`[verifyDomain] Timeout verifying ${domainUrl}`); 441 return { success: false, error: "Timeout verifying domain" }; 442 } 443 - console.error(`[verifyDomain] Error verifying ${domainUrl}: ${error.message}`, { 444 - name: error.name, 445 - stack: error.stack, 446 - }); 447 - return { success: false, error: `Failed to verify domain: ${error.message}` }; 448 } 449 - console.error(`[verifyDomain] Unknown error verifying ${domainUrl}:`, error); 450 return { success: false, error: "Failed to verify domain" }; 451 } 452 } ··· 457 redirectUri: string, 458 ): Promise<{ 459 error?: string; 460 - app?: { name: string | null; redirect_uris: string; logo_url?: string | null }; 461 }> { 462 const existing = db 463 .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") ··· 550 551 // Fetch the newly created app 552 const newApp = db 553 - .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") 554 - .get(canonicalClientId) as { name: string | null; redirect_uris: string; logo_url?: string | null }; 555 556 return { app: newApp }; 557 } ··· 936 const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds 937 938 db.query( 939 - "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 940 ).run( 941 code, 942 user.userId, 943 clientId, 944 redirectUri, 945 JSON.stringify(requestedScopes), ··· 954 ).run(Math.floor(Date.now() / 1000), user.userId, clientId); 955 956 const origin = process.env.ORIGIN || "http://localhost:3000"; 957 - return Response.redirect(`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`); 958 } 959 } 960 ··· 1316 // POST /auth/authorize - Consent form submission 1317 export async function authorizePost(req: Request): Promise<Response> { 1318 const contentType = req.headers.get("Content-Type"); 1319 - 1320 // Parse the request body 1321 let body: Record<string, string>; 1322 let formData: FormData; ··· 1328 body = await req.json(); 1329 // Create a fake FormData for JSON requests 1330 formData = new FormData(); 1331 - Object.entries(body).forEach(([key, value]) => { 1332 formData.append(key, value); 1333 - }); 1334 } 1335 1336 const grantType = body.grant_type; 1337 - 1338 // If grant_type is present, this is a token exchange request (IndieAuth profile scope only) 1339 if (grantType === "authorization_code") { 1340 // Create a mock request for token() function 1341 const mockReq = new Request(req.url, { 1342 method: "POST", 1343 headers: req.headers, 1344 - body: contentType?.includes("application/x-www-form-urlencoded") 1345 ? new URLSearchParams(body).toString() 1346 : JSON.stringify(body), 1347 }); ··· 1373 clientId = canonicalizeURL(rawClientId); 1374 redirectUri = canonicalizeURL(rawRedirectUri); 1375 } catch { 1376 - return new Response("Invalid client_id or redirect_uri URL format", { status: 400 }); 1377 } 1378 1379 if (action === "deny") { ··· 1395 const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds 1396 1397 db.query( 1398 - "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 1399 ).run( 1400 code, 1401 user.userId, 1402 clientId, 1403 redirectUri, 1404 JSON.stringify(approvedScopes), ··· 1487 let redirect_uri: string | undefined; 1488 try { 1489 client_id = raw_client_id ? canonicalizeURL(raw_client_id) : undefined; 1490 - redirect_uri = raw_redirect_uri ? canonicalizeURL(raw_redirect_uri) : undefined; 1491 } catch { 1492 return Response.json( 1493 { ··· 1502 return Response.json( 1503 { 1504 error: "unsupported_grant_type", 1505 - error_description: "Only authorization_code and refresh_token grant types are supported", 1506 }, 1507 { status: 400 }, 1508 ); ··· 1577 const expiresAt = now + expiresIn; 1578 1579 // Update token (rotate access token, keep refresh token) 1580 - db.query( 1581 - "UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?", 1582 - ).run(newAccessToken, expiresAt, tokenData.id); 1583 1584 // Get user profile for me value 1585 const user = db ··· 1614 headers: { 1615 "Content-Type": "application/json", 1616 "Cache-Control": "no-store", 1617 - "Pragma": "no-cache", 1618 }, 1619 }, 1620 ); ··· 1622 1623 // Handle authorization_code grant (existing flow) 1624 // Check if client is pre-registered and requires secret 1625 const app = db 1626 .query( 1627 "SELECT is_preregistered, client_secret_hash FROM apps WHERE client_id = ?", ··· 1699 // Look up authorization code 1700 const authcode = db 1701 .query( 1702 - "SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me FROM authcodes WHERE code = ?", 1703 ) 1704 .get(code) as 1705 | { 1706 user_id: number; 1707 client_id: string; 1708 redirect_uri: string; 1709 scopes: string; ··· 1727 1728 // Check if already used 1729 if (authcode.used) { 1730 - console.error("Token endpoint: authorization code already used", { code }); 1731 return Response.json( 1732 { 1733 error: "invalid_grant", ··· 1740 // Check if expired 1741 const now = Math.floor(Date.now() / 1000); 1742 if (authcode.expires_at < now) { 1743 - console.error("Token endpoint: authorization code expired", { code, expires_at: authcode.expires_at, now, diff: now - authcode.expires_at }); 1744 return Response.json( 1745 { 1746 error: "invalid_grant", ··· 1752 1753 // Verify client_id matches 1754 if (authcode.client_id !== client_id) { 1755 - console.error("Token endpoint: client_id mismatch", { stored: authcode.client_id, received: client_id }); 1756 return Response.json( 1757 { 1758 error: "invalid_grant", ··· 1764 1765 // Verify redirect_uri matches 1766 if (authcode.redirect_uri !== redirect_uri) { 1767 - console.error("Token endpoint: redirect_uri mismatch", { stored: authcode.redirect_uri, received: redirect_uri }); 1768 return Response.json( 1769 { 1770 error: "invalid_grant", ··· 1776 1777 // Verify PKCE code_verifier (required for all clients per IndieAuth spec) 1778 if (!verifyPKCE(code_verifier, authcode.code_challenge)) { 1779 - console.error("Token endpoint: PKCE verification failed", { code_verifier, code_challenge: authcode.code_challenge }); 1780 return Response.json( 1781 { 1782 error: "invalid_grant", ··· 1839 1840 // Validate that the user controls the requested me parameter 1841 if (authcode.me && authcode.me !== meValue) { 1842 - console.error("Token endpoint: me mismatch", { requested: authcode.me, actual: meValue }); 1843 return Response.json( 1844 { 1845 error: "invalid_grant", 1846 - error_description: "The requested identity does not match the user's verified domain", 1847 }, 1848 { status: 400 }, 1849 ); 1850 } 1851 1852 const origin = process.env.ORIGIN || "http://localhost:3000"; 1853 - 1854 // Generate access token 1855 const accessToken = crypto.randomBytes(32).toString("base64url"); 1856 const expiresIn = 3600; // 1 hour ··· 1864 // Store token in database with refresh token 1865 db.query( 1866 "INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", 1867 - ).run(accessToken, authcode.user_id, client_id, scopes.join(" "), expiresAt, refreshToken, refreshExpiresAt); 1868 1869 const response: Record<string, unknown> = { 1870 access_token: accessToken, ··· 1882 response.role = permission.role; 1883 } 1884 1885 - console.log("Token endpoint: success", { me: meValue, scopes: scopes.join(" ") }); 1886 1887 return Response.json(response, { 1888 headers: { 1889 "Content-Type": "application/json", 1890 "Cache-Control": "no-store", 1891 - "Pragma": "no-cache", 1892 }, 1893 }); 1894 } catch (error) { ··· 2052 try { 2053 // Get access token from Authorization header 2054 const authHeader = req.headers.get("Authorization"); 2055 - 2056 if (!authHeader || !authHeader.startsWith("Bearer ")) { 2057 return Response.json( 2058 {
··· 1 + import crypto from "node:crypto"; 2 import { db } from "../db"; 3 4 interface SessionUser { ··· 53 username: session.username, 54 userId: session.id, 55 isAdmin: session.is_admin === 1, 56 + tier: session.tier, 57 }; 58 } 59 ··· 69 }), 70 ); 71 72 + const sessionToken = cookies.indiko_session; 73 if (!sessionToken) return null; 74 75 const session = db ··· 128 } 129 130 // Validate profile URL per IndieAuth spec 131 + export function validateProfileURL(urlString: string): { 132 + valid: boolean; 133 + error?: string; 134 + canonicalUrl?: string; 135 + } { 136 let url: URL; 137 try { 138 url = new URL(urlString); ··· 157 158 // MUST NOT contain username/password 159 if (url.username || url.password) { 160 + return { 161 + valid: false, 162 + error: "Profile URL must not contain username or password", 163 + }; 164 } 165 166 // MUST NOT contain ports ··· 172 const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; 173 const ipv6Regex = /^\[?[0-9a-fA-F:]+\]?$/; 174 if (ipv4Regex.test(url.hostname) || ipv6Regex.test(url.hostname)) { 175 + return { 176 + valid: false, 177 + error: "Profile URL must use domain names, not IP addresses", 178 + }; 179 } 180 181 // MUST NOT contain single-dot or double-dot path segments 182 const pathSegments = url.pathname.split("/"); 183 if (pathSegments.includes(".") || pathSegments.includes("..")) { 184 + return { 185 + valid: false, 186 + error: "Profile URL must not contain . or .. path segments", 187 + }; 188 } 189 190 return { valid: true, canonicalUrl: canonicalizeURL(urlString) }; 191 } 192 193 // Validate client URL per IndieAuth spec 194 + function validateClientURL(urlString: string): { 195 + valid: boolean; 196 + error?: string; 197 + canonicalUrl?: string; 198 + } { 199 let url: URL; 200 try { 201 url = new URL(urlString); ··· 220 221 // MUST NOT contain username/password 222 if (url.username || url.password) { 223 + return { 224 + valid: false, 225 + error: "Client URL must not contain username or password", 226 + }; 227 } 228 229 // MUST NOT contain single-dot or double-dot path segments 230 const pathSegments = url.pathname.split("/"); 231 if (pathSegments.includes(".") || pathSegments.includes("..")) { 232 + return { 233 + valid: false, 234 + error: "Client URL must not contain . or .. path segments", 235 + }; 236 } 237 238 // MAY use loopback interface, but not other IP addresses ··· 241 if (ipv4Regex.test(url.hostname)) { 242 // Allow 127.0.0.1 (loopback), reject others 243 if (!url.hostname.startsWith("127.")) { 244 + return { 245 + valid: false, 246 + error: 247 + "Client URL must use domain names, not IP addresses (except loopback)", 248 + }; 249 } 250 } else if (ipv6Regex.test(url.hostname)) { 251 // Allow ::1 (loopback), reject others 252 const ipv6Match = url.hostname.match(ipv6Regex); 253 if (ipv6Match && ipv6Match[1] !== "::1") { 254 + return { 255 + valid: false, 256 + error: 257 + "Client URL must use domain names, not IP addresses (except loopback)", 258 + }; 259 } 260 } 261 ··· 266 function isLoopbackURL(urlString: string): boolean { 267 try { 268 const url = new URL(urlString); 269 + return ( 270 + url.hostname === "localhost" || 271 + url.hostname === "127.0.0.1" || 272 + url.hostname === "[::1]" || 273 + url.hostname.startsWith("127.") 274 + ); 275 } catch { 276 return false; 277 } ··· 291 }> { 292 // MUST NOT fetch loopback addresses (security requirement) 293 if (isLoopbackURL(clientId)) { 294 + return { 295 + success: false, 296 + error: "Cannot fetch metadata from loopback addresses", 297 + }; 298 } 299 300 try { ··· 313 clearTimeout(timeoutId); 314 315 if (!response.ok) { 316 + return { 317 + success: false, 318 + error: `Failed to fetch client metadata: HTTP ${response.status}`, 319 + }; 320 } 321 322 const contentType = response.headers.get("content-type") || ""; ··· 327 328 // Verify client_id matches 329 if (metadata.client_id && metadata.client_id !== clientId) { 330 + return { 331 + success: false, 332 + error: "client_id in metadata does not match URL", 333 + }; 334 } 335 336 return { success: true, metadata }; ··· 341 const html = await response.text(); 342 343 // Extract redirect URIs from link tags 344 + const redirectUriRegex = 345 + /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 346 const redirectUris: string[] = []; 347 let match: RegExpExecArray | null; 348 349 while ((match = redirectUriRegex.exec(html)) !== null) { 350 + redirectUris.push(match[1] ? match[1] : ""); 351 } 352 353 // Also try reverse order (href before rel) 354 + const redirectUriRegex2 = 355 + /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi; 356 while ((match = redirectUriRegex2.exec(html)) !== null) { 357 + if (!redirectUris.includes(match[1] ? match[1] : "")) { 358 + redirectUris.push(match[1] ? match[1] : ""); 359 } 360 } 361 ··· 369 }; 370 } 371 372 + return { 373 + success: false, 374 + error: "No client metadata or redirect_uri links found in HTML", 375 + }; 376 } 377 378 return { success: false, error: "Unsupported content type" }; ··· 381 if (error.name === "AbortError") { 382 return { success: false, error: "Timeout fetching client metadata" }; 383 } 384 + return { 385 + success: false, 386 + error: `Failed to fetch client metadata: ${error.message}`, 387 + }; 388 } 389 return { success: false, error: "Failed to fetch client metadata" }; 390 } 391 } 392 393 // Verify domain has rel="me" link back to user profile 394 + export async function verifyDomain( 395 + domainUrl: string, 396 + indikoProfileUrl: string, 397 + ): Promise<{ 398 success: boolean; 399 error?: string; 400 }> { ··· 416 417 if (!response.ok) { 418 const errorBody = await response.text(); 419 + console.error( 420 + `[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, 421 + { 422 + status: response.status, 423 + contentType: response.headers.get("content-type"), 424 + bodyPreview: errorBody.substring(0, 200), 425 + }, 426 + ); 427 + return { 428 + success: false, 429 + error: `Failed to fetch domain: HTTP ${response.status}`, 430 + }; 431 } 432 433 const html = await response.text(); ··· 447 448 const relValue = relMatch[1]; 449 // Check if "me" is a separate word in the rel attribute 450 + if (!relValue?.split(/\s+/).includes("me")) return null; 451 452 // Extract href (handle quoted and unquoted attributes) 453 const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i); ··· 476 477 // Check if any rel="me" link matches the indiko profile URL 478 const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl); 479 + const hasRelMe = relMeLinks.some((link) => { 480 try { 481 const normalizedLink = canonicalizeURL(link); 482 return normalizedLink === normalizedIndikoUrl; ··· 486 }); 487 488 if (!hasRelMe) { 489 + console.error( 490 + `[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, 491 + { 492 + foundLinks: relMeLinks, 493 + normalizedTarget: normalizedIndikoUrl, 494 + }, 495 + ); 496 return { 497 success: false, 498 error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`, ··· 506 console.error(`[verifyDomain] Timeout verifying ${domainUrl}`); 507 return { success: false, error: "Timeout verifying domain" }; 508 } 509 + console.error( 510 + `[verifyDomain] Error verifying ${domainUrl}: ${error.message}`, 511 + { 512 + name: error.name, 513 + stack: error.stack, 514 + }, 515 + ); 516 + return { 517 + success: false, 518 + error: `Failed to verify domain: ${error.message}`, 519 + }; 520 } 521 + console.error( 522 + `[verifyDomain] Unknown error verifying ${domainUrl}:`, 523 + error, 524 + ); 525 return { success: false, error: "Failed to verify domain" }; 526 } 527 } ··· 532 redirectUri: string, 533 ): Promise<{ 534 error?: string; 535 + app?: { 536 + name: string | null; 537 + redirect_uris: string; 538 + logo_url?: string | null; 539 + }; 540 }> { 541 const existing = db 542 .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") ··· 629 630 // Fetch the newly created app 631 const newApp = db 632 + .query( 633 + "SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?", 634 + ) 635 + .get(canonicalClientId) as { 636 + name: string | null; 637 + redirect_uris: string; 638 + logo_url?: string | null; 639 + }; 640 641 return { app: newApp }; 642 } ··· 1021 const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds 1022 1023 db.query( 1024 + "INSERT INTO authcodes (code, user_id, username, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", 1025 ).run( 1026 code, 1027 user.userId, 1028 + user.username, 1029 clientId, 1030 redirectUri, 1031 JSON.stringify(requestedScopes), ··· 1040 ).run(Math.floor(Date.now() / 1000), user.userId, clientId); 1041 1042 const origin = process.env.ORIGIN || "http://localhost:3000"; 1043 + return Response.redirect( 1044 + `${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`, 1045 + ); 1046 } 1047 } 1048 ··· 1404 // POST /auth/authorize - Consent form submission 1405 export async function authorizePost(req: Request): Promise<Response> { 1406 const contentType = req.headers.get("Content-Type"); 1407 + 1408 // Parse the request body 1409 let body: Record<string, string>; 1410 let formData: FormData; ··· 1416 body = await req.json(); 1417 // Create a fake FormData for JSON requests 1418 formData = new FormData(); 1419 + for (const [key, value] of Object.entries(body)) { 1420 formData.append(key, value); 1421 + } 1422 } 1423 1424 const grantType = body.grant_type; 1425 + 1426 // If grant_type is present, this is a token exchange request (IndieAuth profile scope only) 1427 if (grantType === "authorization_code") { 1428 // Create a mock request for token() function 1429 const mockReq = new Request(req.url, { 1430 method: "POST", 1431 headers: req.headers, 1432 + body: contentType?.includes("application/x-www-form-urlencoded") 1433 ? new URLSearchParams(body).toString() 1434 : JSON.stringify(body), 1435 }); ··· 1461 clientId = canonicalizeURL(rawClientId); 1462 redirectUri = canonicalizeURL(rawRedirectUri); 1463 } catch { 1464 + return new Response("Invalid client_id or redirect_uri URL format", { 1465 + status: 400, 1466 + }); 1467 } 1468 1469 if (action === "deny") { ··· 1485 const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds 1486 1487 db.query( 1488 + "INSERT INTO authcodes (code, user_id, username, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", 1489 ).run( 1490 code, 1491 user.userId, 1492 + user.username, 1493 clientId, 1494 redirectUri, 1495 JSON.stringify(approvedScopes), ··· 1578 let redirect_uri: string | undefined; 1579 try { 1580 client_id = raw_client_id ? canonicalizeURL(raw_client_id) : undefined; 1581 + redirect_uri = raw_redirect_uri 1582 + ? canonicalizeURL(raw_redirect_uri) 1583 + : undefined; 1584 } catch { 1585 return Response.json( 1586 { ··· 1595 return Response.json( 1596 { 1597 error: "unsupported_grant_type", 1598 + error_description: 1599 + "Only authorization_code and refresh_token grant types are supported", 1600 }, 1601 { status: 400 }, 1602 ); ··· 1671 const expiresAt = now + expiresIn; 1672 1673 // Update token (rotate access token, keep refresh token) 1674 + db.query("UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?").run( 1675 + newAccessToken, 1676 + expiresAt, 1677 + tokenData.id, 1678 + ); 1679 1680 // Get user profile for me value 1681 const user = db ··· 1710 headers: { 1711 "Content-Type": "application/json", 1712 "Cache-Control": "no-store", 1713 + Pragma: "no-cache", 1714 }, 1715 }, 1716 ); ··· 1718 1719 // Handle authorization_code grant (existing flow) 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 + } 1730 const app = db 1731 .query( 1732 "SELECT is_preregistered, client_secret_hash FROM apps WHERE client_id = ?", ··· 1804 // Look up authorization code 1805 const authcode = db 1806 .query( 1807 + "SELECT user_id, username, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me FROM authcodes WHERE code = ?", 1808 ) 1809 .get(code) as 1810 | { 1811 user_id: number; 1812 + username: string; 1813 client_id: string; 1814 redirect_uri: string; 1815 scopes: string; ··· 1833 1834 // Check if already used 1835 if (authcode.used) { 1836 + console.error("Token endpoint: authorization code already used", { 1837 + code, 1838 + }); 1839 return Response.json( 1840 { 1841 error: "invalid_grant", ··· 1848 // Check if expired 1849 const now = Math.floor(Date.now() / 1000); 1850 if (authcode.expires_at < now) { 1851 + console.error("Token endpoint: authorization code expired", { 1852 + code, 1853 + expires_at: authcode.expires_at, 1854 + now, 1855 + diff: now - authcode.expires_at, 1856 + }); 1857 return Response.json( 1858 { 1859 error: "invalid_grant", ··· 1865 1866 // Verify client_id matches 1867 if (authcode.client_id !== client_id) { 1868 + console.error("Token endpoint: client_id mismatch", { 1869 + stored: authcode.client_id, 1870 + received: client_id, 1871 + }); 1872 return Response.json( 1873 { 1874 error: "invalid_grant", ··· 1880 1881 // Verify redirect_uri matches 1882 if (authcode.redirect_uri !== redirect_uri) { 1883 + console.error("Token endpoint: redirect_uri mismatch", { 1884 + stored: authcode.redirect_uri, 1885 + received: redirect_uri, 1886 + }); 1887 return Response.json( 1888 { 1889 error: "invalid_grant", ··· 1895 1896 // Verify PKCE code_verifier (required for all clients per IndieAuth spec) 1897 if (!verifyPKCE(code_verifier, authcode.code_challenge)) { 1898 + console.error("Token endpoint: PKCE verification failed", { 1899 + code_verifier, 1900 + code_challenge: authcode.code_challenge, 1901 + }); 1902 return Response.json( 1903 { 1904 error: "invalid_grant", ··· 1961 1962 // Validate that the user controls the requested me parameter 1963 if (authcode.me && authcode.me !== meValue) { 1964 + console.error("Token endpoint: me mismatch", { 1965 + requested: authcode.me, 1966 + actual: meValue, 1967 + }); 1968 return Response.json( 1969 { 1970 error: "invalid_grant", 1971 + error_description: 1972 + "The requested identity does not match the user's verified domain", 1973 }, 1974 { status: 400 }, 1975 ); 1976 } 1977 1978 const origin = process.env.ORIGIN || "http://localhost:3000"; 1979 + 1980 // Generate access token 1981 const accessToken = crypto.randomBytes(32).toString("base64url"); 1982 const expiresIn = 3600; // 1 hour ··· 1990 // Store token in database with refresh token 1991 db.query( 1992 "INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", 1993 + ).run( 1994 + accessToken, 1995 + authcode.user_id, 1996 + client_id, 1997 + scopes.join(" "), 1998 + expiresAt, 1999 + refreshToken, 2000 + refreshExpiresAt, 2001 + ); 2002 2003 const response: Record<string, unknown> = { 2004 access_token: accessToken, ··· 2016 response.role = permission.role; 2017 } 2018 2019 + console.log("Token endpoint: success", { 2020 + me: meValue, 2021 + scopes: scopes.join(" "), 2022 + }); 2023 2024 return Response.json(response, { 2025 headers: { 2026 "Content-Type": "application/json", 2027 "Cache-Control": "no-store", 2028 + Pragma: "no-cache", 2029 }, 2030 }); 2031 } catch (error) { ··· 2189 try { 2190 // Get access token from Authorization header 2191 const authHeader = req.headers.get("Authorization"); 2192 + 2193 if (!authHeader || !authHeader.startsWith("Bearer ")) { 2194 return Response.json( 2195 {
+14 -6
src/routes/passkeys.ts
··· 1 import { 2 type RegistrationResponseJSON, 3 - generateRegistrationOptions, 4 type VerifiedRegistrationResponse, 5 verifyRegistrationResponse, 6 } from "@simplewebauthn/server"; 7 import { db } from "../db"; ··· 75 .all(session.user_id) as Array<{ credential_id: Buffer }>; 76 77 const excludeCredentials = existingCredentials.map((cred) => ({ 78 - id: cred.credential_id, 79 type: "public-key" as const, 80 })); 81 82 // Generate WebAuthn registration options 83 const options = await generateRegistrationOptions({ 84 rpName: RP_NAME, 85 - rpID: process.env.RP_ID!, 86 userName: user.username, 87 userDisplayName: user.username, 88 attestationType: "none", ··· 133 } 134 135 const body = await req.json(); 136 - const { response, challenge: expectedChallenge, name } = body as { 137 response: RegistrationResponseJSON; 138 challenge: string; 139 name?: string; ··· 167 verification = await verifyRegistrationResponse({ 168 response, 169 expectedChallenge: challenge.challenge, 170 - expectedOrigin: process.env.ORIGIN!, 171 - expectedRPID: process.env.RP_ID!, 172 }); 173 } catch (error) { 174 console.error("WebAuthn verification failed:", error);
··· 1 import { 2 type RegistrationResponseJSON, 3 type VerifiedRegistrationResponse, 4 + generateRegistrationOptions, 5 verifyRegistrationResponse, 6 } from "@simplewebauthn/server"; 7 import { db } from "../db"; ··· 75 .all(session.user_id) as Array<{ credential_id: Buffer }>; 76 77 const excludeCredentials = existingCredentials.map((cred) => ({ 78 + id: Buffer.from(cred.credential_id) 79 + .toString("base64") 80 + .replace(/\+/g, "-") 81 + .replace(/\//g, "_") 82 + .replace(/=+$/, ""), 83 type: "public-key" as const, 84 })); 85 86 // Generate WebAuthn registration options 87 const options = await generateRegistrationOptions({ 88 rpName: RP_NAME, 89 + rpID: process.env.RP_ID || "", 90 userName: user.username, 91 userDisplayName: user.username, 92 attestationType: "none", ··· 137 } 138 139 const body = await req.json(); 140 + const { 141 + response, 142 + challenge: expectedChallenge, 143 + name, 144 + } = body as { 145 response: RegistrationResponseJSON; 146 challenge: string; 147 name?: string; ··· 175 verification = await verifyRegistrationResponse({ 176 response, 177 expectedChallenge: challenge.challenge, 178 + expectedOrigin: process.env.ORIGIN || "", 179 + expectedRPID: process.env.RP_ID || "", 180 }); 181 } catch (error) { 182 console.error("WebAuthn verification failed:", error);
+1
src/styles.css
··· 86 input[type="email"], 87 input[type="url"], 88 input[type="number"], 89 input[type="datetime-local"], 90 textarea { 91 width: 100%;
··· 86 input[type="email"], 87 input[type="url"], 88 input[type="number"], 89 + input[type="password"], 90 input[type="datetime-local"], 91 textarea { 92 width: 100%;