LDAP support to create user if it does not exist in indiko but exists in linked ldap dir. Currently does not support deletion from ldap dir. if the user is deleted in ldap they will not be deleted in indiko
+8
.env.example
+8
.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
+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
+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
63
"bun-sqlite-migrations": ["bun-sqlite-migrations@1.0.2", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-WLw8q67KM+1RN7o4DqVVhmJASypuBp8fygrfA8QD5HZEjiP+E5hD1SV2dpyB7A4tFqLdUF8cdln7+Ptj5+Hz1Q=="],
64
65
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
66
67
"nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
68
69
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
70
71
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
72
73
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
74
75
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
76
77
"tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="],
···
80
81
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
82
83
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
84
}
85
}
···
8
"@simplewebauthn/browser": "^13.2.2",
9
"@simplewebauthn/server": "^13.2.2",
10
"bun-sqlite-migrations": "^1.0.2",
11
+
"ldap-authentication": "^3.3.6",
12
"nanoid": "^5.1.6",
13
},
14
"devDependencies": {
···
55
56
"@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="],
57
58
+
"@types/asn1": ["@types/asn1@0.2.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA=="],
59
+
60
"@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
61
62
"@types/node": ["@types/node@25.0.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew=="],
63
64
+
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
65
+
66
"asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="],
67
68
"bun-sqlite-migrations": ["bun-sqlite-migrations@1.0.2", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-WLw8q67KM+1RN7o4DqVVhmJASypuBp8fygrfA8QD5HZEjiP+E5hD1SV2dpyB7A4tFqLdUF8cdln7+Ptj5+Hz1Q=="],
69
70
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
71
72
+
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
73
+
74
+
"ldap-authentication": ["ldap-authentication@3.3.6", "", { "dependencies": { "ldapts": "^7.3.1" } }, "sha512-j8XxH5wGXhIQ3mMnoRCZTalSLmPhzEaGH4+5RIFP0Higc32fCKDRJBo+9wb5ysy9TnlaNtaf+rgdwYCD15OBpQ=="],
75
+
76
+
"ldapts": ["ldapts@7.4.0", "", { "dependencies": { "@types/asn1": ">=0.2.4", "asn1": "0.2.6", "debug": "4.4.0", "strict-event-emitter-types": "2.0.0", "uuid": "11.1.0", "whatwg-url": "14.2.0" } }, "sha512-QLgx2pLvxMXY1nCc85Fx+cwVJDvC0sQ3l4CJZSl1FJ/iV8Ypfl6m+5xz4lm1lhoXcUlvhPqxEoyIj/8LR6ut+A=="],
77
+
78
+
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
79
+
80
"nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
81
82
+
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
83
+
84
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
85
86
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
87
88
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
89
90
+
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
91
+
92
+
"strict-event-emitter-types": ["strict-event-emitter-types@2.0.0", "", {}, "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA=="],
93
+
94
+
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
95
+
96
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
97
98
"tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="],
···
101
102
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
103
104
+
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
105
+
106
+
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
107
+
108
+
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
109
+
110
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
111
}
112
}
+1
package.json
+1
package.json
+7
-3
src/client/admin-invites.ts
+7
-3
src/client/admin-invites.ts
···
171
"submitInviteBtn",
172
) as HTMLButtonElement;
173
174
-
const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : 1;
175
const expiresAt = expiresAtInput.value
176
? Math.floor(new Date(expiresAtInput.value).getTime() / 1000)
177
: null;
···
187
'input[name="appRole"]:checked',
188
);
189
checkedBoxes.forEach((checkbox) => {
190
-
const appId = parseInt((checkbox as HTMLInputElement).value, 10);
191
const roleSelect = appRolesContainer.querySelector(
192
`select.role-select[data-app-id="${appId}"]`,
193
) as HTMLSelectElement;
···
507
"submitEditInviteBtn",
508
) as HTMLButtonElement;
509
510
-
const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : null;
511
const expiresAt = expiresAtInput.value
512
? Math.floor(new Date(expiresAtInput.value).getTime() / 1000)
513
: null;
···
171
"submitInviteBtn",
172
) as HTMLButtonElement;
173
174
+
const maxUses = maxUsesInput.value
175
+
? Number.parseInt(maxUsesInput.value, 10)
176
+
: 1;
177
const expiresAt = expiresAtInput.value
178
? Math.floor(new Date(expiresAtInput.value).getTime() / 1000)
179
: null;
···
189
'input[name="appRole"]:checked',
190
);
191
checkedBoxes.forEach((checkbox) => {
192
+
const appId = Number.parseInt((checkbox as HTMLInputElement).value, 10);
193
const roleSelect = appRolesContainer.querySelector(
194
`select.role-select[data-app-id="${appId}"]`,
195
) as HTMLSelectElement;
···
509
"submitEditInviteBtn",
510
) as HTMLButtonElement;
511
512
+
const maxUses = maxUsesInput.value
513
+
? Number.parseInt(maxUsesInput.value, 10)
514
+
: null;
515
const expiresAt = expiresAtInput.value
516
? Math.floor(new Date(expiresAtInput.value).getTime() / 1000)
517
: null;
+7
-7
src/client/docs.ts
+7
-7
src/client/docs.ts
···
48
);
49
}
50
51
-
result += attrs + ">";
52
return result;
53
},
54
);
···
462
const rows: string[][] = [];
463
464
// Get headers
465
-
el.querySelectorAll("thead th").forEach((th) => {
466
headers.push(th.textContent?.trim() || "");
467
-
});
468
469
// Get rows
470
el.querySelectorAll("tbody tr").forEach((tr) => {
471
const row: string[] = [];
472
-
tr.querySelectorAll("td").forEach((td) => {
473
row.push(td.textContent?.trim() || "");
474
-
});
475
rows.push(row);
476
});
477
···
479
if (headers.length > 0) {
480
lines.push(`| ${headers.join(" | ")} |`);
481
lines.push(`|${headers.map(() => "-------").join("|")}|`);
482
-
rows.forEach((row) => {
483
lines.push(`| ${row.join(" | ")} |`);
484
-
});
485
lines.push("");
486
}
487
}
···
48
);
49
}
50
51
+
result += `${attrs}>`;
52
return result;
53
},
54
);
···
462
const rows: string[][] = [];
463
464
// Get headers
465
+
for (const th of el.querySelectorAll("thead th")) {
466
headers.push(th.textContent?.trim() || "");
467
+
}
468
469
// Get rows
470
el.querySelectorAll("tbody tr").forEach((tr) => {
471
const row: string[] = [];
472
+
for (const td of tr.querySelectorAll("td")) {
473
row.push(td.textContent?.trim() || "");
474
+
}
475
rows.push(row);
476
});
477
···
479
if (headers.length > 0) {
480
lines.push(`| ${headers.join(" | ")} |`);
481
lines.push(`|${headers.map(() => "-------").join("|")}|`);
482
+
for (const row of rows) {
483
lines.push(`| ${row.join(" | ")} |`);
484
+
}
485
lines.push("");
486
}
487
}
+31
-16
src/client/index.ts
+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() {
12
try {
···
15
const inviteCode = urlParams.get("invite");
16
17
if (inviteCode) {
18
// Fetch invite details to show message
19
try {
20
const response = await fetch("/auth/register/options", {
21
method: "POST",
22
headers: { "Content-Type": "application/json" },
23
-
body: JSON.stringify({ username: "temp", inviteCode }),
24
});
25
26
if (response.ok) {
···
38
if (subtitleElement) {
39
subtitleElement.textContent = "create your account";
40
}
41
-
(
42
-
document.getElementById("registerUsername") as HTMLInputElement
43
-
).placeholder = "choose username";
44
(
45
document.getElementById("registerBtn") as HTMLButtonElement
46
).textContent = "create account";
···
112
113
const options = await optionsRes.json();
114
115
loginBtn.textContent = "use your passkey...";
116
117
// Start authentication
···
212
213
showMessage("Registration successful!", "success");
214
215
-
// Check for return URL parameter
216
-
const returnUrl = urlParams.get("return") || "/";
217
218
const redirectTimer = setTimeout(() => {
219
window.location.href = returnUrl;
···
225
registerBtn.textContent = "register passkey";
226
}
227
});
···
5
6
const loginForm = document.getElementById("loginForm") as HTMLFormElement;
7
const registerForm = document.getElementById("registerForm") as HTMLFormElement;
8
+
const ldapForm = document.getElementById("ldapForm") as HTMLFormElement;
9
const message = document.getElementById("message") as HTMLDivElement;
10
11
+
let pendingLdapUsername: string | null = null;
12
+
13
// Check if registration is allowed on page load
14
async function checkRegistrationAllowed() {
15
try {
···
18
const inviteCode = urlParams.get("invite");
19
20
if (inviteCode) {
21
+
// Check if username is locked (from LDAP flow)
22
+
const lockedUsername = urlParams.get("username");
23
+
const registerUsernameInput = document.getElementById(
24
+
"registerUsername",
25
+
) as HTMLInputElement;
26
+
27
// Fetch invite details to show message
28
try {
29
+
const testUsername = lockedUsername || "temp";
30
const response = await fetch("/auth/register/options", {
31
method: "POST",
32
headers: { "Content-Type": "application/json" },
33
+
body: JSON.stringify({ username: testUsername, inviteCode }),
34
});
35
36
if (response.ok) {
···
48
if (subtitleElement) {
49
subtitleElement.textContent = "create your account";
50
}
51
+
52
+
// If username is locked from LDAP, pre-fill and disable
53
+
if (lockedUsername) {
54
+
registerUsernameInput.value = lockedUsername;
55
+
registerUsernameInput.readOnly = true;
56
+
registerUsernameInput.style.opacity = "0.7";
57
+
registerUsernameInput.style.cursor = "not-allowed";
58
+
} else {
59
+
registerUsernameInput.placeholder = "choose username";
60
+
}
61
+
62
(
63
document.getElementById("registerBtn") as HTMLButtonElement
64
).textContent = "create account";
···
130
131
const options = await optionsRes.json();
132
133
+
// Check if LDAP verification is required (user exists in LDAP but not locally)
134
+
if (options.ldapVerificationRequired) {
135
+
showLdapPasswordPrompt(options.username);
136
+
loginBtn.disabled = false;
137
+
loginBtn.textContent = "sign in";
138
+
return;
139
+
}
140
+
141
loginBtn.textContent = "use your passkey...";
142
143
// Start authentication
···
238
239
showMessage("Registration successful!", "success");
240
241
+
// Check for return URL: first sessionStorage (from LDAP flow), then URL param, fallback to /
242
+
const storedRedirect = sessionStorage.getItem("postRegistrationRedirect");
243
+
const returnUrl = storedRedirect || urlParams.get("return") || "/";
244
+
245
+
// Clear the stored redirect after use
246
+
if (storedRedirect) {
247
+
sessionStorage.removeItem("postRegistrationRedirect");
248
+
}
249
250
const redirectTimer = setTimeout(() => {
251
window.location.href = returnUrl;
···
257
registerBtn.textContent = "register passkey";
258
}
259
});
260
+
261
+
// LDAP verification flow
262
+
function showLdapPasswordPrompt(username: string) {
263
+
pendingLdapUsername = username;
264
+
265
+
// Update UI to show LDAP form
266
+
const subtitleElement = document.querySelector(".subtitle");
267
+
if (subtitleElement) {
268
+
subtitleElement.textContent = "verify your LDAP password";
269
+
}
270
+
271
+
// Update LDAP form username display
272
+
const ldapUsernameSpan = document.getElementById("ldapUsername");
273
+
if (ldapUsernameSpan) {
274
+
ldapUsernameSpan.textContent = username;
275
+
}
276
+
277
+
// Show LDAP form, hide others
278
+
loginForm.style.display = "none";
279
+
registerForm.style.display = "none";
280
+
ldapForm.style.display = "block";
281
+
282
+
showMessage(
283
+
"This username exists in the linked LDAP directory. Enter your LDAP password to create your account.",
284
+
"success",
285
+
true,
286
+
);
287
+
}
288
+
289
+
ldapForm.addEventListener("submit", async (e) => {
290
+
e.preventDefault();
291
+
292
+
if (!pendingLdapUsername) {
293
+
showMessage("No username pending for LDAP verification");
294
+
return;
295
+
}
296
+
297
+
const password = (document.getElementById("ldapPassword") as HTMLInputElement)
298
+
.value;
299
+
const ldapBtn = document.getElementById("ldapBtn") as HTMLButtonElement;
300
+
301
+
try {
302
+
ldapBtn.disabled = true;
303
+
ldapBtn.textContent = "verifying...";
304
+
305
+
// Get return URL for after registration
306
+
const urlParams = new URLSearchParams(window.location.search);
307
+
const returnUrl = urlParams.get("return") || "/";
308
+
309
+
// Verify LDAP credentials
310
+
const verifyRes = await fetch("/api/ldap-verify", {
311
+
method: "POST",
312
+
headers: { "Content-Type": "application/json" },
313
+
body: JSON.stringify({
314
+
username: pendingLdapUsername,
315
+
password: password,
316
+
returnUrl: returnUrl,
317
+
}),
318
+
});
319
+
320
+
if (!verifyRes.ok) {
321
+
const error = await verifyRes.json();
322
+
throw new Error(error.error || "LDAP verification failed");
323
+
}
324
+
325
+
const result = await verifyRes.json();
326
+
327
+
if (result.success) {
328
+
showMessage(
329
+
"LDAP verification successful! Redirecting to setup...",
330
+
"success",
331
+
);
332
+
333
+
// Store return URL for after registration completes
334
+
if (result.returnUrl) {
335
+
sessionStorage.setItem("postRegistrationRedirect", result.returnUrl);
336
+
}
337
+
338
+
// Redirect to registration with the invite code and locked username
339
+
const registerUrl = `/login?invite=${encodeURIComponent(result.inviteCode)}&username=${encodeURIComponent(result.username)}`;
340
+
341
+
setTimeout(() => {
342
+
window.location.href = registerUrl;
343
+
}, 1000);
344
+
}
345
+
} catch (error) {
346
+
showMessage((error as Error).message || "LDAP verification failed");
347
+
ldapBtn.disabled = false;
348
+
ldapBtn.textContent = "verify & continue";
349
+
}
350
+
});
+32
-1
src/html/login.html
+32
-1
src/html/login.html
···
49
margin-bottom: 1rem;
50
}
51
52
-
input[type="text"] {
53
margin-bottom: 1rem;
54
}
55
56
button {
57
width: 100%;
58
padding: 1.25rem 2rem;
···
95
autocomplete="username webauthn" />
96
<button type="submit" class="secondary-btn" id="registerBtn">create passkey</button>
97
</form>
98
</div>
99
100
<div class="info">
···
49
margin-bottom: 1rem;
50
}
51
52
+
input[type="text"],
53
+
input[type="password"] {
54
margin-bottom: 1rem;
55
}
56
57
+
.ldap-user-display {
58
+
background: rgba(188, 141, 160, 0.1);
59
+
border-left: 3px solid var(--berry-crush);
60
+
padding: 0.75rem 1rem;
61
+
margin-bottom: 1rem;
62
+
text-align: left;
63
+
font-size: 0.875rem;
64
+
}
65
+
66
+
.ldap-user-display .label {
67
+
color: var(--old-rose);
68
+
font-size: 0.75rem;
69
+
text-transform: uppercase;
70
+
letter-spacing: 0.05rem;
71
+
}
72
+
73
+
.ldap-user-display .username {
74
+
color: var(--lavender);
75
+
font-weight: 600;
76
+
}
77
+
78
button {
79
width: 100%;
80
padding: 1.25rem 2rem;
···
117
autocomplete="username webauthn" />
118
<button type="submit" class="secondary-btn" id="registerBtn">create passkey</button>
119
</form>
120
+
121
+
<form id="ldapForm" style="display: none;">
122
+
<div class="ldap-user-display">
123
+
<div class="label">username</div>
124
+
<div class="username" id="ldapUsername"></div>
125
+
</div>
126
+
<input type="password" id="ldapPassword" placeholder="LDAP password" required autocomplete="current-password" />
127
+
<button type="submit" id="ldapBtn">verify & continue</button>
128
+
</form>
129
</div>
130
131
<div class="info">
+18
-12
src/index.ts
+18
-12
src/index.ts
···
1
import { env } from "bun";
2
import { db } from "./db";
3
-
import adminHTML from "./html/admin.html";
4
import adminClientsHTML from "./html/admin-clients.html";
5
import adminInvitesHTML from "./html/admin-invites.html";
6
import appsHTML from "./html/apps.html";
7
import docsHTML from "./html/docs.html";
8
import indexHTML from "./html/index.html";
···
25
} from "./routes/api";
26
import {
27
canRegister,
28
loginOptions,
29
loginVerify,
30
registerOptions,
31
registerVerify,
32
} from "./routes/auth";
33
-
import {
34
-
addPasskeyOptions,
35
-
addPasskeyVerify,
36
-
deletePasskey,
37
-
listPasskeys,
38
-
renamePasskey,
39
-
} from "./routes/passkeys";
40
import {
41
createClient,
42
deleteClient,
···
61
userProfile,
62
userinfo,
63
} from "./routes/indieauth";
64
65
(() => {
66
const required = ["ORIGIN", "RP_ID"];
···
197
if (req.method === "POST") {
198
const url = new URL(req.url);
199
const userId = url.pathname.split("/")[4];
200
-
return disableUser(req, userId);
201
}
202
return new Response("Method not allowed", { status: 405 });
203
},
···
205
if (req.method === "POST") {
206
const url = new URL(req.url);
207
const userId = url.pathname.split("/")[4];
208
-
return enableUser(req, userId);
209
}
210
return new Response("Method not allowed", { status: 405 });
211
},
···
213
if (req.method === "PUT") {
214
const url = new URL(req.url);
215
const userId = url.pathname.split("/")[4];
216
-
return updateUserTier(req, userId);
217
}
218
return new Response("Method not allowed", { status: 405 });
219
},
···
221
if (req.method === "DELETE") {
222
const url = new URL(req.url);
223
const userId = url.pathname.split("/")[4];
224
-
return deleteUser(req, userId);
225
}
226
return new Response("Method not allowed", { status: 405 });
227
},
···
253
"/auth/register/verify": registerVerify,
254
"/auth/login/options": loginOptions,
255
"/auth/login/verify": loginVerify,
256
// Passkey management endpoints
257
"/api/passkeys": (req: Request) => {
258
if (req.method === "GET") return listPasskeys(req);
···
1
import { env } from "bun";
2
import { db } from "./db";
3
import adminClientsHTML from "./html/admin-clients.html";
4
import adminInvitesHTML from "./html/admin-invites.html";
5
+
import adminHTML from "./html/admin.html";
6
import appsHTML from "./html/apps.html";
7
import docsHTML from "./html/docs.html";
8
import indexHTML from "./html/index.html";
···
25
} from "./routes/api";
26
import {
27
canRegister,
28
+
ldapVerify,
29
loginOptions,
30
loginVerify,
31
registerOptions,
32
registerVerify,
33
} from "./routes/auth";
34
import {
35
createClient,
36
deleteClient,
···
55
userProfile,
56
userinfo,
57
} from "./routes/indieauth";
58
+
import {
59
+
addPasskeyOptions,
60
+
addPasskeyVerify,
61
+
deletePasskey,
62
+
listPasskeys,
63
+
renamePasskey,
64
+
} from "./routes/passkeys";
65
66
(() => {
67
const required = ["ORIGIN", "RP_ID"];
···
198
if (req.method === "POST") {
199
const url = new URL(req.url);
200
const userId = url.pathname.split("/")[4];
201
+
return disableUser(req, userId ? userId : "");
202
}
203
return new Response("Method not allowed", { status: 405 });
204
},
···
206
if (req.method === "POST") {
207
const url = new URL(req.url);
208
const userId = url.pathname.split("/")[4];
209
+
return enableUser(req, userId ? userId : "");
210
}
211
return new Response("Method not allowed", { status: 405 });
212
},
···
214
if (req.method === "PUT") {
215
const url = new URL(req.url);
216
const userId = url.pathname.split("/")[4];
217
+
return updateUserTier(req, userId ? userId : "");
218
}
219
return new Response("Method not allowed", { status: 405 });
220
},
···
222
if (req.method === "DELETE") {
223
const url = new URL(req.url);
224
const userId = url.pathname.split("/")[4];
225
+
return deleteUser(req, userId ? userId : "");
226
}
227
return new Response("Method not allowed", { status: 405 });
228
},
···
254
"/auth/register/verify": registerVerify,
255
"/auth/login/options": loginOptions,
256
"/auth/login/verify": loginVerify,
257
+
// LDAP verification endpoint
258
+
"/api/ldap-verify": (req: Request) => {
259
+
if (req.method === "POST") return ldapVerify(req);
260
+
return new Response("Method not allowed", { status: 405 });
261
+
},
262
// Passkey management endpoints
263
"/api/passkeys": (req: Request) => {
264
if (req.method === "GET") return listPasskeys(req);
+2
src/migrations/007_add_username_to_authcodes.sql
+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
+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 });
+147
-9
src/routes/auth.ts
+147
-9
src/routes/auth.ts
···
1
import {
2
type AuthenticationResponseJSON,
3
-
generateAuthenticationOptions,
4
-
generateRegistrationOptions,
5
type PublicKeyCredentialCreationOptionsJSON,
6
type PublicKeyCredentialRequestOptionsJSON,
7
type RegistrationResponseJSON,
8
type VerifiedAuthenticationResponse,
9
type VerifiedRegistrationResponse,
10
verifyAuthenticationResponse,
11
verifyRegistrationResponse,
12
} from "@simplewebauthn/server";
13
import { db } from "../db";
14
15
const RP_NAME = "Indiko";
···
66
// Validate invite code
67
const invite = db
68
.query(
69
-
"SELECT id, max_uses, current_uses, expires_at, message FROM invites WHERE code = ?",
70
)
71
.get(inviteCode) as
72
| {
···
75
current_uses: number;
76
expires_at: number | null;
77
message: string | null;
78
}
79
| undefined;
80
···
94
);
95
}
96
97
// Store invite message to return with options
98
inviteMessage = invite.message;
99
}
···
160
);
161
}
162
163
-
// Verify challenge exists and is valid
164
const challenge = db
165
.query(
166
"SELECT challenge, expires_at FROM challenges WHERE challenge = ? AND username = ? AND type = 'registration'",
···
198
199
const invite = db
200
.query(
201
-
"SELECT id, max_uses, current_uses, expires_at FROM invites WHERE code = ?",
202
)
203
.get(inviteCode) as
204
| {
···
206
max_uses: number;
207
current_uses: number;
208
expires_at: number | null;
209
}
210
| undefined;
211
···
225
);
226
}
227
228
inviteId = invite.id;
229
230
// Get app role assignments for this invite
···
239
verification = await verifyRegistrationResponse({
240
response,
241
expectedChallenge: challenge.challenge,
242
-
expectedOrigin: process.env.ORIGIN!,
243
-
expectedRPID: process.env.RP_ID!,
244
});
245
} catch (error) {
246
console.error("WebAuthn verification failed:", error);
···
352
.get(username) as { id: number; status: string } | undefined;
353
354
if (!user) {
355
return Response.json({ error: "User not found" }, { status: 404 });
356
}
357
···
471
expectedOrigin: process.env.ORIGIN!,
472
expectedRPID: process.env.RP_ID!,
473
credential: {
474
-
id: credential.credential_id,
475
-
publicKey: credential.public_key,
476
counter: credential.counter,
477
},
478
});
···
525
return Response.json({ error: "Internal server error" }, { status: 500 });
526
}
527
}
···
1
import {
2
type AuthenticationResponseJSON,
3
type PublicKeyCredentialCreationOptionsJSON,
4
type PublicKeyCredentialRequestOptionsJSON,
5
type RegistrationResponseJSON,
6
type VerifiedAuthenticationResponse,
7
type VerifiedRegistrationResponse,
8
+
generateAuthenticationOptions,
9
+
generateRegistrationOptions,
10
verifyAuthenticationResponse,
11
verifyRegistrationResponse,
12
} from "@simplewebauthn/server";
13
+
import { authenticate } from "ldap-authentication";
14
import { db } from "../db";
15
16
const RP_NAME = "Indiko";
···
67
// Validate invite code
68
const invite = db
69
.query(
70
+
"SELECT id, max_uses, current_uses, expires_at, message, ldap_username FROM invites WHERE code = ?",
71
)
72
.get(inviteCode) as
73
| {
···
76
current_uses: number;
77
expires_at: number | null;
78
message: string | null;
79
+
ldap_username: string | null;
80
}
81
| undefined;
82
···
96
);
97
}
98
99
+
// If invite is locked to an LDAP username, enforce it
100
+
if (invite.ldap_username && invite.ldap_username !== username) {
101
+
return Response.json(
102
+
{ error: "Username must match LDAP account" },
103
+
{ status: 400 },
104
+
);
105
+
}
106
+
107
// Store invite message to return with options
108
inviteMessage = invite.message;
109
}
···
170
);
171
}
172
173
+
if (!expectedChallenge) {
174
+
return Response.json({ error: "Invalid challenge" }, { status: 400 });
175
+
}
176
+
177
const challenge = db
178
.query(
179
"SELECT challenge, expires_at FROM challenges WHERE challenge = ? AND username = ? AND type = 'registration'",
···
211
212
const invite = db
213
.query(
214
+
"SELECT id, max_uses, current_uses, expires_at, ldap_username FROM invites WHERE code = ?",
215
)
216
.get(inviteCode) as
217
| {
···
219
max_uses: number;
220
current_uses: number;
221
expires_at: number | null;
222
+
ldap_username: string | null;
223
}
224
| undefined;
225
···
239
);
240
}
241
242
+
// If invite is locked to an LDAP username, enforce it
243
+
if (invite.ldap_username && invite.ldap_username !== username) {
244
+
return Response.json(
245
+
{ error: "Username must match LDAP account" },
246
+
{ status: 400 },
247
+
);
248
+
}
249
+
250
inviteId = invite.id;
251
252
// Get app role assignments for this invite
···
261
verification = await verifyRegistrationResponse({
262
response,
263
expectedChallenge: challenge.challenge,
264
+
expectedOrigin: process.env.ORIGIN ? process.env.ORIGIN : "",
265
+
expectedRPID: process.env.RP_ID ? process.env.RP_ID : "",
266
});
267
} catch (error) {
268
console.error("WebAuthn verification failed:", error);
···
374
.get(username) as { id: number; status: string } | undefined;
375
376
if (!user) {
377
+
// Check if LDAP is enabled - if so, user may exist in LDAP and need to register
378
+
if (process.env.LDAP_ENABLED === "true") {
379
+
return Response.json({
380
+
ldapVerificationRequired: true,
381
+
username: username,
382
+
});
383
+
}
384
return Response.json({ error: "User not found" }, { status: 404 });
385
}
386
···
500
expectedOrigin: process.env.ORIGIN!,
501
expectedRPID: process.env.RP_ID!,
502
credential: {
503
+
id: credential.credential_id.toString(),
504
+
publicKey: new Uint8Array(credential.public_key),
505
counter: credential.counter,
506
},
507
});
···
554
return Response.json({ error: "Internal server error" }, { status: 500 });
555
}
556
}
557
+
558
+
export async function ldapVerify(req: Request): Promise<Response> {
559
+
try {
560
+
const body = await req.json();
561
+
const { username, password, returnUrl } = body as {
562
+
username: string;
563
+
password: string;
564
+
returnUrl?: string;
565
+
};
566
+
567
+
if (!username || !password) {
568
+
return Response.json(
569
+
{ error: "Username and password required" },
570
+
{ status: 400 },
571
+
);
572
+
}
573
+
574
+
// Verify user doesn't already exist locally (race condition check)
575
+
const existingUser = db
576
+
.query("SELECT id FROM users WHERE username = ?")
577
+
.get(username);
578
+
579
+
if (existingUser) {
580
+
return Response.json(
581
+
{ error: "Account already exists. Please use passkey login." },
582
+
{ status: 400 },
583
+
);
584
+
}
585
+
586
+
// Attempt LDAP bind WITH password verification
587
+
let ldapUser: unknown;
588
+
try {
589
+
ldapUser = await authenticate({
590
+
ldapOpts: {
591
+
url: process.env.LDAP_URL || "ldap://localhost:389",
592
+
},
593
+
adminDn: process.env.LDAP_ADMIN_DN,
594
+
adminPassword: process.env.LDAP_ADMIN_PASSWORD,
595
+
userSearchBase:
596
+
process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com",
597
+
usernameAttribute: process.env.LDAP_USERNAME_ATTRIBUTE || "uid",
598
+
username: username,
599
+
userPassword: password,
600
+
});
601
+
} catch (ldapError) {
602
+
console.error("LDAP verification failed:", ldapError);
603
+
return Response.json({ error: "Invalid credentials" }, { status: 401 });
604
+
}
605
+
606
+
if (!ldapUser) {
607
+
return Response.json({ error: "Invalid credentials" }, { status: 401 });
608
+
}
609
+
610
+
// LDAP auth succeeded - create single-use invite locked to this username
611
+
const inviteCode = crypto.randomUUID();
612
+
const expiresAt = Math.floor(Date.now() / 1000) + 600; // 10 minutes
613
+
614
+
// Get an admin user to be the creator (required by NOT NULL constraint)
615
+
const adminUser = db
616
+
.query("SELECT id FROM users WHERE is_admin = 1 LIMIT 1")
617
+
.get() as { id: number } | undefined;
618
+
619
+
if (!adminUser) {
620
+
return Response.json(
621
+
{ error: "System not configured for LDAP provisioning" },
622
+
{ status: 500 },
623
+
);
624
+
}
625
+
626
+
// Create the LDAP invite (max_uses=1, tied to username)
627
+
db.query(
628
+
"INSERT INTO invites (code, max_uses, current_uses, expires_at, created_by, message, ldap_username) VALUES (?, 1, 0, ?, ?, ?, ?)",
629
+
).run(inviteCode, expiresAt, adminUser.id, "LDAP-verified account", username);
630
+
631
+
const newInviteId = db
632
+
.query("SELECT id FROM invites WHERE code = ?")
633
+
.get(inviteCode) as { id: number };
634
+
635
+
// Copy roles from most recent admin-created invite if exists
636
+
const defaultInvite = db
637
+
.query(
638
+
"SELECT id FROM invites WHERE created_by IN (SELECT id FROM users WHERE is_admin = 1) ORDER BY created_at DESC LIMIT 1",
639
+
)
640
+
.get() as { id: number } | undefined;
641
+
642
+
if (defaultInvite) {
643
+
const inviteRoles = db
644
+
.query("SELECT app_id, role FROM invite_roles WHERE invite_id = ?")
645
+
.all(defaultInvite.id) as Array<{ app_id: number; role: string }>;
646
+
647
+
const insertRole = db.query(
648
+
"INSERT INTO invite_roles (invite_id, app_id, role) VALUES (?, ?, ?)",
649
+
);
650
+
for (const { app_id, role } of inviteRoles) {
651
+
insertRole.run(newInviteId.id, app_id, role);
652
+
}
653
+
}
654
+
655
+
return Response.json({
656
+
success: true,
657
+
inviteCode: inviteCode,
658
+
username: username,
659
+
returnUrl: returnUrl || null,
660
+
});
661
+
} catch (error) {
662
+
console.error("LDAP verify error:", error);
663
+
return Response.json({ error: "Internal server error" }, { status: 500 });
664
+
}
665
+
}
+3
-1
src/routes/clients.ts
+3
-1
src/routes/clients.ts
+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
{
+6
-2
src/routes/passkeys.ts
+6
-2
src/routes/passkeys.ts
···
1
import {
2
type RegistrationResponseJSON,
3
-
generateRegistrationOptions,
4
type VerifiedRegistrationResponse,
5
verifyRegistrationResponse,
6
} from "@simplewebauthn/server";
7
import { db } from "../db";
···
133
}
134
135
const body = await req.json();
136
-
const { response, challenge: expectedChallenge, name } = body as {
137
response: RegistrationResponseJSON;
138
challenge: string;
139
name?: string;
···
1
import {
2
type RegistrationResponseJSON,
3
type VerifiedRegistrationResponse,
4
+
generateRegistrationOptions,
5
verifyRegistrationResponse,
6
} from "@simplewebauthn/server";
7
import { db } from "../db";
···
133
}
134
135
const body = await req.json();
136
+
const {
137
+
response,
138
+
challenge: expectedChallenge,
139
+
name,
140
+
} = body as {
141
response: RegistrationResponseJSON;
142
challenge: string;
143
name?: string;
+1
src/styles.css
+1
src/styles.css