- Fix userId property access (user.id → user.userId) in disableUser
- Add null coalescing for optional userId path params
- Replace non-null assertions with fallback values
- Use node: prefix for crypto imports
- Fix unused error variable in catch block
- Add missing checkLdapUser import
- Add SessionUser.tier property and BunRequest type for userProfile
- Fix passkey excludeCredentials id encoding
- Add global.d.ts for Window interface augmentation
- Format improvements throughout
+1092
-438
Diff
round #0
+10
CRUSH.md
+10
CRUSH.md
···
9
9
## Architecture Patterns
10
10
11
11
### Route Organization
12
+
12
13
- Use separate route files in `src/routes/` directory
13
14
- Export handler functions that accept `Request` and return `Response`
14
15
- Import handlers in `src/index.ts` and wire them in the `routes` object
···
17
18
- IndieAuth/OAuth 2.0 endpoints in `src/routes/indieauth.ts`
18
19
19
20
### Project Structure
21
+
20
22
```
21
23
src/
22
24
├── db.ts # Database setup and exports
···
43
45
### Database Migrations
44
46
45
47
**Migration Versioning:**
48
+
46
49
- SQLite uses `PRAGMA user_version` to track migration state
47
50
- Version starts at 0, increments by 1 for each migration
48
51
- The `bun-sqlite-migrations` package handles version tracking
···
55
58
- Use descriptive name (e.g., `008_add_auth_tokens.sql`)
56
59
57
60
2. **Write SQL statements**: Add schema changes in the file
61
+
58
62
```sql
59
63
-- Add new column to users table
60
64
ALTER TABLE users ADD COLUMN new_field TEXT DEFAULT '';
···
72
76
- Each migration increments `user_version` by 1
73
77
74
78
**Version Tracking:**
79
+
75
80
- Check current version: `sqlite3 data/indiko.db "PRAGMA user_version;"`
76
81
- The migration system compares `user_version` against migration files
77
82
- No manual version updates needed - handled by `bun-sqlite-migrations`
78
83
79
84
**Best Practices:**
85
+
80
86
- Use `ALTER TABLE` for adding columns to existing tables
81
87
- Use `CREATE TABLE IF NOT EXISTS` for new tables
82
88
- Use `DEFAULT` values when adding non-null columns
···
84
90
- Test migrations locally before committing
85
91
86
92
### Client-Side Code
93
+
87
94
- Extract JavaScript from HTML into separate TypeScript modules in `src/client/`
88
95
- Import client modules into HTML with `<script type="module" src="../client/file.ts"></script>`
89
96
- Bun will bundle the imports automatically
···
91
98
- 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
92
99
93
100
### IndieAuth/OAuth 2.0 Implementation
101
+
94
102
- Full IndieAuth server supporting OAuth 2.0 with PKCE
95
103
- Authorization code flow with single-use, short-lived codes (60 seconds)
96
104
- Auto-registration of client apps on first authorization
···
103
111
- **`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
104
112
105
113
### Database Schema
114
+
106
115
- **users**: username, name, email, photo, url, status, role, tier, is_admin, provisioned_via_ldap, last_ldap_verified_at
107
116
- **tier**: User access level - 'admin' (full access), 'developer' (can create apps), 'user' (can only authenticate with apps)
108
117
- **is_admin**: Legacy flag, automatically synced with tier (1 if tier='admin', 0 otherwise)
···
117
126
- **invites**: admin-created invite codes, includes `ldap_username` for LDAP-provisioned accounts
118
127
119
128
### WebAuthn/Passkey Settings
129
+
120
130
- **Registration**: residentKey="required", userVerification="required"
121
131
- **Authentication**: omit allowCredentials to show all passkeys (discoverable credentials)
122
132
- **Credential lookup**: credential_id stored as Buffer, compare using base64url string
+60
SPEC.md
+60
SPEC.md
···
3
3
## Overview
4
4
5
5
**indiko** is a centralized authentication and user management system for personal projects. It provides:
6
+
6
7
- Passkey-based authentication (WebAuthn)
7
8
- IndieAuth server implementation
8
9
- User profile management
···
12
13
## Core Concepts
13
14
14
15
### Single Source of Truth
16
+
15
17
- Authentication via passkeys
16
18
- User profiles (name, email, picture, URL)
17
19
- Authorization with per-app scoping
18
20
- User management (admin + invite system)
19
21
20
22
### Trust Model
23
+
21
24
- First user becomes admin
22
25
- Admin can create invite links
23
26
- Apps auto-register on first use
···
30
33
## Data Structures
31
34
32
35
### Users
36
+
33
37
```
34
38
user:{username} -> {
35
39
credential: {
···
49
53
```
50
54
51
55
### Admin Marker
56
+
52
57
```
53
58
admin:user -> username // marks first/admin user
54
59
```
55
60
56
61
### Sessions
62
+
57
63
```
58
64
session:{token} -> {
59
65
username: string,
···
67
73
There are two types of OAuth clients in indiko:
68
74
69
75
#### Auto-registered Apps (IndieAuth)
76
+
70
77
```
71
78
app:{client_id} -> {
72
79
client_id: string, // e.g. "https://blog.kierank.dev" (any valid URL)
···
80
87
```
81
88
82
89
**Features:**
90
+
83
91
- Client ID is any valid URL per IndieAuth spec
84
92
- No client secret (public client)
85
93
- MUST use PKCE (code_verifier)
···
88
96
- Cannot use role-based access control
89
97
90
98
#### Pre-registered Apps (OAuth 2.0 with secrets)
99
+
91
100
```
92
101
app:{client_id} -> {
93
102
client_id: string, // e.g. "ikc_xxxxxxxxxxxxxxxxxxxxx" (generated ID)
···
105
114
```
106
115
107
116
**Features:**
117
+
108
118
- Client ID format: `ikc_` + 21 character nanoid
109
119
- Client secret format: `iks_` + 43 character nanoid (shown once on creation)
110
120
- MUST use PKCE (code_verifier) AND client_secret
···
113
123
- Created via admin interface
114
124
115
125
### User Permissions (Per-App)
126
+
116
127
```
117
128
permission:{user_id}:{client_id} -> {
118
129
scopes: string[], // e.g. ["profile", "email"]
···
123
134
```
124
135
125
136
### Authorization Codes (Short-lived)
137
+
126
138
```
127
139
authcode:{code} -> {
128
140
username: string,
···
138
150
```
139
151
140
152
### Invites
153
+
141
154
```
142
155
invite:{code} -> {
143
156
code: string,
···
150
163
```
151
164
152
165
### Challenges (WebAuthn)
166
+
153
167
```
154
168
challenge:{challenge} -> {
155
169
username: string,
···
170
184
### Authentication (WebAuthn/Passkey)
171
185
172
186
#### `GET /login`
187
+
173
188
- Login/registration page
174
189
- Shows passkey auth interface
175
190
- First user: admin registration flow
176
191
- With `?invite=CODE`: invite-based registration
177
192
178
193
#### `GET /auth/can-register`
194
+
179
195
- Check if open registration allowed
180
196
- Returns `{ canRegister: boolean }`
181
197
182
198
#### `POST /auth/register/options`
199
+
183
200
- Generate WebAuthn registration options
184
201
- Body: `{ username: string, inviteCode?: string }`
185
202
- Validates invite code if not first user
186
203
- Returns registration options
187
204
188
205
#### `POST /auth/register/verify`
206
+
189
207
- Verify WebAuthn registration response
190
208
- Body: `{ username: string, response: RegistrationResponseJSON, inviteCode?: string }`
191
209
- Creates user, stores credential
···
193
211
- Returns `{ token: string, username: string }`
194
212
195
213
#### `POST /auth/login/options`
214
+
196
215
- Generate WebAuthn authentication options
197
216
- Body: `{ username: string }`
198
217
- Returns authentication options
199
218
200
219
#### `POST /auth/login/verify`
220
+
201
221
- Verify WebAuthn authentication response
202
222
- Body: `{ username: string, response: AuthenticationResponseJSON }`
203
223
- Creates session
204
224
- Returns `{ token: string, username: string }`
205
225
206
226
#### `POST /auth/logout`
227
+
207
228
- Clear session
208
229
- Requires: `Authorization: Bearer {token}`
209
230
- Returns `{ success: true }`
···
211
232
### IndieAuth Endpoints
212
233
213
234
#### `GET /auth/authorize`
235
+
214
236
Authorization request from client app
215
237
216
238
**Query Parameters:**
239
+
217
240
- `response_type=code` (required)
218
241
- `client_id` (required) - App's URL
219
242
- `redirect_uri` (required) - Callback URL
···
224
247
- `me` (optional) - User's URL (hint)
225
248
226
249
**Flow:**
250
+
227
251
1. Validate parameters
228
252
2. Auto-register app if not exists
229
253
3. If no session → redirect to `/login`
···
233
257
- If no → show consent screen
234
258
235
259
**Response:**
260
+
236
261
- HTML consent screen
237
262
- Shows: app name, requested scopes
238
263
- Buttons: "Allow" / "Deny"
239
264
240
265
#### `POST /auth/authorize`
266
+
241
267
Consent form submission (CSRF protected)
242
268
243
269
**Body:**
270
+
244
271
- `client_id` (required)
245
272
- `redirect_uri` (required)
246
273
- `state` (required)
···
249
276
- `action` (required) - "allow" | "deny"
250
277
251
278
**Flow:**
279
+
252
280
1. Validate CSRF token
253
281
2. Validate session
254
282
3. If denied → redirect with error
···
259
287
- Redirect to redirect_uri with code & state
260
288
261
289
**Success Response:**
290
+
262
291
```
263
292
HTTP/1.1 302 Found
264
293
Location: {redirect_uri}?code={authcode}&state={state}
265
294
```
266
295
267
296
**Error Response:**
297
+
268
298
```
269
299
HTTP/1.1 302 Found
270
300
Location: {redirect_uri}?error=access_denied&state={state}
271
301
```
272
302
273
303
#### `POST /auth/token`
304
+
274
305
Exchange authorization code for user identity (NOT CSRF protected)
275
306
276
307
**Headers:**
308
+
277
309
- `Content-Type: application/json`
278
310
279
311
**Body:**
312
+
280
313
```json
281
314
{
282
315
"grant_type": "authorization_code",
···
288
321
```
289
322
290
323
**Flow:**
324
+
291
325
1. Validate authorization code exists
292
326
2. Verify code not expired
293
327
3. Verify code not already used
···
298
332
8. Return user identity + profile
299
333
300
334
**Success Response:**
335
+
301
336
```json
302
337
{
303
338
"me": "https://indiko.yourdomain.com/u/kieran",
···
311
346
```
312
347
313
348
**Error Response:**
349
+
314
350
```json
315
351
{
316
352
"error": "invalid_grant",
···
319
355
```
320
356
321
357
#### `GET /auth/userinfo` (Optional)
358
+
322
359
Get current user profile with bearer token
323
360
324
361
**Headers:**
362
+
325
363
- `Authorization: Bearer {access_token}`
326
364
327
365
**Response:**
366
+
328
367
```json
329
368
{
330
369
"sub": "https://indiko.yourdomain.com/u/kieran",
···
338
377
### User Profile & Settings
339
378
340
379
#### `GET /settings`
380
+
341
381
User settings page (requires session)
342
382
343
383
**Shows:**
384
+
344
385
- Profile form (name, email, photo, URL)
345
386
- Connected apps list
346
387
- Revoke access buttons
347
388
- (Admin only) Invite generation
348
389
349
390
#### `POST /settings/profile`
391
+
350
392
Update user profile
351
393
352
394
**Body:**
395
+
353
396
```json
354
397
{
355
398
"name": "Kieran Klukas",
···
360
403
```
361
404
362
405
**Response:**
406
+
363
407
```json
364
408
{
365
409
"success": true,
···
368
412
```
369
413
370
414
#### `POST /settings/apps/:client_id/revoke`
415
+
371
416
Revoke app access
372
417
373
418
**Response:**
419
+
374
420
```json
375
421
{
376
422
"success": true
···
378
424
```
379
425
380
426
#### `GET /u/:username`
427
+
381
428
Public user profile page (h-card)
382
429
383
430
**Response:**
384
431
HTML page with microformats h-card:
432
+
385
433
```html
386
434
<div class="h-card">
387
435
<img class="u-photo" src="...">
···
393
441
### Admin Endpoints
394
442
395
443
#### `POST /api/invites/create`
444
+
396
445
Create invite link (admin only)
397
446
398
447
**Headers:**
448
+
399
449
- `Authorization: Bearer {token}`
400
450
401
451
**Response:**
452
+
402
453
```json
403
454
{
404
455
"inviteCode": "abc123xyz"
···
410
461
### Dashboard
411
462
412
463
#### `GET /`
464
+
413
465
Main dashboard (requires session)
414
466
415
467
**Shows:**
468
+
416
469
- User info
417
470
- Test API button
418
471
- (Admin only) Admin controls section
···
420
473
- Invite display
421
474
422
475
#### `GET /api/hello`
476
+
423
477
Test endpoint (requires session)
424
478
425
479
**Headers:**
480
+
426
481
- `Authorization: Bearer {token}`
427
482
428
483
**Response:**
484
+
429
485
```json
430
486
{
431
487
"message": "Hello kieran! You're authenticated with passkeys.",
···
437
493
## Session Behavior
438
494
439
495
### Single Sign-On
496
+
440
497
- Once logged into indiko (valid session), subsequent app authorization requests:
441
498
- Skip passkey authentication
442
499
- Show consent screen directly
···
445
502
- Passkey required only when session expires
446
503
447
504
### Security
505
+
448
506
- PKCE required for all authorization flows
449
507
- Authorization codes:
450
508
- Single-use only
···
455
513
## Client Integration Example
456
514
457
515
### 1. Initiate Authorization
516
+
458
517
```javascript
459
518
const params = new URLSearchParams({
460
519
response_type: 'code',
···
470
529
```
471
530
472
531
### 2. Handle Callback
532
+
473
533
```javascript
474
534
// At https://blog.kierank.dev/auth/callback?code=...&state=...
475
535
const code = new URLSearchParams(window.location.search).get('code');
+34
biome.json
+34
biome.json
···
1
+
{
2
+
"$schema": "https://biomejs.dev/schemas/2.3.9/schema.json",
3
+
"vcs": {
4
+
"enabled": true,
5
+
"clientKind": "git",
6
+
"useIgnoreFile": true
7
+
},
8
+
"files": {
9
+
"ignoreUnknown": false
10
+
},
11
+
"formatter": {
12
+
"enabled": true,
13
+
"indentStyle": "tab"
14
+
},
15
+
"linter": {
16
+
"enabled": true,
17
+
"rules": {
18
+
"recommended": true
19
+
}
20
+
},
21
+
"javascript": {
22
+
"formatter": {
23
+
"quoteStyle": "double"
24
+
}
25
+
},
26
+
"assist": {
27
+
"enabled": true,
28
+
"actions": {
29
+
"source": {
30
+
"organizeImports": "on"
31
+
}
32
+
}
33
+
}
34
+
}
+2
-1
package.json
+2
-1
package.json
···
6
6
"scripts": {
7
7
"dev": "bun run --hot src/index.ts",
8
8
"start": "bun run src/index.ts",
9
-
"format": "bun run --bun biome check --write ."
9
+
"forqmat": "bun run --bun biome check --write .",
10
+
"lint": "bun run --bun biome check"
10
11
},
11
12
"devDependencies": {
12
13
"@simplewebauthn/types": "^12.0.0",
+1
-1
scripts/audit-ldap-orphans.ts
+1
-1
scripts/audit-ldap-orphans.ts
+14
-26
src/client/admin-clients.ts
+14
-26
src/client/admin-clients.ts
···
104
104
lastUsed: number;
105
105
}
106
106
107
-
interface AppPermission {
108
-
username: string;
109
-
name: string;
110
-
scopes: string[];
111
-
grantedAt: number;
112
-
lastUsed: number;
113
-
}
114
-
115
107
async function loadClients() {
116
108
try {
117
109
const response = await fetch("/api/admin/clients", {
···
191
183
.join("");
192
184
}
193
185
194
-
(window as any).toggleClient = async (clientId: string) => {
186
+
window.toggleClient = async (clientId: string) => {
195
187
const card = document.querySelector(
196
188
`[data-client-id="${clientId}"]`,
197
189
) as HTMLElement;
···
319
311
}
320
312
};
321
313
322
-
(window as any).setUserRole = async (
314
+
window.setUserRole = async (
323
315
clientId: string,
324
316
username: string,
325
317
role: string,
···
348
340
}
349
341
};
350
342
351
-
(window as any).editClient = async (clientId: string) => {
343
+
window.editClient = async (clientId: string) => {
352
344
try {
353
345
const response = await fetch(
354
346
`/api/admin/clients/${encodeURIComponent(clientId)}`,
···
398
390
}
399
391
};
400
392
401
-
(window as any).deleteClient = async (clientId: string, event?: Event) => {
393
+
window.deleteClient = async (clientId: string, event?: Event) => {
402
394
const btn = event?.target as HTMLButtonElement | undefined;
403
395
404
396
// Double-click confirmation pattern
405
397
if (btn?.dataset.confirmState === "pending") {
406
398
// Second click - execute delete
407
-
delete btn.dataset.confirmState;
399
+
btn.dataset.confirmState = undefined;
408
400
btn.disabled = true;
409
401
btn.textContent = "deleting...";
410
402
···
440
432
// Reset after 3 seconds if not confirmed
441
433
setTimeout(() => {
442
434
if (btn.dataset.confirmState === "pending") {
443
-
delete btn.dataset.confirmState;
435
+
btn.dataset.confirmState = undefined;
444
436
btn.textContent = originalText;
445
437
}
446
438
}, 3000);
···
479
471
redirectUrisList.appendChild(newItem);
480
472
});
481
473
482
-
(window as any).removeRedirectUri = (btn: HTMLButtonElement) => {
474
+
window.removeRedirectUri = (btn: HTMLButtonElement) => {
483
475
const items = redirectUrisList.querySelectorAll(".redirect-uri-item");
484
476
if (items.length > 1) {
485
477
btn.parentElement?.remove();
···
569
561
// If creating a new client, show the credentials in modal
570
562
if (!isEdit) {
571
563
const result = await response.json();
572
-
if (
573
-
result.client &&
574
-
result.client.clientId &&
575
-
result.client.clientSecret
576
-
) {
564
+
if (result.client?.clientId && result.client.clientSecret) {
577
565
const secretModal = document.getElementById(
578
566
"secretModal",
579
567
) as HTMLElement;
···
604
592
}
605
593
});
606
594
607
-
(window as any).regenerateSecret = async (clientId: string, event?: Event) => {
595
+
window.regenerateSecret = async (clientId: string, event?: Event) => {
608
596
const btn = event?.target as HTMLButtonElement | undefined;
609
597
610
598
// Double-click confirmation pattern (same as delete)
611
599
if (btn?.dataset.confirmState === "pending") {
612
600
// Second click - execute regenerate
613
-
delete btn.dataset.confirmState;
601
+
btn.dataset.confirmState = undefined;
614
602
btn.disabled = true;
615
603
btn.textContent = "regenerating...";
616
604
···
667
655
// Reset after 3 seconds if not confirmed
668
656
setTimeout(() => {
669
657
if (btn.dataset.confirmState === "pending") {
670
-
delete btn.dataset.confirmState;
658
+
btn.dataset.confirmState = undefined;
671
659
btn.textContent = originalText;
672
660
}
673
661
}, 3000);
···
675
663
}
676
664
};
677
665
678
-
(window as any).revokeUserPermission = async (
666
+
window.revokeUserPermission = async (
679
667
clientId: string,
680
668
username: string,
681
669
event?: Event,
···
685
673
// Double-click confirmation pattern
686
674
if (btn?.dataset.confirmState === "pending") {
687
675
// Second click - execute revoke
688
-
delete btn.dataset.confirmState;
676
+
btn.dataset.confirmState = undefined;
689
677
btn.disabled = true;
690
678
btn.textContent = "revoking...";
691
679
···
736
724
// Reset after 3 seconds if not confirmed
737
725
setTimeout(() => {
738
726
if (btn.dataset.confirmState === "pending") {
739
-
delete btn.dataset.confirmState;
727
+
btn.dataset.confirmState = undefined;
740
728
btn.textContent = originalText;
741
729
}
742
730
}, 3000);
+13
-13
src/client/admin-invites.ts
+13
-13
src/client/admin-invites.ts
···
60
60
} catch (error) {
61
61
console.error("Auth check failed:", error);
62
62
footer.textContent = "error loading user info";
63
-
usersList.innerHTML = '<div class="error">Failed to load users</div>';
63
+
invitesList.innerHTML = '<div class="error">Failed to load users</div>';
64
64
}
65
65
}
66
66
···
193
193
) as HTMLSelectElement;
194
194
195
195
let role = "";
196
-
if (roleSelect && roleSelect.value) {
196
+
if (roleSelect?.value) {
197
197
role = roleSelect.value;
198
198
}
199
199
···
266
266
}
267
267
268
268
// Expose functions to global scope for HTML onclick handlers
269
-
(window as any).submitCreateInvite = submitCreateInvite;
270
-
(window as any).closeCreateInviteModal = closeCreateInviteModal;
269
+
window.submitCreateInvite = submitCreateInvite;
270
+
window.closeCreateInviteModal = closeCreateInviteModal;
271
271
272
272
async function loadInvites() {
273
273
try {
···
408
408
document.addEventListener("keydown", (e) => {
409
409
if (e.key === "Escape") {
410
410
closeCreateInviteModal();
411
-
closeEditInviteModal();
411
+
window.closeEditInviteModal();
412
412
}
413
413
});
414
414
···
421
421
422
422
document.getElementById("editInviteModal")?.addEventListener("click", (e) => {
423
423
if (e.target === e.currentTarget) {
424
-
closeEditInviteModal();
424
+
window.closeEditInviteModal();
425
425
}
426
426
});
427
427
428
428
let currentEditInviteId: number | null = null;
429
429
430
430
// Make editInvite globally available for onclick handler
431
-
(window as any).editInvite = async (inviteId: number) => {
431
+
window.editInvite = async (inviteId: number) => {
432
432
try {
433
433
const response = await fetch("/api/invites", {
434
434
headers: {
···
488
488
}
489
489
};
490
490
491
-
(window as any).submitEditInvite = async () => {
491
+
window.submitEditInvite = async () => {
492
492
if (currentEditInviteId === null) return;
493
493
494
494
const maxUsesInput = document.getElementById(
···
532
532
}
533
533
534
534
await loadInvites();
535
-
closeEditInviteModal();
535
+
window.closeEditInviteModal();
536
536
} catch (error) {
537
537
console.error("Failed to update invite:", error);
538
538
alert("Failed to update invite");
···
542
542
}
543
543
};
544
544
545
-
(window as any).closeEditInviteModal = () => {
545
+
window.closeEditInviteModal = () => {
546
546
const modal = document.getElementById("editInviteModal");
547
547
if (modal) {
548
548
modal.style.display = "none";
···
557
557
}
558
558
};
559
559
560
-
(window as any).deleteInvite = async (inviteId: number, event?: Event) => {
560
+
window.deleteInvite = async (inviteId: number, event?: Event) => {
561
561
const btn = event?.target as HTMLButtonElement | undefined;
562
562
563
563
// Double-click confirmation pattern
564
564
if (btn?.dataset.confirmState === "pending") {
565
565
// Second click - execute delete
566
-
delete btn.dataset.confirmState;
566
+
btn.dataset.confirmState = undefined;
567
567
btn.textContent = "deleting...";
568
568
btn.disabled = true;
569
569
···
596
596
// Reset after 3 seconds if not confirmed
597
597
setTimeout(() => {
598
598
if (btn.dataset.confirmState === "pending") {
599
-
delete btn.dataset.confirmState;
599
+
btn.dataset.confirmState = undefined;
600
600
btn.textContent = originalText;
601
601
}
602
602
}, 3000);
+3
-3
src/client/apps.ts
+3
-3
src/client/apps.ts
···
73
73
.join("");
74
74
}
75
75
76
-
(window as any).revokeApp = async (clientId: string, event?: Event) => {
76
+
window.revokeApp = async (clientId: string, event?: Event) => {
77
77
const btn = event?.target as HTMLButtonElement | undefined;
78
78
79
79
// Double-click confirmation pattern
80
80
if (btn?.dataset.confirmState === "pending") {
81
81
// Second click - execute revoke
82
-
delete btn.dataset.confirmState;
82
+
btn.dataset.confirmState = undefined;
83
83
btn.disabled = true;
84
84
btn.textContent = "revoking...";
85
85
···
127
127
// Reset after 3 seconds if not confirmed
128
128
setTimeout(() => {
129
129
if (btn.dataset.confirmState === "pending") {
130
-
delete btn.dataset.confirmState;
130
+
btn.dataset.confirmState = undefined;
131
131
btn.textContent = originalText;
132
132
}
133
133
}, 3000);
+8
-6
src/client/docs.ts
+8
-6
src/client/docs.ts
···
36
36
/<(\/?)([\w-]+)([\s\S]*?)>/g,
37
37
(_match, slash, tag, attrs) => {
38
38
let result = `<${slash}<span class="html-tag">${tag}</span>`;
39
+
let replaced_attrs = attrs ?? "";
39
40
40
-
if (attrs) {
41
-
attrs = attrs.replace(
41
+
if (replaced_attrs) {
42
+
replaced_attrs = replaced_attrs.replace(
42
43
/([\w-]+)="([^"]*)"/g,
43
44
'<span class="html-attr">$1</span>="<span class="html-string">$2</span>"',
44
45
);
45
-
attrs = attrs.replace(
46
-
/(?<=\s)([\w-]+)(?=\s|$)/g,
47
-
'<span class="html-attr">$1</span>',
46
+
47
+
replaced_attrs = replaced_attrs.replace(
48
+
/(\s)([\w-]+)(?=\s|$)/g,
49
+
'$1<span class="html-attr">$2</span>',
48
50
);
49
51
}
50
52
51
-
result += attrs + ">";
53
+
result += `${replaced_attrs}>`;
52
54
return result;
53
55
},
54
56
);
+31
-16
src/client/index.ts
+31
-16
src/client/index.ts
···
1
-
import {
2
-
startRegistration,
3
-
} from "@simplewebauthn/browser";
1
+
import { startRegistration } from "@simplewebauthn/browser";
4
2
5
3
const token = localStorage.getItem("indiko_session");
6
4
const footer = document.getElementById("footer") as HTMLElement;
···
8
6
const subtitle = document.getElementById("subtitle") as HTMLElement;
9
7
const recentApps = document.getElementById("recentApps") as HTMLElement;
10
8
const passkeysList = document.getElementById("passkeysList") as HTMLElement;
11
-
const addPasskeyBtn = document.getElementById("addPasskeyBtn") as HTMLButtonElement;
9
+
const addPasskeyBtn = document.getElementById(
10
+
"addPasskeyBtn",
11
+
) as HTMLButtonElement;
12
12
const toast = document.getElementById("toast") as HTMLElement;
13
13
14
14
// Profile form elements
···
320
320
const passkeys = data.passkeys as Passkey[];
321
321
322
322
if (passkeys.length === 0) {
323
-
passkeysList.innerHTML = '<div class="empty">No passkeys registered</div>';
323
+
passkeysList.innerHTML =
324
+
'<div class="empty">No passkeys registered</div>';
324
325
return;
325
326
}
326
327
327
328
passkeysList.innerHTML = passkeys
328
329
.map((passkey) => {
329
-
const createdDate = new Date(passkey.created_at * 1000).toLocaleDateString();
330
+
const createdDate = new Date(
331
+
passkey.created_at * 1000,
332
+
).toLocaleDateString();
330
333
331
334
return `
332
335
<div class="passkey-item" data-passkey-id="${passkey.id}">
···
336
339
</div>
337
340
<div class="passkey-actions">
338
341
<button type="button" class="rename-passkey-btn" data-passkey-id="${passkey.id}">rename</button>
339
-
${passkeys.length > 1 ? `<button type="button" class="delete-passkey-btn" data-passkey-id="${passkey.id}">delete</button>` : ''}
342
+
${passkeys.length > 1 ? `<button type="button" class="delete-passkey-btn" data-passkey-id="${passkey.id}">delete</button>` : ""}
340
343
</div>
341
344
</div>
342
345
`;
···
365
368
}
366
369
367
370
function showRenameForm(passkeyId: number) {
368
-
const passkeyItem = document.querySelector(`[data-passkey-id="${passkeyId}"]`);
371
+
const passkeyItem = document.querySelector(
372
+
`[data-passkey-id="${passkeyId}"]`,
373
+
);
369
374
if (!passkeyItem) return;
370
375
371
376
const infoDiv = passkeyItem.querySelector(".passkey-info");
···
389
394
input.select();
390
395
391
396
// Save button
392
-
infoDiv.querySelector(".save-rename-btn")?.addEventListener("click", async () => {
393
-
await renamePasskeyHandler(passkeyId, input.value);
394
-
});
397
+
infoDiv
398
+
.querySelector(".save-rename-btn")
399
+
?.addEventListener("click", async () => {
400
+
await renamePasskeyHandler(passkeyId, input.value);
401
+
});
395
402
396
403
// Cancel button
397
-
infoDiv.querySelector(".cancel-rename-btn")?.addEventListener("click", () => {
398
-
loadPasskeys();
399
-
});
404
+
infoDiv
405
+
.querySelector(".cancel-rename-btn")
406
+
?.addEventListener("click", () => {
407
+
loadPasskeys();
408
+
});
400
409
401
410
// Enter to save
402
411
input.addEventListener("keypress", async (e) => {
···
443
452
}
444
453
445
454
async function deletePasskeyHandler(passkeyId: number) {
446
-
if (!confirm("Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.")) {
455
+
if (
456
+
!confirm(
457
+
"Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.",
458
+
)
459
+
) {
447
460
return;
448
461
}
449
462
···
496
509
addPasskeyBtn.textContent = "verifying...";
497
510
498
511
// Ask for a name
499
-
const name = prompt("Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):");
512
+
const name = prompt(
513
+
"Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):",
514
+
);
500
515
501
516
// Verify registration
502
517
const verifyRes = await fetch("/api/passkeys/add/verify", {
+1
-1
src/client/oauth-test.ts
+1
-1
src/client/oauth-test.ts
···
205
205
resultDiv.className = `result show ${type}`;
206
206
}
207
207
208
-
function syntaxHighlightJSON(obj: any): string {
208
+
function syntaxHighlightJSON(obj: unknown): string {
209
209
const json = JSON.stringify(obj, null, 2);
210
210
return json.replace(
211
211
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,
+65
-43
src/html/admin-clients.html
+65
-43
src/html/admin-clients.html
···
7
7
<title>oauth clients • admin • indiko</title>
8
8
<meta name="description" content="Manage OAuth clients and application registrations" />
9
9
<link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" />
10
-
10
+
11
11
<!-- Open Graph / Facebook -->
12
12
<meta property="og:type" content="website" />
13
13
<meta property="og:title" content="OAuth Clients • Indiko Admin" />
14
14
<meta property="og:description" content="Manage OAuth clients and application registrations" />
15
-
15
+
16
16
<!-- Twitter -->
17
17
<meta name="twitter:card" content="summary" />
18
18
<meta name="twitter:title" content="OAuth Clients • Indiko Admin" />
···
58
58
align-items: flex-start;
59
59
}
60
60
61
+
footer {
62
+
width: 100%;
63
+
max-width: 56.25rem;
64
+
padding: 1rem;
65
+
text-align: center;
66
+
color: var(--old-rose);
67
+
font-size: 0.875rem;
68
+
font-weight: 300;
69
+
letter-spacing: 0.05rem;
70
+
}
71
+
72
+
footer a {
73
+
color: var(--berry-crush);
74
+
text-decoration: none;
75
+
transition: color 0.2s;
76
+
}
77
+
78
+
footer a:hover {
79
+
color: var(--rosewood);
80
+
text-decoration: underline;
81
+
}
82
+
61
83
.header-nav {
62
84
display: flex;
63
85
gap: 1rem;
···
111
133
letter-spacing: -0.05rem;
112
134
}
113
135
114
-
footer {
115
-
width: 100%;
116
-
max-width: 56.25rem;
117
-
padding: 1rem;
118
-
text-align: center;
119
-
color: var(--old-rose);
120
-
font-size: 0.875rem;
121
-
font-weight: 300;
122
-
letter-spacing: 0.05rem;
123
-
}
124
-
125
-
footer a {
126
-
color: var(--berry-crush);
127
-
text-decoration: none;
128
-
transition: color 0.2s;
129
-
}
130
-
131
-
footer a:hover {
132
-
color: var(--rosewood);
133
-
text-decoration: underline;
134
-
}
135
-
136
136
.back-link {
137
137
margin-top: 0.5rem;
138
138
font-size: 0.875rem;
···
385
385
margin-top: 1rem;
386
386
}
387
387
388
-
.btn-edit, .btn-delete, .revoke-btn {
388
+
.btn-edit,
389
+
.btn-delete,
390
+
.revoke-btn {
389
391
padding: 0.5rem 1rem;
390
392
font-family: inherit;
391
393
font-size: 0.875rem;
···
404
406
background: rgba(188, 141, 160, 0.3);
405
407
}
406
408
407
-
.btn-delete, .revoke-btn {
409
+
.btn-delete,
410
+
.revoke-btn {
408
411
background: rgba(160, 70, 104, 0.2);
409
412
color: var(--lavender);
410
413
border: 2px solid var(--rosewood);
411
414
}
412
415
413
-
.btn-delete:hover, .revoke-btn:hover {
416
+
.btn-delete:hover,
417
+
.revoke-btn:hover {
414
418
background: rgba(160, 70, 104, 0.3);
415
419
}
416
420
417
-
.loading, .error, .empty {
421
+
.loading,
422
+
.error,
423
+
.empty {
418
424
text-align: center;
419
425
padding: 2rem;
420
426
color: var(--old-rose);
···
645
651
</div>
646
652
<div class="form-group">
647
653
<label class="form-label" for="description">Description</label>
648
-
<textarea class="form-input form-textarea" id="description" placeholder="A brief description of your application"></textarea>
654
+
<textarea class="form-input form-textarea" id="description"
655
+
placeholder="A brief description of your application"></textarea>
649
656
</div>
650
657
<div class="form-group">
651
658
<label class="form-label">Redirect URIs</label>
652
659
<div id="redirectUrisList" class="redirect-uris-list">
653
660
<div class="redirect-uri-item">
654
-
<input type="url" class="form-input redirect-uri-input" placeholder="https://example.com/auth/callback" required />
661
+
<input type="url" class="form-input redirect-uri-input"
662
+
placeholder="https://example.com/auth/callback" required />
655
663
<button type="button" class="btn-remove" onclick="removeRedirectUri(this)">remove</button>
656
664
</div>
657
665
</div>
···
659
667
</div>
660
668
<div class="form-group">
661
669
<label class="form-label">Available Roles (one per line)</label>
662
-
<textarea class="form-input form-textarea" id="availableRoles" placeholder="admin editor viewer" style="min-height: 6rem;"></textarea>
663
-
<p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Define which roles can be assigned to users for this app. Leave empty to allow free-text roles.</p>
670
+
<textarea class="form-input form-textarea" id="availableRoles"
671
+
placeholder="admin editor viewer" style="min-height: 6rem;"></textarea>
672
+
<p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Define which roles can be
673
+
assigned to users for this app. Leave empty to allow free-text roles.</p>
664
674
</div>
665
675
<div class="form-group">
666
676
<label class="form-label" for="defaultRole">Default Role</label>
667
677
<input type="text" class="form-input" id="defaultRole" placeholder="Leave empty for no default" />
668
-
<p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Automatically assigned when users first authorize this app.</p>
678
+
<p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Automatically assigned
679
+
when users first authorize this app.</p>
669
680
</div>
670
681
<div class="form-actions">
671
-
<button type="button" class="btn" style="background: rgba(188, 141, 160, 0.2);" id="cancelBtn">cancel</button>
682
+
<button type="button" class="btn" style="background: rgba(188, 141, 160, 0.2);"
683
+
id="cancelBtn">cancel</button>
672
684
<button type="submit" class="btn">save</button>
673
685
</div>
674
686
</form>
···
686
698
⚠️ Save these credentials now. You won't be able to see the secret again!
687
699
</p>
688
700
<div style="margin-bottom: 1rem;">
689
-
<label style="display: block; color: var(--old-rose); font-size: 0.875rem; margin-bottom: 0.5rem;">Client ID</label>
690
-
<div style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;">
691
-
<code id="generatedClientId" style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code>
701
+
<label
702
+
style="display: block; color: var(--old-rose); font-size: 0.875rem; margin-bottom: 0.5rem;">Client
703
+
ID</label>
704
+
<div
705
+
style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;">
706
+
<code id="generatedClientId"
707
+
style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code>
692
708
</div>
693
-
<button class="btn" id="copyClientIdBtn" style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy client id</button>
709
+
<button class="btn" id="copyClientIdBtn"
710
+
style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy client id</button>
694
711
</div>
695
712
<div style="margin-bottom: 1rem;">
696
-
<label style="display: block; color: var(--old-rose); font-size: 0.875rem; margin-bottom: 0.5rem;">Client Secret</label>
697
-
<div style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;">
698
-
<code id="generatedSecret" style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code>
713
+
<label
714
+
style="display: block; color: var(--old-rose); font-size: 0.875rem; margin-bottom: 0.5rem;">Client
715
+
Secret</label>
716
+
<div
717
+
style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;">
718
+
<code id="generatedSecret"
719
+
style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code>
699
720
</div>
700
-
<button class="btn" id="copySecretBtn" style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy secret</button>
721
+
<button class="btn" id="copySecretBtn"
722
+
style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy secret</button>
701
723
</div>
702
724
</div>
703
725
</div>
···
706
728
<script type="module" src="../client/admin-clients.ts"></script>
707
729
</body>
708
730
709
-
</html>
731
+
</html>
+1
-1
src/html/login.html
+1
-1
src/html/login.html
+16
-7
src/index.ts
+16
-7
src/index.ts
···
199
199
if (req.method === "POST") {
200
200
const url = new URL(req.url);
201
201
const userId = url.pathname.split("/")[4];
202
-
return disableUser(req, userId);
202
+
return disableUser(req, userId || "");
203
203
}
204
204
return new Response("Method not allowed", { status: 405 });
205
205
},
···
207
207
if (req.method === "POST") {
208
208
const url = new URL(req.url);
209
209
const userId = url.pathname.split("/")[4];
210
-
return enableUser(req, userId);
210
+
return enableUser(req, userId || "");
211
211
}
212
212
return new Response("Method not allowed", { status: 405 });
213
213
},
···
215
215
if (req.method === "PUT") {
216
216
const url = new URL(req.url);
217
217
const userId = url.pathname.split("/")[4];
218
-
return updateUserTier(req, userId);
218
+
return updateUserTier(req, userId || "");
219
219
}
220
220
return new Response("Method not allowed", { status: 405 });
221
221
},
···
223
223
if (req.method === "DELETE") {
224
224
const url = new URL(req.url);
225
225
const userId = url.pathname.split("/")[4];
226
-
return deleteUser(req, userId);
226
+
return deleteUser(req, userId || "");
227
227
}
228
228
return new Response("Method not allowed", { status: 405 });
229
229
},
···
365
365
366
366
if (expiredOrphans.length > 0) {
367
367
if (action === "suspend") {
368
-
await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "suspend");
368
+
await updateOrphanedAccounts(
369
+
{ ...result, orphanedUsers: expiredOrphans },
370
+
"suspend",
371
+
);
369
372
} else if (action === "deactivate") {
370
-
await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "deactivate");
373
+
await updateOrphanedAccounts(
374
+
{ ...result, orphanedUsers: expiredOrphans },
375
+
"deactivate",
376
+
);
371
377
} else if (action === "remove") {
372
-
await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "remove");
378
+
await updateOrphanedAccounts(
379
+
{ ...result, orphanedUsers: expiredOrphans },
380
+
"remove",
381
+
);
373
382
}
374
383
console.log(
375
384
`[LDAP Cleanup] ${action === "remove" ? "Removed" : action === "suspend" ? "Suspended" : "Deactivated"} ${expiredOrphans.length} LDAP orphan accounts (grace period: ${gracePeriod}s)`,
+1
-1
src/ldap-cleanup.ts
+1
-1
src/ldap-cleanup.ts
+16
-6
src/routes/api.ts
+16
-6
src/routes/api.ts
···
1
1
import { db } from "../db";
2
-
import { verifyDomain, validateProfileURL } from "./indieauth";
2
+
import { validateProfileURL, verifyDomain } from "./indieauth";
3
3
4
4
function getSessionUser(
5
5
req: Request,
6
-
): { username: string; userId: number; is_admin: boolean; tier: string } | Response {
6
+
):
7
+
| { username: string; userId: number; is_admin: boolean; tier: string }
8
+
| Response {
7
9
const authHeader = req.headers.get("Authorization");
8
10
9
11
if (!authHeader || !authHeader.startsWith("Bearer ")) {
···
193
195
const origin = process.env.ORIGIN || "http://localhost:3000";
194
196
const indikoProfileUrl = `${origin}/u/${user.username}`;
195
197
196
-
const verification = await verifyDomain(validation.canonicalUrl!, indikoProfileUrl);
198
+
const verification = await verifyDomain(
199
+
validation.canonicalUrl || "",
200
+
indikoProfileUrl,
201
+
);
197
202
if (!verification.success) {
198
203
return Response.json(
199
204
{ error: verification.error || "Failed to verify domain" },
···
456
461
}
457
462
458
463
// Prevent disabling self
459
-
if (targetUserId === user.id) {
464
+
if (targetUserId === user.userId) {
460
465
return Response.json(
461
466
{ error: "Cannot disable your own account" },
462
467
{ status: 400 },
···
508
513
return Response.json({ success: true });
509
514
}
510
515
511
-
export async function updateUserTier(req: Request, userId: string): Promise<Response> {
516
+
export async function updateUserTier(
517
+
req: Request,
518
+
userId: string,
519
+
): Promise<Response> {
512
520
const user = getSessionUser(req);
513
521
if (user instanceof Response) {
514
522
return user;
···
536
544
537
545
const targetUser = db
538
546
.query("SELECT id, username, tier FROM users WHERE id = ?")
539
-
.get(targetUserId) as { id: number; username: string; tier: string } | undefined;
547
+
.get(targetUserId) as
548
+
| { id: number; username: string; tier: string }
549
+
| undefined;
540
550
541
551
if (!targetUser) {
542
552
return Response.json({ error: "User not found" }, { status: 404 });
+17
-6
src/routes/auth.ts
+17
-6
src/routes/auth.ts
···
1
1
import {
2
2
type AuthenticationResponseJSON,
3
+
generateAuthenticationOptions,
4
+
generateRegistrationOptions,
3
5
type PublicKeyCredentialCreationOptionsJSON,
4
6
type PublicKeyCredentialRequestOptionsJSON,
5
7
type RegistrationResponseJSON,
6
8
type VerifiedAuthenticationResponse,
7
9
type VerifiedRegistrationResponse,
8
-
generateAuthenticationOptions,
9
-
generateRegistrationOptions,
10
10
verifyAuthenticationResponse,
11
11
verifyRegistrationResponse,
12
12
} from "@simplewebauthn/server";
13
13
import { authenticate } from "ldap-authentication";
14
14
import { db } from "../db";
15
-
import { checkLdapGroupMembership } from "../ldap-cleanup";
15
+
import { checkLdapGroupMembership, checkLdapUser } from "../ldap-cleanup";
16
16
17
17
const RP_NAME = "Indiko";
18
18
···
381
381
382
382
// Check if user exists and is active
383
383
const user = db
384
-
.query("SELECT id, status, provisioned_via_ldap, last_ldap_verified_at FROM users WHERE username = ?")
385
-
.get(username) as { id: number; status: string; provisioned_via_ldap: number; last_ldap_verified_at: number | null } | undefined;
384
+
.query(
385
+
"SELECT id, status, provisioned_via_ldap, last_ldap_verified_at FROM users WHERE username = ?",
386
+
)
387
+
.get(username) as
388
+
| {
389
+
id: number;
390
+
status: string;
391
+
provisioned_via_ldap: number;
392
+
last_ldap_verified_at: number | null;
393
+
}
394
+
| undefined;
386
395
387
396
if (!user) {
388
397
return Response.json({ error: "Invalid credentials" }, { status: 401 });
···
405
414
const existsInLdap = await checkLdapUser(username);
406
415
if (!existsInLdap) {
407
416
// User no longer exists in LDAP - suspend the account
408
-
db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run(user.id);
417
+
db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run(
418
+
user.id,
419
+
);
409
420
return Response.json(
410
421
{ error: "Invalid credentials" },
411
422
{ status: 401 },
+5
-3
src/routes/clients.ts
+5
-3
src/routes/clients.ts
···
1
-
import crypto from "crypto";
1
+
import crypto from "node:crypto";
2
2
import { nanoid } from "nanoid";
3
3
import { db } from "../db";
4
4
···
16
16
17
17
function getSessionUser(
18
18
req: Request,
19
-
): { username: string; userId: number; is_admin: boolean; tier: string } | Response {
19
+
):
20
+
| { username: string; userId: number; is_admin: boolean; tier: string }
21
+
| Response {
20
22
const authHeader = req.headers.get("Authorization");
21
23
22
24
if (!authHeader || !authHeader.startsWith("Bearer ")) {
···
119
121
if (!rolesByApp.has(app_id)) {
120
122
rolesByApp.set(app_id, []);
121
123
}
122
-
rolesByApp.get(app_id)!.push(role);
124
+
rolesByApp.get(app_id)?.push(role);
123
125
}
124
126
125
127
return Response.json({
+210
-79
src/routes/indieauth.ts
+210
-79
src/routes/indieauth.ts
···
1
-
import crypto from "crypto";
1
+
import crypto from "node:crypto";
2
+
import type { BunRequest } from "bun";
2
3
import { db } from "../db";
3
4
4
5
interface SessionUser {
···
53
54
username: session.username,
54
55
userId: session.id,
55
56
isAdmin: session.is_admin === 1,
57
+
tier: session.tier,
56
58
};
57
59
}
58
60
···
68
70
}),
69
71
);
70
72
71
-
const sessionToken = cookies["indiko_session"];
73
+
const sessionToken = cookies.indiko_session;
72
74
if (!sessionToken) return null;
73
75
74
76
const session = db
···
127
129
}
128
130
129
131
// Validate profile URL per IndieAuth spec
130
-
export function validateProfileURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } {
132
+
export function validateProfileURL(urlString: string): {
133
+
valid: boolean;
134
+
error?: string;
135
+
canonicalUrl?: string;
136
+
} {
131
137
let url: URL;
132
138
try {
133
139
url = new URL(urlString);
···
152
158
153
159
// MUST NOT contain username/password
154
160
if (url.username || url.password) {
155
-
return { valid: false, error: "Profile URL must not contain username or password" };
161
+
return {
162
+
valid: false,
163
+
error: "Profile URL must not contain username or password",
164
+
};
156
165
}
157
166
158
167
// MUST NOT contain ports
···
164
173
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
165
174
const ipv6Regex = /^\[?[0-9a-fA-F:]+\]?$/;
166
175
if (ipv4Regex.test(url.hostname) || ipv6Regex.test(url.hostname)) {
167
-
return { valid: false, error: "Profile URL must use domain names, not IP addresses" };
176
+
return {
177
+
valid: false,
178
+
error: "Profile URL must use domain names, not IP addresses",
179
+
};
168
180
}
169
181
170
182
// MUST NOT contain single-dot or double-dot path segments
171
183
const pathSegments = url.pathname.split("/");
172
184
if (pathSegments.includes(".") || pathSegments.includes("..")) {
173
-
return { valid: false, error: "Profile URL must not contain . or .. path segments" };
185
+
return {
186
+
valid: false,
187
+
error: "Profile URL must not contain . or .. path segments",
188
+
};
174
189
}
175
190
176
191
return { valid: true, canonicalUrl: canonicalizeURL(urlString) };
177
192
}
178
193
179
194
// Validate client URL per IndieAuth spec
180
-
function validateClientURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } {
195
+
function validateClientURL(urlString: string): {
196
+
valid: boolean;
197
+
error?: string;
198
+
canonicalUrl?: string;
199
+
} {
181
200
let url: URL;
182
201
try {
183
202
url = new URL(urlString);
···
202
221
203
222
// MUST NOT contain username/password
204
223
if (url.username || url.password) {
205
-
return { valid: false, error: "Client URL must not contain username or password" };
224
+
return {
225
+
valid: false,
226
+
error: "Client URL must not contain username or password",
227
+
};
206
228
}
207
229
208
230
// MUST NOT contain single-dot or double-dot path segments
209
231
const pathSegments = url.pathname.split("/");
210
232
if (pathSegments.includes(".") || pathSegments.includes("..")) {
211
-
return { valid: false, error: "Client URL must not contain . or .. path segments" };
233
+
return {
234
+
valid: false,
235
+
error: "Client URL must not contain . or .. path segments",
236
+
};
212
237
}
213
238
214
239
// MAY use loopback interface, but not other IP addresses
···
217
242
if (ipv4Regex.test(url.hostname)) {
218
243
// Allow 127.0.0.1 (loopback), reject others
219
244
if (!url.hostname.startsWith("127.")) {
220
-
return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" };
245
+
return {
246
+
valid: false,
247
+
error:
248
+
"Client URL must use domain names, not IP addresses (except loopback)",
249
+
};
221
250
}
222
251
} else if (ipv6Regex.test(url.hostname)) {
223
252
// Allow ::1 (loopback), reject others
224
253
const ipv6Match = url.hostname.match(ipv6Regex);
225
254
if (ipv6Match && ipv6Match[1] !== "::1") {
226
-
return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" };
255
+
return {
256
+
valid: false,
257
+
error:
258
+
"Client URL must use domain names, not IP addresses (except loopback)",
259
+
};
227
260
}
228
261
}
229
262
···
234
267
function isLoopbackURL(urlString: string): boolean {
235
268
try {
236
269
const url = new URL(urlString);
237
-
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname.startsWith("127.");
270
+
return (
271
+
url.hostname === "localhost" ||
272
+
url.hostname === "127.0.0.1" ||
273
+
url.hostname === "[::1]" ||
274
+
url.hostname.startsWith("127.")
275
+
);
238
276
} catch {
239
277
return false;
240
278
}
···
254
292
}> {
255
293
// MUST NOT fetch loopback addresses (security requirement)
256
294
if (isLoopbackURL(clientId)) {
257
-
return { success: false, error: "Cannot fetch metadata from loopback addresses" };
295
+
return {
296
+
success: false,
297
+
error: "Cannot fetch metadata from loopback addresses",
298
+
};
258
299
}
259
300
260
301
try {
···
273
314
clearTimeout(timeoutId);
274
315
275
316
if (!response.ok) {
276
-
return { success: false, error: `Failed to fetch client metadata: HTTP ${response.status}` };
317
+
return {
318
+
success: false,
319
+
error: `Failed to fetch client metadata: HTTP ${response.status}`,
320
+
};
277
321
}
278
322
279
323
const contentType = response.headers.get("content-type") || "";
···
284
328
285
329
// Verify client_id matches
286
330
if (metadata.client_id && metadata.client_id !== clientId) {
287
-
return { success: false, error: "client_id in metadata does not match URL" };
331
+
return {
332
+
success: false,
333
+
error: "client_id in metadata does not match URL",
334
+
};
288
335
}
289
336
290
337
return { success: true, metadata };
···
295
342
const html = await response.text();
296
343
297
344
// Extract redirect URIs from link tags
298
-
const redirectUriRegex = /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
345
+
const redirectUriRegex =
346
+
/<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
299
347
const redirectUris: string[] = [];
300
-
let match: RegExpExecArray | null;
348
+
let match = redirectUriRegex.exec(html);
301
349
302
-
while ((match = redirectUriRegex.exec(html)) !== null) {
303
-
redirectUris.push(match[1]);
350
+
while (match !== null) {
351
+
redirectUris.push(match[1] || "");
352
+
match = redirectUriRegex.exec(html);
304
353
}
305
354
306
355
// 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]);
356
+
const redirectUriRegex2 =
357
+
/<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi;
358
+
match = redirectUriRegex2.exec(html);
359
+
while (match !== null) {
360
+
if (!redirectUris.includes(match[1] || "")) {
361
+
redirectUris.push(match[1] || "");
311
362
}
363
+
match = redirectUriRegex2.exec(html);
312
364
}
313
365
314
366
if (redirectUris.length > 0) {
···
321
373
};
322
374
}
323
375
324
-
return { success: false, error: "No client metadata or redirect_uri links found in HTML" };
376
+
return {
377
+
success: false,
378
+
error: "No client metadata or redirect_uri links found in HTML",
379
+
};
325
380
}
326
381
327
382
return { success: false, error: "Unsupported content type" };
···
330
385
if (error.name === "AbortError") {
331
386
return { success: false, error: "Timeout fetching client metadata" };
332
387
}
333
-
return { success: false, error: `Failed to fetch client metadata: ${error.message}` };
388
+
return {
389
+
success: false,
390
+
error: `Failed to fetch client metadata: ${error.message}`,
391
+
};
334
392
}
335
393
return { success: false, error: "Failed to fetch client metadata" };
336
394
}
337
395
}
338
396
339
397
// Verify domain has rel="me" link back to user profile
340
-
export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): Promise<{
398
+
export async function verifyDomain(
399
+
domainUrl: string,
400
+
indikoProfileUrl: string,
401
+
): Promise<{
341
402
success: boolean;
342
403
error?: string;
343
404
}> {
···
359
420
360
421
if (!response.ok) {
361
422
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}` };
423
+
console.error(
424
+
`[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`,
425
+
{
426
+
status: response.status,
427
+
contentType: response.headers.get("content-type"),
428
+
bodyPreview: errorBody.substring(0, 200),
429
+
},
430
+
);
431
+
return {
432
+
success: false,
433
+
error: `Failed to fetch domain: HTTP ${response.status}`,
434
+
};
368
435
}
369
436
370
437
const html = await response.text();
···
384
451
385
452
const relValue = relMatch[1];
386
453
// Check if "me" is a separate word in the rel attribute
387
-
if (!relValue.split(/\s+/).includes("me")) return null;
454
+
if (!relValue?.split(/\s+/).includes("me")) return null;
388
455
389
456
// Extract href (handle quoted and unquoted attributes)
390
457
const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i);
···
394
461
};
395
462
396
463
// Process all link tags
397
-
let linkMatch;
398
-
while ((linkMatch = linkRegex.exec(html)) !== null) {
464
+
let linkMatch = linkRegex.exec(html);
465
+
while (linkMatch !== null) {
399
466
const href = processTag(linkMatch[0]);
400
467
if (href && !relMeLinks.includes(href)) {
401
468
relMeLinks.push(href);
402
469
}
470
+
linkMatch = linkRegex.exec(html);
403
471
}
404
472
405
473
// Process all a tags
406
-
let aMatch;
407
-
while ((aMatch = aRegex.exec(html)) !== null) {
474
+
let aMatch = aRegex.exec(html);
475
+
while (aMatch !== null) {
408
476
const href = processTag(aMatch[0]);
409
477
if (href && !relMeLinks.includes(href)) {
410
478
relMeLinks.push(href);
411
479
}
480
+
aMatch = aRegex.exec(html);
412
481
}
413
482
414
483
// Check if any rel="me" link matches the indiko profile URL
415
484
const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl);
416
-
const hasRelMe = relMeLinks.some(link => {
485
+
const hasRelMe = relMeLinks.some((link) => {
417
486
try {
418
487
const normalizedLink = canonicalizeURL(link);
419
488
return normalizedLink === normalizedIndikoUrl;
···
423
492
});
424
493
425
494
if (!hasRelMe) {
426
-
console.error(`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, {
427
-
foundLinks: relMeLinks,
428
-
normalizedTarget: normalizedIndikoUrl,
429
-
});
495
+
console.error(
496
+
`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`,
497
+
{
498
+
foundLinks: relMeLinks,
499
+
normalizedTarget: normalizedIndikoUrl,
500
+
},
501
+
);
430
502
return {
431
503
success: false,
432
504
error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`,
···
440
512
console.error(`[verifyDomain] Timeout verifying ${domainUrl}`);
441
513
return { success: false, error: "Timeout verifying domain" };
442
514
}
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}` };
515
+
console.error(
516
+
`[verifyDomain] Error verifying ${domainUrl}: ${error.message}`,
517
+
{
518
+
name: error.name,
519
+
stack: error.stack,
520
+
},
521
+
);
522
+
return {
523
+
success: false,
524
+
error: `Failed to verify domain: ${error.message}`,
525
+
};
448
526
}
449
-
console.error(`[verifyDomain] Unknown error verifying ${domainUrl}:`, error);
527
+
console.error(
528
+
`[verifyDomain] Unknown error verifying ${domainUrl}:`,
529
+
error,
530
+
);
450
531
return { success: false, error: "Failed to verify domain" };
451
532
}
452
533
}
···
457
538
redirectUri: string,
458
539
): Promise<{
459
540
error?: string;
460
-
app?: { name: string | null; redirect_uris: string; logo_url?: string | null };
541
+
app?: {
542
+
name: string | null;
543
+
redirect_uris: string;
544
+
logo_url?: string | null;
545
+
};
461
546
}> {
462
547
const existing = db
463
548
.query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?")
···
474
559
};
475
560
}
476
561
477
-
const canonicalClientId = validation.canonicalUrl!;
562
+
const canonicalClientId = validation.canonicalUrl || "";
478
563
479
564
// Fetch client metadata per IndieAuth spec
480
565
const metadataResult = await fetchClientMetadata(canonicalClientId);
···
550
635
551
636
// Fetch the newly created app
552
637
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 };
638
+
.query(
639
+
"SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?",
640
+
)
641
+
.get(canonicalClientId) as {
642
+
name: string | null;
643
+
redirect_uris: string;
644
+
logo_url?: string | null;
645
+
};
555
646
556
647
return { app: newApp };
557
648
}
···
800
891
);
801
892
}
802
893
803
-
const app = appResult.app!;
894
+
const app = appResult.app as NonNullable<typeof appResult.app>;
804
895
805
896
const allowedRedirects = JSON.parse(app.redirect_uris) as string[];
806
897
if (!allowedRedirects.includes(redirectUri)) {
···
954
1045
).run(Math.floor(Date.now() / 1000), user.userId, clientId);
955
1046
956
1047
const origin = process.env.ORIGIN || "http://localhost:3000";
957
-
return Response.redirect(`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`);
1048
+
return Response.redirect(
1049
+
`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`,
1050
+
);
958
1051
}
959
1052
}
960
1053
···
1316
1409
// POST /auth/authorize - Consent form submission
1317
1410
export async function authorizePost(req: Request): Promise<Response> {
1318
1411
const contentType = req.headers.get("Content-Type");
1319
-
1412
+
1320
1413
// Parse the request body
1321
1414
let body: Record<string, string>;
1322
1415
let formData: FormData;
···
1334
1427
}
1335
1428
1336
1429
const grantType = body.grant_type;
1337
-
1430
+
1338
1431
// If grant_type is present, this is a token exchange request (IndieAuth profile scope only)
1339
1432
if (grantType === "authorization_code") {
1340
1433
// Create a mock request for token() function
1341
1434
const mockReq = new Request(req.url, {
1342
1435
method: "POST",
1343
1436
headers: req.headers,
1344
-
body: contentType?.includes("application/x-www-form-urlencoded")
1437
+
body: contentType?.includes("application/x-www-form-urlencoded")
1345
1438
? new URLSearchParams(body).toString()
1346
1439
: JSON.stringify(body),
1347
1440
});
···
1373
1466
clientId = canonicalizeURL(rawClientId);
1374
1467
redirectUri = canonicalizeURL(rawRedirectUri);
1375
1468
} catch {
1376
-
return new Response("Invalid client_id or redirect_uri URL format", { status: 400 });
1469
+
return new Response("Invalid client_id or redirect_uri URL format", {
1470
+
status: 400,
1471
+
});
1377
1472
}
1378
1473
1379
1474
if (action === "deny") {
···
1487
1582
let redirect_uri: string | undefined;
1488
1583
try {
1489
1584
client_id = raw_client_id ? canonicalizeURL(raw_client_id) : undefined;
1490
-
redirect_uri = raw_redirect_uri ? canonicalizeURL(raw_redirect_uri) : undefined;
1585
+
redirect_uri = raw_redirect_uri
1586
+
? canonicalizeURL(raw_redirect_uri)
1587
+
: undefined;
1491
1588
} catch {
1492
1589
return Response.json(
1493
1590
{
···
1502
1599
return Response.json(
1503
1600
{
1504
1601
error: "unsupported_grant_type",
1505
-
error_description: "Only authorization_code and refresh_token grant types are supported",
1602
+
error_description:
1603
+
"Only authorization_code and refresh_token grant types are supported",
1506
1604
},
1507
1605
{ status: 400 },
1508
1606
);
···
1577
1675
const expiresAt = now + expiresIn;
1578
1676
1579
1677
// 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);
1678
+
db.query("UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?").run(
1679
+
newAccessToken,
1680
+
expiresAt,
1681
+
tokenData.id,
1682
+
);
1583
1683
1584
1684
// Get user profile for me value
1585
1685
const user = db
···
1614
1714
headers: {
1615
1715
"Content-Type": "application/json",
1616
1716
"Cache-Control": "no-store",
1617
-
"Pragma": "no-cache",
1717
+
Pragma: "no-cache",
1618
1718
},
1619
1719
},
1620
1720
);
···
1626
1726
.query(
1627
1727
"SELECT is_preregistered, client_secret_hash FROM apps WHERE client_id = ?",
1628
1728
)
1629
-
.get(client_id) as
1729
+
.get(client_id || "") as
1630
1730
| { is_preregistered: number; client_secret_hash: string | null }
1631
1731
| undefined;
1632
1732
···
1727
1827
1728
1828
// Check if already used
1729
1829
if (authcode.used) {
1730
-
console.error("Token endpoint: authorization code already used", { code });
1830
+
console.error("Token endpoint: authorization code already used", {
1831
+
code,
1832
+
});
1731
1833
return Response.json(
1732
1834
{
1733
1835
error: "invalid_grant",
···
1740
1842
// Check if expired
1741
1843
const now = Math.floor(Date.now() / 1000);
1742
1844
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 });
1845
+
console.error("Token endpoint: authorization code expired", {
1846
+
code,
1847
+
expires_at: authcode.expires_at,
1848
+
now,
1849
+
diff: now - authcode.expires_at,
1850
+
});
1744
1851
return Response.json(
1745
1852
{
1746
1853
error: "invalid_grant",
···
1752
1859
1753
1860
// Verify client_id matches
1754
1861
if (authcode.client_id !== client_id) {
1755
-
console.error("Token endpoint: client_id mismatch", { stored: authcode.client_id, received: client_id });
1862
+
console.error("Token endpoint: client_id mismatch", {
1863
+
stored: authcode.client_id,
1864
+
received: client_id,
1865
+
});
1756
1866
return Response.json(
1757
1867
{
1758
1868
error: "invalid_grant",
···
1764
1874
1765
1875
// Verify redirect_uri matches
1766
1876
if (authcode.redirect_uri !== redirect_uri) {
1767
-
console.error("Token endpoint: redirect_uri mismatch", { stored: authcode.redirect_uri, received: redirect_uri });
1877
+
console.error("Token endpoint: redirect_uri mismatch", {
1878
+
stored: authcode.redirect_uri,
1879
+
received: redirect_uri,
1880
+
});
1768
1881
return Response.json(
1769
1882
{
1770
1883
error: "invalid_grant",
···
1776
1889
1777
1890
// Verify PKCE code_verifier (required for all clients per IndieAuth spec)
1778
1891
if (!verifyPKCE(code_verifier, authcode.code_challenge)) {
1779
-
console.error("Token endpoint: PKCE verification failed", { code_verifier, code_challenge: authcode.code_challenge });
1892
+
console.error("Token endpoint: PKCE verification failed", {
1893
+
code_verifier,
1894
+
code_challenge: authcode.code_challenge,
1895
+
});
1780
1896
return Response.json(
1781
1897
{
1782
1898
error: "invalid_grant",
···
1839
1955
1840
1956
// Validate that the user controls the requested me parameter
1841
1957
if (authcode.me && authcode.me !== meValue) {
1842
-
console.error("Token endpoint: me mismatch", { requested: authcode.me, actual: meValue });
1958
+
console.error("Token endpoint: me mismatch", {
1959
+
requested: authcode.me,
1960
+
actual: meValue,
1961
+
});
1843
1962
return Response.json(
1844
1963
{
1845
1964
error: "invalid_grant",
1846
-
error_description: "The requested identity does not match the user's verified domain",
1965
+
error_description:
1966
+
"The requested identity does not match the user's verified domain",
1847
1967
},
1848
1968
{ status: 400 },
1849
1969
);
1850
1970
}
1851
1971
1852
1972
const origin = process.env.ORIGIN || "http://localhost:3000";
1853
-
1973
+
1854
1974
// Generate access token
1855
1975
const accessToken = crypto.randomBytes(32).toString("base64url");
1856
1976
const expiresIn = 3600; // 1 hour
···
1864
1984
// Store token in database with refresh token
1865
1985
db.query(
1866
1986
"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);
1987
+
).run(
1988
+
accessToken,
1989
+
authcode.user_id,
1990
+
client_id,
1991
+
scopes.join(" "),
1992
+
expiresAt,
1993
+
refreshToken,
1994
+
refreshExpiresAt,
1995
+
);
1868
1996
1869
1997
const response: Record<string, unknown> = {
1870
1998
access_token: accessToken,
···
1882
2010
response.role = permission.role;
1883
2011
}
1884
2012
1885
-
console.log("Token endpoint: success", { me: meValue, scopes: scopes.join(" ") });
2013
+
console.log("Token endpoint: success", {
2014
+
me: meValue,
2015
+
scopes: scopes.join(" "),
2016
+
});
1886
2017
1887
2018
return Response.json(response, {
1888
2019
headers: {
1889
2020
"Content-Type": "application/json",
1890
2021
"Cache-Control": "no-store",
1891
-
"Pragma": "no-cache",
2022
+
Pragma: "no-cache",
1892
2023
},
1893
2024
});
1894
2025
} catch (error) {
···
2052
2183
try {
2053
2184
// Get access token from Authorization header
2054
2185
const authHeader = req.headers.get("Authorization");
2055
-
2186
+
2056
2187
if (!authHeader || !authHeader.startsWith("Bearer ")) {
2057
2188
return Response.json(
2058
2189
{
···
2175
2306
}
2176
2307
2177
2308
// GET /u/:username - Public user profile (h-card)
2178
-
export function userProfile(req: Request): Response {
2179
-
const username = (req as any).params?.username;
2309
+
export function userProfile(req: BunRequest): Response {
2310
+
const username = req.params?.username;
2180
2311
if (!username) {
2181
2312
return new Response("Username required", { status: 400 });
2182
2313
}
+10
-6
src/routes/passkeys.ts
+10
-6
src/routes/passkeys.ts
···
1
1
import {
2
-
type RegistrationResponseJSON,
3
2
generateRegistrationOptions,
3
+
type RegistrationResponseJSON,
4
4
type VerifiedRegistrationResponse,
5
5
verifyRegistrationResponse,
6
6
} from "@simplewebauthn/server";
···
75
75
.all(session.user_id) as Array<{ credential_id: Buffer }>;
76
76
77
77
const excludeCredentials = existingCredentials.map((cred) => ({
78
-
id: cred.credential_id,
78
+
id: cred.credential_id.toString("base64url"),
79
79
type: "public-key" as const,
80
80
}));
81
81
82
82
// Generate WebAuthn registration options
83
83
const options = await generateRegistrationOptions({
84
84
rpName: RP_NAME,
85
-
rpID: process.env.RP_ID!,
85
+
rpID: process.env.RP_ID || "",
86
86
userName: user.username,
87
87
userDisplayName: user.username,
88
88
attestationType: "none",
···
133
133
}
134
134
135
135
const body = await req.json();
136
-
const { response, challenge: expectedChallenge, name } = body as {
136
+
const {
137
+
response,
138
+
challenge: expectedChallenge,
139
+
name,
140
+
} = body as {
137
141
response: RegistrationResponseJSON;
138
142
challenge: string;
139
143
name?: string;
···
167
171
verification = await verifyRegistrationResponse({
168
172
response,
169
173
expectedChallenge: challenge.challenge,
170
-
expectedOrigin: process.env.ORIGIN!,
171
-
expectedRPID: process.env.RP_ID!,
174
+
expectedOrigin: process.env.ORIGIN || "",
175
+
expectedRPID: process.env.RP_ID || "",
172
176
});
173
177
} catch (error) {
174
178
console.error("WebAuthn verification failed:", error);
+28
types/global.d.ts
+28
types/global.d.ts
···
1
+
export {};
2
+
3
+
declare global {
4
+
interface Window {
5
+
closeEditInviteModal(): void;
6
+
deleteInvite(inviteId: number, event?: Event): Promise<void>;
7
+
submitCreateInvite(): Promise<void>;
8
+
closeCreateInviteModal(): void;
9
+
editInvite(inviteId: number): Promise<void>;
10
+
submitEditInvite(): Promise<void>;
11
+
toggleClient(clientId: string): Promise<void>;
12
+
setUserRole(
13
+
clientId: string,
14
+
username: string,
15
+
role: string,
16
+
): Promise<void>;
17
+
editClient(clientId: string): Promise<void>;
18
+
deleteClient(clientId: string, event?: Event): Promise<void>;
19
+
removeRedirectUri(btn: HTMLButtonElement): void;
20
+
regenerateSecret(clientId: string, event?: Event): Promise<void>;
21
+
revokeUserPermission(
22
+
clientId: string,
23
+
username: string,
24
+
event?: Event,
25
+
): Promise<void>;
26
+
revokeApp(clientId: string, event?: Event): Promise<void>;
27
+
}
28
+
}
+10
CRUSH.md
+10
CRUSH.md
···
9
9
## Architecture Patterns
10
10
11
11
### Route Organization
12
+
12
13
- Use separate route files in `src/routes/` directory
13
14
- Export handler functions that accept `Request` and return `Response`
14
15
- Import handlers in `src/index.ts` and wire them in the `routes` object
···
17
18
- IndieAuth/OAuth 2.0 endpoints in `src/routes/indieauth.ts`
18
19
19
20
### Project Structure
21
+
20
22
```
21
23
src/
22
24
├── db.ts # Database setup and exports
···
43
45
### Database Migrations
44
46
45
47
**Migration Versioning:**
48
+
46
49
- SQLite uses `PRAGMA user_version` to track migration state
47
50
- Version starts at 0, increments by 1 for each migration
48
51
- The `bun-sqlite-migrations` package handles version tracking
···
55
58
- Use descriptive name (e.g., `008_add_auth_tokens.sql`)
56
59
57
60
2. **Write SQL statements**: Add schema changes in the file
61
+
58
62
```sql
59
63
-- Add new column to users table
60
64
ALTER TABLE users ADD COLUMN new_field TEXT DEFAULT '';
···
72
76
- Each migration increments `user_version` by 1
73
77
74
78
**Version Tracking:**
79
+
75
80
- Check current version: `sqlite3 data/indiko.db "PRAGMA user_version;"`
76
81
- The migration system compares `user_version` against migration files
77
82
- No manual version updates needed - handled by `bun-sqlite-migrations`
78
83
79
84
**Best Practices:**
85
+
80
86
- Use `ALTER TABLE` for adding columns to existing tables
81
87
- Use `CREATE TABLE IF NOT EXISTS` for new tables
82
88
- Use `DEFAULT` values when adding non-null columns
···
84
90
- Test migrations locally before committing
85
91
86
92
### Client-Side Code
93
+
87
94
- Extract JavaScript from HTML into separate TypeScript modules in `src/client/`
88
95
- Import client modules into HTML with `<script type="module" src="../client/file.ts"></script>`
89
96
- Bun will bundle the imports automatically
···
91
98
- 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
92
99
93
100
### IndieAuth/OAuth 2.0 Implementation
101
+
94
102
- Full IndieAuth server supporting OAuth 2.0 with PKCE
95
103
- Authorization code flow with single-use, short-lived codes (60 seconds)
96
104
- Auto-registration of client apps on first authorization
···
103
111
- **`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
104
112
105
113
### Database Schema
114
+
106
115
- **users**: username, name, email, photo, url, status, role, tier, is_admin, provisioned_via_ldap, last_ldap_verified_at
107
116
- **tier**: User access level - 'admin' (full access), 'developer' (can create apps), 'user' (can only authenticate with apps)
108
117
- **is_admin**: Legacy flag, automatically synced with tier (1 if tier='admin', 0 otherwise)
···
117
126
- **invites**: admin-created invite codes, includes `ldap_username` for LDAP-provisioned accounts
118
127
119
128
### WebAuthn/Passkey Settings
129
+
120
130
- **Registration**: residentKey="required", userVerification="required"
121
131
- **Authentication**: omit allowCredentials to show all passkeys (discoverable credentials)
122
132
- **Credential lookup**: credential_id stored as Buffer, compare using base64url string
+60
SPEC.md
+60
SPEC.md
···
3
3
## Overview
4
4
5
5
**indiko** is a centralized authentication and user management system for personal projects. It provides:
6
+
6
7
- Passkey-based authentication (WebAuthn)
7
8
- IndieAuth server implementation
8
9
- User profile management
···
12
13
## Core Concepts
13
14
14
15
### Single Source of Truth
16
+
15
17
- Authentication via passkeys
16
18
- User profiles (name, email, picture, URL)
17
19
- Authorization with per-app scoping
18
20
- User management (admin + invite system)
19
21
20
22
### Trust Model
23
+
21
24
- First user becomes admin
22
25
- Admin can create invite links
23
26
- Apps auto-register on first use
···
30
33
## Data Structures
31
34
32
35
### Users
36
+
33
37
```
34
38
user:{username} -> {
35
39
credential: {
···
49
53
```
50
54
51
55
### Admin Marker
56
+
52
57
```
53
58
admin:user -> username // marks first/admin user
54
59
```
55
60
56
61
### Sessions
62
+
57
63
```
58
64
session:{token} -> {
59
65
username: string,
···
67
73
There are two types of OAuth clients in indiko:
68
74
69
75
#### Auto-registered Apps (IndieAuth)
76
+
70
77
```
71
78
app:{client_id} -> {
72
79
client_id: string, // e.g. "https://blog.kierank.dev" (any valid URL)
···
80
87
```
81
88
82
89
**Features:**
90
+
83
91
- Client ID is any valid URL per IndieAuth spec
84
92
- No client secret (public client)
85
93
- MUST use PKCE (code_verifier)
···
88
96
- Cannot use role-based access control
89
97
90
98
#### Pre-registered Apps (OAuth 2.0 with secrets)
99
+
91
100
```
92
101
app:{client_id} -> {
93
102
client_id: string, // e.g. "ikc_xxxxxxxxxxxxxxxxxxxxx" (generated ID)
···
105
114
```
106
115
107
116
**Features:**
117
+
108
118
- Client ID format: `ikc_` + 21 character nanoid
109
119
- Client secret format: `iks_` + 43 character nanoid (shown once on creation)
110
120
- MUST use PKCE (code_verifier) AND client_secret
···
113
123
- Created via admin interface
114
124
115
125
### User Permissions (Per-App)
126
+
116
127
```
117
128
permission:{user_id}:{client_id} -> {
118
129
scopes: string[], // e.g. ["profile", "email"]
···
123
134
```
124
135
125
136
### Authorization Codes (Short-lived)
137
+
126
138
```
127
139
authcode:{code} -> {
128
140
username: string,
···
138
150
```
139
151
140
152
### Invites
153
+
141
154
```
142
155
invite:{code} -> {
143
156
code: string,
···
150
163
```
151
164
152
165
### Challenges (WebAuthn)
166
+
153
167
```
154
168
challenge:{challenge} -> {
155
169
username: string,
···
170
184
### Authentication (WebAuthn/Passkey)
171
185
172
186
#### `GET /login`
187
+
173
188
- Login/registration page
174
189
- Shows passkey auth interface
175
190
- First user: admin registration flow
176
191
- With `?invite=CODE`: invite-based registration
177
192
178
193
#### `GET /auth/can-register`
194
+
179
195
- Check if open registration allowed
180
196
- Returns `{ canRegister: boolean }`
181
197
182
198
#### `POST /auth/register/options`
199
+
183
200
- Generate WebAuthn registration options
184
201
- Body: `{ username: string, inviteCode?: string }`
185
202
- Validates invite code if not first user
186
203
- Returns registration options
187
204
188
205
#### `POST /auth/register/verify`
206
+
189
207
- Verify WebAuthn registration response
190
208
- Body: `{ username: string, response: RegistrationResponseJSON, inviteCode?: string }`
191
209
- Creates user, stores credential
···
193
211
- Returns `{ token: string, username: string }`
194
212
195
213
#### `POST /auth/login/options`
214
+
196
215
- Generate WebAuthn authentication options
197
216
- Body: `{ username: string }`
198
217
- Returns authentication options
199
218
200
219
#### `POST /auth/login/verify`
220
+
201
221
- Verify WebAuthn authentication response
202
222
- Body: `{ username: string, response: AuthenticationResponseJSON }`
203
223
- Creates session
204
224
- Returns `{ token: string, username: string }`
205
225
206
226
#### `POST /auth/logout`
227
+
207
228
- Clear session
208
229
- Requires: `Authorization: Bearer {token}`
209
230
- Returns `{ success: true }`
···
211
232
### IndieAuth Endpoints
212
233
213
234
#### `GET /auth/authorize`
235
+
214
236
Authorization request from client app
215
237
216
238
**Query Parameters:**
239
+
217
240
- `response_type=code` (required)
218
241
- `client_id` (required) - App's URL
219
242
- `redirect_uri` (required) - Callback URL
···
224
247
- `me` (optional) - User's URL (hint)
225
248
226
249
**Flow:**
250
+
227
251
1. Validate parameters
228
252
2. Auto-register app if not exists
229
253
3. If no session → redirect to `/login`
···
233
257
- If no → show consent screen
234
258
235
259
**Response:**
260
+
236
261
- HTML consent screen
237
262
- Shows: app name, requested scopes
238
263
- Buttons: "Allow" / "Deny"
239
264
240
265
#### `POST /auth/authorize`
266
+
241
267
Consent form submission (CSRF protected)
242
268
243
269
**Body:**
270
+
244
271
- `client_id` (required)
245
272
- `redirect_uri` (required)
246
273
- `state` (required)
···
249
276
- `action` (required) - "allow" | "deny"
250
277
251
278
**Flow:**
279
+
252
280
1. Validate CSRF token
253
281
2. Validate session
254
282
3. If denied → redirect with error
···
259
287
- Redirect to redirect_uri with code & state
260
288
261
289
**Success Response:**
290
+
262
291
```
263
292
HTTP/1.1 302 Found
264
293
Location: {redirect_uri}?code={authcode}&state={state}
265
294
```
266
295
267
296
**Error Response:**
297
+
268
298
```
269
299
HTTP/1.1 302 Found
270
300
Location: {redirect_uri}?error=access_denied&state={state}
271
301
```
272
302
273
303
#### `POST /auth/token`
304
+
274
305
Exchange authorization code for user identity (NOT CSRF protected)
275
306
276
307
**Headers:**
308
+
277
309
- `Content-Type: application/json`
278
310
279
311
**Body:**
312
+
280
313
```json
281
314
{
282
315
"grant_type": "authorization_code",
···
288
321
```
289
322
290
323
**Flow:**
324
+
291
325
1. Validate authorization code exists
292
326
2. Verify code not expired
293
327
3. Verify code not already used
···
298
332
8. Return user identity + profile
299
333
300
334
**Success Response:**
335
+
301
336
```json
302
337
{
303
338
"me": "https://indiko.yourdomain.com/u/kieran",
···
311
346
```
312
347
313
348
**Error Response:**
349
+
314
350
```json
315
351
{
316
352
"error": "invalid_grant",
···
319
355
```
320
356
321
357
#### `GET /auth/userinfo` (Optional)
358
+
322
359
Get current user profile with bearer token
323
360
324
361
**Headers:**
362
+
325
363
- `Authorization: Bearer {access_token}`
326
364
327
365
**Response:**
366
+
328
367
```json
329
368
{
330
369
"sub": "https://indiko.yourdomain.com/u/kieran",
···
338
377
### User Profile & Settings
339
378
340
379
#### `GET /settings`
380
+
341
381
User settings page (requires session)
342
382
343
383
**Shows:**
384
+
344
385
- Profile form (name, email, photo, URL)
345
386
- Connected apps list
346
387
- Revoke access buttons
347
388
- (Admin only) Invite generation
348
389
349
390
#### `POST /settings/profile`
391
+
350
392
Update user profile
351
393
352
394
**Body:**
395
+
353
396
```json
354
397
{
355
398
"name": "Kieran Klukas",
···
360
403
```
361
404
362
405
**Response:**
406
+
363
407
```json
364
408
{
365
409
"success": true,
···
368
412
```
369
413
370
414
#### `POST /settings/apps/:client_id/revoke`
415
+
371
416
Revoke app access
372
417
373
418
**Response:**
419
+
374
420
```json
375
421
{
376
422
"success": true
···
378
424
```
379
425
380
426
#### `GET /u/:username`
427
+
381
428
Public user profile page (h-card)
382
429
383
430
**Response:**
384
431
HTML page with microformats h-card:
432
+
385
433
```html
386
434
<div class="h-card">
387
435
<img class="u-photo" src="...">
···
393
441
### Admin Endpoints
394
442
395
443
#### `POST /api/invites/create`
444
+
396
445
Create invite link (admin only)
397
446
398
447
**Headers:**
448
+
399
449
- `Authorization: Bearer {token}`
400
450
401
451
**Response:**
452
+
402
453
```json
403
454
{
404
455
"inviteCode": "abc123xyz"
···
410
461
### Dashboard
411
462
412
463
#### `GET /`
464
+
413
465
Main dashboard (requires session)
414
466
415
467
**Shows:**
468
+
416
469
- User info
417
470
- Test API button
418
471
- (Admin only) Admin controls section
···
420
473
- Invite display
421
474
422
475
#### `GET /api/hello`
476
+
423
477
Test endpoint (requires session)
424
478
425
479
**Headers:**
480
+
426
481
- `Authorization: Bearer {token}`
427
482
428
483
**Response:**
484
+
429
485
```json
430
486
{
431
487
"message": "Hello kieran! You're authenticated with passkeys.",
···
437
493
## Session Behavior
438
494
439
495
### Single Sign-On
496
+
440
497
- Once logged into indiko (valid session), subsequent app authorization requests:
441
498
- Skip passkey authentication
442
499
- Show consent screen directly
···
445
502
- Passkey required only when session expires
446
503
447
504
### Security
505
+
448
506
- PKCE required for all authorization flows
449
507
- Authorization codes:
450
508
- Single-use only
···
455
513
## Client Integration Example
456
514
457
515
### 1. Initiate Authorization
516
+
458
517
```javascript
459
518
const params = new URLSearchParams({
460
519
response_type: 'code',
···
470
529
```
471
530
472
531
### 2. Handle Callback
532
+
473
533
```javascript
474
534
// At https://blog.kierank.dev/auth/callback?code=...&state=...
475
535
const code = new URLSearchParams(window.location.search).get('code');
+34
biome.json
+34
biome.json
···
1
+
{
2
+
"$schema": "https://biomejs.dev/schemas/2.3.9/schema.json",
3
+
"vcs": {
4
+
"enabled": true,
5
+
"clientKind": "git",
6
+
"useIgnoreFile": true
7
+
},
8
+
"files": {
9
+
"ignoreUnknown": false
10
+
},
11
+
"formatter": {
12
+
"enabled": true,
13
+
"indentStyle": "tab"
14
+
},
15
+
"linter": {
16
+
"enabled": true,
17
+
"rules": {
18
+
"recommended": true
19
+
}
20
+
},
21
+
"javascript": {
22
+
"formatter": {
23
+
"quoteStyle": "double"
24
+
}
25
+
},
26
+
"assist": {
27
+
"enabled": true,
28
+
"actions": {
29
+
"source": {
30
+
"organizeImports": "on"
31
+
}
32
+
}
33
+
}
34
+
}
+2
-1
package.json
+2
-1
package.json
···
6
6
"scripts": {
7
7
"dev": "bun run --hot src/index.ts",
8
8
"start": "bun run src/index.ts",
9
-
"format": "bun run --bun biome check --write ."
9
+
"forqmat": "bun run --bun biome check --write .",
10
+
"lint": "bun run --bun biome check"
10
11
},
11
12
"devDependencies": {
12
13
"@simplewebauthn/types": "^12.0.0",
+1
-1
scripts/audit-ldap-orphans.ts
+1
-1
scripts/audit-ldap-orphans.ts
+14
-26
src/client/admin-clients.ts
+14
-26
src/client/admin-clients.ts
···
104
104
lastUsed: number;
105
105
}
106
106
107
-
interface AppPermission {
108
-
username: string;
109
-
name: string;
110
-
scopes: string[];
111
-
grantedAt: number;
112
-
lastUsed: number;
113
-
}
114
-
115
107
async function loadClients() {
116
108
try {
117
109
const response = await fetch("/api/admin/clients", {
···
191
183
.join("");
192
184
}
193
185
194
-
(window as any).toggleClient = async (clientId: string) => {
186
+
window.toggleClient = async (clientId: string) => {
195
187
const card = document.querySelector(
196
188
`[data-client-id="${clientId}"]`,
197
189
) as HTMLElement;
···
319
311
}
320
312
};
321
313
322
-
(window as any).setUserRole = async (
314
+
window.setUserRole = async (
323
315
clientId: string,
324
316
username: string,
325
317
role: string,
···
348
340
}
349
341
};
350
342
351
-
(window as any).editClient = async (clientId: string) => {
343
+
window.editClient = async (clientId: string) => {
352
344
try {
353
345
const response = await fetch(
354
346
`/api/admin/clients/${encodeURIComponent(clientId)}`,
···
398
390
}
399
391
};
400
392
401
-
(window as any).deleteClient = async (clientId: string, event?: Event) => {
393
+
window.deleteClient = async (clientId: string, event?: Event) => {
402
394
const btn = event?.target as HTMLButtonElement | undefined;
403
395
404
396
// Double-click confirmation pattern
405
397
if (btn?.dataset.confirmState === "pending") {
406
398
// Second click - execute delete
407
-
delete btn.dataset.confirmState;
399
+
btn.dataset.confirmState = undefined;
408
400
btn.disabled = true;
409
401
btn.textContent = "deleting...";
410
402
···
440
432
// Reset after 3 seconds if not confirmed
441
433
setTimeout(() => {
442
434
if (btn.dataset.confirmState === "pending") {
443
-
delete btn.dataset.confirmState;
435
+
btn.dataset.confirmState = undefined;
444
436
btn.textContent = originalText;
445
437
}
446
438
}, 3000);
···
479
471
redirectUrisList.appendChild(newItem);
480
472
});
481
473
482
-
(window as any).removeRedirectUri = (btn: HTMLButtonElement) => {
474
+
window.removeRedirectUri = (btn: HTMLButtonElement) => {
483
475
const items = redirectUrisList.querySelectorAll(".redirect-uri-item");
484
476
if (items.length > 1) {
485
477
btn.parentElement?.remove();
···
569
561
// If creating a new client, show the credentials in modal
570
562
if (!isEdit) {
571
563
const result = await response.json();
572
-
if (
573
-
result.client &&
574
-
result.client.clientId &&
575
-
result.client.clientSecret
576
-
) {
564
+
if (result.client?.clientId && result.client.clientSecret) {
577
565
const secretModal = document.getElementById(
578
566
"secretModal",
579
567
) as HTMLElement;
···
604
592
}
605
593
});
606
594
607
-
(window as any).regenerateSecret = async (clientId: string, event?: Event) => {
595
+
window.regenerateSecret = async (clientId: string, event?: Event) => {
608
596
const btn = event?.target as HTMLButtonElement | undefined;
609
597
610
598
// Double-click confirmation pattern (same as delete)
611
599
if (btn?.dataset.confirmState === "pending") {
612
600
// Second click - execute regenerate
613
-
delete btn.dataset.confirmState;
601
+
btn.dataset.confirmState = undefined;
614
602
btn.disabled = true;
615
603
btn.textContent = "regenerating...";
616
604
···
667
655
// Reset after 3 seconds if not confirmed
668
656
setTimeout(() => {
669
657
if (btn.dataset.confirmState === "pending") {
670
-
delete btn.dataset.confirmState;
658
+
btn.dataset.confirmState = undefined;
671
659
btn.textContent = originalText;
672
660
}
673
661
}, 3000);
···
675
663
}
676
664
};
677
665
678
-
(window as any).revokeUserPermission = async (
666
+
window.revokeUserPermission = async (
679
667
clientId: string,
680
668
username: string,
681
669
event?: Event,
···
685
673
// Double-click confirmation pattern
686
674
if (btn?.dataset.confirmState === "pending") {
687
675
// Second click - execute revoke
688
-
delete btn.dataset.confirmState;
676
+
btn.dataset.confirmState = undefined;
689
677
btn.disabled = true;
690
678
btn.textContent = "revoking...";
691
679
···
736
724
// Reset after 3 seconds if not confirmed
737
725
setTimeout(() => {
738
726
if (btn.dataset.confirmState === "pending") {
739
-
delete btn.dataset.confirmState;
727
+
btn.dataset.confirmState = undefined;
740
728
btn.textContent = originalText;
741
729
}
742
730
}, 3000);
+13
-13
src/client/admin-invites.ts
+13
-13
src/client/admin-invites.ts
···
60
60
} catch (error) {
61
61
console.error("Auth check failed:", error);
62
62
footer.textContent = "error loading user info";
63
-
usersList.innerHTML = '<div class="error">Failed to load users</div>';
63
+
invitesList.innerHTML = '<div class="error">Failed to load users</div>';
64
64
}
65
65
}
66
66
···
193
193
) as HTMLSelectElement;
194
194
195
195
let role = "";
196
-
if (roleSelect && roleSelect.value) {
196
+
if (roleSelect?.value) {
197
197
role = roleSelect.value;
198
198
}
199
199
···
266
266
}
267
267
268
268
// Expose functions to global scope for HTML onclick handlers
269
-
(window as any).submitCreateInvite = submitCreateInvite;
270
-
(window as any).closeCreateInviteModal = closeCreateInviteModal;
269
+
window.submitCreateInvite = submitCreateInvite;
270
+
window.closeCreateInviteModal = closeCreateInviteModal;
271
271
272
272
async function loadInvites() {
273
273
try {
···
408
408
document.addEventListener("keydown", (e) => {
409
409
if (e.key === "Escape") {
410
410
closeCreateInviteModal();
411
-
closeEditInviteModal();
411
+
window.closeEditInviteModal();
412
412
}
413
413
});
414
414
···
421
421
422
422
document.getElementById("editInviteModal")?.addEventListener("click", (e) => {
423
423
if (e.target === e.currentTarget) {
424
-
closeEditInviteModal();
424
+
window.closeEditInviteModal();
425
425
}
426
426
});
427
427
428
428
let currentEditInviteId: number | null = null;
429
429
430
430
// Make editInvite globally available for onclick handler
431
-
(window as any).editInvite = async (inviteId: number) => {
431
+
window.editInvite = async (inviteId: number) => {
432
432
try {
433
433
const response = await fetch("/api/invites", {
434
434
headers: {
···
488
488
}
489
489
};
490
490
491
-
(window as any).submitEditInvite = async () => {
491
+
window.submitEditInvite = async () => {
492
492
if (currentEditInviteId === null) return;
493
493
494
494
const maxUsesInput = document.getElementById(
···
532
532
}
533
533
534
534
await loadInvites();
535
-
closeEditInviteModal();
535
+
window.closeEditInviteModal();
536
536
} catch (error) {
537
537
console.error("Failed to update invite:", error);
538
538
alert("Failed to update invite");
···
542
542
}
543
543
};
544
544
545
-
(window as any).closeEditInviteModal = () => {
545
+
window.closeEditInviteModal = () => {
546
546
const modal = document.getElementById("editInviteModal");
547
547
if (modal) {
548
548
modal.style.display = "none";
···
557
557
}
558
558
};
559
559
560
-
(window as any).deleteInvite = async (inviteId: number, event?: Event) => {
560
+
window.deleteInvite = async (inviteId: number, event?: Event) => {
561
561
const btn = event?.target as HTMLButtonElement | undefined;
562
562
563
563
// Double-click confirmation pattern
564
564
if (btn?.dataset.confirmState === "pending") {
565
565
// Second click - execute delete
566
-
delete btn.dataset.confirmState;
566
+
btn.dataset.confirmState = undefined;
567
567
btn.textContent = "deleting...";
568
568
btn.disabled = true;
569
569
···
596
596
// Reset after 3 seconds if not confirmed
597
597
setTimeout(() => {
598
598
if (btn.dataset.confirmState === "pending") {
599
-
delete btn.dataset.confirmState;
599
+
btn.dataset.confirmState = undefined;
600
600
btn.textContent = originalText;
601
601
}
602
602
}, 3000);
+3
-3
src/client/apps.ts
+3
-3
src/client/apps.ts
···
73
73
.join("");
74
74
}
75
75
76
-
(window as any).revokeApp = async (clientId: string, event?: Event) => {
76
+
window.revokeApp = async (clientId: string, event?: Event) => {
77
77
const btn = event?.target as HTMLButtonElement | undefined;
78
78
79
79
// Double-click confirmation pattern
80
80
if (btn?.dataset.confirmState === "pending") {
81
81
// Second click - execute revoke
82
-
delete btn.dataset.confirmState;
82
+
btn.dataset.confirmState = undefined;
83
83
btn.disabled = true;
84
84
btn.textContent = "revoking...";
85
85
···
127
127
// Reset after 3 seconds if not confirmed
128
128
setTimeout(() => {
129
129
if (btn.dataset.confirmState === "pending") {
130
-
delete btn.dataset.confirmState;
130
+
btn.dataset.confirmState = undefined;
131
131
btn.textContent = originalText;
132
132
}
133
133
}, 3000);
+8
-6
src/client/docs.ts
+8
-6
src/client/docs.ts
···
36
36
/<(\/?)([\w-]+)([\s\S]*?)>/g,
37
37
(_match, slash, tag, attrs) => {
38
38
let result = `<${slash}<span class="html-tag">${tag}</span>`;
39
+
let replaced_attrs = attrs ?? "";
39
40
40
-
if (attrs) {
41
-
attrs = attrs.replace(
41
+
if (replaced_attrs) {
42
+
replaced_attrs = replaced_attrs.replace(
42
43
/([\w-]+)="([^"]*)"/g,
43
44
'<span class="html-attr">$1</span>="<span class="html-string">$2</span>"',
44
45
);
45
-
attrs = attrs.replace(
46
-
/(?<=\s)([\w-]+)(?=\s|$)/g,
47
-
'<span class="html-attr">$1</span>',
46
+
47
+
replaced_attrs = replaced_attrs.replace(
48
+
/(\s)([\w-]+)(?=\s|$)/g,
49
+
'$1<span class="html-attr">$2</span>',
48
50
);
49
51
}
50
52
51
-
result += attrs + ">";
53
+
result += `${replaced_attrs}>`;
52
54
return result;
53
55
},
54
56
);
+31
-16
src/client/index.ts
+31
-16
src/client/index.ts
···
1
-
import {
2
-
startRegistration,
3
-
} from "@simplewebauthn/browser";
1
+
import { startRegistration } from "@simplewebauthn/browser";
4
2
5
3
const token = localStorage.getItem("indiko_session");
6
4
const footer = document.getElementById("footer") as HTMLElement;
···
8
6
const subtitle = document.getElementById("subtitle") as HTMLElement;
9
7
const recentApps = document.getElementById("recentApps") as HTMLElement;
10
8
const passkeysList = document.getElementById("passkeysList") as HTMLElement;
11
-
const addPasskeyBtn = document.getElementById("addPasskeyBtn") as HTMLButtonElement;
9
+
const addPasskeyBtn = document.getElementById(
10
+
"addPasskeyBtn",
11
+
) as HTMLButtonElement;
12
12
const toast = document.getElementById("toast") as HTMLElement;
13
13
14
14
// Profile form elements
···
320
320
const passkeys = data.passkeys as Passkey[];
321
321
322
322
if (passkeys.length === 0) {
323
-
passkeysList.innerHTML = '<div class="empty">No passkeys registered</div>';
323
+
passkeysList.innerHTML =
324
+
'<div class="empty">No passkeys registered</div>';
324
325
return;
325
326
}
326
327
327
328
passkeysList.innerHTML = passkeys
328
329
.map((passkey) => {
329
-
const createdDate = new Date(passkey.created_at * 1000).toLocaleDateString();
330
+
const createdDate = new Date(
331
+
passkey.created_at * 1000,
332
+
).toLocaleDateString();
330
333
331
334
return `
332
335
<div class="passkey-item" data-passkey-id="${passkey.id}">
···
336
339
</div>
337
340
<div class="passkey-actions">
338
341
<button type="button" class="rename-passkey-btn" data-passkey-id="${passkey.id}">rename</button>
339
-
${passkeys.length > 1 ? `<button type="button" class="delete-passkey-btn" data-passkey-id="${passkey.id}">delete</button>` : ''}
342
+
${passkeys.length > 1 ? `<button type="button" class="delete-passkey-btn" data-passkey-id="${passkey.id}">delete</button>` : ""}
340
343
</div>
341
344
</div>
342
345
`;
···
365
368
}
366
369
367
370
function showRenameForm(passkeyId: number) {
368
-
const passkeyItem = document.querySelector(`[data-passkey-id="${passkeyId}"]`);
371
+
const passkeyItem = document.querySelector(
372
+
`[data-passkey-id="${passkeyId}"]`,
373
+
);
369
374
if (!passkeyItem) return;
370
375
371
376
const infoDiv = passkeyItem.querySelector(".passkey-info");
···
389
394
input.select();
390
395
391
396
// Save button
392
-
infoDiv.querySelector(".save-rename-btn")?.addEventListener("click", async () => {
393
-
await renamePasskeyHandler(passkeyId, input.value);
394
-
});
397
+
infoDiv
398
+
.querySelector(".save-rename-btn")
399
+
?.addEventListener("click", async () => {
400
+
await renamePasskeyHandler(passkeyId, input.value);
401
+
});
395
402
396
403
// Cancel button
397
-
infoDiv.querySelector(".cancel-rename-btn")?.addEventListener("click", () => {
398
-
loadPasskeys();
399
-
});
404
+
infoDiv
405
+
.querySelector(".cancel-rename-btn")
406
+
?.addEventListener("click", () => {
407
+
loadPasskeys();
408
+
});
400
409
401
410
// Enter to save
402
411
input.addEventListener("keypress", async (e) => {
···
443
452
}
444
453
445
454
async function deletePasskeyHandler(passkeyId: number) {
446
-
if (!confirm("Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.")) {
455
+
if (
456
+
!confirm(
457
+
"Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.",
458
+
)
459
+
) {
447
460
return;
448
461
}
449
462
···
496
509
addPasskeyBtn.textContent = "verifying...";
497
510
498
511
// Ask for a name
499
-
const name = prompt("Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):");
512
+
const name = prompt(
513
+
"Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):",
514
+
);
500
515
501
516
// Verify registration
502
517
const verifyRes = await fetch("/api/passkeys/add/verify", {
+1
-1
src/client/oauth-test.ts
+1
-1
src/client/oauth-test.ts
···
205
205
resultDiv.className = `result show ${type}`;
206
206
}
207
207
208
-
function syntaxHighlightJSON(obj: any): string {
208
+
function syntaxHighlightJSON(obj: unknown): string {
209
209
const json = JSON.stringify(obj, null, 2);
210
210
return json.replace(
211
211
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,
+65
-43
src/html/admin-clients.html
+65
-43
src/html/admin-clients.html
···
7
7
<title>oauth clients • admin • indiko</title>
8
8
<meta name="description" content="Manage OAuth clients and application registrations" />
9
9
<link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" />
10
-
10
+
11
11
<!-- Open Graph / Facebook -->
12
12
<meta property="og:type" content="website" />
13
13
<meta property="og:title" content="OAuth Clients • Indiko Admin" />
14
14
<meta property="og:description" content="Manage OAuth clients and application registrations" />
15
-
15
+
16
16
<!-- Twitter -->
17
17
<meta name="twitter:card" content="summary" />
18
18
<meta name="twitter:title" content="OAuth Clients • Indiko Admin" />
···
58
58
align-items: flex-start;
59
59
}
60
60
61
+
footer {
62
+
width: 100%;
63
+
max-width: 56.25rem;
64
+
padding: 1rem;
65
+
text-align: center;
66
+
color: var(--old-rose);
67
+
font-size: 0.875rem;
68
+
font-weight: 300;
69
+
letter-spacing: 0.05rem;
70
+
}
71
+
72
+
footer a {
73
+
color: var(--berry-crush);
74
+
text-decoration: none;
75
+
transition: color 0.2s;
76
+
}
77
+
78
+
footer a:hover {
79
+
color: var(--rosewood);
80
+
text-decoration: underline;
81
+
}
82
+
61
83
.header-nav {
62
84
display: flex;
63
85
gap: 1rem;
···
111
133
letter-spacing: -0.05rem;
112
134
}
113
135
114
-
footer {
115
-
width: 100%;
116
-
max-width: 56.25rem;
117
-
padding: 1rem;
118
-
text-align: center;
119
-
color: var(--old-rose);
120
-
font-size: 0.875rem;
121
-
font-weight: 300;
122
-
letter-spacing: 0.05rem;
123
-
}
124
-
125
-
footer a {
126
-
color: var(--berry-crush);
127
-
text-decoration: none;
128
-
transition: color 0.2s;
129
-
}
130
-
131
-
footer a:hover {
132
-
color: var(--rosewood);
133
-
text-decoration: underline;
134
-
}
135
-
136
136
.back-link {
137
137
margin-top: 0.5rem;
138
138
font-size: 0.875rem;
···
385
385
margin-top: 1rem;
386
386
}
387
387
388
-
.btn-edit, .btn-delete, .revoke-btn {
388
+
.btn-edit,
389
+
.btn-delete,
390
+
.revoke-btn {
389
391
padding: 0.5rem 1rem;
390
392
font-family: inherit;
391
393
font-size: 0.875rem;
···
404
406
background: rgba(188, 141, 160, 0.3);
405
407
}
406
408
407
-
.btn-delete, .revoke-btn {
409
+
.btn-delete,
410
+
.revoke-btn {
408
411
background: rgba(160, 70, 104, 0.2);
409
412
color: var(--lavender);
410
413
border: 2px solid var(--rosewood);
411
414
}
412
415
413
-
.btn-delete:hover, .revoke-btn:hover {
416
+
.btn-delete:hover,
417
+
.revoke-btn:hover {
414
418
background: rgba(160, 70, 104, 0.3);
415
419
}
416
420
417
-
.loading, .error, .empty {
421
+
.loading,
422
+
.error,
423
+
.empty {
418
424
text-align: center;
419
425
padding: 2rem;
420
426
color: var(--old-rose);
···
645
651
</div>
646
652
<div class="form-group">
647
653
<label class="form-label" for="description">Description</label>
648
-
<textarea class="form-input form-textarea" id="description" placeholder="A brief description of your application"></textarea>
654
+
<textarea class="form-input form-textarea" id="description"
655
+
placeholder="A brief description of your application"></textarea>
649
656
</div>
650
657
<div class="form-group">
651
658
<label class="form-label">Redirect URIs</label>
652
659
<div id="redirectUrisList" class="redirect-uris-list">
653
660
<div class="redirect-uri-item">
654
-
<input type="url" class="form-input redirect-uri-input" placeholder="https://example.com/auth/callback" required />
661
+
<input type="url" class="form-input redirect-uri-input"
662
+
placeholder="https://example.com/auth/callback" required />
655
663
<button type="button" class="btn-remove" onclick="removeRedirectUri(this)">remove</button>
656
664
</div>
657
665
</div>
···
659
667
</div>
660
668
<div class="form-group">
661
669
<label class="form-label">Available Roles (one per line)</label>
662
-
<textarea class="form-input form-textarea" id="availableRoles" placeholder="admin editor viewer" style="min-height: 6rem;"></textarea>
663
-
<p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Define which roles can be assigned to users for this app. Leave empty to allow free-text roles.</p>
670
+
<textarea class="form-input form-textarea" id="availableRoles"
671
+
placeholder="admin editor viewer" style="min-height: 6rem;"></textarea>
672
+
<p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Define which roles can be
673
+
assigned to users for this app. Leave empty to allow free-text roles.</p>
664
674
</div>
665
675
<div class="form-group">
666
676
<label class="form-label" for="defaultRole">Default Role</label>
667
677
<input type="text" class="form-input" id="defaultRole" placeholder="Leave empty for no default" />
668
-
<p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Automatically assigned when users first authorize this app.</p>
678
+
<p style="font-size: 0.75rem; color: var(--old-rose); margin-top: 0.5rem;">Automatically assigned
679
+
when users first authorize this app.</p>
669
680
</div>
670
681
<div class="form-actions">
671
-
<button type="button" class="btn" style="background: rgba(188, 141, 160, 0.2);" id="cancelBtn">cancel</button>
682
+
<button type="button" class="btn" style="background: rgba(188, 141, 160, 0.2);"
683
+
id="cancelBtn">cancel</button>
672
684
<button type="submit" class="btn">save</button>
673
685
</div>
674
686
</form>
···
686
698
⚠️ Save these credentials now. You won't be able to see the secret again!
687
699
</p>
688
700
<div style="margin-bottom: 1rem;">
689
-
<label style="display: block; color: var(--old-rose); font-size: 0.875rem; margin-bottom: 0.5rem;">Client ID</label>
690
-
<div style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;">
691
-
<code id="generatedClientId" style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code>
701
+
<label
702
+
style="display: block; color: var(--old-rose); font-size: 0.875rem; margin-bottom: 0.5rem;">Client
703
+
ID</label>
704
+
<div
705
+
style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;">
706
+
<code id="generatedClientId"
707
+
style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code>
692
708
</div>
693
-
<button class="btn" id="copyClientIdBtn" style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy client id</button>
709
+
<button class="btn" id="copyClientIdBtn"
710
+
style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy client id</button>
694
711
</div>
695
712
<div style="margin-bottom: 1rem;">
696
-
<label style="display: block; color: var(--old-rose); font-size: 0.875rem; margin-bottom: 0.5rem;">Client Secret</label>
697
-
<div style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;">
698
-
<code id="generatedSecret" style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code>
713
+
<label
714
+
style="display: block; color: var(--old-rose); font-size: 0.875rem; margin-bottom: 0.5rem;">Client
715
+
Secret</label>
716
+
<div
717
+
style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;">
718
+
<code id="generatedSecret"
719
+
style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code>
699
720
</div>
700
-
<button class="btn" id="copySecretBtn" style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy secret</button>
721
+
<button class="btn" id="copySecretBtn"
722
+
style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy secret</button>
701
723
</div>
702
724
</div>
703
725
</div>
···
706
728
<script type="module" src="../client/admin-clients.ts"></script>
707
729
</body>
708
730
709
-
</html>
731
+
</html>
+1
-1
src/html/login.html
+1
-1
src/html/login.html
+16
-7
src/index.ts
+16
-7
src/index.ts
···
199
199
if (req.method === "POST") {
200
200
const url = new URL(req.url);
201
201
const userId = url.pathname.split("/")[4];
202
-
return disableUser(req, userId);
202
+
return disableUser(req, userId || "");
203
203
}
204
204
return new Response("Method not allowed", { status: 405 });
205
205
},
···
207
207
if (req.method === "POST") {
208
208
const url = new URL(req.url);
209
209
const userId = url.pathname.split("/")[4];
210
-
return enableUser(req, userId);
210
+
return enableUser(req, userId || "");
211
211
}
212
212
return new Response("Method not allowed", { status: 405 });
213
213
},
···
215
215
if (req.method === "PUT") {
216
216
const url = new URL(req.url);
217
217
const userId = url.pathname.split("/")[4];
218
-
return updateUserTier(req, userId);
218
+
return updateUserTier(req, userId || "");
219
219
}
220
220
return new Response("Method not allowed", { status: 405 });
221
221
},
···
223
223
if (req.method === "DELETE") {
224
224
const url = new URL(req.url);
225
225
const userId = url.pathname.split("/")[4];
226
-
return deleteUser(req, userId);
226
+
return deleteUser(req, userId || "");
227
227
}
228
228
return new Response("Method not allowed", { status: 405 });
229
229
},
···
365
365
366
366
if (expiredOrphans.length > 0) {
367
367
if (action === "suspend") {
368
-
await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "suspend");
368
+
await updateOrphanedAccounts(
369
+
{ ...result, orphanedUsers: expiredOrphans },
370
+
"suspend",
371
+
);
369
372
} else if (action === "deactivate") {
370
-
await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "deactivate");
373
+
await updateOrphanedAccounts(
374
+
{ ...result, orphanedUsers: expiredOrphans },
375
+
"deactivate",
376
+
);
371
377
} else if (action === "remove") {
372
-
await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "remove");
378
+
await updateOrphanedAccounts(
379
+
{ ...result, orphanedUsers: expiredOrphans },
380
+
"remove",
381
+
);
373
382
}
374
383
console.log(
375
384
`[LDAP Cleanup] ${action === "remove" ? "Removed" : action === "suspend" ? "Suspended" : "Deactivated"} ${expiredOrphans.length} LDAP orphan accounts (grace period: ${gracePeriod}s)`,
+1
-1
src/ldap-cleanup.ts
+1
-1
src/ldap-cleanup.ts
+16
-6
src/routes/api.ts
+16
-6
src/routes/api.ts
···
1
1
import { db } from "../db";
2
-
import { verifyDomain, validateProfileURL } from "./indieauth";
2
+
import { validateProfileURL, verifyDomain } from "./indieauth";
3
3
4
4
function getSessionUser(
5
5
req: Request,
6
-
): { username: string; userId: number; is_admin: boolean; tier: string } | Response {
6
+
):
7
+
| { username: string; userId: number; is_admin: boolean; tier: string }
8
+
| Response {
7
9
const authHeader = req.headers.get("Authorization");
8
10
9
11
if (!authHeader || !authHeader.startsWith("Bearer ")) {
···
193
195
const origin = process.env.ORIGIN || "http://localhost:3000";
194
196
const indikoProfileUrl = `${origin}/u/${user.username}`;
195
197
196
-
const verification = await verifyDomain(validation.canonicalUrl!, indikoProfileUrl);
198
+
const verification = await verifyDomain(
199
+
validation.canonicalUrl || "",
200
+
indikoProfileUrl,
201
+
);
197
202
if (!verification.success) {
198
203
return Response.json(
199
204
{ error: verification.error || "Failed to verify domain" },
···
456
461
}
457
462
458
463
// Prevent disabling self
459
-
if (targetUserId === user.id) {
464
+
if (targetUserId === user.userId) {
460
465
return Response.json(
461
466
{ error: "Cannot disable your own account" },
462
467
{ status: 400 },
···
508
513
return Response.json({ success: true });
509
514
}
510
515
511
-
export async function updateUserTier(req: Request, userId: string): Promise<Response> {
516
+
export async function updateUserTier(
517
+
req: Request,
518
+
userId: string,
519
+
): Promise<Response> {
512
520
const user = getSessionUser(req);
513
521
if (user instanceof Response) {
514
522
return user;
···
536
544
537
545
const targetUser = db
538
546
.query("SELECT id, username, tier FROM users WHERE id = ?")
539
-
.get(targetUserId) as { id: number; username: string; tier: string } | undefined;
547
+
.get(targetUserId) as
548
+
| { id: number; username: string; tier: string }
549
+
| undefined;
540
550
541
551
if (!targetUser) {
542
552
return Response.json({ error: "User not found" }, { status: 404 });
+17
-6
src/routes/auth.ts
+17
-6
src/routes/auth.ts
···
1
1
import {
2
2
type AuthenticationResponseJSON,
3
+
generateAuthenticationOptions,
4
+
generateRegistrationOptions,
3
5
type PublicKeyCredentialCreationOptionsJSON,
4
6
type PublicKeyCredentialRequestOptionsJSON,
5
7
type RegistrationResponseJSON,
6
8
type VerifiedAuthenticationResponse,
7
9
type VerifiedRegistrationResponse,
8
-
generateAuthenticationOptions,
9
-
generateRegistrationOptions,
10
10
verifyAuthenticationResponse,
11
11
verifyRegistrationResponse,
12
12
} from "@simplewebauthn/server";
13
13
import { authenticate } from "ldap-authentication";
14
14
import { db } from "../db";
15
-
import { checkLdapGroupMembership } from "../ldap-cleanup";
15
+
import { checkLdapGroupMembership, checkLdapUser } from "../ldap-cleanup";
16
16
17
17
const RP_NAME = "Indiko";
18
18
···
381
381
382
382
// Check if user exists and is active
383
383
const user = db
384
-
.query("SELECT id, status, provisioned_via_ldap, last_ldap_verified_at FROM users WHERE username = ?")
385
-
.get(username) as { id: number; status: string; provisioned_via_ldap: number; last_ldap_verified_at: number | null } | undefined;
384
+
.query(
385
+
"SELECT id, status, provisioned_via_ldap, last_ldap_verified_at FROM users WHERE username = ?",
386
+
)
387
+
.get(username) as
388
+
| {
389
+
id: number;
390
+
status: string;
391
+
provisioned_via_ldap: number;
392
+
last_ldap_verified_at: number | null;
393
+
}
394
+
| undefined;
386
395
387
396
if (!user) {
388
397
return Response.json({ error: "Invalid credentials" }, { status: 401 });
···
405
414
const existsInLdap = await checkLdapUser(username);
406
415
if (!existsInLdap) {
407
416
// User no longer exists in LDAP - suspend the account
408
-
db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run(user.id);
417
+
db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run(
418
+
user.id,
419
+
);
409
420
return Response.json(
410
421
{ error: "Invalid credentials" },
411
422
{ status: 401 },
+5
-3
src/routes/clients.ts
+5
-3
src/routes/clients.ts
···
1
-
import crypto from "crypto";
1
+
import crypto from "node:crypto";
2
2
import { nanoid } from "nanoid";
3
3
import { db } from "../db";
4
4
···
16
16
17
17
function getSessionUser(
18
18
req: Request,
19
-
): { username: string; userId: number; is_admin: boolean; tier: string } | Response {
19
+
):
20
+
| { username: string; userId: number; is_admin: boolean; tier: string }
21
+
| Response {
20
22
const authHeader = req.headers.get("Authorization");
21
23
22
24
if (!authHeader || !authHeader.startsWith("Bearer ")) {
···
119
121
if (!rolesByApp.has(app_id)) {
120
122
rolesByApp.set(app_id, []);
121
123
}
122
-
rolesByApp.get(app_id)!.push(role);
124
+
rolesByApp.get(app_id)?.push(role);
123
125
}
124
126
125
127
return Response.json({
+210
-79
src/routes/indieauth.ts
+210
-79
src/routes/indieauth.ts
···
1
-
import crypto from "crypto";
1
+
import crypto from "node:crypto";
2
+
import type { BunRequest } from "bun";
2
3
import { db } from "../db";
3
4
4
5
interface SessionUser {
···
53
54
username: session.username,
54
55
userId: session.id,
55
56
isAdmin: session.is_admin === 1,
57
+
tier: session.tier,
56
58
};
57
59
}
58
60
···
68
70
}),
69
71
);
70
72
71
-
const sessionToken = cookies["indiko_session"];
73
+
const sessionToken = cookies.indiko_session;
72
74
if (!sessionToken) return null;
73
75
74
76
const session = db
···
127
129
}
128
130
129
131
// Validate profile URL per IndieAuth spec
130
-
export function validateProfileURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } {
132
+
export function validateProfileURL(urlString: string): {
133
+
valid: boolean;
134
+
error?: string;
135
+
canonicalUrl?: string;
136
+
} {
131
137
let url: URL;
132
138
try {
133
139
url = new URL(urlString);
···
152
158
153
159
// MUST NOT contain username/password
154
160
if (url.username || url.password) {
155
-
return { valid: false, error: "Profile URL must not contain username or password" };
161
+
return {
162
+
valid: false,
163
+
error: "Profile URL must not contain username or password",
164
+
};
156
165
}
157
166
158
167
// MUST NOT contain ports
···
164
173
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
165
174
const ipv6Regex = /^\[?[0-9a-fA-F:]+\]?$/;
166
175
if (ipv4Regex.test(url.hostname) || ipv6Regex.test(url.hostname)) {
167
-
return { valid: false, error: "Profile URL must use domain names, not IP addresses" };
176
+
return {
177
+
valid: false,
178
+
error: "Profile URL must use domain names, not IP addresses",
179
+
};
168
180
}
169
181
170
182
// MUST NOT contain single-dot or double-dot path segments
171
183
const pathSegments = url.pathname.split("/");
172
184
if (pathSegments.includes(".") || pathSegments.includes("..")) {
173
-
return { valid: false, error: "Profile URL must not contain . or .. path segments" };
185
+
return {
186
+
valid: false,
187
+
error: "Profile URL must not contain . or .. path segments",
188
+
};
174
189
}
175
190
176
191
return { valid: true, canonicalUrl: canonicalizeURL(urlString) };
177
192
}
178
193
179
194
// Validate client URL per IndieAuth spec
180
-
function validateClientURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } {
195
+
function validateClientURL(urlString: string): {
196
+
valid: boolean;
197
+
error?: string;
198
+
canonicalUrl?: string;
199
+
} {
181
200
let url: URL;
182
201
try {
183
202
url = new URL(urlString);
···
202
221
203
222
// MUST NOT contain username/password
204
223
if (url.username || url.password) {
205
-
return { valid: false, error: "Client URL must not contain username or password" };
224
+
return {
225
+
valid: false,
226
+
error: "Client URL must not contain username or password",
227
+
};
206
228
}
207
229
208
230
// MUST NOT contain single-dot or double-dot path segments
209
231
const pathSegments = url.pathname.split("/");
210
232
if (pathSegments.includes(".") || pathSegments.includes("..")) {
211
-
return { valid: false, error: "Client URL must not contain . or .. path segments" };
233
+
return {
234
+
valid: false,
235
+
error: "Client URL must not contain . or .. path segments",
236
+
};
212
237
}
213
238
214
239
// MAY use loopback interface, but not other IP addresses
···
217
242
if (ipv4Regex.test(url.hostname)) {
218
243
// Allow 127.0.0.1 (loopback), reject others
219
244
if (!url.hostname.startsWith("127.")) {
220
-
return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" };
245
+
return {
246
+
valid: false,
247
+
error:
248
+
"Client URL must use domain names, not IP addresses (except loopback)",
249
+
};
221
250
}
222
251
} else if (ipv6Regex.test(url.hostname)) {
223
252
// Allow ::1 (loopback), reject others
224
253
const ipv6Match = url.hostname.match(ipv6Regex);
225
254
if (ipv6Match && ipv6Match[1] !== "::1") {
226
-
return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" };
255
+
return {
256
+
valid: false,
257
+
error:
258
+
"Client URL must use domain names, not IP addresses (except loopback)",
259
+
};
227
260
}
228
261
}
229
262
···
234
267
function isLoopbackURL(urlString: string): boolean {
235
268
try {
236
269
const url = new URL(urlString);
237
-
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname.startsWith("127.");
270
+
return (
271
+
url.hostname === "localhost" ||
272
+
url.hostname === "127.0.0.1" ||
273
+
url.hostname === "[::1]" ||
274
+
url.hostname.startsWith("127.")
275
+
);
238
276
} catch {
239
277
return false;
240
278
}
···
254
292
}> {
255
293
// MUST NOT fetch loopback addresses (security requirement)
256
294
if (isLoopbackURL(clientId)) {
257
-
return { success: false, error: "Cannot fetch metadata from loopback addresses" };
295
+
return {
296
+
success: false,
297
+
error: "Cannot fetch metadata from loopback addresses",
298
+
};
258
299
}
259
300
260
301
try {
···
273
314
clearTimeout(timeoutId);
274
315
275
316
if (!response.ok) {
276
-
return { success: false, error: `Failed to fetch client metadata: HTTP ${response.status}` };
317
+
return {
318
+
success: false,
319
+
error: `Failed to fetch client metadata: HTTP ${response.status}`,
320
+
};
277
321
}
278
322
279
323
const contentType = response.headers.get("content-type") || "";
···
284
328
285
329
// Verify client_id matches
286
330
if (metadata.client_id && metadata.client_id !== clientId) {
287
-
return { success: false, error: "client_id in metadata does not match URL" };
331
+
return {
332
+
success: false,
333
+
error: "client_id in metadata does not match URL",
334
+
};
288
335
}
289
336
290
337
return { success: true, metadata };
···
295
342
const html = await response.text();
296
343
297
344
// Extract redirect URIs from link tags
298
-
const redirectUriRegex = /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
345
+
const redirectUriRegex =
346
+
/<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
299
347
const redirectUris: string[] = [];
300
-
let match: RegExpExecArray | null;
348
+
let match = redirectUriRegex.exec(html);
301
349
302
-
while ((match = redirectUriRegex.exec(html)) !== null) {
303
-
redirectUris.push(match[1]);
350
+
while (match !== null) {
351
+
redirectUris.push(match[1] || "");
352
+
match = redirectUriRegex.exec(html);
304
353
}
305
354
306
355
// 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]);
356
+
const redirectUriRegex2 =
357
+
/<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi;
358
+
match = redirectUriRegex2.exec(html);
359
+
while (match !== null) {
360
+
if (!redirectUris.includes(match[1] || "")) {
361
+
redirectUris.push(match[1] || "");
311
362
}
363
+
match = redirectUriRegex2.exec(html);
312
364
}
313
365
314
366
if (redirectUris.length > 0) {
···
321
373
};
322
374
}
323
375
324
-
return { success: false, error: "No client metadata or redirect_uri links found in HTML" };
376
+
return {
377
+
success: false,
378
+
error: "No client metadata or redirect_uri links found in HTML",
379
+
};
325
380
}
326
381
327
382
return { success: false, error: "Unsupported content type" };
···
330
385
if (error.name === "AbortError") {
331
386
return { success: false, error: "Timeout fetching client metadata" };
332
387
}
333
-
return { success: false, error: `Failed to fetch client metadata: ${error.message}` };
388
+
return {
389
+
success: false,
390
+
error: `Failed to fetch client metadata: ${error.message}`,
391
+
};
334
392
}
335
393
return { success: false, error: "Failed to fetch client metadata" };
336
394
}
337
395
}
338
396
339
397
// Verify domain has rel="me" link back to user profile
340
-
export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): Promise<{
398
+
export async function verifyDomain(
399
+
domainUrl: string,
400
+
indikoProfileUrl: string,
401
+
): Promise<{
341
402
success: boolean;
342
403
error?: string;
343
404
}> {
···
359
420
360
421
if (!response.ok) {
361
422
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}` };
423
+
console.error(
424
+
`[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`,
425
+
{
426
+
status: response.status,
427
+
contentType: response.headers.get("content-type"),
428
+
bodyPreview: errorBody.substring(0, 200),
429
+
},
430
+
);
431
+
return {
432
+
success: false,
433
+
error: `Failed to fetch domain: HTTP ${response.status}`,
434
+
};
368
435
}
369
436
370
437
const html = await response.text();
···
384
451
385
452
const relValue = relMatch[1];
386
453
// Check if "me" is a separate word in the rel attribute
387
-
if (!relValue.split(/\s+/).includes("me")) return null;
454
+
if (!relValue?.split(/\s+/).includes("me")) return null;
388
455
389
456
// Extract href (handle quoted and unquoted attributes)
390
457
const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i);
···
394
461
};
395
462
396
463
// Process all link tags
397
-
let linkMatch;
398
-
while ((linkMatch = linkRegex.exec(html)) !== null) {
464
+
let linkMatch = linkRegex.exec(html);
465
+
while (linkMatch !== null) {
399
466
const href = processTag(linkMatch[0]);
400
467
if (href && !relMeLinks.includes(href)) {
401
468
relMeLinks.push(href);
402
469
}
470
+
linkMatch = linkRegex.exec(html);
403
471
}
404
472
405
473
// Process all a tags
406
-
let aMatch;
407
-
while ((aMatch = aRegex.exec(html)) !== null) {
474
+
let aMatch = aRegex.exec(html);
475
+
while (aMatch !== null) {
408
476
const href = processTag(aMatch[0]);
409
477
if (href && !relMeLinks.includes(href)) {
410
478
relMeLinks.push(href);
411
479
}
480
+
aMatch = aRegex.exec(html);
412
481
}
413
482
414
483
// Check if any rel="me" link matches the indiko profile URL
415
484
const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl);
416
-
const hasRelMe = relMeLinks.some(link => {
485
+
const hasRelMe = relMeLinks.some((link) => {
417
486
try {
418
487
const normalizedLink = canonicalizeURL(link);
419
488
return normalizedLink === normalizedIndikoUrl;
···
423
492
});
424
493
425
494
if (!hasRelMe) {
426
-
console.error(`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, {
427
-
foundLinks: relMeLinks,
428
-
normalizedTarget: normalizedIndikoUrl,
429
-
});
495
+
console.error(
496
+
`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`,
497
+
{
498
+
foundLinks: relMeLinks,
499
+
normalizedTarget: normalizedIndikoUrl,
500
+
},
501
+
);
430
502
return {
431
503
success: false,
432
504
error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`,
···
440
512
console.error(`[verifyDomain] Timeout verifying ${domainUrl}`);
441
513
return { success: false, error: "Timeout verifying domain" };
442
514
}
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}` };
515
+
console.error(
516
+
`[verifyDomain] Error verifying ${domainUrl}: ${error.message}`,
517
+
{
518
+
name: error.name,
519
+
stack: error.stack,
520
+
},
521
+
);
522
+
return {
523
+
success: false,
524
+
error: `Failed to verify domain: ${error.message}`,
525
+
};
448
526
}
449
-
console.error(`[verifyDomain] Unknown error verifying ${domainUrl}:`, error);
527
+
console.error(
528
+
`[verifyDomain] Unknown error verifying ${domainUrl}:`,
529
+
error,
530
+
);
450
531
return { success: false, error: "Failed to verify domain" };
451
532
}
452
533
}
···
457
538
redirectUri: string,
458
539
): Promise<{
459
540
error?: string;
460
-
app?: { name: string | null; redirect_uris: string; logo_url?: string | null };
541
+
app?: {
542
+
name: string | null;
543
+
redirect_uris: string;
544
+
logo_url?: string | null;
545
+
};
461
546
}> {
462
547
const existing = db
463
548
.query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?")
···
474
559
};
475
560
}
476
561
477
-
const canonicalClientId = validation.canonicalUrl!;
562
+
const canonicalClientId = validation.canonicalUrl || "";
478
563
479
564
// Fetch client metadata per IndieAuth spec
480
565
const metadataResult = await fetchClientMetadata(canonicalClientId);
···
550
635
551
636
// Fetch the newly created app
552
637
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 };
638
+
.query(
639
+
"SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?",
640
+
)
641
+
.get(canonicalClientId) as {
642
+
name: string | null;
643
+
redirect_uris: string;
644
+
logo_url?: string | null;
645
+
};
555
646
556
647
return { app: newApp };
557
648
}
···
800
891
);
801
892
}
802
893
803
-
const app = appResult.app!;
894
+
const app = appResult.app as NonNullable<typeof appResult.app>;
804
895
805
896
const allowedRedirects = JSON.parse(app.redirect_uris) as string[];
806
897
if (!allowedRedirects.includes(redirectUri)) {
···
954
1045
).run(Math.floor(Date.now() / 1000), user.userId, clientId);
955
1046
956
1047
const origin = process.env.ORIGIN || "http://localhost:3000";
957
-
return Response.redirect(`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`);
1048
+
return Response.redirect(
1049
+
`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`,
1050
+
);
958
1051
}
959
1052
}
960
1053
···
1316
1409
// POST /auth/authorize - Consent form submission
1317
1410
export async function authorizePost(req: Request): Promise<Response> {
1318
1411
const contentType = req.headers.get("Content-Type");
1319
-
1412
+
1320
1413
// Parse the request body
1321
1414
let body: Record<string, string>;
1322
1415
let formData: FormData;
···
1334
1427
}
1335
1428
1336
1429
const grantType = body.grant_type;
1337
-
1430
+
1338
1431
// If grant_type is present, this is a token exchange request (IndieAuth profile scope only)
1339
1432
if (grantType === "authorization_code") {
1340
1433
// Create a mock request for token() function
1341
1434
const mockReq = new Request(req.url, {
1342
1435
method: "POST",
1343
1436
headers: req.headers,
1344
-
body: contentType?.includes("application/x-www-form-urlencoded")
1437
+
body: contentType?.includes("application/x-www-form-urlencoded")
1345
1438
? new URLSearchParams(body).toString()
1346
1439
: JSON.stringify(body),
1347
1440
});
···
1373
1466
clientId = canonicalizeURL(rawClientId);
1374
1467
redirectUri = canonicalizeURL(rawRedirectUri);
1375
1468
} catch {
1376
-
return new Response("Invalid client_id or redirect_uri URL format", { status: 400 });
1469
+
return new Response("Invalid client_id or redirect_uri URL format", {
1470
+
status: 400,
1471
+
});
1377
1472
}
1378
1473
1379
1474
if (action === "deny") {
···
1487
1582
let redirect_uri: string | undefined;
1488
1583
try {
1489
1584
client_id = raw_client_id ? canonicalizeURL(raw_client_id) : undefined;
1490
-
redirect_uri = raw_redirect_uri ? canonicalizeURL(raw_redirect_uri) : undefined;
1585
+
redirect_uri = raw_redirect_uri
1586
+
? canonicalizeURL(raw_redirect_uri)
1587
+
: undefined;
1491
1588
} catch {
1492
1589
return Response.json(
1493
1590
{
···
1502
1599
return Response.json(
1503
1600
{
1504
1601
error: "unsupported_grant_type",
1505
-
error_description: "Only authorization_code and refresh_token grant types are supported",
1602
+
error_description:
1603
+
"Only authorization_code and refresh_token grant types are supported",
1506
1604
},
1507
1605
{ status: 400 },
1508
1606
);
···
1577
1675
const expiresAt = now + expiresIn;
1578
1676
1579
1677
// 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);
1678
+
db.query("UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?").run(
1679
+
newAccessToken,
1680
+
expiresAt,
1681
+
tokenData.id,
1682
+
);
1583
1683
1584
1684
// Get user profile for me value
1585
1685
const user = db
···
1614
1714
headers: {
1615
1715
"Content-Type": "application/json",
1616
1716
"Cache-Control": "no-store",
1617
-
"Pragma": "no-cache",
1717
+
Pragma: "no-cache",
1618
1718
},
1619
1719
},
1620
1720
);
···
1626
1726
.query(
1627
1727
"SELECT is_preregistered, client_secret_hash FROM apps WHERE client_id = ?",
1628
1728
)
1629
-
.get(client_id) as
1729
+
.get(client_id || "") as
1630
1730
| { is_preregistered: number; client_secret_hash: string | null }
1631
1731
| undefined;
1632
1732
···
1727
1827
1728
1828
// Check if already used
1729
1829
if (authcode.used) {
1730
-
console.error("Token endpoint: authorization code already used", { code });
1830
+
console.error("Token endpoint: authorization code already used", {
1831
+
code,
1832
+
});
1731
1833
return Response.json(
1732
1834
{
1733
1835
error: "invalid_grant",
···
1740
1842
// Check if expired
1741
1843
const now = Math.floor(Date.now() / 1000);
1742
1844
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 });
1845
+
console.error("Token endpoint: authorization code expired", {
1846
+
code,
1847
+
expires_at: authcode.expires_at,
1848
+
now,
1849
+
diff: now - authcode.expires_at,
1850
+
});
1744
1851
return Response.json(
1745
1852
{
1746
1853
error: "invalid_grant",
···
1752
1859
1753
1860
// Verify client_id matches
1754
1861
if (authcode.client_id !== client_id) {
1755
-
console.error("Token endpoint: client_id mismatch", { stored: authcode.client_id, received: client_id });
1862
+
console.error("Token endpoint: client_id mismatch", {
1863
+
stored: authcode.client_id,
1864
+
received: client_id,
1865
+
});
1756
1866
return Response.json(
1757
1867
{
1758
1868
error: "invalid_grant",
···
1764
1874
1765
1875
// Verify redirect_uri matches
1766
1876
if (authcode.redirect_uri !== redirect_uri) {
1767
-
console.error("Token endpoint: redirect_uri mismatch", { stored: authcode.redirect_uri, received: redirect_uri });
1877
+
console.error("Token endpoint: redirect_uri mismatch", {
1878
+
stored: authcode.redirect_uri,
1879
+
received: redirect_uri,
1880
+
});
1768
1881
return Response.json(
1769
1882
{
1770
1883
error: "invalid_grant",
···
1776
1889
1777
1890
// Verify PKCE code_verifier (required for all clients per IndieAuth spec)
1778
1891
if (!verifyPKCE(code_verifier, authcode.code_challenge)) {
1779
-
console.error("Token endpoint: PKCE verification failed", { code_verifier, code_challenge: authcode.code_challenge });
1892
+
console.error("Token endpoint: PKCE verification failed", {
1893
+
code_verifier,
1894
+
code_challenge: authcode.code_challenge,
1895
+
});
1780
1896
return Response.json(
1781
1897
{
1782
1898
error: "invalid_grant",
···
1839
1955
1840
1956
// Validate that the user controls the requested me parameter
1841
1957
if (authcode.me && authcode.me !== meValue) {
1842
-
console.error("Token endpoint: me mismatch", { requested: authcode.me, actual: meValue });
1958
+
console.error("Token endpoint: me mismatch", {
1959
+
requested: authcode.me,
1960
+
actual: meValue,
1961
+
});
1843
1962
return Response.json(
1844
1963
{
1845
1964
error: "invalid_grant",
1846
-
error_description: "The requested identity does not match the user's verified domain",
1965
+
error_description:
1966
+
"The requested identity does not match the user's verified domain",
1847
1967
},
1848
1968
{ status: 400 },
1849
1969
);
1850
1970
}
1851
1971
1852
1972
const origin = process.env.ORIGIN || "http://localhost:3000";
1853
-
1973
+
1854
1974
// Generate access token
1855
1975
const accessToken = crypto.randomBytes(32).toString("base64url");
1856
1976
const expiresIn = 3600; // 1 hour
···
1864
1984
// Store token in database with refresh token
1865
1985
db.query(
1866
1986
"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);
1987
+
).run(
1988
+
accessToken,
1989
+
authcode.user_id,
1990
+
client_id,
1991
+
scopes.join(" "),
1992
+
expiresAt,
1993
+
refreshToken,
1994
+
refreshExpiresAt,
1995
+
);
1868
1996
1869
1997
const response: Record<string, unknown> = {
1870
1998
access_token: accessToken,
···
1882
2010
response.role = permission.role;
1883
2011
}
1884
2012
1885
-
console.log("Token endpoint: success", { me: meValue, scopes: scopes.join(" ") });
2013
+
console.log("Token endpoint: success", {
2014
+
me: meValue,
2015
+
scopes: scopes.join(" "),
2016
+
});
1886
2017
1887
2018
return Response.json(response, {
1888
2019
headers: {
1889
2020
"Content-Type": "application/json",
1890
2021
"Cache-Control": "no-store",
1891
-
"Pragma": "no-cache",
2022
+
Pragma: "no-cache",
1892
2023
},
1893
2024
});
1894
2025
} catch (error) {
···
2052
2183
try {
2053
2184
// Get access token from Authorization header
2054
2185
const authHeader = req.headers.get("Authorization");
2055
-
2186
+
2056
2187
if (!authHeader || !authHeader.startsWith("Bearer ")) {
2057
2188
return Response.json(
2058
2189
{
···
2175
2306
}
2176
2307
2177
2308
// GET /u/:username - Public user profile (h-card)
2178
-
export function userProfile(req: Request): Response {
2179
-
const username = (req as any).params?.username;
2309
+
export function userProfile(req: BunRequest): Response {
2310
+
const username = req.params?.username;
2180
2311
if (!username) {
2181
2312
return new Response("Username required", { status: 400 });
2182
2313
}
+10
-6
src/routes/passkeys.ts
+10
-6
src/routes/passkeys.ts
···
1
1
import {
2
-
type RegistrationResponseJSON,
3
2
generateRegistrationOptions,
3
+
type RegistrationResponseJSON,
4
4
type VerifiedRegistrationResponse,
5
5
verifyRegistrationResponse,
6
6
} from "@simplewebauthn/server";
···
75
75
.all(session.user_id) as Array<{ credential_id: Buffer }>;
76
76
77
77
const excludeCredentials = existingCredentials.map((cred) => ({
78
-
id: cred.credential_id,
78
+
id: cred.credential_id.toString("base64url"),
79
79
type: "public-key" as const,
80
80
}));
81
81
82
82
// Generate WebAuthn registration options
83
83
const options = await generateRegistrationOptions({
84
84
rpName: RP_NAME,
85
-
rpID: process.env.RP_ID!,
85
+
rpID: process.env.RP_ID || "",
86
86
userName: user.username,
87
87
userDisplayName: user.username,
88
88
attestationType: "none",
···
133
133
}
134
134
135
135
const body = await req.json();
136
-
const { response, challenge: expectedChallenge, name } = body as {
136
+
const {
137
+
response,
138
+
challenge: expectedChallenge,
139
+
name,
140
+
} = body as {
137
141
response: RegistrationResponseJSON;
138
142
challenge: string;
139
143
name?: string;
···
167
171
verification = await verifyRegistrationResponse({
168
172
response,
169
173
expectedChallenge: challenge.challenge,
170
-
expectedOrigin: process.env.ORIGIN!,
171
-
expectedRPID: process.env.RP_ID!,
174
+
expectedOrigin: process.env.ORIGIN || "",
175
+
expectedRPID: process.env.RP_ID || "",
172
176
});
173
177
} catch (error) {
174
178
console.error("WebAuthn verification failed:", error);
+28
types/global.d.ts
+28
types/global.d.ts
···
1
+
export {};
2
+
3
+
declare global {
4
+
interface Window {
5
+
closeEditInviteModal(): void;
6
+
deleteInvite(inviteId: number, event?: Event): Promise<void>;
7
+
submitCreateInvite(): Promise<void>;
8
+
closeCreateInviteModal(): void;
9
+
editInvite(inviteId: number): Promise<void>;
10
+
submitEditInvite(): Promise<void>;
11
+
toggleClient(clientId: string): Promise<void>;
12
+
setUserRole(
13
+
clientId: string,
14
+
username: string,
15
+
role: string,
16
+
): Promise<void>;
17
+
editClient(clientId: string): Promise<void>;
18
+
deleteClient(clientId: string, event?: Event): Promise<void>;
19
+
removeRedirectUri(btn: HTMLButtonElement): void;
20
+
regenerateSecret(clientId: string, event?: Event): Promise<void>;
21
+
revokeUserPermission(
22
+
clientId: string,
23
+
username: string,
24
+
event?: Event,
25
+
): Promise<void>;
26
+
revokeApp(clientId: string, event?: Event): Promise<void>;
27
+
}
28
+
}
History
1 round
0 comments
avycado13.tngl.sh
submitted
#0
1 commit
expand
collapse
20c6a038
From 20c6a038d5de182fbf0d5b806bd790b621c8df41 Mon Sep 17 00:00:00 2001
- Fix userId property access (user.id → user.userId) in disableUser
- Add null coalescing for optional userId path params
- Replace non-null assertions with fallback values
- Use node: prefix for crypto imports
- Fix unused error variable in catch block
- Add missing checkLdapUser import
- Add SessionUser.tier property and BunRequest type for userProfile
- Fix passkey excludeCredentials id encoding
- Add global.d.ts for Window interface augmentation
- Format improvements throughout
expand 0 comments
closed without merging