LDAP support to create user if it does not exist in indiko but exists in linked ldap dir. Currently does not support deletion from ldap dir. if the user is deleted in ldap they will not be deleted in indiko
+14
.env.example
+14
.env.example
···
3
PORT=3000
4
NODE_ENV="production"
5
DATABASE_URL=data/indiko.db
6
+
7
+
# LDAP Configuration (optional)
8
+
LDAP_ENABLED=false
9
+
LDAP_URL=ldap://localhost:389
10
+
LDAP_ADMIN_DN=cn=admin,dc=example,dc=com
11
+
LDAP_ADMIN_PASSWORD=your_admin_password
12
+
LDAP_USER_SEARCH_BASE=dc=example,dc=com
13
+
LDAP_USERNAME_ATTRIBUTE=uid
14
+
LDAP_ORPHAN_ACTION=false
15
+
16
+
# LDAP Group verification (optional)
17
+
LDAP_GROUP_DN=cn=allowed-users,ou=groups,dc=example,dc=com
18
+
LDAP_GROUP_CLASS=groupOfUniqueNames
19
+
LDAP_GROUP_MEMBER_ATTRIBUTE=uniqueMember
+6
CRUSH.md
+6
CRUSH.md
···
9
## Architecture Patterns
10
11
### Route Organization
12
- Use separate route files in `src/routes/` directory
13
- Export handler functions that accept `Request` and return `Response`
14
- Import handlers in `src/index.ts` and wire them in the `routes` object
···
17
- IndieAuth/OAuth 2.0 endpoints in `src/routes/indieauth.ts`
18
19
### Project Structure
20
```
21
src/
22
├── db.ts # Database setup and exports
···
40
```
41
42
### Client-Side Code
43
- Extract JavaScript from HTML into separate TypeScript modules in `src/client/`
44
- Import client modules into HTML with `<script type="module" src="../client/file.ts"></script>`
45
- Bun will bundle the imports automatically
···
47
- In HTML files: use paths relative to server root (e.g., `/logo.svg`, `/favicon.svg`) since Bun bundles HTML and resolves paths from server context
48
49
### IndieAuth/OAuth 2.0 Implementation
50
- Full IndieAuth server supporting OAuth 2.0 with PKCE
51
- Authorization code flow with single-use, short-lived codes (60 seconds)
52
- Auto-registration of client apps on first authorization
···
59
- **`me` parameter delegation**: When a client passes `me=https://example.com` in the authorization request and it matches the user's website URL, the token response returns that URL instead of the canonical `/u/{username}` URL
60
61
### Database Schema
62
- **users**: username, name, email, photo, url, status, role, tier, is_admin
63
- **tier**: User access level - 'admin' (full access), 'developer' (can create apps), 'user' (can only authenticate with apps)
64
- **is_admin**: Legacy flag, automatically synced with tier (1 if tier='admin', 0 otherwise)
···
71
- **invites**: admin-created invite codes
72
73
### WebAuthn/Passkey Settings
74
- **Registration**: residentKey="required", userVerification="required"
75
- **Authentication**: omit allowCredentials to show all passkeys (discoverable credentials)
76
- **Credential lookup**: credential_id stored as Buffer, compare using base64url string
···
9
## Architecture Patterns
10
11
### Route Organization
12
+
13
- Use separate route files in `src/routes/` directory
14
- Export handler functions that accept `Request` and return `Response`
15
- Import handlers in `src/index.ts` and wire them in the `routes` object
···
18
- IndieAuth/OAuth 2.0 endpoints in `src/routes/indieauth.ts`
19
20
### Project Structure
21
+
22
```
23
src/
24
├── db.ts # Database setup and exports
···
42
```
43
44
### Client-Side Code
45
+
46
- Extract JavaScript from HTML into separate TypeScript modules in `src/client/`
47
- Import client modules into HTML with `<script type="module" src="../client/file.ts"></script>`
48
- Bun will bundle the imports automatically
···
50
- In HTML files: use paths relative to server root (e.g., `/logo.svg`, `/favicon.svg`) since Bun bundles HTML and resolves paths from server context
51
52
### IndieAuth/OAuth 2.0 Implementation
53
+
54
- Full IndieAuth server supporting OAuth 2.0 with PKCE
55
- Authorization code flow with single-use, short-lived codes (60 seconds)
56
- Auto-registration of client apps on first authorization
···
63
- **`me` parameter delegation**: When a client passes `me=https://example.com` in the authorization request and it matches the user's website URL, the token response returns that URL instead of the canonical `/u/{username}` URL
64
65
### Database Schema
66
+
67
- **users**: username, name, email, photo, url, status, role, tier, is_admin
68
- **tier**: User access level - 'admin' (full access), 'developer' (can create apps), 'user' (can only authenticate with apps)
69
- **is_admin**: Legacy flag, automatically synced with tier (1 if tier='admin', 0 otherwise)
···
76
- **invites**: admin-created invite codes
77
78
### WebAuthn/Passkey Settings
79
+
80
- **Registration**: residentKey="required", userVerification="required"
81
- **Authentication**: omit allowCredentials to show all passkeys (discoverable credentials)
82
- **Credential lookup**: credential_id stored as Buffer, compare using base64url string
+25
-3
SECURITY.md
+25
-3
SECURITY.md
···
4
5
If you discover a security vulnerability in Indiko, please report it privately:
6
7
-
- **Email:** security@dunkirk.sh
8
- **Do not** open public issues for security vulnerabilities
9
- You will receive a response within 48 hours
10
···
50
---
51
52
## Known Security Considerations
53
54
### Rate Limiting ⚠️
55
···
177
178
## Contact
179
180
-
- **Security Issues:** security@dunkirk.sh
181
-
- **General Support:** https://tangled.org/@dunkirk.sh/indiko
182
- **Maintainer:** Kieran Klukas (@taciturnaxolotl)
183
184
---
···
4
5
If you discover a security vulnerability in Indiko, please report it privately:
6
7
+
- **Email:** <security@dunkirk.sh>
8
- **Do not** open public issues for security vulnerabilities
9
- You will receive a response within 48 hours
10
···
50
---
51
52
## Known Security Considerations
53
+
54
+
### LDAP Account Provisioning ⚠️
55
+
56
+
When using LDAP authentication, accounts are provisioned on first successful LDAP login. **Important:** If a user is subsequently deleted from LDAP, their Indiko account **remains active**. This is by design—account lifecycle is managed independently from LDAP.
57
+
58
+
**Admin responsibilities:**
59
+
60
+
- **Audit provisioned accounts:** Query the `provisioned_via_ldap` column to identify LDAP-provisioned users
61
+
- **Manual deprovisioning:** Suspended or delete accounts in Indiko when users are removed from LDAP
62
+
- **Document policy:** Establish clear procedures for account deletion when LDAP users are removed
63
+
64
+
**Example audit query:**
65
+
66
+
```sql
67
+
SELECT username, created_at, status FROM users WHERE provisioned_via_ldap = 1;
68
+
```
69
+
70
+
To suspend an LDAP account:
71
+
72
+
```sql
73
+
UPDATE users SET status = 'suspended' WHERE username = 'username_here';
74
+
```
75
76
### Rate Limiting ⚠️
77
···
199
200
## Contact
201
202
+
- **Security Issues:** <security@dunkirk.sh>
203
+
- **General Support:** <https://tangled.org/@dunkirk.sh/indiko>
204
- **Maintainer:** Kieran Klukas (@taciturnaxolotl)
205
206
---
+57
SPEC.md
+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
+27
bun.lock
···
8
"@simplewebauthn/browser": "^13.2.2",
9
"@simplewebauthn/server": "^13.2.2",
10
"bun-sqlite-migrations": "^1.0.2",
11
"nanoid": "^5.1.6",
12
},
13
"devDependencies": {
···
54
55
"@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="],
56
57
"@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
58
59
"@types/node": ["@types/node@25.0.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew=="],
60
61
"asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="],
62
···
64
65
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
66
67
"nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
68
69
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
70
71
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
72
73
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
74
75
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
76
···
79
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
80
81
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
82
83
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
84
}
···
8
"@simplewebauthn/browser": "^13.2.2",
9
"@simplewebauthn/server": "^13.2.2",
10
"bun-sqlite-migrations": "^1.0.2",
11
+
"ldap-authentication": "^3.3.6",
12
"nanoid": "^5.1.6",
13
},
14
"devDependencies": {
···
55
56
"@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="],
57
58
+
"@types/asn1": ["@types/asn1@0.2.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA=="],
59
+
60
"@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
61
62
"@types/node": ["@types/node@25.0.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew=="],
63
+
64
+
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
65
66
"asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="],
67
···
69
70
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
71
72
+
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
73
+
74
+
"ldap-authentication": ["ldap-authentication@3.3.6", "", { "dependencies": { "ldapts": "^7.3.1" } }, "sha512-j8XxH5wGXhIQ3mMnoRCZTalSLmPhzEaGH4+5RIFP0Higc32fCKDRJBo+9wb5ysy9TnlaNtaf+rgdwYCD15OBpQ=="],
75
+
76
+
"ldapts": ["ldapts@7.4.0", "", { "dependencies": { "@types/asn1": ">=0.2.4", "asn1": "0.2.6", "debug": "4.4.0", "strict-event-emitter-types": "2.0.0", "uuid": "11.1.0", "whatwg-url": "14.2.0" } }, "sha512-QLgx2pLvxMXY1nCc85Fx+cwVJDvC0sQ3l4CJZSl1FJ/iV8Ypfl6m+5xz4lm1lhoXcUlvhPqxEoyIj/8LR6ut+A=="],
77
+
78
+
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
79
+
80
"nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
81
82
+
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
83
+
84
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
85
86
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
87
88
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
89
+
90
+
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
91
+
92
+
"strict-event-emitter-types": ["strict-event-emitter-types@2.0.0", "", {}, "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA=="],
93
+
94
+
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
95
96
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
97
···
100
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
101
102
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
103
+
104
+
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
105
+
106
+
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
107
+
108
+
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
109
110
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
111
}
+1
package.json
+1
package.json
+217
scripts/audit-ldap-orphans.ts
+217
scripts/audit-ldap-orphans.ts
···
···
1
+
/**
2
+
* LDAP Orphan Account Audit Script
3
+
*
4
+
* This script identifies Indiko accounts provisioned via LDAP that no longer exist in LDAP.
5
+
* Useful for detecting when users have been removed from LDAP but their Indiko accounts remain active.
6
+
*
7
+
* Usage: bun scripts/audit-ldap-orphans.ts [--suspend | --deactivate | --dry-run]
8
+
*
9
+
* Flags:
10
+
* --dry-run Show what would be done without making changes (default)
11
+
* --suspend Set status to 'suspended' for orphaned accounts
12
+
* --deactivate Set status to 'inactive' for orphaned accounts
13
+
*/
14
+
15
+
import { Database } from "bun:sqlite";
16
+
import * as path from "node:path";
17
+
import { authenticate } from "ldap-authentication";
18
+
19
+
// Load database
20
+
const dbPath = path.join(import.meta.dir, "..", "data", "indiko.db");
21
+
const db = new Database(dbPath);
22
+
23
+
// Configuration from environment
24
+
const LDAP_URL = process.env.LDAP_URL || "ldap://localhost:389";
25
+
const LDAP_ADMIN_DN = process.env.LDAP_ADMIN_DN;
26
+
const LDAP_ADMIN_PASSWORD = process.env.LDAP_ADMIN_PASSWORD;
27
+
const LDAP_USER_SEARCH_BASE =
28
+
process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com";
29
+
const LDAP_USERNAME_ATTRIBUTE = process.env.LDAP_USERNAME_ATTRIBUTE || "uid";
30
+
31
+
interface LdapUser {
32
+
username: string;
33
+
id: number;
34
+
status: string;
35
+
created_at: number;
36
+
}
37
+
38
+
interface AuditResult {
39
+
total: number;
40
+
active: number;
41
+
orphaned: number;
42
+
errors: number;
43
+
orphanedUsers: Array<{
44
+
username: string;
45
+
id: number;
46
+
status: string;
47
+
createdDate: string | undefined;
48
+
}>;
49
+
}
50
+
51
+
async function checkLdapUser(username: string): Promise<boolean> {
52
+
try {
53
+
const user = await authenticate({
54
+
ldapOpts: {
55
+
url: LDAP_URL,
56
+
},
57
+
adminDn: LDAP_ADMIN_DN,
58
+
adminPassword: LDAP_ADMIN_PASSWORD,
59
+
userSearchBase: LDAP_USER_SEARCH_BASE,
60
+
usernameAttribute: LDAP_USERNAME_ATTRIBUTE,
61
+
username: username,
62
+
verifyUserExists: true,
63
+
});
64
+
return !!user;
65
+
} catch (error) {
66
+
// User not found or invalid credentials (expected for non-existence check)
67
+
return false;
68
+
}
69
+
}
70
+
71
+
async function auditLdapAccounts(): Promise<AuditResult> {
72
+
console.log("🔍 Starting LDAP orphan account audit...\n");
73
+
74
+
// Get all LDAP-provisioned users
75
+
const ldapUsers = db
76
+
.query(
77
+
"SELECT id, username, status, created_at FROM users WHERE provisioned_via_ldap = 1",
78
+
)
79
+
.all() as LdapUser[];
80
+
81
+
const result: AuditResult = {
82
+
total: ldapUsers.length,
83
+
active: 0,
84
+
orphaned: 0,
85
+
errors: 0,
86
+
orphanedUsers: [],
87
+
};
88
+
89
+
console.log(`Found ${result.total} LDAP-provisioned accounts\n`);
90
+
91
+
// Check each user against LDAP
92
+
for (const user of ldapUsers) {
93
+
process.stdout.write(`Checking ${user.username}... `);
94
+
95
+
try {
96
+
const existsInLdap = await checkLdapUser(user.username);
97
+
98
+
if (existsInLdap) {
99
+
console.log("✅ Found in LDAP");
100
+
result.active++;
101
+
} else {
102
+
console.log("❌ NOT FOUND in LDAP");
103
+
result.orphaned++;
104
+
result.orphanedUsers.push({
105
+
username: user.username,
106
+
id: user.id,
107
+
status: user.status,
108
+
createdDate: new Date(user.created_at * 1000)
109
+
.toISOString()
110
+
.split("T")[0],
111
+
});
112
+
}
113
+
} catch (error) {
114
+
console.log("⚠️ Error checking LDAP");
115
+
result.errors++;
116
+
console.error(
117
+
` Error: ${error instanceof Error ? error.message : String(error)}`,
118
+
);
119
+
}
120
+
}
121
+
122
+
return result;
123
+
}
124
+
125
+
function printReport(result: AuditResult): void {
126
+
console.log(`\n${"=".repeat(60)}`);
127
+
console.log("LDAP ORPHAN ACCOUNT AUDIT REPORT");
128
+
console.log(`${"=".repeat(60)}\n`);
129
+
130
+
console.log(`Total LDAP-provisioned accounts: ${result.total}`);
131
+
console.log(`Active in LDAP: ${result.active}`);
132
+
console.log(`Orphaned (missing from LDAP): ${result.orphaned}`);
133
+
console.log(`Check errors: ${result.errors}`);
134
+
135
+
if (result.orphaned === 0) {
136
+
console.log("\n✅ No orphaned accounts found!");
137
+
return;
138
+
}
139
+
140
+
console.log(`\n${"-".repeat(60)}`);
141
+
console.log("ORPHANED ACCOUNTS:");
142
+
console.log(`${"-".repeat(60)}\n`);
143
+
144
+
result.orphanedUsers.forEach((user, idx) => {
145
+
console.log(`${idx + 1}. ${user.username}`);
146
+
console.log(
147
+
` ID: ${user.id} | Status: ${user.status} | Created: ${user.createdDate}`,
148
+
);
149
+
});
150
+
}
151
+
152
+
async function updateOrphanedAccounts(
153
+
result: AuditResult,
154
+
action: "suspend" | "deactivate",
155
+
): Promise<void> {
156
+
const newStatus = action === "suspend" ? "suspended" : "inactive";
157
+
158
+
console.log(
159
+
`\n📝 Updating ${result.orphaned} orphaned account(s) to status: '${newStatus}'`,
160
+
);
161
+
162
+
for (const user of result.orphanedUsers) {
163
+
db.query("UPDATE users SET status = ? WHERE id = ?").run(
164
+
newStatus,
165
+
user.id,
166
+
);
167
+
console.log(` Updated: ${user.username}`);
168
+
}
169
+
170
+
console.log(`\n✅ Updated ${result.orphaned} account(s)`);
171
+
}
172
+
173
+
async function main() {
174
+
// Validate LDAP configuration
175
+
if (!LDAP_ADMIN_DN || !LDAP_ADMIN_PASSWORD) {
176
+
console.error(
177
+
"❌ Error: LDAP_ADMIN_DN and LDAP_ADMIN_PASSWORD environment variables are required",
178
+
);
179
+
process.exit(1);
180
+
}
181
+
182
+
const args = process.argv.slice(2);
183
+
const dryRun = args.includes("--dry-run") || args.length === 0;
184
+
const shouldSuspend = args.includes("--suspend");
185
+
const shouldDeactivate = args.includes("--deactivate");
186
+
187
+
if (dryRun) {
188
+
console.log("🔄 Running in DRY-RUN mode (no changes will be made)\n");
189
+
}
190
+
191
+
try {
192
+
const result = await auditLdapAccounts();
193
+
printReport(result);
194
+
195
+
if (!dryRun && result.orphaned > 0) {
196
+
if (shouldSuspend) {
197
+
await updateOrphanedAccounts(result, "suspend");
198
+
} else if (shouldDeactivate) {
199
+
await updateOrphanedAccounts(result, "deactivate");
200
+
} else {
201
+
console.log(
202
+
"\n⚠️ No action specified. Use --suspend or --deactivate to update accounts.",
203
+
);
204
+
}
205
+
}
206
+
207
+
process.exit(0);
208
+
} catch (error) {
209
+
console.error(
210
+
"\n❌ Audit failed:",
211
+
error instanceof Error ? error.message : String(error),
212
+
);
213
+
process.exit(1);
214
+
}
215
+
}
216
+
217
+
main();
+1
-5
src/client/admin-clients.ts
+1
-5
src/client/admin-clients.ts
···
569
// If creating a new client, show the credentials in modal
570
if (!isEdit) {
571
const result = await response.json();
572
-
if (
573
-
result.client &&
574
-
result.client.clientId &&
575
-
result.client.clientSecret
576
-
) {
577
const secretModal = document.getElementById(
578
"secretModal",
579
) as HTMLElement;
+8
-4
src/client/admin-invites.ts
+8
-4
src/client/admin-invites.ts
···
171
"submitInviteBtn",
172
) as HTMLButtonElement;
173
174
-
const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : 1;
175
const expiresAt = expiresAtInput.value
176
? Math.floor(new Date(expiresAtInput.value).getTime() / 1000)
177
: null;
···
187
'input[name="appRole"]:checked',
188
);
189
checkedBoxes.forEach((checkbox) => {
190
-
const appId = parseInt((checkbox as HTMLInputElement).value, 10);
191
const roleSelect = appRolesContainer.querySelector(
192
`select.role-select[data-app-id="${appId}"]`,
193
) as HTMLSelectElement;
194
195
let role = "";
196
-
if (roleSelect && roleSelect.value) {
197
role = roleSelect.value;
198
}
199
···
507
"submitEditInviteBtn",
508
) as HTMLButtonElement;
509
510
-
const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : null;
511
const expiresAt = expiresAtInput.value
512
? Math.floor(new Date(expiresAtInput.value).getTime() / 1000)
513
: null;
···
171
"submitInviteBtn",
172
) as HTMLButtonElement;
173
174
+
const maxUses = maxUsesInput.value
175
+
? Number.parseInt(maxUsesInput.value, 10)
176
+
: 1;
177
const expiresAt = expiresAtInput.value
178
? Math.floor(new Date(expiresAtInput.value).getTime() / 1000)
179
: null;
···
189
'input[name="appRole"]:checked',
190
);
191
checkedBoxes.forEach((checkbox) => {
192
+
const appId = Number.parseInt((checkbox as HTMLInputElement).value, 10);
193
const roleSelect = appRolesContainer.querySelector(
194
`select.role-select[data-app-id="${appId}"]`,
195
) as HTMLSelectElement;
196
197
let role = "";
198
+
if (roleSelect?.value) {
199
role = roleSelect.value;
200
}
201
···
509
"submitEditInviteBtn",
510
) as HTMLButtonElement;
511
512
+
const maxUses = maxUsesInput.value
513
+
? Number.parseInt(maxUsesInput.value, 10)
514
+
: null;
515
const expiresAt = expiresAtInput.value
516
? Math.floor(new Date(expiresAtInput.value).getTime() / 1000)
517
: null;
+50
-51
src/client/docs.ts
+50
-51
src/client/docs.ts
···
48
);
49
}
50
51
-
result += attrs + ">";
52
return result;
53
},
54
);
55
-
} else {
56
-
// Process CSS (inside <style> tags)
57
-
return (
58
-
part
59
-
.replace(
60
-
/<style>/g,
61
-
'<<span class="html-tag">style</span>>',
62
-
)
63
-
.replace(
64
-
/<\/style>/g,
65
-
'</<span class="html-tag">style</span>>',
66
-
)
67
-
// CSS selectors (anything before { including pseudo-selectors)
68
-
.replace(
69
-
/^(\s*)([\w.-]+(?::+[\w-]+(?:\([^)]*\))?)*)\s*\{/gm,
70
-
'$1<span class="css-selector">$2</span> {',
71
-
)
72
-
// CSS properties (word followed by colon, but not :: for pseudo-elements)
73
-
.replace(
74
-
/^(\s+)([\w-]+):\s+/gm,
75
-
'$1<span class="css-property">$2</span>: ',
76
-
)
77
-
// CSS values (everything between property: and ;)
78
-
.replace(
79
-
/(<span class="css-property">[\w-]+<\/span>:\s+)([^;]+);/g,
80
-
(_match, prop, value) => {
81
-
const highlightedValue = value
82
-
.replace(
83
-
/(#[0-9a-fA-F]{3,6})/g,
84
-
'<span class="css-value">$1</span>',
85
-
)
86
-
.replace(
87
-
/([\d.]+(?:px|rem|em|s|%))/g,
88
-
'<span class="css-value">$1</span>',
89
-
)
90
-
.replace(/('.*?')/g, '<span class="css-value">$1</span>')
91
-
.replace(
92
-
/([\w-]+\([^)]*\))/g,
93
-
'<span class="css-value">$1</span>',
94
-
);
95
-
return `${prop}${highlightedValue};`;
96
-
},
97
-
)
98
-
);
99
}
100
})
101
.join("");
102
···
462
const rows: string[][] = [];
463
464
// Get headers
465
-
el.querySelectorAll("thead th").forEach((th) => {
466
headers.push(th.textContent?.trim() || "");
467
-
});
468
469
// Get rows
470
el.querySelectorAll("tbody tr").forEach((tr) => {
471
const row: string[] = [];
472
-
tr.querySelectorAll("td").forEach((td) => {
473
row.push(td.textContent?.trim() || "");
474
-
});
475
rows.push(row);
476
});
477
···
479
if (headers.length > 0) {
480
lines.push(`| ${headers.join(" | ")} |`);
481
lines.push(`|${headers.map(() => "-------").join("|")}|`);
482
-
rows.forEach((row) => {
483
lines.push(`| ${row.join(" | ")} |`);
484
-
});
485
lines.push("");
486
}
487
}
···
48
);
49
}
50
51
+
result += `${attrs}>`;
52
return result;
53
},
54
);
55
}
56
+
// Process CSS (inside <style> tags)
57
+
return (
58
+
part
59
+
.replace(
60
+
/<style>/g,
61
+
'<<span class="html-tag">style</span>>',
62
+
)
63
+
.replace(
64
+
/<\/style>/g,
65
+
'</<span class="html-tag">style</span>>',
66
+
)
67
+
// CSS selectors (anything before { including pseudo-selectors)
68
+
.replace(
69
+
/^(\s*)([\w.-]+(?::+[\w-]+(?:\([^)]*\))?)*)\s*\{/gm,
70
+
'$1<span class="css-selector">$2</span> {',
71
+
)
72
+
// CSS properties (word followed by colon, but not :: for pseudo-elements)
73
+
.replace(
74
+
/^(\s+)([\w-]+):\s+/gm,
75
+
'$1<span class="css-property">$2</span>: ',
76
+
)
77
+
// CSS values (everything between property: and ;)
78
+
.replace(
79
+
/(<span class="css-property">[\w-]+<\/span>:\s+)([^;]+);/g,
80
+
(_match, prop, value) => {
81
+
const highlightedValue = value
82
+
.replace(
83
+
/(#[0-9a-fA-F]{3,6})/g,
84
+
'<span class="css-value">$1</span>',
85
+
)
86
+
.replace(
87
+
/([\d.]+(?:px|rem|em|s|%))/g,
88
+
'<span class="css-value">$1</span>',
89
+
)
90
+
.replace(/('.*?')/g, '<span class="css-value">$1</span>')
91
+
.replace(
92
+
/([\w-]+\([^)]*\))/g,
93
+
'<span class="css-value">$1</span>',
94
+
);
95
+
return `${prop}${highlightedValue};`;
96
+
},
97
+
)
98
+
);
99
})
100
.join("");
101
···
461
const rows: string[][] = [];
462
463
// Get headers
464
+
for (const th of el.querySelectorAll("thead th")) {
465
headers.push(th.textContent?.trim() || "");
466
+
}
467
468
// Get rows
469
el.querySelectorAll("tbody tr").forEach((tr) => {
470
const row: string[] = [];
471
+
for (const td of tr.querySelectorAll("td")) {
472
row.push(td.textContent?.trim() || "");
473
+
}
474
rows.push(row);
475
});
476
···
478
if (headers.length > 0) {
479
lines.push(`| ${headers.join(" | ")} |`);
480
lines.push(`|${headers.map(() => "-------").join("|")}|`);
481
+
for (const row of rows) {
482
lines.push(`| ${row.join(" | ")} |`);
483
+
}
484
lines.push("");
485
}
486
}
+31
-16
src/client/index.ts
+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
+129
-6
src/client/login.ts
···
5
6
const loginForm = document.getElementById("loginForm") as HTMLFormElement;
7
const registerForm = document.getElementById("registerForm") as HTMLFormElement;
8
const message = document.getElementById("message") as HTMLDivElement;
9
10
// Check if registration is allowed on page load
11
async function checkRegistrationAllowed() {
···
15
const inviteCode = urlParams.get("invite");
16
17
if (inviteCode) {
18
// Fetch invite details to show message
19
try {
20
const response = await fetch("/auth/register/options", {
21
method: "POST",
22
headers: { "Content-Type": "application/json" },
23
-
body: JSON.stringify({ username: "temp", inviteCode }),
24
});
25
26
if (response.ok) {
···
38
if (subtitleElement) {
39
subtitleElement.textContent = "create your account";
40
}
41
-
(
42
-
document.getElementById("registerUsername") as HTMLInputElement
43
-
).placeholder = "choose username";
44
(
45
document.getElementById("registerBtn") as HTMLButtonElement
46
).textContent = "create account";
···
111
}
112
113
const options = await optionsRes.json();
114
115
loginBtn.textContent = "use your passkey...";
116
···
212
213
showMessage("Registration successful!", "success");
214
215
-
// Check for return URL parameter
216
-
const returnUrl = urlParams.get("return") || "/";
217
218
const redirectTimer = setTimeout(() => {
219
window.location.href = returnUrl;
···
225
registerBtn.textContent = "register passkey";
226
}
227
});
···
5
6
const loginForm = document.getElementById("loginForm") as HTMLFormElement;
7
const registerForm = document.getElementById("registerForm") as HTMLFormElement;
8
+
const ldapForm = document.getElementById("ldapForm") as HTMLFormElement;
9
const message = document.getElementById("message") as HTMLDivElement;
10
+
11
+
let pendingLdapUsername: string | null = null;
12
13
// Check if registration is allowed on page load
14
async function checkRegistrationAllowed() {
···
18
const inviteCode = urlParams.get("invite");
19
20
if (inviteCode) {
21
+
// Check if username is locked (from LDAP flow)
22
+
const lockedUsername = urlParams.get("username");
23
+
const registerUsernameInput = document.getElementById(
24
+
"registerUsername",
25
+
) as HTMLInputElement;
26
+
27
// Fetch invite details to show message
28
try {
29
+
const testUsername = lockedUsername || "temp";
30
const response = await fetch("/auth/register/options", {
31
method: "POST",
32
headers: { "Content-Type": "application/json" },
33
+
body: JSON.stringify({ username: testUsername, inviteCode }),
34
});
35
36
if (response.ok) {
···
48
if (subtitleElement) {
49
subtitleElement.textContent = "create your account";
50
}
51
+
52
+
// If username is locked from LDAP, pre-fill and disable
53
+
if (lockedUsername) {
54
+
registerUsernameInput.value = lockedUsername;
55
+
registerUsernameInput.readOnly = true;
56
+
registerUsernameInput.style.opacity = "0.7";
57
+
registerUsernameInput.style.cursor = "not-allowed";
58
+
} else {
59
+
registerUsernameInput.placeholder = "choose username";
60
+
}
61
+
62
(
63
document.getElementById("registerBtn") as HTMLButtonElement
64
).textContent = "create account";
···
129
}
130
131
const options = await optionsRes.json();
132
+
133
+
// Check if LDAP verification is required (user exists in LDAP but not locally)
134
+
if (options.ldapVerificationRequired) {
135
+
showLdapPasswordPrompt(options.username);
136
+
loginBtn.disabled = false;
137
+
loginBtn.textContent = "sign in";
138
+
return;
139
+
}
140
141
loginBtn.textContent = "use your passkey...";
142
···
238
239
showMessage("Registration successful!", "success");
240
241
+
// Check for return URL: first sessionStorage (from LDAP flow), then URL param, fallback to /
242
+
const storedRedirect = sessionStorage.getItem("postRegistrationRedirect");
243
+
const returnUrl = storedRedirect || urlParams.get("return") || "/";
244
+
245
+
// Clear the stored redirect after use
246
+
if (storedRedirect) {
247
+
sessionStorage.removeItem("postRegistrationRedirect");
248
+
}
249
250
const redirectTimer = setTimeout(() => {
251
window.location.href = returnUrl;
···
257
registerBtn.textContent = "register passkey";
258
}
259
});
260
+
261
+
// LDAP verification flow
262
+
function showLdapPasswordPrompt(username: string) {
263
+
pendingLdapUsername = username;
264
+
265
+
// Update UI to show LDAP form
266
+
const subtitleElement = document.querySelector(".subtitle");
267
+
if (subtitleElement) {
268
+
subtitleElement.textContent = "verify your LDAP password";
269
+
}
270
+
271
+
// Update LDAP form username display
272
+
const ldapUsernameSpan = document.getElementById("ldapUsername");
273
+
if (ldapUsernameSpan) {
274
+
ldapUsernameSpan.textContent = username;
275
+
}
276
+
277
+
// Show LDAP form, hide others
278
+
loginForm.style.display = "none";
279
+
registerForm.style.display = "none";
280
+
ldapForm.style.display = "block";
281
+
282
+
showMessage(
283
+
"This username exists in the linked LDAP directory. Enter your LDAP password to create your account.",
284
+
"success",
285
+
true,
286
+
);
287
+
}
288
+
289
+
ldapForm.addEventListener("submit", async (e) => {
290
+
e.preventDefault();
291
+
292
+
if (!pendingLdapUsername) {
293
+
showMessage("No username pending for LDAP verification");
294
+
return;
295
+
}
296
+
297
+
const password = (document.getElementById("ldapPassword") as HTMLInputElement)
298
+
.value;
299
+
const ldapBtn = document.getElementById("ldapBtn") as HTMLButtonElement;
300
+
301
+
try {
302
+
ldapBtn.disabled = true;
303
+
ldapBtn.textContent = "verifying...";
304
+
305
+
// Get return URL for after registration
306
+
const urlParams = new URLSearchParams(window.location.search);
307
+
const returnUrl = urlParams.get("return") || "/";
308
+
309
+
// Verify LDAP credentials
310
+
const verifyRes = await fetch("/api/ldap-verify", {
311
+
method: "POST",
312
+
headers: { "Content-Type": "application/json" },
313
+
body: JSON.stringify({
314
+
username: pendingLdapUsername,
315
+
password: password,
316
+
returnUrl: returnUrl,
317
+
}),
318
+
});
319
+
320
+
if (!verifyRes.ok) {
321
+
const error = await verifyRes.json();
322
+
throw new Error(error.error || "LDAP verification failed");
323
+
}
324
+
325
+
const result = await verifyRes.json();
326
+
327
+
if (result.success) {
328
+
showMessage(
329
+
"LDAP verification successful! Redirecting to setup...",
330
+
"success",
331
+
);
332
+
333
+
// Store return URL for after registration completes
334
+
if (result.returnUrl) {
335
+
sessionStorage.setItem("postRegistrationRedirect", result.returnUrl);
336
+
}
337
+
338
+
// Redirect to registration with the invite code and locked username
339
+
const registerUrl = `/login?invite=${encodeURIComponent(result.inviteCode)}&username=${encodeURIComponent(result.username)}`;
340
+
341
+
setTimeout(() => {
342
+
window.location.href = registerUrl;
343
+
}, 1000);
344
+
}
345
+
} catch (error) {
346
+
showMessage((error as Error).message || "LDAP verification failed");
347
+
ldapBtn.disabled = false;
348
+
ldapBtn.textContent = "verify & continue";
349
+
}
350
+
});
+32
-1
src/html/login.html
+32
-1
src/html/login.html
···
49
margin-bottom: 1rem;
50
}
51
52
-
input[type="text"] {
53
margin-bottom: 1rem;
54
}
55
56
button {
···
94
<input type="text" id="registerUsername" placeholder="create username" required
95
autocomplete="username webauthn" />
96
<button type="submit" class="secondary-btn" id="registerBtn">create passkey</button>
97
</form>
98
</div>
99
···
49
margin-bottom: 1rem;
50
}
51
52
+
input[type="text"],
53
+
input[type="password"] {
54
margin-bottom: 1rem;
55
+
}
56
+
57
+
.ldap-user-display {
58
+
background: rgba(188, 141, 160, 0.1);
59
+
border-left: 3px solid var(--berry-crush);
60
+
padding: 0.75rem 1rem;
61
+
margin-bottom: 1rem;
62
+
text-align: left;
63
+
font-size: 0.875rem;
64
+
}
65
+
66
+
.ldap-user-display .label {
67
+
color: var(--old-rose);
68
+
font-size: 0.75rem;
69
+
text-transform: uppercase;
70
+
letter-spacing: 0.05rem;
71
+
}
72
+
73
+
.ldap-user-display .username {
74
+
color: var(--lavender);
75
+
font-weight: 600;
76
}
77
78
button {
···
116
<input type="text" id="registerUsername" placeholder="create username" required
117
autocomplete="username webauthn" />
118
<button type="submit" class="secondary-btn" id="registerBtn">create passkey</button>
119
+
</form>
120
+
121
+
<form id="ldapForm" style="display: none;">
122
+
<div class="ldap-user-display">
123
+
<div class="label">username</div>
124
+
<div class="username" id="ldapUsername"></div>
125
+
</div>
126
+
<input type="password" id="ldapPassword" placeholder="LDAP password" required autocomplete="current-password" />
127
+
<button type="submit" id="ldapBtn">verify & continue</button>
128
</form>
129
</div>
130
+32
-6
src/index.ts
+32
-6
src/index.ts
···
1
import { env } from "bun";
2
import { db } from "./db";
3
-
import adminHTML from "./html/admin.html";
4
import adminClientsHTML from "./html/admin-clients.html";
5
import adminInvitesHTML from "./html/admin-invites.html";
6
import appsHTML from "./html/apps.html";
7
import docsHTML from "./html/docs.html";
8
import indexHTML from "./html/index.html";
9
import loginHTML from "./html/login.html";
10
import {
11
deleteSelfAccount,
12
deleteUser,
···
25
} from "./routes/api";
26
import {
27
canRegister,
28
loginOptions,
29
loginVerify,
30
registerOptions,
···
51
tokenIntrospect,
52
tokenRevoke,
53
updateInvite,
54
-
userinfo,
55
userProfile,
56
} from "./routes/indieauth";
57
import {
58
addPasskeyOptions,
···
197
if (req.method === "POST") {
198
const url = new URL(req.url);
199
const userId = url.pathname.split("/")[4];
200
-
return disableUser(req, userId);
201
}
202
return new Response("Method not allowed", { status: 405 });
203
},
···
205
if (req.method === "POST") {
206
const url = new URL(req.url);
207
const userId = url.pathname.split("/")[4];
208
-
return enableUser(req, userId);
209
}
210
return new Response("Method not allowed", { status: 405 });
211
},
···
213
if (req.method === "PUT") {
214
const url = new URL(req.url);
215
const userId = url.pathname.split("/")[4];
216
-
return updateUserTier(req, userId);
217
}
218
return new Response("Method not allowed", { status: 405 });
219
},
···
221
if (req.method === "DELETE") {
222
const url = new URL(req.url);
223
const userId = url.pathname.split("/")[4];
224
-
return deleteUser(req, userId);
225
}
226
return new Response("Method not allowed", { status: 405 });
227
},
···
253
"/auth/register/verify": registerVerify,
254
"/auth/login/options": loginOptions,
255
"/auth/login/verify": loginVerify,
256
// Passkey management endpoints
257
"/api/passkeys": (req: Request) => {
258
if (req.method === "GET") return listPasskeys(req);
···
339
}
340
}, 3600000); // 1 hour in milliseconds
341
342
let is_shutting_down = false;
343
function shutdown(sig: string) {
344
if (is_shutting_down) return;
···
347
console.log(`[Shutdown] triggering shutdown due to ${sig}`);
348
349
clearInterval(cleanupJob);
350
console.log("[Shutdown] stopped cleanup job");
351
352
server.stop();
···
1
import { env } from "bun";
2
import { db } from "./db";
3
import adminClientsHTML from "./html/admin-clients.html";
4
import adminInvitesHTML from "./html/admin-invites.html";
5
+
import adminHTML from "./html/admin.html";
6
import appsHTML from "./html/apps.html";
7
import docsHTML from "./html/docs.html";
8
import indexHTML from "./html/index.html";
9
import loginHTML from "./html/login.html";
10
+
import { getLdapAccounts, updateOrphanedAccounts } from "./ldap-cleanup";
11
import {
12
deleteSelfAccount,
13
deleteUser,
···
26
} from "./routes/api";
27
import {
28
canRegister,
29
+
ldapVerify,
30
loginOptions,
31
loginVerify,
32
registerOptions,
···
53
tokenIntrospect,
54
tokenRevoke,
55
updateInvite,
56
userProfile,
57
+
userinfo,
58
} from "./routes/indieauth";
59
import {
60
addPasskeyOptions,
···
199
if (req.method === "POST") {
200
const url = new URL(req.url);
201
const userId = url.pathname.split("/")[4];
202
+
return disableUser(req, userId ? userId : "");
203
}
204
return new Response("Method not allowed", { status: 405 });
205
},
···
207
if (req.method === "POST") {
208
const url = new URL(req.url);
209
const userId = url.pathname.split("/")[4];
210
+
return enableUser(req, userId ? userId : "");
211
}
212
return new Response("Method not allowed", { status: 405 });
213
},
···
215
if (req.method === "PUT") {
216
const url = new URL(req.url);
217
const userId = url.pathname.split("/")[4];
218
+
return updateUserTier(req, userId ? userId : "");
219
}
220
return new Response("Method not allowed", { status: 405 });
221
},
···
223
if (req.method === "DELETE") {
224
const url = new URL(req.url);
225
const userId = url.pathname.split("/")[4];
226
+
return deleteUser(req, userId ? userId : "");
227
}
228
return new Response("Method not allowed", { status: 405 });
229
},
···
255
"/auth/register/verify": registerVerify,
256
"/auth/login/options": loginOptions,
257
"/auth/login/verify": loginVerify,
258
+
// LDAP verification endpoint
259
+
"/api/ldap-verify": (req: Request) => {
260
+
if (req.method === "POST") return ldapVerify(req);
261
+
return new Response("Method not allowed", { status: 405 });
262
+
},
263
// Passkey management endpoints
264
"/api/passkeys": (req: Request) => {
265
if (req.method === "GET") return listPasskeys(req);
···
346
}
347
}, 3600000); // 1 hour in milliseconds
348
349
+
const ldapCleanupJob =
350
+
process.env.LDAP_ADMIN_DN && process.env.LDAP_ADMIN_PASSWORD
351
+
? setInterval(async () => {
352
+
const result = await getLdapAccounts();
353
+
const action = process.env.LDAP_ORPHAN_ACTION || "deactivate";
354
+
if (action === "suspend") {
355
+
await updateOrphanedAccounts(result, "suspend");
356
+
} else if (action === "deactivate") {
357
+
await updateOrphanedAccounts(result, "deactivate");
358
+
} else if (action === "remove") {
359
+
await updateOrphanedAccounts(result, "remove");
360
+
}
361
+
console.log(
362
+
`[LDAP Cleanup] ${action === "remove" ? "Removed" : action === "suspend" ? "Suspended" : "Deactivated"} LDAP orphan accounts: ${result.total} total, ${result.active} active, ${result.orphaned} orphaned, ${result.errors} errors.`,
363
+
);
364
+
}, 43200000)
365
+
: null; // 12 hours in milliseconds
366
+
367
let is_shutting_down = false;
368
function shutdown(sig: string) {
369
if (is_shutting_down) return;
···
372
console.log(`[Shutdown] triggering shutdown due to ${sig}`);
373
374
clearInterval(cleanupJob);
375
+
if (ldapCleanupJob) clearInterval(ldapCleanupJob);
376
console.log("[Shutdown] stopped cleanup job");
377
378
server.stop();
+158
src/ldap-cleanup.ts
+158
src/ldap-cleanup.ts
···
···
1
+
import { authenticate } from "ldap-authentication";
2
+
import { db } from "./db";
3
+
4
+
interface LdapUser {
5
+
username: string;
6
+
id: number;
7
+
status: string;
8
+
created_at: number;
9
+
}
10
+
11
+
interface AuditResult {
12
+
total: number;
13
+
active: number;
14
+
orphaned: number;
15
+
errors: number;
16
+
orphanedUsers: Array<{
17
+
username: string;
18
+
id: number;
19
+
status: string;
20
+
createdDate: string | undefined;
21
+
}>;
22
+
}
23
+
24
+
export async function checkLdapUser(username: string): Promise<boolean> {
25
+
try {
26
+
const user = await authenticate({
27
+
ldapOpts: {
28
+
url: process.env.LDAP_URL || "ldap://localhost:389",
29
+
},
30
+
adminDn: process.env.LDAP_ADMIN_DN || "",
31
+
adminPassword: process.env.LDAP_ADMIN_PASSWORD || "",
32
+
userSearchBase: process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com",
33
+
usernameAttribute: process.env.LDAP_USERNAME_ATTRIBUTE || "uid",
34
+
username: username,
35
+
verifyUserExists: true,
36
+
});
37
+
return !!user;
38
+
} catch (error) {
39
+
// User not found or invalid credentials (expected for non-existence check)
40
+
return false;
41
+
}
42
+
}
43
+
44
+
export async function checkLdapGroupMembership(
45
+
username: string,
46
+
userDn: string,
47
+
): Promise<boolean> {
48
+
if (!process.env.LDAP_GROUP_DN) {
49
+
return true; // No group restriction configured
50
+
}
51
+
52
+
try {
53
+
const groupDn = process.env.LDAP_GROUP_DN;
54
+
const groupClass = process.env.LDAP_GROUP_CLASS || "groupOfUniqueNames";
55
+
const memberAttribute =
56
+
process.env.LDAP_GROUP_MEMBER_ATTRIBUTE || "uniqueMember";
57
+
58
+
const user = await authenticate({
59
+
ldapOpts: {
60
+
url: process.env.LDAP_URL || "ldap://localhost:389",
61
+
},
62
+
userDn: userDn,
63
+
userPassword: "", // We only check groups, not authenticate
64
+
userSearchBase: process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com",
65
+
usernameAttribute: process.env.LDAP_USERNAME_ATTRIBUTE || "uid",
66
+
username: username,
67
+
groupsSearchBase: groupDn,
68
+
groupClass: groupClass,
69
+
groupMemberAttribute: memberAttribute,
70
+
});
71
+
72
+
// If user was found and authenticate returns it, groups are available
73
+
return !!user;
74
+
} catch (error) {
75
+
console.error("LDAP group membership check failed:", error);
76
+
return false;
77
+
}
78
+
}
79
+
80
+
export async function getLdapAccounts(): Promise<AuditResult> {
81
+
console.log("🔍 Starting LDAP orphan account audit...\n");
82
+
83
+
// Get all LDAP-provisioned users
84
+
const ldapUsers = db
85
+
.query(
86
+
"SELECT id, username, status, created_at FROM users WHERE provisioned_via_ldap = 1",
87
+
)
88
+
.all() as LdapUser[];
89
+
90
+
const result: AuditResult = {
91
+
total: ldapUsers.length,
92
+
active: 0,
93
+
orphaned: 0,
94
+
errors: 0,
95
+
orphanedUsers: [],
96
+
};
97
+
98
+
console.log(`Found ${result.total} LDAP-provisioned accounts\n`);
99
+
100
+
// Check each user against LDAP
101
+
for (const user of ldapUsers) {
102
+
process.stdout.write(`Checking ${user.username}... `);
103
+
104
+
try {
105
+
const existsInLdap = await checkLdapUser(user.username);
106
+
107
+
if (existsInLdap) {
108
+
console.log("✅ Found in LDAP");
109
+
result.active++;
110
+
} else {
111
+
console.log("❌ NOT FOUND in LDAP");
112
+
result.orphaned++;
113
+
result.orphanedUsers.push({
114
+
username: user.username,
115
+
id: user.id,
116
+
status: user.status,
117
+
createdDate: new Date(user.created_at * 1000)
118
+
.toISOString()
119
+
.split("T")[0],
120
+
});
121
+
}
122
+
} catch (error) {
123
+
console.log("⚠️ Error checking LDAP");
124
+
result.errors++;
125
+
console.error(
126
+
` Error: ${error instanceof Error ? error.message : String(error)}`,
127
+
);
128
+
}
129
+
}
130
+
131
+
return result;
132
+
}
133
+
134
+
export async function updateOrphanedAccounts(
135
+
result: AuditResult,
136
+
action: "suspend" | "deactivate" | "remove",
137
+
): Promise<void> {
138
+
const newStatus = action === "suspend" ? "suspended" : "inactive";
139
+
140
+
console.log(
141
+
`\n📝 Updating ${result.orphaned} orphaned account(s) to status: '${newStatus}'`,
142
+
);
143
+
144
+
for (const user of result.orphanedUsers) {
145
+
if (action === "remove") {
146
+
db.query("DELETE FROM users WHERE id = ?").run(user.id);
147
+
console.log(` Removed: ${user.username}`);
148
+
continue;
149
+
}
150
+
db.query("UPDATE users SET status = ? WHERE id = ?").run(
151
+
newStatus,
152
+
user.id,
153
+
);
154
+
console.log(` Updated: ${user.username}`);
155
+
}
156
+
157
+
console.log(`\n✅ Updated ${result.orphaned} account(s)`);
158
+
}
+2
src/migrations/007_add_username_to_authcodes.sql
+2
src/migrations/007_add_username_to_authcodes.sql
+4
src/migrations/008_add_ldap_username_to_invites.sql
+4
src/migrations/008_add_ldap_username_to_invites.sql
+4
src/migrations/009_add_ldap_provisioned_flag.sql
+4
src/migrations/009_add_ldap_provisioned_flag.sql
···
···
1
+
-- Add provisioned_via_ldap flag for audit purposes
2
+
-- Allows admins to identify LDAP-provisioned accounts
3
+
-- Important: If user is deleted from LDAP, the account remains active but this flag tracks its origin
4
+
ALTER TABLE users ADD COLUMN provisioned_via_ldap INTEGER NOT NULL DEFAULT 0;
+16
-6
src/routes/api.ts
+16
-6
src/routes/api.ts
···
1
import { db } from "../db";
2
-
import { verifyDomain, validateProfileURL } from "./indieauth";
3
4
function getSessionUser(
5
req: Request,
6
-
): { username: string; userId: number; is_admin: boolean; tier: string } | Response {
7
const authHeader = req.headers.get("Authorization");
8
9
if (!authHeader || !authHeader.startsWith("Bearer ")) {
···
193
const origin = process.env.ORIGIN || "http://localhost:3000";
194
const indikoProfileUrl = `${origin}/u/${user.username}`;
195
196
-
const verification = await verifyDomain(validation.canonicalUrl!, indikoProfileUrl);
197
if (!verification.success) {
198
return Response.json(
199
{ error: verification.error || "Failed to verify domain" },
···
456
}
457
458
// Prevent disabling self
459
-
if (targetUserId === user.id) {
460
return Response.json(
461
{ error: "Cannot disable your own account" },
462
{ status: 400 },
···
508
return Response.json({ success: true });
509
}
510
511
-
export async function updateUserTier(req: Request, userId: string): Promise<Response> {
512
const user = getSessionUser(req);
513
if (user instanceof Response) {
514
return user;
···
536
537
const targetUser = db
538
.query("SELECT id, username, tier FROM users WHERE id = ?")
539
-
.get(targetUserId) as { id: number; username: string; tier: string } | undefined;
540
541
if (!targetUser) {
542
return Response.json({ error: "User not found" }, { status: 404 });
···
1
import { db } from "../db";
2
+
import { validateProfileURL, verifyDomain } from "./indieauth";
3
4
function getSessionUser(
5
req: Request,
6
+
):
7
+
| { username: string; userId: number; is_admin: boolean; tier: string }
8
+
| Response {
9
const authHeader = req.headers.get("Authorization");
10
11
if (!authHeader || !authHeader.startsWith("Bearer ")) {
···
195
const origin = process.env.ORIGIN || "http://localhost:3000";
196
const indikoProfileUrl = `${origin}/u/${user.username}`;
197
198
+
const verification = await verifyDomain(
199
+
validation.canonicalUrl!,
200
+
indikoProfileUrl,
201
+
);
202
if (!verification.success) {
203
return Response.json(
204
{ error: verification.error || "Failed to verify domain" },
···
461
}
462
463
// Prevent disabling self
464
+
if (targetUserId === user.userId) {
465
return Response.json(
466
{ error: "Cannot disable your own account" },
467
{ status: 400 },
···
513
return Response.json({ success: true });
514
}
515
516
+
export async function updateUserTier(
517
+
req: Request,
518
+
userId: string,
519
+
): Promise<Response> {
520
const user = getSessionUser(req);
521
if (user instanceof Response) {
522
return user;
···
544
545
const targetUser = db
546
.query("SELECT id, username, tier FROM users WHERE id = ?")
547
+
.get(targetUserId) as
548
+
| { id: number; username: string; tier: string }
549
+
| undefined;
550
551
if (!targetUser) {
552
return Response.json({ error: "User not found" }, { status: 404 });
+204
-24
src/routes/auth.ts
+204
-24
src/routes/auth.ts
···
1
import {
2
type AuthenticationResponseJSON,
3
-
generateAuthenticationOptions,
4
-
generateRegistrationOptions,
5
type PublicKeyCredentialCreationOptionsJSON,
6
type PublicKeyCredentialRequestOptionsJSON,
7
type RegistrationResponseJSON,
8
type VerifiedAuthenticationResponse,
9
type VerifiedRegistrationResponse,
10
verifyAuthenticationResponse,
11
verifyRegistrationResponse,
12
} from "@simplewebauthn/server";
13
import { db } from "../db";
14
15
const RP_NAME = "Indiko";
16
···
66
// Validate invite code
67
const invite = db
68
.query(
69
-
"SELECT id, max_uses, current_uses, expires_at, message FROM invites WHERE code = ?",
70
)
71
.get(inviteCode) as
72
| {
···
75
current_uses: number;
76
expires_at: number | null;
77
message: string | null;
78
}
79
| undefined;
80
···
87
return Response.json({ error: "Invite code expired" }, { status: 403 });
88
}
89
90
-
if (invite.current_uses >= invite.max_uses) {
91
return Response.json(
92
-
{ error: "Invite code fully used" },
93
-
{ status: 403 },
94
);
95
}
96
···
160
);
161
}
162
163
-
// Verify challenge exists and is valid
164
const challenge = db
165
.query(
166
"SELECT challenge, expires_at FROM challenges WHERE challenge = ? AND username = ? AND type = 'registration'",
···
198
199
const invite = db
200
.query(
201
-
"SELECT id, max_uses, current_uses, expires_at FROM invites WHERE code = ?",
202
)
203
.get(inviteCode) as
204
| {
···
206
max_uses: number;
207
current_uses: number;
208
expires_at: number | null;
209
}
210
| undefined;
211
···
218
return Response.json({ error: "Invite code expired" }, { status: 403 });
219
}
220
221
-
if (invite.current_uses >= invite.max_uses) {
222
return Response.json(
223
-
{ error: "Invite code fully used" },
224
-
{ status: 403 },
225
);
226
}
227
···
239
verification = await verifyRegistrationResponse({
240
response,
241
expectedChallenge: challenge.challenge,
242
-
expectedOrigin: process.env.ORIGIN!,
243
-
expectedRPID: process.env.RP_ID!,
244
});
245
} catch (error) {
246
console.error("WebAuthn verification failed:", error);
···
253
254
const { credential } = verification.registrationInfo;
255
256
// Create user (bootstrap is always admin, invited users are regular users)
257
const insertUser = db.query(
258
-
"INSERT INTO users (username, name, is_admin, tier, role) VALUES (?, ?, ?, ?, ?) RETURNING id",
259
);
260
const user = insertUser.get(
261
username,
···
263
isBootstrap ? 1 : 0,
264
isBootstrap ? "admin" : "user",
265
isBootstrap ? "admin" : "user",
266
) as {
267
id: number;
268
};
···
283
if (inviteId) {
284
const usedAt = Math.floor(Date.now() / 1000);
285
286
-
// Increment invite usage counter
287
-
db.query(
288
-
"UPDATE invites SET current_uses = current_uses + 1 WHERE id = ?",
289
-
).run(inviteId);
290
291
// Record this invite use
292
db.query(
···
352
.get(username) as { id: number; status: string } | undefined;
353
354
if (!user) {
355
-
return Response.json({ error: "User not found" }, { status: 404 });
356
}
357
358
if (user.status !== "active") {
···
365
.all(user.id) as { credential_id: Buffer }[];
366
367
if (credentials.length === 0) {
368
-
return Response.json({ error: "No credentials found" }, { status: 404 });
369
}
370
371
// Generate authentication options
···
382
"INSERT INTO challenges (challenge, username, type, expires_at) VALUES (?, ?, 'authentication', ?)",
383
).run(options.challenge, username, expiresAt);
384
385
-
return Response.json(options);
386
} catch (error) {
387
console.error("Login options error:", error);
388
return Response.json({ error: "Internal server error" }, { status: 500 });
···
428
429
// Check if user account is active
430
if (credentialWithUser.status !== "active") {
431
-
return Response.json({ error: "Account is suspended" }, { status: 403 });
432
}
433
434
// Verify the username matches
···
471
expectedOrigin: process.env.ORIGIN!,
472
expectedRPID: process.env.RP_ID!,
473
credential: {
474
-
id: credential.credential_id,
475
-
publicKey: credential.public_key,
476
counter: credential.counter,
477
},
478
});
···
525
return Response.json({ error: "Internal server error" }, { status: 500 });
526
}
527
}
···
1
import {
2
type AuthenticationResponseJSON,
3
type PublicKeyCredentialCreationOptionsJSON,
4
type PublicKeyCredentialRequestOptionsJSON,
5
type RegistrationResponseJSON,
6
type VerifiedAuthenticationResponse,
7
type VerifiedRegistrationResponse,
8
+
generateAuthenticationOptions,
9
+
generateRegistrationOptions,
10
verifyAuthenticationResponse,
11
verifyRegistrationResponse,
12
} from "@simplewebauthn/server";
13
+
import { authenticate } from "ldap-authentication";
14
import { db } from "../db";
15
+
import { checkLdapGroupMembership } from "../ldap-cleanup";
16
17
const RP_NAME = "Indiko";
18
···
68
// Validate invite code
69
const invite = db
70
.query(
71
+
"SELECT id, max_uses, current_uses, expires_at, message, ldap_username FROM invites WHERE code = ?",
72
)
73
.get(inviteCode) as
74
| {
···
77
current_uses: number;
78
expires_at: number | null;
79
message: string | null;
80
+
ldap_username: string | null;
81
}
82
| undefined;
83
···
90
return Response.json({ error: "Invite code expired" }, { status: 403 });
91
}
92
93
+
// Will check usage limit atomically during update
94
+
95
+
// If invite is locked to an LDAP username, enforce it
96
+
if (invite.ldap_username && invite.ldap_username !== username) {
97
return Response.json(
98
+
{ error: "Username must match LDAP account" },
99
+
{ status: 400 },
100
);
101
}
102
···
166
);
167
}
168
169
+
if (!expectedChallenge) {
170
+
return Response.json({ error: "Invalid challenge" }, { status: 400 });
171
+
}
172
+
173
const challenge = db
174
.query(
175
"SELECT challenge, expires_at FROM challenges WHERE challenge = ? AND username = ? AND type = 'registration'",
···
207
208
const invite = db
209
.query(
210
+
"SELECT id, max_uses, current_uses, expires_at, ldap_username FROM invites WHERE code = ?",
211
)
212
.get(inviteCode) as
213
| {
···
215
max_uses: number;
216
current_uses: number;
217
expires_at: number | null;
218
+
ldap_username: string | null;
219
}
220
| undefined;
221
···
228
return Response.json({ error: "Invite code expired" }, { status: 403 });
229
}
230
231
+
// If invite is locked to an LDAP username, enforce it
232
+
if (invite.ldap_username && invite.ldap_username !== username) {
233
return Response.json(
234
+
{ error: "Username must match LDAP account" },
235
+
{ status: 400 },
236
);
237
}
238
···
250
verification = await verifyRegistrationResponse({
251
response,
252
expectedChallenge: challenge.challenge,
253
+
expectedOrigin: process.env.ORIGIN ? process.env.ORIGIN : "",
254
+
expectedRPID: process.env.RP_ID ? process.env.RP_ID : "",
255
});
256
} catch (error) {
257
console.error("WebAuthn verification failed:", error);
···
264
265
const { credential } = verification.registrationInfo;
266
267
+
// Check if this user is being provisioned via LDAP
268
+
let isLdapProvisioned = false;
269
+
if (inviteId) {
270
+
const invite = db
271
+
.query("SELECT ldap_username FROM invites WHERE id = ?")
272
+
.get(inviteId) as { ldap_username: string | null } | undefined;
273
+
isLdapProvisioned =
274
+
invite?.ldap_username !== null && invite?.ldap_username !== undefined;
275
+
}
276
+
277
// Create user (bootstrap is always admin, invited users are regular users)
278
const insertUser = db.query(
279
+
"INSERT INTO users (username, name, is_admin, tier, role, provisioned_via_ldap) VALUES (?, ?, ?, ?, ?, ?) RETURNING id",
280
);
281
const user = insertUser.get(
282
username,
···
284
isBootstrap ? 1 : 0,
285
isBootstrap ? "admin" : "user",
286
isBootstrap ? "admin" : "user",
287
+
isLdapProvisioned ? 1 : 0,
288
) as {
289
id: number;
290
};
···
305
if (inviteId) {
306
const usedAt = Math.floor(Date.now() / 1000);
307
308
+
// Atomically increment invite usage counter while checking max_uses limit
309
+
const result = db
310
+
.query(
311
+
"UPDATE invites SET current_uses = current_uses + 1 WHERE id = ? AND current_uses < max_uses",
312
+
)
313
+
.run(inviteId);
314
+
315
+
// Check if update was successful (0 rows affected means invite was already fully used)
316
+
if (result.changes === 0) {
317
+
return Response.json(
318
+
{ error: "Invite code fully used" },
319
+
{ status: 403 },
320
+
);
321
+
}
322
323
// Record this invite use
324
db.query(
···
384
.get(username) as { id: number; status: string } | undefined;
385
386
if (!user) {
387
+
return Response.json({ error: "Invalid credentials" }, { status: 401 });
388
}
389
390
if (user.status !== "active") {
···
397
.all(user.id) as { credential_id: Buffer }[];
398
399
if (credentials.length === 0) {
400
+
return Response.json({ error: "Invalid credentials" }, { status: 401 });
401
}
402
403
// Generate authentication options
···
414
"INSERT INTO challenges (challenge, username, type, expires_at) VALUES (?, ?, 'authentication', ?)",
415
).run(options.challenge, username, expiresAt);
416
417
+
// Local user always uses passkey login, no LDAP verification needed
418
+
return Response.json({
419
+
...options,
420
+
ldapVerificationRequired: false,
421
+
});
422
} catch (error) {
423
console.error("Login options error:", error);
424
return Response.json({ error: "Internal server error" }, { status: 500 });
···
464
465
// Check if user account is active
466
if (credentialWithUser.status !== "active") {
467
+
return Response.json({ error: "Invalid credentials" }, { status: 401 });
468
}
469
470
// Verify the username matches
···
507
expectedOrigin: process.env.ORIGIN!,
508
expectedRPID: process.env.RP_ID!,
509
credential: {
510
+
id: credential.credential_id.toString(),
511
+
publicKey: new Uint8Array(credential.public_key),
512
counter: credential.counter,
513
},
514
});
···
561
return Response.json({ error: "Internal server error" }, { status: 500 });
562
}
563
}
564
+
565
+
export async function ldapVerify(req: Request): Promise<Response> {
566
+
try {
567
+
const body = await req.json();
568
+
const { username, password, returnUrl } = body as {
569
+
username: string;
570
+
password: string;
571
+
returnUrl?: string;
572
+
};
573
+
574
+
// Check if LDAP is configured
575
+
if (!process.env.LDAP_ADMIN_DN || !process.env.LDAP_ADMIN_PASSWORD) {
576
+
return Response.json(
577
+
{ error: "LDAP is not configured" },
578
+
{ status: 400 },
579
+
);
580
+
}
581
+
582
+
if (
583
+
!username ||
584
+
username.length > 128 ||
585
+
!/^[A-Za-z0-9._@-]+$/.test(username)
586
+
) {
587
+
return Response.json(
588
+
{ error: "Invalid username format" },
589
+
{ status: 400 },
590
+
);
591
+
}
592
+
593
+
// Verify user doesn't already exist locally (race condition check)
594
+
const existingUser = db
595
+
.query("SELECT id FROM users WHERE username = ?")
596
+
.get(username);
597
+
598
+
if (existingUser) {
599
+
return Response.json(
600
+
{ error: "Account already exists. Please use passkey login." },
601
+
{ status: 400 },
602
+
);
603
+
}
604
+
605
+
// Attempt LDAP bind WITH password verification
606
+
let ldapUser: unknown;
607
+
let userDn: string | null = null;
608
+
try {
609
+
ldapUser = await authenticate({
610
+
ldapOpts: {
611
+
url: process.env.LDAP_URL || "ldap://localhost:389",
612
+
},
613
+
adminDn: process.env.LDAP_ADMIN_DN || "",
614
+
adminPassword: process.env.LDAP_ADMIN_PASSWORD || "",
615
+
userSearchBase:
616
+
process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com",
617
+
usernameAttribute: process.env.LDAP_USERNAME_ATTRIBUTE || "uid",
618
+
username: username,
619
+
userPassword: password,
620
+
});
621
+
622
+
// Extract userDn from the returned user object
623
+
if (ldapUser && typeof ldapUser === "object" && "dn" in ldapUser) {
624
+
userDn = (ldapUser as { dn: string }).dn;
625
+
}
626
+
} catch (ldapError) {
627
+
console.error("LDAP verification failed:", ldapError);
628
+
return Response.json({ error: "Invalid credentials" }, { status: 401 });
629
+
}
630
+
631
+
if (!ldapUser) {
632
+
return Response.json({ error: "Invalid credentials" }, { status: 401 });
633
+
}
634
+
635
+
// Check group membership if configured
636
+
if (userDn) {
637
+
const isInGroup = await checkLdapGroupMembership(username, userDn);
638
+
if (!isInGroup) {
639
+
return Response.json(
640
+
{ error: "User is not a member of the required group" },
641
+
{ status: 403 },
642
+
);
643
+
}
644
+
}
645
+
646
+
// LDAP auth succeeded - create single-use invite locked to this username
647
+
const inviteCode = crypto.randomUUID();
648
+
const expiresAt = Math.floor(Date.now() / 1000) + 600; // 10 minutes
649
+
650
+
// Get an admin user to be the creator (required by NOT NULL constraint)
651
+
const adminUser = db
652
+
.query("SELECT id FROM users WHERE is_admin = 1 LIMIT 1")
653
+
.get() as { id: number } | undefined;
654
+
655
+
if (!adminUser) {
656
+
return Response.json(
657
+
{ error: "System not configured for LDAP provisioning" },
658
+
{ status: 500 },
659
+
);
660
+
}
661
+
662
+
// Create the LDAP invite (max_uses=1, tied to username)
663
+
db.query(
664
+
"INSERT INTO invites (code, max_uses, current_uses, expires_at, created_by, message, ldap_username) VALUES (?, 1, 0, ?, ?, ?, ?)",
665
+
).run(
666
+
inviteCode,
667
+
expiresAt,
668
+
adminUser.id,
669
+
"LDAP-verified account",
670
+
username,
671
+
);
672
+
673
+
const newInviteId = db
674
+
.query("SELECT id FROM invites WHERE code = ?")
675
+
.get(inviteCode) as { id: number };
676
+
677
+
// Copy roles from most recent admin-created invite if exists
678
+
const defaultInvite = db
679
+
.query(
680
+
"SELECT id FROM invites WHERE created_by IN (SELECT id FROM users WHERE is_admin = 1) ORDER BY created_at DESC LIMIT 1",
681
+
)
682
+
.get() as { id: number } | undefined;
683
+
684
+
if (defaultInvite) {
685
+
const inviteRoles = db
686
+
.query("SELECT app_id, role FROM invite_roles WHERE invite_id = ?")
687
+
.all(defaultInvite.id) as Array<{ app_id: number; role: string }>;
688
+
689
+
const insertRole = db.query(
690
+
"INSERT INTO invite_roles (invite_id, app_id, role) VALUES (?, ?, ?)",
691
+
);
692
+
for (const { app_id, role } of inviteRoles) {
693
+
insertRole.run(newInviteId.id, app_id, role);
694
+
}
695
+
}
696
+
697
+
return Response.json({
698
+
success: true,
699
+
inviteCode: inviteCode,
700
+
username: username,
701
+
returnUrl: returnUrl || null,
702
+
});
703
+
} catch (error) {
704
+
console.error("LDAP verify error:", error);
705
+
return Response.json({ error: "Internal server error" }, { status: 500 });
706
+
}
707
+
}
+5
-3
src/routes/clients.ts
+5
-3
src/routes/clients.ts
···
1
-
import crypto from "crypto";
2
import { nanoid } from "nanoid";
3
import { db } from "../db";
4
···
16
17
function getSessionUser(
18
req: Request,
19
-
): { username: string; userId: number; is_admin: boolean; tier: string } | Response {
20
const authHeader = req.headers.get("Authorization");
21
22
if (!authHeader || !authHeader.startsWith("Bearer ")) {
···
119
if (!rolesByApp.has(app_id)) {
120
rolesByApp.set(app_id, []);
121
}
122
-
rolesByApp.get(app_id)!.push(role);
123
}
124
125
return Response.json({
···
1
+
import crypto from "node:crypto";
2
import { nanoid } from "nanoid";
3
import { db } from "../db";
4
···
16
17
function getSessionUser(
18
req: Request,
19
+
):
20
+
| { username: string; userId: number; is_admin: boolean; tier: string }
21
+
| Response {
22
const authHeader = req.headers.get("Authorization");
23
24
if (!authHeader || !authHeader.startsWith("Bearer ")) {
···
121
if (!rolesByApp.has(app_id)) {
122
rolesByApp.set(app_id, []);
123
}
124
+
rolesByApp.get(app_id)?.push(role);
125
}
126
127
return Response.json({
+209
-72
src/routes/indieauth.ts
+209
-72
src/routes/indieauth.ts
···
1
-
import crypto from "crypto";
2
import { db } from "../db";
3
4
interface SessionUser {
···
53
username: session.username,
54
userId: session.id,
55
isAdmin: session.is_admin === 1,
56
};
57
}
58
···
68
}),
69
);
70
71
-
const sessionToken = cookies["indiko_session"];
72
if (!sessionToken) return null;
73
74
const session = db
···
127
}
128
129
// Validate profile URL per IndieAuth spec
130
-
export function validateProfileURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } {
131
let url: URL;
132
try {
133
url = new URL(urlString);
···
152
153
// MUST NOT contain username/password
154
if (url.username || url.password) {
155
-
return { valid: false, error: "Profile URL must not contain username or password" };
156
}
157
158
// MUST NOT contain ports
···
164
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
165
const ipv6Regex = /^\[?[0-9a-fA-F:]+\]?$/;
166
if (ipv4Regex.test(url.hostname) || ipv6Regex.test(url.hostname)) {
167
-
return { valid: false, error: "Profile URL must use domain names, not IP addresses" };
168
}
169
170
// MUST NOT contain single-dot or double-dot path segments
171
const pathSegments = url.pathname.split("/");
172
if (pathSegments.includes(".") || pathSegments.includes("..")) {
173
-
return { valid: false, error: "Profile URL must not contain . or .. path segments" };
174
}
175
176
return { valid: true, canonicalUrl: canonicalizeURL(urlString) };
177
}
178
179
// Validate client URL per IndieAuth spec
180
-
function validateClientURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } {
181
let url: URL;
182
try {
183
url = new URL(urlString);
···
202
203
// MUST NOT contain username/password
204
if (url.username || url.password) {
205
-
return { valid: false, error: "Client URL must not contain username or password" };
206
}
207
208
// MUST NOT contain single-dot or double-dot path segments
209
const pathSegments = url.pathname.split("/");
210
if (pathSegments.includes(".") || pathSegments.includes("..")) {
211
-
return { valid: false, error: "Client URL must not contain . or .. path segments" };
212
}
213
214
// MAY use loopback interface, but not other IP addresses
···
217
if (ipv4Regex.test(url.hostname)) {
218
// Allow 127.0.0.1 (loopback), reject others
219
if (!url.hostname.startsWith("127.")) {
220
-
return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" };
221
}
222
} else if (ipv6Regex.test(url.hostname)) {
223
// Allow ::1 (loopback), reject others
224
const ipv6Match = url.hostname.match(ipv6Regex);
225
if (ipv6Match && ipv6Match[1] !== "::1") {
226
-
return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" };
227
}
228
}
229
···
234
function isLoopbackURL(urlString: string): boolean {
235
try {
236
const url = new URL(urlString);
237
-
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname.startsWith("127.");
238
} catch {
239
return false;
240
}
···
254
}> {
255
// MUST NOT fetch loopback addresses (security requirement)
256
if (isLoopbackURL(clientId)) {
257
-
return { success: false, error: "Cannot fetch metadata from loopback addresses" };
258
}
259
260
try {
···
273
clearTimeout(timeoutId);
274
275
if (!response.ok) {
276
-
return { success: false, error: `Failed to fetch client metadata: HTTP ${response.status}` };
277
}
278
279
const contentType = response.headers.get("content-type") || "";
···
284
285
// Verify client_id matches
286
if (metadata.client_id && metadata.client_id !== clientId) {
287
-
return { success: false, error: "client_id in metadata does not match URL" };
288
}
289
290
return { success: true, metadata };
···
295
const html = await response.text();
296
297
// Extract redirect URIs from link tags
298
-
const redirectUriRegex = /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
299
const redirectUris: string[] = [];
300
let match: RegExpExecArray | null;
301
302
while ((match = redirectUriRegex.exec(html)) !== null) {
303
-
redirectUris.push(match[1]);
304
}
305
306
// Also try reverse order (href before rel)
307
-
const redirectUriRegex2 = /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi;
308
while ((match = redirectUriRegex2.exec(html)) !== null) {
309
-
if (!redirectUris.includes(match[1])) {
310
-
redirectUris.push(match[1]);
311
}
312
}
313
···
321
};
322
}
323
324
-
return { success: false, error: "No client metadata or redirect_uri links found in HTML" };
325
}
326
327
return { success: false, error: "Unsupported content type" };
···
330
if (error.name === "AbortError") {
331
return { success: false, error: "Timeout fetching client metadata" };
332
}
333
-
return { success: false, error: `Failed to fetch client metadata: ${error.message}` };
334
}
335
return { success: false, error: "Failed to fetch client metadata" };
336
}
337
}
338
339
// Verify domain has rel="me" link back to user profile
340
-
export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): Promise<{
341
success: boolean;
342
error?: string;
343
}> {
···
359
360
if (!response.ok) {
361
const errorBody = await response.text();
362
-
console.error(`[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, {
363
-
status: response.status,
364
-
contentType: response.headers.get("content-type"),
365
-
bodyPreview: errorBody.substring(0, 200),
366
-
});
367
-
return { success: false, error: `Failed to fetch domain: HTTP ${response.status}` };
368
}
369
370
const html = await response.text();
···
384
385
const relValue = relMatch[1];
386
// Check if "me" is a separate word in the rel attribute
387
-
if (!relValue.split(/\s+/).includes("me")) return null;
388
389
// Extract href (handle quoted and unquoted attributes)
390
const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i);
···
413
414
// Check if any rel="me" link matches the indiko profile URL
415
const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl);
416
-
const hasRelMe = relMeLinks.some(link => {
417
try {
418
const normalizedLink = canonicalizeURL(link);
419
return normalizedLink === normalizedIndikoUrl;
···
423
});
424
425
if (!hasRelMe) {
426
-
console.error(`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, {
427
-
foundLinks: relMeLinks,
428
-
normalizedTarget: normalizedIndikoUrl,
429
-
});
430
return {
431
success: false,
432
error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`,
···
440
console.error(`[verifyDomain] Timeout verifying ${domainUrl}`);
441
return { success: false, error: "Timeout verifying domain" };
442
}
443
-
console.error(`[verifyDomain] Error verifying ${domainUrl}: ${error.message}`, {
444
-
name: error.name,
445
-
stack: error.stack,
446
-
});
447
-
return { success: false, error: `Failed to verify domain: ${error.message}` };
448
}
449
-
console.error(`[verifyDomain] Unknown error verifying ${domainUrl}:`, error);
450
return { success: false, error: "Failed to verify domain" };
451
}
452
}
···
457
redirectUri: string,
458
): Promise<{
459
error?: string;
460
-
app?: { name: string | null; redirect_uris: string; logo_url?: string | null };
461
}> {
462
const existing = db
463
.query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?")
···
550
551
// Fetch the newly created app
552
const newApp = db
553
-
.query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?")
554
-
.get(canonicalClientId) as { name: string | null; redirect_uris: string; logo_url?: string | null };
555
556
return { app: newApp };
557
}
···
936
const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds
937
938
db.query(
939
-
"INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
940
).run(
941
code,
942
user.userId,
943
clientId,
944
redirectUri,
945
JSON.stringify(requestedScopes),
···
954
).run(Math.floor(Date.now() / 1000), user.userId, clientId);
955
956
const origin = process.env.ORIGIN || "http://localhost:3000";
957
-
return Response.redirect(`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`);
958
}
959
}
960
···
1316
// POST /auth/authorize - Consent form submission
1317
export async function authorizePost(req: Request): Promise<Response> {
1318
const contentType = req.headers.get("Content-Type");
1319
-
1320
// Parse the request body
1321
let body: Record<string, string>;
1322
let formData: FormData;
···
1328
body = await req.json();
1329
// Create a fake FormData for JSON requests
1330
formData = new FormData();
1331
-
Object.entries(body).forEach(([key, value]) => {
1332
formData.append(key, value);
1333
-
});
1334
}
1335
1336
const grantType = body.grant_type;
1337
-
1338
// If grant_type is present, this is a token exchange request (IndieAuth profile scope only)
1339
if (grantType === "authorization_code") {
1340
// Create a mock request for token() function
1341
const mockReq = new Request(req.url, {
1342
method: "POST",
1343
headers: req.headers,
1344
-
body: contentType?.includes("application/x-www-form-urlencoded")
1345
? new URLSearchParams(body).toString()
1346
: JSON.stringify(body),
1347
});
···
1373
clientId = canonicalizeURL(rawClientId);
1374
redirectUri = canonicalizeURL(rawRedirectUri);
1375
} catch {
1376
-
return new Response("Invalid client_id or redirect_uri URL format", { status: 400 });
1377
}
1378
1379
if (action === "deny") {
···
1395
const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds
1396
1397
db.query(
1398
-
"INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1399
).run(
1400
code,
1401
user.userId,
1402
clientId,
1403
redirectUri,
1404
JSON.stringify(approvedScopes),
···
1487
let redirect_uri: string | undefined;
1488
try {
1489
client_id = raw_client_id ? canonicalizeURL(raw_client_id) : undefined;
1490
-
redirect_uri = raw_redirect_uri ? canonicalizeURL(raw_redirect_uri) : undefined;
1491
} catch {
1492
return Response.json(
1493
{
···
1502
return Response.json(
1503
{
1504
error: "unsupported_grant_type",
1505
-
error_description: "Only authorization_code and refresh_token grant types are supported",
1506
},
1507
{ status: 400 },
1508
);
···
1577
const expiresAt = now + expiresIn;
1578
1579
// Update token (rotate access token, keep refresh token)
1580
-
db.query(
1581
-
"UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?",
1582
-
).run(newAccessToken, expiresAt, tokenData.id);
1583
1584
// Get user profile for me value
1585
const user = db
···
1614
headers: {
1615
"Content-Type": "application/json",
1616
"Cache-Control": "no-store",
1617
-
"Pragma": "no-cache",
1618
},
1619
},
1620
);
···
1622
1623
// Handle authorization_code grant (existing flow)
1624
// Check if client is pre-registered and requires secret
1625
const app = db
1626
.query(
1627
"SELECT is_preregistered, client_secret_hash FROM apps WHERE client_id = ?",
···
1699
// Look up authorization code
1700
const authcode = db
1701
.query(
1702
-
"SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me FROM authcodes WHERE code = ?",
1703
)
1704
.get(code) as
1705
| {
1706
user_id: number;
1707
client_id: string;
1708
redirect_uri: string;
1709
scopes: string;
···
1727
1728
// Check if already used
1729
if (authcode.used) {
1730
-
console.error("Token endpoint: authorization code already used", { code });
1731
return Response.json(
1732
{
1733
error: "invalid_grant",
···
1740
// Check if expired
1741
const now = Math.floor(Date.now() / 1000);
1742
if (authcode.expires_at < now) {
1743
-
console.error("Token endpoint: authorization code expired", { code, expires_at: authcode.expires_at, now, diff: now - authcode.expires_at });
1744
return Response.json(
1745
{
1746
error: "invalid_grant",
···
1752
1753
// Verify client_id matches
1754
if (authcode.client_id !== client_id) {
1755
-
console.error("Token endpoint: client_id mismatch", { stored: authcode.client_id, received: client_id });
1756
return Response.json(
1757
{
1758
error: "invalid_grant",
···
1764
1765
// Verify redirect_uri matches
1766
if (authcode.redirect_uri !== redirect_uri) {
1767
-
console.error("Token endpoint: redirect_uri mismatch", { stored: authcode.redirect_uri, received: redirect_uri });
1768
return Response.json(
1769
{
1770
error: "invalid_grant",
···
1776
1777
// Verify PKCE code_verifier (required for all clients per IndieAuth spec)
1778
if (!verifyPKCE(code_verifier, authcode.code_challenge)) {
1779
-
console.error("Token endpoint: PKCE verification failed", { code_verifier, code_challenge: authcode.code_challenge });
1780
return Response.json(
1781
{
1782
error: "invalid_grant",
···
1839
1840
// Validate that the user controls the requested me parameter
1841
if (authcode.me && authcode.me !== meValue) {
1842
-
console.error("Token endpoint: me mismatch", { requested: authcode.me, actual: meValue });
1843
return Response.json(
1844
{
1845
error: "invalid_grant",
1846
-
error_description: "The requested identity does not match the user's verified domain",
1847
},
1848
{ status: 400 },
1849
);
1850
}
1851
1852
const origin = process.env.ORIGIN || "http://localhost:3000";
1853
-
1854
// Generate access token
1855
const accessToken = crypto.randomBytes(32).toString("base64url");
1856
const expiresIn = 3600; // 1 hour
···
1864
// Store token in database with refresh token
1865
db.query(
1866
"INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
1867
-
).run(accessToken, authcode.user_id, client_id, scopes.join(" "), expiresAt, refreshToken, refreshExpiresAt);
1868
1869
const response: Record<string, unknown> = {
1870
access_token: accessToken,
···
1882
response.role = permission.role;
1883
}
1884
1885
-
console.log("Token endpoint: success", { me: meValue, scopes: scopes.join(" ") });
1886
1887
return Response.json(response, {
1888
headers: {
1889
"Content-Type": "application/json",
1890
"Cache-Control": "no-store",
1891
-
"Pragma": "no-cache",
1892
},
1893
});
1894
} catch (error) {
···
2052
try {
2053
// Get access token from Authorization header
2054
const authHeader = req.headers.get("Authorization");
2055
-
2056
if (!authHeader || !authHeader.startsWith("Bearer ")) {
2057
return Response.json(
2058
{
···
1
+
import crypto from "node:crypto";
2
import { db } from "../db";
3
4
interface SessionUser {
···
53
username: session.username,
54
userId: session.id,
55
isAdmin: session.is_admin === 1,
56
+
tier: session.tier,
57
};
58
}
59
···
69
}),
70
);
71
72
+
const sessionToken = cookies.indiko_session;
73
if (!sessionToken) return null;
74
75
const session = db
···
128
}
129
130
// Validate profile URL per IndieAuth spec
131
+
export function validateProfileURL(urlString: string): {
132
+
valid: boolean;
133
+
error?: string;
134
+
canonicalUrl?: string;
135
+
} {
136
let url: URL;
137
try {
138
url = new URL(urlString);
···
157
158
// MUST NOT contain username/password
159
if (url.username || url.password) {
160
+
return {
161
+
valid: false,
162
+
error: "Profile URL must not contain username or password",
163
+
};
164
}
165
166
// MUST NOT contain ports
···
172
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
173
const ipv6Regex = /^\[?[0-9a-fA-F:]+\]?$/;
174
if (ipv4Regex.test(url.hostname) || ipv6Regex.test(url.hostname)) {
175
+
return {
176
+
valid: false,
177
+
error: "Profile URL must use domain names, not IP addresses",
178
+
};
179
}
180
181
// MUST NOT contain single-dot or double-dot path segments
182
const pathSegments = url.pathname.split("/");
183
if (pathSegments.includes(".") || pathSegments.includes("..")) {
184
+
return {
185
+
valid: false,
186
+
error: "Profile URL must not contain . or .. path segments",
187
+
};
188
}
189
190
return { valid: true, canonicalUrl: canonicalizeURL(urlString) };
191
}
192
193
// Validate client URL per IndieAuth spec
194
+
function validateClientURL(urlString: string): {
195
+
valid: boolean;
196
+
error?: string;
197
+
canonicalUrl?: string;
198
+
} {
199
let url: URL;
200
try {
201
url = new URL(urlString);
···
220
221
// MUST NOT contain username/password
222
if (url.username || url.password) {
223
+
return {
224
+
valid: false,
225
+
error: "Client URL must not contain username or password",
226
+
};
227
}
228
229
// MUST NOT contain single-dot or double-dot path segments
230
const pathSegments = url.pathname.split("/");
231
if (pathSegments.includes(".") || pathSegments.includes("..")) {
232
+
return {
233
+
valid: false,
234
+
error: "Client URL must not contain . or .. path segments",
235
+
};
236
}
237
238
// MAY use loopback interface, but not other IP addresses
···
241
if (ipv4Regex.test(url.hostname)) {
242
// Allow 127.0.0.1 (loopback), reject others
243
if (!url.hostname.startsWith("127.")) {
244
+
return {
245
+
valid: false,
246
+
error:
247
+
"Client URL must use domain names, not IP addresses (except loopback)",
248
+
};
249
}
250
} else if (ipv6Regex.test(url.hostname)) {
251
// Allow ::1 (loopback), reject others
252
const ipv6Match = url.hostname.match(ipv6Regex);
253
if (ipv6Match && ipv6Match[1] !== "::1") {
254
+
return {
255
+
valid: false,
256
+
error:
257
+
"Client URL must use domain names, not IP addresses (except loopback)",
258
+
};
259
}
260
}
261
···
266
function isLoopbackURL(urlString: string): boolean {
267
try {
268
const url = new URL(urlString);
269
+
return (
270
+
url.hostname === "localhost" ||
271
+
url.hostname === "127.0.0.1" ||
272
+
url.hostname === "[::1]" ||
273
+
url.hostname.startsWith("127.")
274
+
);
275
} catch {
276
return false;
277
}
···
291
}> {
292
// MUST NOT fetch loopback addresses (security requirement)
293
if (isLoopbackURL(clientId)) {
294
+
return {
295
+
success: false,
296
+
error: "Cannot fetch metadata from loopback addresses",
297
+
};
298
}
299
300
try {
···
313
clearTimeout(timeoutId);
314
315
if (!response.ok) {
316
+
return {
317
+
success: false,
318
+
error: `Failed to fetch client metadata: HTTP ${response.status}`,
319
+
};
320
}
321
322
const contentType = response.headers.get("content-type") || "";
···
327
328
// Verify client_id matches
329
if (metadata.client_id && metadata.client_id !== clientId) {
330
+
return {
331
+
success: false,
332
+
error: "client_id in metadata does not match URL",
333
+
};
334
}
335
336
return { success: true, metadata };
···
341
const html = await response.text();
342
343
// Extract redirect URIs from link tags
344
+
const redirectUriRegex =
345
+
/<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
346
const redirectUris: string[] = [];
347
let match: RegExpExecArray | null;
348
349
while ((match = redirectUriRegex.exec(html)) !== null) {
350
+
redirectUris.push(match[1] ? match[1] : "");
351
}
352
353
// Also try reverse order (href before rel)
354
+
const redirectUriRegex2 =
355
+
/<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi;
356
while ((match = redirectUriRegex2.exec(html)) !== null) {
357
+
if (!redirectUris.includes(match[1] ? match[1] : "")) {
358
+
redirectUris.push(match[1] ? match[1] : "");
359
}
360
}
361
···
369
};
370
}
371
372
+
return {
373
+
success: false,
374
+
error: "No client metadata or redirect_uri links found in HTML",
375
+
};
376
}
377
378
return { success: false, error: "Unsupported content type" };
···
381
if (error.name === "AbortError") {
382
return { success: false, error: "Timeout fetching client metadata" };
383
}
384
+
return {
385
+
success: false,
386
+
error: `Failed to fetch client metadata: ${error.message}`,
387
+
};
388
}
389
return { success: false, error: "Failed to fetch client metadata" };
390
}
391
}
392
393
// Verify domain has rel="me" link back to user profile
394
+
export async function verifyDomain(
395
+
domainUrl: string,
396
+
indikoProfileUrl: string,
397
+
): Promise<{
398
success: boolean;
399
error?: string;
400
}> {
···
416
417
if (!response.ok) {
418
const errorBody = await response.text();
419
+
console.error(
420
+
`[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`,
421
+
{
422
+
status: response.status,
423
+
contentType: response.headers.get("content-type"),
424
+
bodyPreview: errorBody.substring(0, 200),
425
+
},
426
+
);
427
+
return {
428
+
success: false,
429
+
error: `Failed to fetch domain: HTTP ${response.status}`,
430
+
};
431
}
432
433
const html = await response.text();
···
447
448
const relValue = relMatch[1];
449
// Check if "me" is a separate word in the rel attribute
450
+
if (!relValue?.split(/\s+/).includes("me")) return null;
451
452
// Extract href (handle quoted and unquoted attributes)
453
const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i);
···
476
477
// Check if any rel="me" link matches the indiko profile URL
478
const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl);
479
+
const hasRelMe = relMeLinks.some((link) => {
480
try {
481
const normalizedLink = canonicalizeURL(link);
482
return normalizedLink === normalizedIndikoUrl;
···
486
});
487
488
if (!hasRelMe) {
489
+
console.error(
490
+
`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`,
491
+
{
492
+
foundLinks: relMeLinks,
493
+
normalizedTarget: normalizedIndikoUrl,
494
+
},
495
+
);
496
return {
497
success: false,
498
error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`,
···
506
console.error(`[verifyDomain] Timeout verifying ${domainUrl}`);
507
return { success: false, error: "Timeout verifying domain" };
508
}
509
+
console.error(
510
+
`[verifyDomain] Error verifying ${domainUrl}: ${error.message}`,
511
+
{
512
+
name: error.name,
513
+
stack: error.stack,
514
+
},
515
+
);
516
+
return {
517
+
success: false,
518
+
error: `Failed to verify domain: ${error.message}`,
519
+
};
520
}
521
+
console.error(
522
+
`[verifyDomain] Unknown error verifying ${domainUrl}:`,
523
+
error,
524
+
);
525
return { success: false, error: "Failed to verify domain" };
526
}
527
}
···
532
redirectUri: string,
533
): Promise<{
534
error?: string;
535
+
app?: {
536
+
name: string | null;
537
+
redirect_uris: string;
538
+
logo_url?: string | null;
539
+
};
540
}> {
541
const existing = db
542
.query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?")
···
629
630
// Fetch the newly created app
631
const newApp = db
632
+
.query(
633
+
"SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?",
634
+
)
635
+
.get(canonicalClientId) as {
636
+
name: string | null;
637
+
redirect_uris: string;
638
+
logo_url?: string | null;
639
+
};
640
641
return { app: newApp };
642
}
···
1021
const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds
1022
1023
db.query(
1024
+
"INSERT INTO authcodes (code, user_id, username, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1025
).run(
1026
code,
1027
user.userId,
1028
+
user.username,
1029
clientId,
1030
redirectUri,
1031
JSON.stringify(requestedScopes),
···
1040
).run(Math.floor(Date.now() / 1000), user.userId, clientId);
1041
1042
const origin = process.env.ORIGIN || "http://localhost:3000";
1043
+
return Response.redirect(
1044
+
`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`,
1045
+
);
1046
}
1047
}
1048
···
1404
// POST /auth/authorize - Consent form submission
1405
export async function authorizePost(req: Request): Promise<Response> {
1406
const contentType = req.headers.get("Content-Type");
1407
+
1408
// Parse the request body
1409
let body: Record<string, string>;
1410
let formData: FormData;
···
1416
body = await req.json();
1417
// Create a fake FormData for JSON requests
1418
formData = new FormData();
1419
+
for (const [key, value] of Object.entries(body)) {
1420
formData.append(key, value);
1421
+
}
1422
}
1423
1424
const grantType = body.grant_type;
1425
+
1426
// If grant_type is present, this is a token exchange request (IndieAuth profile scope only)
1427
if (grantType === "authorization_code") {
1428
// Create a mock request for token() function
1429
const mockReq = new Request(req.url, {
1430
method: "POST",
1431
headers: req.headers,
1432
+
body: contentType?.includes("application/x-www-form-urlencoded")
1433
? new URLSearchParams(body).toString()
1434
: JSON.stringify(body),
1435
});
···
1461
clientId = canonicalizeURL(rawClientId);
1462
redirectUri = canonicalizeURL(rawRedirectUri);
1463
} catch {
1464
+
return new Response("Invalid client_id or redirect_uri URL format", {
1465
+
status: 400,
1466
+
});
1467
}
1468
1469
if (action === "deny") {
···
1485
const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds
1486
1487
db.query(
1488
+
"INSERT INTO authcodes (code, user_id, username, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1489
).run(
1490
code,
1491
user.userId,
1492
+
user.username,
1493
clientId,
1494
redirectUri,
1495
JSON.stringify(approvedScopes),
···
1578
let redirect_uri: string | undefined;
1579
try {
1580
client_id = raw_client_id ? canonicalizeURL(raw_client_id) : undefined;
1581
+
redirect_uri = raw_redirect_uri
1582
+
? canonicalizeURL(raw_redirect_uri)
1583
+
: undefined;
1584
} catch {
1585
return Response.json(
1586
{
···
1595
return Response.json(
1596
{
1597
error: "unsupported_grant_type",
1598
+
error_description:
1599
+
"Only authorization_code and refresh_token grant types are supported",
1600
},
1601
{ status: 400 },
1602
);
···
1671
const expiresAt = now + expiresIn;
1672
1673
// Update token (rotate access token, keep refresh token)
1674
+
db.query("UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?").run(
1675
+
newAccessToken,
1676
+
expiresAt,
1677
+
tokenData.id,
1678
+
);
1679
1680
// Get user profile for me value
1681
const user = db
···
1710
headers: {
1711
"Content-Type": "application/json",
1712
"Cache-Control": "no-store",
1713
+
Pragma: "no-cache",
1714
},
1715
},
1716
);
···
1718
1719
// Handle authorization_code grant (existing flow)
1720
// Check if client is pre-registered and requires secret
1721
+
if (!client_id) {
1722
+
return Response.json(
1723
+
{
1724
+
error: "invalid_request",
1725
+
error_description: "client_id is required",
1726
+
},
1727
+
{ status: 400 },
1728
+
);
1729
+
}
1730
const app = db
1731
.query(
1732
"SELECT is_preregistered, client_secret_hash FROM apps WHERE client_id = ?",
···
1804
// Look up authorization code
1805
const authcode = db
1806
.query(
1807
+
"SELECT user_id, username, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me FROM authcodes WHERE code = ?",
1808
)
1809
.get(code) as
1810
| {
1811
user_id: number;
1812
+
username: string;
1813
client_id: string;
1814
redirect_uri: string;
1815
scopes: string;
···
1833
1834
// Check if already used
1835
if (authcode.used) {
1836
+
console.error("Token endpoint: authorization code already used", {
1837
+
code,
1838
+
});
1839
return Response.json(
1840
{
1841
error: "invalid_grant",
···
1848
// Check if expired
1849
const now = Math.floor(Date.now() / 1000);
1850
if (authcode.expires_at < now) {
1851
+
console.error("Token endpoint: authorization code expired", {
1852
+
code,
1853
+
expires_at: authcode.expires_at,
1854
+
now,
1855
+
diff: now - authcode.expires_at,
1856
+
});
1857
return Response.json(
1858
{
1859
error: "invalid_grant",
···
1865
1866
// Verify client_id matches
1867
if (authcode.client_id !== client_id) {
1868
+
console.error("Token endpoint: client_id mismatch", {
1869
+
stored: authcode.client_id,
1870
+
received: client_id,
1871
+
});
1872
return Response.json(
1873
{
1874
error: "invalid_grant",
···
1880
1881
// Verify redirect_uri matches
1882
if (authcode.redirect_uri !== redirect_uri) {
1883
+
console.error("Token endpoint: redirect_uri mismatch", {
1884
+
stored: authcode.redirect_uri,
1885
+
received: redirect_uri,
1886
+
});
1887
return Response.json(
1888
{
1889
error: "invalid_grant",
···
1895
1896
// Verify PKCE code_verifier (required for all clients per IndieAuth spec)
1897
if (!verifyPKCE(code_verifier, authcode.code_challenge)) {
1898
+
console.error("Token endpoint: PKCE verification failed", {
1899
+
code_verifier,
1900
+
code_challenge: authcode.code_challenge,
1901
+
});
1902
return Response.json(
1903
{
1904
error: "invalid_grant",
···
1961
1962
// Validate that the user controls the requested me parameter
1963
if (authcode.me && authcode.me !== meValue) {
1964
+
console.error("Token endpoint: me mismatch", {
1965
+
requested: authcode.me,
1966
+
actual: meValue,
1967
+
});
1968
return Response.json(
1969
{
1970
error: "invalid_grant",
1971
+
error_description:
1972
+
"The requested identity does not match the user's verified domain",
1973
},
1974
{ status: 400 },
1975
);
1976
}
1977
1978
const origin = process.env.ORIGIN || "http://localhost:3000";
1979
+
1980
// Generate access token
1981
const accessToken = crypto.randomBytes(32).toString("base64url");
1982
const expiresIn = 3600; // 1 hour
···
1990
// Store token in database with refresh token
1991
db.query(
1992
"INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
1993
+
).run(
1994
+
accessToken,
1995
+
authcode.user_id,
1996
+
client_id,
1997
+
scopes.join(" "),
1998
+
expiresAt,
1999
+
refreshToken,
2000
+
refreshExpiresAt,
2001
+
);
2002
2003
const response: Record<string, unknown> = {
2004
access_token: accessToken,
···
2016
response.role = permission.role;
2017
}
2018
2019
+
console.log("Token endpoint: success", {
2020
+
me: meValue,
2021
+
scopes: scopes.join(" "),
2022
+
});
2023
2024
return Response.json(response, {
2025
headers: {
2026
"Content-Type": "application/json",
2027
"Cache-Control": "no-store",
2028
+
Pragma: "no-cache",
2029
},
2030
});
2031
} catch (error) {
···
2189
try {
2190
// Get access token from Authorization header
2191
const authHeader = req.headers.get("Authorization");
2192
+
2193
if (!authHeader || !authHeader.startsWith("Bearer ")) {
2194
return Response.json(
2195
{
+14
-6
src/routes/passkeys.ts
+14
-6
src/routes/passkeys.ts
···
1
import {
2
type RegistrationResponseJSON,
3
-
generateRegistrationOptions,
4
type VerifiedRegistrationResponse,
5
verifyRegistrationResponse,
6
} from "@simplewebauthn/server";
7
import { db } from "../db";
···
75
.all(session.user_id) as Array<{ credential_id: Buffer }>;
76
77
const excludeCredentials = existingCredentials.map((cred) => ({
78
-
id: cred.credential_id,
79
type: "public-key" as const,
80
}));
81
82
// Generate WebAuthn registration options
83
const options = await generateRegistrationOptions({
84
rpName: RP_NAME,
85
-
rpID: process.env.RP_ID!,
86
userName: user.username,
87
userDisplayName: user.username,
88
attestationType: "none",
···
133
}
134
135
const body = await req.json();
136
-
const { response, challenge: expectedChallenge, name } = body as {
137
response: RegistrationResponseJSON;
138
challenge: string;
139
name?: string;
···
167
verification = await verifyRegistrationResponse({
168
response,
169
expectedChallenge: challenge.challenge,
170
-
expectedOrigin: process.env.ORIGIN!,
171
-
expectedRPID: process.env.RP_ID!,
172
});
173
} catch (error) {
174
console.error("WebAuthn verification failed:", error);
···
1
import {
2
type RegistrationResponseJSON,
3
type VerifiedRegistrationResponse,
4
+
generateRegistrationOptions,
5
verifyRegistrationResponse,
6
} from "@simplewebauthn/server";
7
import { db } from "../db";
···
75
.all(session.user_id) as Array<{ credential_id: Buffer }>;
76
77
const excludeCredentials = existingCredentials.map((cred) => ({
78
+
id: Buffer.from(cred.credential_id)
79
+
.toString("base64")
80
+
.replace(/\+/g, "-")
81
+
.replace(/\//g, "_")
82
+
.replace(/=+$/, ""),
83
type: "public-key" as const,
84
}));
85
86
// Generate WebAuthn registration options
87
const options = await generateRegistrationOptions({
88
rpName: RP_NAME,
89
+
rpID: process.env.RP_ID || "",
90
userName: user.username,
91
userDisplayName: user.username,
92
attestationType: "none",
···
137
}
138
139
const body = await req.json();
140
+
const {
141
+
response,
142
+
challenge: expectedChallenge,
143
+
name,
144
+
} = body as {
145
response: RegistrationResponseJSON;
146
challenge: string;
147
name?: string;
···
175
verification = await verifyRegistrationResponse({
176
response,
177
expectedChallenge: challenge.challenge,
178
+
expectedOrigin: process.env.ORIGIN || "",
179
+
expectedRPID: process.env.RP_ID || "",
180
});
181
} catch (error) {
182
console.error("WebAuthn verification failed:", error);
+1
src/styles.css
+1
src/styles.css