Add ldap support #1

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

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

+8
.env.example
··· 3 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
+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
+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 63 "bun-sqlite-migrations": ["bun-sqlite-migrations@1.0.2", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-WLw8q67KM+1RN7o4DqVVhmJASypuBp8fygrfA8QD5HZEjiP+E5hD1SV2dpyB7A4tFqLdUF8cdln7+Ptj5+Hz1Q=="], 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 77 "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], ··· 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 } 85 }
··· 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 68 "bun-sqlite-migrations": ["bun-sqlite-migrations@1.0.2", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-WLw8q67KM+1RN7o4DqVVhmJASypuBp8fygrfA8QD5HZEjiP+E5hD1SV2dpyB7A4tFqLdUF8cdln7+Ptj5+Hz1Q=="], 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 98 "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], ··· 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 } 112 }
+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 }
+7 -3
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; ··· 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; ··· 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;
+7 -7
src/client/docs.ts
··· 48 ); 49 } 50 51 - result += attrs + "&gt;"; 52 return result; 53 }, 54 ); ··· 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 ); ··· 462 const rows: string[][] = []; 463 464 // Get headers 465 + for (const th of el.querySelectorAll("thead th")) { 466 headers.push(th.textContent?.trim() || ""); 467 + } 468 469 // Get rows 470 el.querySelectorAll("tbody tr").forEach((tr) => { 471 const row: string[] = []; 472 + for (const td of tr.querySelectorAll("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 + for (const row of rows) { 483 lines.push(`| ${row.join(" | ")} |`); 484 + } 485 lines.push(""); 486 } 487 }
+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() { 12 try { ··· 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"; ··· 112 113 const options = await optionsRes.json(); 114 115 loginBtn.textContent = "use your passkey..."; 116 117 // Start authentication ··· 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() { 15 try { ··· 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"; ··· 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 143 // Start authentication ··· 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 { 57 width: 100%; 58 padding: 1.25rem 2rem; ··· 95 autocomplete="username webauthn" /> 96 <button type="submit" class="secondary-btn" id="registerBtn">create passkey</button> 97 </form> 98 </div> 99 100 <div class="info">
··· 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 { 79 width: 100%; 80 padding: 1.25rem 2rem; ··· 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 131 <div class="info">
+18 -12
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"; ··· 25 } from "./routes/api"; 26 import { 27 canRegister, 28 loginOptions, 29 loginVerify, 30 registerOptions, 31 registerVerify, 32 } from "./routes/auth"; 33 - import { 34 - addPasskeyOptions, 35 - addPasskeyVerify, 36 - deletePasskey, 37 - listPasskeys, 38 - renamePasskey, 39 - } from "./routes/passkeys"; 40 import { 41 createClient, 42 deleteClient, ··· 61 userProfile, 62 userinfo, 63 } from "./routes/indieauth"; 64 65 (() => { 66 const required = ["ORIGIN", "RP_ID"]; ··· 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);
··· 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"; ··· 25 } from "./routes/api"; 26 import { 27 canRegister, 28 + ldapVerify, 29 loginOptions, 30 loginVerify, 31 registerOptions, 32 registerVerify, 33 } from "./routes/auth"; 34 import { 35 createClient, 36 deleteClient, ··· 55 userProfile, 56 userinfo, 57 } from "./routes/indieauth"; 58 + import { 59 + addPasskeyOptions, 60 + addPasskeyVerify, 61 + deletePasskey, 62 + listPasskeys, 63 + renamePasskey, 64 + } from "./routes/passkeys"; 65 66 (() => { 67 const required = ["ORIGIN", "RP_ID"]; ··· 198 if (req.method === "POST") { 199 const url = new URL(req.url); 200 const userId = url.pathname.split("/")[4]; 201 + return disableUser(req, userId ? userId : ""); 202 } 203 return new Response("Method not allowed", { status: 405 }); 204 }, ··· 206 if (req.method === "POST") { 207 const url = new URL(req.url); 208 const userId = url.pathname.split("/")[4]; 209 + return enableUser(req, userId ? userId : ""); 210 } 211 return new Response("Method not allowed", { status: 405 }); 212 }, ··· 214 if (req.method === "PUT") { 215 const url = new URL(req.url); 216 const userId = url.pathname.split("/")[4]; 217 + return updateUserTier(req, userId ? userId : ""); 218 } 219 return new Response("Method not allowed", { status: 405 }); 220 }, ··· 222 if (req.method === "DELETE") { 223 const url = new URL(req.url); 224 const userId = url.pathname.split("/")[4]; 225 + return deleteUser(req, userId ? userId : ""); 226 } 227 return new Response("Method not allowed", { status: 405 }); 228 }, ··· 254 "/auth/register/verify": registerVerify, 255 "/auth/login/options": loginOptions, 256 "/auth/login/verify": loginVerify, 257 + // LDAP verification endpoint 258 + "/api/ldap-verify": (req: Request) => { 259 + if (req.method === "POST") return ldapVerify(req); 260 + return new Response("Method not allowed", { status: 405 }); 261 + }, 262 // Passkey management endpoints 263 "/api/passkeys": (req: Request) => { 264 if (req.method === "GET") return listPasskeys(req);
+2
src/migrations/007_add_username_to_authcodes.sql
···
··· 1 + -- Add username column to authcodes table for direct access without user_id lookup 2 + ALTER TABLE authcodes ADD COLUMN username TEXT NOT NULL DEFAULT '';
+4
src/migrations/008_add_ldap_username_to_invites.sql
···
··· 1 + -- Add ldap_username column to invites table 2 + -- When set, the invite can only be used by a user with that exact username 3 + -- Used for LDAP-verified user provisioning flow 4 + ALTER TABLE invites ADD COLUMN ldap_username TEXT DEFAULT NULL;
+16 -6
src/routes/api.ts
··· 1 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 });
+147 -9
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"; ··· 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 ··· 94 ); 95 } 96 97 // Store invite message to return with options 98 inviteMessage = invite.message; 99 } ··· 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 ··· 225 ); 226 } 227 228 inviteId = invite.id; 229 230 // Get app role assignments for this invite ··· 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); ··· 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 ··· 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 16 const RP_NAME = "Indiko"; ··· 67 // Validate invite code 68 const invite = db 69 .query( 70 + "SELECT id, max_uses, current_uses, expires_at, message, ldap_username FROM invites WHERE code = ?", 71 ) 72 .get(inviteCode) as 73 | { ··· 76 current_uses: number; 77 expires_at: number | null; 78 message: string | null; 79 + ldap_username: string | null; 80 } 81 | undefined; 82 ··· 96 ); 97 } 98 99 + // If invite is locked to an LDAP username, enforce it 100 + if (invite.ldap_username && invite.ldap_username !== username) { 101 + return Response.json( 102 + { error: "Username must match LDAP account" }, 103 + { status: 400 }, 104 + ); 105 + } 106 + 107 // Store invite message to return with options 108 inviteMessage = invite.message; 109 } ··· 170 ); 171 } 172 173 + if (!expectedChallenge) { 174 + return Response.json({ error: "Invalid challenge" }, { status: 400 }); 175 + } 176 + 177 const challenge = db 178 .query( 179 "SELECT challenge, expires_at FROM challenges WHERE challenge = ? AND username = ? AND type = 'registration'", ··· 211 212 const invite = db 213 .query( 214 + "SELECT id, max_uses, current_uses, expires_at, ldap_username FROM invites WHERE code = ?", 215 ) 216 .get(inviteCode) as 217 | { ··· 219 max_uses: number; 220 current_uses: number; 221 expires_at: number | null; 222 + ldap_username: string | null; 223 } 224 | undefined; 225 ··· 239 ); 240 } 241 242 + // If invite is locked to an LDAP username, enforce it 243 + if (invite.ldap_username && invite.ldap_username !== username) { 244 + return Response.json( 245 + { error: "Username must match LDAP account" }, 246 + { status: 400 }, 247 + ); 248 + } 249 + 250 inviteId = invite.id; 251 252 // Get app role assignments for this invite ··· 261 verification = await verifyRegistrationResponse({ 262 response, 263 expectedChallenge: challenge.challenge, 264 + expectedOrigin: process.env.ORIGIN ? process.env.ORIGIN : "", 265 + expectedRPID: process.env.RP_ID ? process.env.RP_ID : "", 266 }); 267 } catch (error) { 268 console.error("WebAuthn verification failed:", error); ··· 374 .get(username) as { id: number; status: string } | undefined; 375 376 if (!user) { 377 + // Check if LDAP is enabled - if so, user may exist in LDAP and need to register 378 + if (process.env.LDAP_ENABLED === "true") { 379 + return Response.json({ 380 + ldapVerificationRequired: true, 381 + username: username, 382 + }); 383 + } 384 return Response.json({ error: "User not found" }, { status: 404 }); 385 } 386 ··· 500 expectedOrigin: process.env.ORIGIN!, 501 expectedRPID: process.env.RP_ID!, 502 credential: { 503 + id: credential.credential_id.toString(), 504 + publicKey: new Uint8Array(credential.public_key), 505 counter: credential.counter, 506 }, 507 }); ··· 554 return Response.json({ error: "Internal server error" }, { status: 500 }); 555 } 556 } 557 + 558 + export async function ldapVerify(req: Request): Promise<Response> { 559 + try { 560 + const body = await req.json(); 561 + const { username, password, returnUrl } = body as { 562 + username: string; 563 + password: string; 564 + returnUrl?: string; 565 + }; 566 + 567 + if (!username || !password) { 568 + return Response.json( 569 + { error: "Username and password required" }, 570 + { status: 400 }, 571 + ); 572 + } 573 + 574 + // Verify user doesn't already exist locally (race condition check) 575 + const existingUser = db 576 + .query("SELECT id FROM users WHERE username = ?") 577 + .get(username); 578 + 579 + if (existingUser) { 580 + return Response.json( 581 + { error: "Account already exists. Please use passkey login." }, 582 + { status: 400 }, 583 + ); 584 + } 585 + 586 + // Attempt LDAP bind WITH password verification 587 + let ldapUser: unknown; 588 + try { 589 + ldapUser = await authenticate({ 590 + ldapOpts: { 591 + url: process.env.LDAP_URL || "ldap://localhost:389", 592 + }, 593 + adminDn: process.env.LDAP_ADMIN_DN, 594 + adminPassword: process.env.LDAP_ADMIN_PASSWORD, 595 + userSearchBase: 596 + process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com", 597 + usernameAttribute: process.env.LDAP_USERNAME_ATTRIBUTE || "uid", 598 + username: username, 599 + userPassword: password, 600 + }); 601 + } catch (ldapError) { 602 + console.error("LDAP verification failed:", ldapError); 603 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 604 + } 605 + 606 + if (!ldapUser) { 607 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 608 + } 609 + 610 + // LDAP auth succeeded - create single-use invite locked to this username 611 + const inviteCode = crypto.randomUUID(); 612 + const expiresAt = Math.floor(Date.now() / 1000) + 600; // 10 minutes 613 + 614 + // Get an admin user to be the creator (required by NOT NULL constraint) 615 + const adminUser = db 616 + .query("SELECT id FROM users WHERE is_admin = 1 LIMIT 1") 617 + .get() as { id: number } | undefined; 618 + 619 + if (!adminUser) { 620 + return Response.json( 621 + { error: "System not configured for LDAP provisioning" }, 622 + { status: 500 }, 623 + ); 624 + } 625 + 626 + // Create the LDAP invite (max_uses=1, tied to username) 627 + db.query( 628 + "INSERT INTO invites (code, max_uses, current_uses, expires_at, created_by, message, ldap_username) VALUES (?, 1, 0, ?, ?, ?, ?)", 629 + ).run(inviteCode, expiresAt, adminUser.id, "LDAP-verified account", username); 630 + 631 + const newInviteId = db 632 + .query("SELECT id FROM invites WHERE code = ?") 633 + .get(inviteCode) as { id: number }; 634 + 635 + // Copy roles from most recent admin-created invite if exists 636 + const defaultInvite = db 637 + .query( 638 + "SELECT id FROM invites WHERE created_by IN (SELECT id FROM users WHERE is_admin = 1) ORDER BY created_at DESC LIMIT 1", 639 + ) 640 + .get() as { id: number } | undefined; 641 + 642 + if (defaultInvite) { 643 + const inviteRoles = db 644 + .query("SELECT app_id, role FROM invite_roles WHERE invite_id = ?") 645 + .all(defaultInvite.id) as Array<{ app_id: number; role: string }>; 646 + 647 + const insertRole = db.query( 648 + "INSERT INTO invite_roles (invite_id, app_id, role) VALUES (?, ?, ?)", 649 + ); 650 + for (const { app_id, role } of inviteRoles) { 651 + insertRole.run(newInviteId.id, app_id, role); 652 + } 653 + } 654 + 655 + return Response.json({ 656 + success: true, 657 + inviteCode: inviteCode, 658 + username: username, 659 + returnUrl: returnUrl || null, 660 + }); 661 + } catch (error) { 662 + console.error("LDAP verify error:", error); 663 + return Response.json({ error: "Internal server error" }, { status: 500 }); 664 + } 665 + }
+3 -1
src/routes/clients.ts
··· 16 17 function getSessionUser( 18 req: Request, 19 - ): { username: string; userId: number; is_admin: boolean; tier: string } | Response { 20 const authHeader = req.headers.get("Authorization"); 21 22 if (!authHeader || !authHeader.startsWith("Bearer ")) {
··· 16 17 function getSessionUser( 18 req: Request, 19 + ): 20 + | { username: string; userId: number; is_admin: boolean; tier: string } 21 + | Response { 22 const authHeader = req.headers.get("Authorization"); 23 24 if (!authHeader || !authHeader.startsWith("Bearer ")) {
+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 {
+6 -2
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"; ··· 133 } 134 135 const body = await req.json(); 136 - const { response, challenge: expectedChallenge, name } = body as { 137 response: RegistrationResponseJSON; 138 challenge: string; 139 name?: string;
··· 1 import { 2 type RegistrationResponseJSON, 3 type VerifiedRegistrationResponse, 4 + generateRegistrationOptions, 5 verifyRegistrationResponse, 6 } from "@simplewebauthn/server"; 7 import { db } from "../db"; ··· 133 } 134 135 const body = await req.json(); 136 + const { 137 + response, 138 + challenge: expectedChallenge, 139 + name, 140 + } = body as { 141 response: RegistrationResponseJSON; 142 challenge: string; 143 name?: string;
+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%;