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