+24
-3
README.md
+24
-3
README.md
···
130
131
Now you can sign in to IndieAuth-compatible sites using `https://your-domain.com/` as your identity.
132
133
## API Reference
134
135
-
### OAuth 2.0 Endpoints
136
137
-
- `GET /auth/authorize` - Authorization endpoint
138
-
- `POST /auth/token` - Token exchange endpoint
139
- `POST /auth/logout` - Session logout
140
141
### User Profile
···
130
131
Now you can sign in to IndieAuth-compatible sites using `https://your-domain.com/` as your identity.
132
133
+
### Using as an OpenID Connect (OIDC) Provider
134
+
135
+
Indiko also supports OpenID Connect (OIDC) for modern authentication flows:
136
+
137
+
**Discovery endpoint:**
138
+
```
139
+
https://your-indiko-domain.com/.well-known/openid-configuration
140
+
```
141
+
142
+
**Key features:**
143
+
- Authorization Code Flow with PKCE
144
+
- ID Token with RS256 signing
145
+
- JWKS endpoint for token verification
146
+
- Support for `openid`, `profile`, and `email` scopes
147
+
- Userinfo endpoint for retrieving user claims
148
+
149
+
Test your OIDC setup using the [OIDC Debugger](https://oidcdebugger.com/).
150
+
151
## API Reference
152
153
+
### OAuth 2.0 / OpenID Connect Endpoints
154
155
+
- `GET /auth/authorize` - Authorization endpoint (OAuth 2.0 / OIDC)
156
+
- `POST /auth/token` - Token exchange endpoint (returns access token and ID token for OIDC)
157
+
- `GET /userinfo` - OIDC userinfo endpoint (returns user claims)
158
+
- `GET /.well-known/openid-configuration` - OIDC discovery document
159
+
- `GET /jwks` - JSON Web Key Set for ID token verification
160
- `POST /auth/logout` - Session logout
161
162
### User Profile
+140
SPEC.md
+140
SPEC.md
···
497
// Create session for user
498
```
499
500
## Future Enhancements
501
502
- Token endpoint for longer-lived access tokens
···
509
- Audit log for admin
510
- Rate limiting
511
- Account recovery flow
512
513
## Standards Compliance
514
···
516
- [WebAuthn/FIDO2](https://www.w3.org/TR/webauthn-2/)
517
- [OAuth 2.0 PKCE](https://tools.ietf.org/html/rfc7636)
518
- [Microformats h-card](http://microformats.org/wiki/h-card)
···
497
// Create session for user
498
```
499
500
+
## OpenID Connect (OIDC) Support
501
+
502
+
Indiko implements OpenID Connect Core 1.0 as an identity layer on top of OAuth 2.0, enabling "Sign in with Indiko" for any OIDC-compatible application.
503
+
504
+
### Overview
505
+
506
+
OIDC extends the existing OAuth 2.0 authorization flow by:
507
+
- Adding the `openid` scope to request identity information
508
+
- Returning an **ID Token** (signed JWT) alongside the authorization code exchange
509
+
- Providing a standardized `/userinfo` endpoint
510
+
- Publishing discovery metadata at `/.well-known/openid-configuration`
511
+
512
+
### Supported Scopes
513
+
514
+
| Scope | Claims Returned |
515
+
|-------|-----------------|
516
+
| `openid` | `sub`, `iss`, `aud`, `exp`, `iat`, `auth_time` |
517
+
| `profile` | `name`, `picture`, `website` |
518
+
| `email` | `email` |
519
+
520
+
### OIDC Endpoints
521
+
522
+
#### `GET /.well-known/openid-configuration`
523
+
Discovery document for OIDC clients.
524
+
525
+
**Response:**
526
+
```json
527
+
{
528
+
"issuer": "https://indiko.yourdomain.com",
529
+
"authorization_endpoint": "https://indiko.yourdomain.com/auth/authorize",
530
+
"token_endpoint": "https://indiko.yourdomain.com/auth/token",
531
+
"userinfo_endpoint": "https://indiko.yourdomain.com/auth/userinfo",
532
+
"jwks_uri": "https://indiko.yourdomain.com/jwks",
533
+
"scopes_supported": ["openid", "profile", "email"],
534
+
"response_types_supported": ["code"],
535
+
"grant_types_supported": ["authorization_code"],
536
+
"subject_types_supported": ["public"],
537
+
"id_token_signing_alg_values_supported": ["RS256"],
538
+
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
539
+
"claims_supported": ["sub", "iss", "aud", "exp", "iat", "auth_time", "name", "email", "picture", "website"],
540
+
"code_challenge_methods_supported": ["S256"]
541
+
}
542
+
```
543
+
544
+
#### `GET /jwks`
545
+
JSON Web Key Set containing the public key for ID Token verification.
546
+
547
+
**Response:**
548
+
```json
549
+
{
550
+
"keys": [
551
+
{
552
+
"kty": "RSA",
553
+
"use": "sig",
554
+
"alg": "RS256",
555
+
"kid": "indiko-oidc-key-1",
556
+
"n": "...",
557
+
"e": "AQAB"
558
+
}
559
+
]
560
+
}
561
+
```
562
+
563
+
### ID Token
564
+
565
+
When the `openid` scope is requested, the token endpoint returns an `id_token` JWT:
566
+
567
+
**Token Endpoint Response (with openid scope):**
568
+
```json
569
+
{
570
+
"me": "https://indiko.yourdomain.com/u/kieran",
571
+
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImluZGlrby1vaWRjLWtleS0xIn0...",
572
+
"profile": {
573
+
"name": "Kieran Klukas",
574
+
"email": "kieran@example.com",
575
+
"photo": "https://...",
576
+
"url": "https://kierank.dev"
577
+
}
578
+
}
579
+
```
580
+
581
+
**ID Token Claims:**
582
+
```json
583
+
{
584
+
"iss": "https://indiko.yourdomain.com",
585
+
"sub": "https://indiko.yourdomain.com/u/kieran",
586
+
"aud": "https://blog.kierank.dev",
587
+
"exp": 1234567890,
588
+
"iat": 1234567800,
589
+
"auth_time": 1234567700,
590
+
"nonce": "abc123",
591
+
"name": "Kieran Klukas",
592
+
"email": "kieran@example.com",
593
+
"picture": "https://...",
594
+
"website": "https://kierank.dev"
595
+
}
596
+
```
597
+
598
+
### OIDC Authorization Flow
599
+
600
+
1. Client initiates authorization with `scope=openid profile email`
601
+
2. User authenticates and consents (same as IndieAuth)
602
+
3. Client receives authorization code
603
+
4. Client exchanges code at `/auth/token` with `code_verifier`
604
+
5. Token endpoint returns `id_token` JWT + profile data
605
+
6. Client verifies `id_token` signature using keys from `/jwks`
606
+
607
+
### Key Management
608
+
609
+
- RSA 2048-bit key pair generated on first OIDC request
610
+
- Private key stored in database (`oidc_keys` table)
611
+
- Key rotation: manual via admin interface (future)
612
+
- Key ID format: `indiko-oidc-key-{version}`
613
+
614
+
### Data Structures
615
+
616
+
#### OIDC Keys
617
+
```
618
+
oidc_keys -> {
619
+
id: number,
620
+
kid: string, // e.g. "indiko-oidc-key-1"
621
+
private_key: string, // PEM-encoded RSA private key
622
+
public_key: string, // PEM-encoded RSA public key
623
+
created_at: timestamp,
624
+
is_active: boolean
625
+
}
626
+
```
627
+
628
+
#### Authorization Code (Extended)
629
+
```
630
+
authcode:{code} -> {
631
+
...existing fields...,
632
+
nonce?: string, // OIDC nonce for replay protection
633
+
auth_time: timestamp // when user authenticated
634
+
}
635
+
```
636
+
637
## Future Enhancements
638
639
- Token endpoint for longer-lived access tokens
···
646
- Audit log for admin
647
- Rate limiting
648
- Account recovery flow
649
+
- OIDC key rotation via admin interface
650
651
## Standards Compliance
652
···
654
- [WebAuthn/FIDO2](https://www.w3.org/TR/webauthn-2/)
655
- [OAuth 2.0 PKCE](https://tools.ietf.org/html/rfc7636)
656
- [Microformats h-card](http://microformats.org/wiki/h-card)
657
+
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
658
+
- [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html)
+3
bun.lock
+3
bun.lock
···
8
"@simplewebauthn/browser": "^13.2.2",
9
"@simplewebauthn/server": "^13.2.2",
10
"bun-sqlite-migrations": "^1.0.2",
11
"ldap-authentication": "^3.3.6",
12
"nanoid": "^5.1.6",
13
},
···
70
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
71
72
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
73
74
"ldap-authentication": ["ldap-authentication@3.3.6", "", { "dependencies": { "ldapts": "^7.3.1" } }, "sha512-j8XxH5wGXhIQ3mMnoRCZTalSLmPhzEaGH4+5RIFP0Higc32fCKDRJBo+9wb5ysy9TnlaNtaf+rgdwYCD15OBpQ=="],
75
···
8
"@simplewebauthn/browser": "^13.2.2",
9
"@simplewebauthn/server": "^13.2.2",
10
"bun-sqlite-migrations": "^1.0.2",
11
+
"jose": "^6.1.3",
12
"ldap-authentication": "^3.3.6",
13
"nanoid": "^5.1.6",
14
},
···
71
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
72
73
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
74
+
75
+
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
76
77
"ldap-authentication": ["ldap-authentication@3.3.6", "", { "dependencies": { "ldapts": "^7.3.1" } }, "sha512-j8XxH5wGXhIQ3mMnoRCZTalSLmPhzEaGH4+5RIFP0Higc32fCKDRJBo+9wb5ysy9TnlaNtaf+rgdwYCD15OBpQ=="],
78
+1
package.json
+1
package.json
+240
scripts/reset-passkey.ts
+240
scripts/reset-passkey.ts
···
···
1
+
#!/usr/bin/env bun
2
+
/**
3
+
* Passkey Reset Script
4
+
*
5
+
* Resets a user's passkey credentials and generates a one-time reset link.
6
+
* The user can use this link to register a new passkey while preserving
7
+
* their existing account, permissions, and app authorizations.
8
+
*
9
+
* Usage: bun scripts/reset-passkey.ts <username>
10
+
*
11
+
* Example:
12
+
* bun scripts/reset-passkey.ts kieran
13
+
*
14
+
* The script will:
15
+
* 1. Verify the user exists
16
+
* 2. Delete all their existing passkey credentials
17
+
* 3. Invalidate all active sessions (logs them out)
18
+
* 4. Create a single-use reset invite locked to their username
19
+
* 5. Output a reset link
20
+
*
21
+
* IMPORTANT: This preserves:
22
+
* - User account and profile data
23
+
* - All app permissions and authorizations
24
+
* - Role assignments
25
+
* - Admin status
26
+
*/
27
+
28
+
import { Database } from "bun:sqlite";
29
+
import crypto from "node:crypto";
30
+
import * as path from "node:path";
31
+
32
+
// Load database
33
+
const dbPath = path.join(import.meta.dir, "..", "data", "indiko.db");
34
+
const db = new Database(dbPath);
35
+
36
+
const ORIGIN = process.env.ORIGIN || "http://localhost:3000";
37
+
38
+
interface User {
39
+
id: number;
40
+
username: string;
41
+
name: string;
42
+
email: string | null;
43
+
status: string;
44
+
is_admin: number;
45
+
}
46
+
47
+
interface Credential {
48
+
id: number;
49
+
name: string | null;
50
+
created_at: number;
51
+
}
52
+
53
+
function getUser(username: string): User | null {
54
+
return db
55
+
.query("SELECT id, username, name, email, status, is_admin FROM users WHERE username = ?")
56
+
.get(username) as User | null;
57
+
}
58
+
59
+
function getCredentials(userId: number): Credential[] {
60
+
return db
61
+
.query("SELECT id, name, created_at FROM credentials WHERE user_id = ?")
62
+
.all(userId) as Credential[];
63
+
}
64
+
65
+
function deleteCredentials(userId: number): number {
66
+
const result = db
67
+
.query("DELETE FROM credentials WHERE user_id = ?")
68
+
.run(userId);
69
+
return result.changes;
70
+
}
71
+
72
+
function deleteSessions(userId: number): number {
73
+
const result = db
74
+
.query("DELETE FROM sessions WHERE user_id = ?")
75
+
.run(userId);
76
+
return result.changes;
77
+
}
78
+
79
+
function createResetInvite(adminUserId: number, targetUsername: string): string {
80
+
const code = crypto.randomBytes(16).toString("base64url");
81
+
const now = Math.floor(Date.now() / 1000);
82
+
const expiresAt = now + 86400; // 24 hours
83
+
84
+
// Check if there's a reset_username column, if not we'll use the note field
85
+
const hasResetColumn = db
86
+
.query("SELECT name FROM pragma_table_info('invites') WHERE name = 'reset_username'")
87
+
.get();
88
+
89
+
if (hasResetColumn) {
90
+
db.query(
91
+
"INSERT INTO invites (code, created_by, max_uses, current_uses, expires_at, note, reset_username) VALUES (?, ?, 1, 0, ?, ?, ?)",
92
+
).run(code, adminUserId, expiresAt, `Passkey reset for ${targetUsername}`, targetUsername);
93
+
} else {
94
+
// Use a special note format to indicate this is a reset invite
95
+
// Format: PASSKEY_RESET:username
96
+
db.query(
97
+
"INSERT INTO invites (code, created_by, max_uses, current_uses, expires_at, note, message) VALUES (?, ?, 1, 0, ?, ?, ?)",
98
+
).run(
99
+
code,
100
+
adminUserId,
101
+
expiresAt,
102
+
`PASSKEY_RESET:${targetUsername}`,
103
+
`Your passkey has been reset. Please register a new passkey to regain access to your account.`,
104
+
);
105
+
}
106
+
107
+
return code;
108
+
}
109
+
110
+
function getAdminUser(): User | null {
111
+
return db
112
+
.query("SELECT id, username, name, email, status, is_admin FROM users WHERE is_admin = 1 LIMIT 1")
113
+
.get() as User | null;
114
+
}
115
+
116
+
async function main() {
117
+
const args = process.argv.slice(2);
118
+
119
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
120
+
console.log(`
121
+
Passkey Reset Script
122
+
123
+
Usage: bun scripts/reset-passkey.ts <username>
124
+
125
+
Options:
126
+
--help, -h Show this help message
127
+
--dry-run Show what would happen without making changes
128
+
--force Skip confirmation prompt
129
+
130
+
Example:
131
+
bun scripts/reset-passkey.ts kieran
132
+
bun scripts/reset-passkey.ts kieran --dry-run
133
+
`);
134
+
process.exit(0);
135
+
}
136
+
137
+
const username = args.find((arg) => !arg.startsWith("--"));
138
+
const dryRun = args.includes("--dry-run");
139
+
const force = args.includes("--force");
140
+
141
+
if (!username) {
142
+
console.error("โ Error: Username is required");
143
+
process.exit(1);
144
+
}
145
+
146
+
console.log(`\n๐ Passkey Reset for: ${username}`);
147
+
console.log("โ".repeat(50));
148
+
149
+
// Look up user
150
+
const user = getUser(username);
151
+
if (!user) {
152
+
console.error(`\nโ Error: User '${username}' not found`);
153
+
process.exit(1);
154
+
}
155
+
156
+
console.log(`\n๐ User Details:`);
157
+
console.log(` โข ID: ${user.id}`);
158
+
console.log(` โข Name: ${user.name}`);
159
+
console.log(` โข Email: ${user.email || "(not set)"}`);
160
+
console.log(` โข Status: ${user.status}`);
161
+
console.log(` โข Admin: ${user.is_admin ? "Yes" : "No"}`);
162
+
163
+
// Get existing credentials
164
+
const credentials = getCredentials(user.id);
165
+
console.log(`\n๐ Existing Passkeys: ${credentials.length}`);
166
+
credentials.forEach((cred, idx) => {
167
+
const date = new Date(cred.created_at * 1000).toISOString().split("T")[0];
168
+
console.log(` ${idx + 1}. ${cred.name || "(unnamed)"} - created ${date}`);
169
+
});
170
+
171
+
if (credentials.length === 0) {
172
+
console.log("\nโ ๏ธ User has no passkeys registered. Creating reset link anyway...");
173
+
}
174
+
175
+
if (dryRun) {
176
+
console.log("\n๐ DRY RUN - No changes will be made");
177
+
console.log("\nWould perform:");
178
+
console.log(` โข Delete ${credentials.length} passkey(s)`);
179
+
console.log(" โข Invalidate all active sessions");
180
+
console.log(" โข Create single-use reset invite");
181
+
process.exit(0);
182
+
}
183
+
184
+
// Confirmation prompt (unless --force)
185
+
if (!force) {
186
+
console.log("\nโ ๏ธ This will:");
187
+
console.log(` โข Delete ALL ${credentials.length} passkey(s) for this user`);
188
+
console.log(" โข Log them out of all sessions");
189
+
console.log(" โข Generate a 24-hour reset link\n");
190
+
191
+
process.stdout.write("Continue? [y/N] ");
192
+
193
+
for await (const line of console) {
194
+
const answer = line.trim().toLowerCase();
195
+
if (answer !== "y" && answer !== "yes") {
196
+
console.log("Cancelled.");
197
+
process.exit(0);
198
+
}
199
+
break;
200
+
}
201
+
}
202
+
203
+
// Get admin user for creating invite
204
+
const admin = getAdminUser();
205
+
if (!admin) {
206
+
console.error("\nโ Error: No admin user found to create invite");
207
+
process.exit(1);
208
+
}
209
+
210
+
// Perform reset
211
+
console.log("\n๐ Performing reset...");
212
+
213
+
// Delete credentials
214
+
const deletedCreds = deleteCredentials(user.id);
215
+
console.log(` โ
Deleted ${deletedCreds} passkey(s)`);
216
+
217
+
// Delete sessions
218
+
const deletedSessions = deleteSessions(user.id);
219
+
console.log(` โ
Invalidated ${deletedSessions} session(s)`);
220
+
221
+
// Create reset invite
222
+
const inviteCode = createResetInvite(admin.id, username);
223
+
console.log(" โ
Created reset invite");
224
+
225
+
// Generate reset URL
226
+
const resetUrl = `${ORIGIN}/login?invite=${inviteCode}&username=${encodeURIComponent(username)}`;
227
+
228
+
console.log("\n" + "โ".repeat(50));
229
+
console.log("โจ PASSKEY RESET COMPLETE");
230
+
console.log("โ".repeat(50));
231
+
console.log(`\n๐ง Send this link to ${user.name || username}:\n`);
232
+
console.log(` ${resetUrl}`);
233
+
console.log(`\nโฐ This link expires in 24 hours and can only be used once.`);
234
+
console.log(`\n๐ก The user must register with username: ${username}`);
235
+
}
236
+
237
+
main().catch((error) => {
238
+
console.error("\nโ Error:", error instanceof Error ? error.message : error);
239
+
process.exit(1);
240
+
});
+31
-16
src/client/index.ts
+31
-16
src/client/index.ts
···
1
-
import {
2
-
startRegistration,
3
-
} from "@simplewebauthn/browser";
4
5
const token = localStorage.getItem("indiko_session");
6
const footer = document.getElementById("footer") as HTMLElement;
···
8
const subtitle = document.getElementById("subtitle") as HTMLElement;
9
const recentApps = document.getElementById("recentApps") as HTMLElement;
10
const passkeysList = document.getElementById("passkeysList") as HTMLElement;
11
-
const addPasskeyBtn = document.getElementById("addPasskeyBtn") as HTMLButtonElement;
12
const toast = document.getElementById("toast") as HTMLElement;
13
14
// Profile form elements
···
320
const passkeys = data.passkeys as Passkey[];
321
322
if (passkeys.length === 0) {
323
-
passkeysList.innerHTML = '<div class="empty">No passkeys registered</div>';
324
return;
325
}
326
327
passkeysList.innerHTML = passkeys
328
.map((passkey) => {
329
-
const createdDate = new Date(passkey.created_at * 1000).toLocaleDateString();
330
331
return `
332
<div class="passkey-item" data-passkey-id="${passkey.id}">
···
336
</div>
337
<div class="passkey-actions">
338
<button type="button" class="rename-passkey-btn" data-passkey-id="${passkey.id}">rename</button>
339
-
${passkeys.length > 1 ? `<button type="button" class="delete-passkey-btn" data-passkey-id="${passkey.id}">delete</button>` : ''}
340
</div>
341
</div>
342
`;
···
365
}
366
367
function showRenameForm(passkeyId: number) {
368
-
const passkeyItem = document.querySelector(`[data-passkey-id="${passkeyId}"]`);
369
if (!passkeyItem) return;
370
371
const infoDiv = passkeyItem.querySelector(".passkey-info");
···
389
input.select();
390
391
// Save button
392
-
infoDiv.querySelector(".save-rename-btn")?.addEventListener("click", async () => {
393
-
await renamePasskeyHandler(passkeyId, input.value);
394
-
});
395
396
// Cancel button
397
-
infoDiv.querySelector(".cancel-rename-btn")?.addEventListener("click", () => {
398
-
loadPasskeys();
399
-
});
400
401
// Enter to save
402
input.addEventListener("keypress", async (e) => {
···
443
}
444
445
async function deletePasskeyHandler(passkeyId: number) {
446
-
if (!confirm("Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.")) {
447
return;
448
}
449
···
496
addPasskeyBtn.textContent = "verifying...";
497
498
// Ask for a name
499
-
const name = prompt("Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):");
500
501
// Verify registration
502
const verifyRes = await fetch("/api/passkeys/add/verify", {
···
1
+
import { startRegistration } from "@simplewebauthn/browser";
2
3
const token = localStorage.getItem("indiko_session");
4
const footer = document.getElementById("footer") as HTMLElement;
···
6
const subtitle = document.getElementById("subtitle") as HTMLElement;
7
const recentApps = document.getElementById("recentApps") as HTMLElement;
8
const passkeysList = document.getElementById("passkeysList") as HTMLElement;
9
+
const addPasskeyBtn = document.getElementById(
10
+
"addPasskeyBtn",
11
+
) as HTMLButtonElement;
12
const toast = document.getElementById("toast") as HTMLElement;
13
14
// Profile form elements
···
320
const passkeys = data.passkeys as Passkey[];
321
322
if (passkeys.length === 0) {
323
+
passkeysList.innerHTML =
324
+
'<div class="empty">No passkeys registered</div>';
325
return;
326
}
327
328
passkeysList.innerHTML = passkeys
329
.map((passkey) => {
330
+
const createdDate = new Date(
331
+
passkey.created_at * 1000,
332
+
).toLocaleDateString();
333
334
return `
335
<div class="passkey-item" data-passkey-id="${passkey.id}">
···
339
</div>
340
<div class="passkey-actions">
341
<button type="button" class="rename-passkey-btn" data-passkey-id="${passkey.id}">rename</button>
342
+
${passkeys.length > 1 ? `<button type="button" class="delete-passkey-btn" data-passkey-id="${passkey.id}">delete</button>` : ""}
343
</div>
344
</div>
345
`;
···
368
}
369
370
function showRenameForm(passkeyId: number) {
371
+
const passkeyItem = document.querySelector(
372
+
`[data-passkey-id="${passkeyId}"]`,
373
+
);
374
if (!passkeyItem) return;
375
376
const infoDiv = passkeyItem.querySelector(".passkey-info");
···
394
input.select();
395
396
// Save button
397
+
infoDiv
398
+
.querySelector(".save-rename-btn")
399
+
?.addEventListener("click", async () => {
400
+
await renamePasskeyHandler(passkeyId, input.value);
401
+
});
402
403
// Cancel button
404
+
infoDiv
405
+
.querySelector(".cancel-rename-btn")
406
+
?.addEventListener("click", () => {
407
+
loadPasskeys();
408
+
});
409
410
// Enter to save
411
input.addEventListener("keypress", async (e) => {
···
452
}
453
454
async function deletePasskeyHandler(passkeyId: number) {
455
+
if (
456
+
!confirm(
457
+
"Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.",
458
+
)
459
+
) {
460
return;
461
}
462
···
509
addPasskeyBtn.textContent = "verifying...";
510
511
// Ask for a name
512
+
const name = prompt(
513
+
"Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):",
514
+
);
515
516
// Verify registration
517
const verifyRes = await fetch("/api/passkeys/add/verify", {
+76
-1
src/html/docs.html
+76
-1
src/html/docs.html
···
577
<h3>table of contents</h3>
578
<ul>
579
<li><a href="#overview">overview</a></li>
580
<li><a href="#getting-started">getting started</a></li>
581
<li><a href="#button">sign in button</a></li>
582
<li><a href="#endpoints">endpoints</a></li>
···
612
<ul>
613
<li>Passwordless authentication via WebAuthn passkeys</li>
614
<li>Full IndieAuth and OAuth 2.0 support with PKCE</li>
615
<li>Access tokens and refresh tokens for API access</li>
616
<li>Token introspection and revocation endpoints</li>
617
<li>UserInfo endpoint for profile data</li>
···
621
<li>User profile endpoints with h-card microformats</li>
622
<li>Invite-based user registration</li>
623
</ul>
624
</section>
625
626
<section id="getting-started" class="section">
···
1032
</thead>
1033
<tbody>
1034
<tr>
1035
<td><code>profile</code></td>
1036
<td>Basic profile information</td>
1037
<td>name, photo, URL</td>
···
1046
1047
<div class="info-box">
1048
<strong>Note:</strong>
1049
-
Users can selectively approve scopes during authorization. Your app may receive fewer scopes than requested.
1050
</div>
1051
</section>
1052
···
577
<h3>table of contents</h3>
578
<ul>
579
<li><a href="#overview">overview</a></li>
580
+
<li><a href="#oidc">openid connect (oidc)</a></li>
581
<li><a href="#getting-started">getting started</a></li>
582
<li><a href="#button">sign in button</a></li>
583
<li><a href="#endpoints">endpoints</a></li>
···
613
<ul>
614
<li>Passwordless authentication via WebAuthn passkeys</li>
615
<li>Full IndieAuth and OAuth 2.0 support with PKCE</li>
616
+
<li>OpenID Connect (OIDC) support with ID tokens</li>
617
<li>Access tokens and refresh tokens for API access</li>
618
<li>Token introspection and revocation endpoints</li>
619
<li>UserInfo endpoint for profile data</li>
···
623
<li>User profile endpoints with h-card microformats</li>
624
<li>Invite-based user registration</li>
625
</ul>
626
+
</section>
627
+
628
+
<section id="oidc" class="section">
629
+
<h2>openid connect (oidc)</h2>
630
+
<p>
631
+
Indiko supports OpenID Connect (OIDC) for modern authentication flows, enabling "Sign in with Indiko" for any OIDC-compatible application.
632
+
</p>
633
+
634
+
<h3>oidc endpoints</h3>
635
+
<table>
636
+
<thead>
637
+
<tr>
638
+
<th>Endpoint</th>
639
+
<th>Description</th>
640
+
</tr>
641
+
</thead>
642
+
<tbody>
643
+
<tr>
644
+
<td><code>/.well-known/openid-configuration</code></td>
645
+
<td>OIDC discovery document</td>
646
+
</tr>
647
+
<tr>
648
+
<td><code>/jwks</code></td>
649
+
<td>JSON Web Key Set for ID token verification</td>
650
+
</tr>
651
+
<tr>
652
+
<td><code>/auth/authorize</code></td>
653
+
<td>Authorization endpoint (same as OAuth 2.0)</td>
654
+
</tr>
655
+
<tr>
656
+
<td><code>/auth/token</code></td>
657
+
<td>Token endpoint (returns ID token when <code>openid</code> scope requested)</td>
658
+
</tr>
659
+
<tr>
660
+
<td><code>/userinfo</code></td>
661
+
<td>OIDC userinfo endpoint</td>
662
+
</tr>
663
+
</tbody>
664
+
</table>
665
+
666
+
<h3>key features</h3>
667
+
<ul>
668
+
<li>Authorization Code Flow with PKCE</li>
669
+
<li>ID Token with RS256 signing</li>
670
+
<li>Support for <code>openid</code>, <code>profile</code>, and <code>email</code> scopes</li>
671
+
<li>Automatic key generation and management</li>
672
+
<li>Standards-compliant discovery document</li>
673
+
</ul>
674
+
675
+
<h3>id token claims</h3>
676
+
<p>
677
+
When the <code>openid</code> scope is requested, the token endpoint returns an ID token (JWT) containing:
678
+
</p>
679
+
<ul>
680
+
<li><code>iss</code> - Issuer (Indiko server URL)</li>
681
+
<li><code>sub</code> - Subject (user identifier)</li>
682
+
<li><code>aud</code> - Audience (client ID)</li>
683
+
<li><code>exp</code> - Expiration time</li>
684
+
<li><code>iat</code> - Issued at time</li>
685
+
<li><code>auth_time</code> - Authentication time</li>
686
+
<li><code>nonce</code> - Nonce (if provided in authorization request)</li>
687
+
<li><code>name</code>, <code>email</code>, <code>picture</code>, <code>website</code> - User claims (based on granted scopes)</li>
688
+
</ul>
689
+
690
+
<div class="info-box">
691
+
<strong>Testing:</strong>
692
+
You can test your OIDC setup using the <a href="https://oidcdebugger.com/" target="_blank" rel="noopener noreferrer">OIDC Debugger</a>. Set the discovery endpoint and use PKCE with SHA-256.
693
+
</div>
694
</section>
695
696
<section id="getting-started" class="section">
···
1102
</thead>
1103
<tbody>
1104
<tr>
1105
+
<td><code>openid</code></td>
1106
+
<td>OpenID Connect authentication</td>
1107
+
<td>Triggers ID token issuance (OIDC only)</td>
1108
+
</tr>
1109
+
<tr>
1110
<td><code>profile</code></td>
1111
<td>Basic profile information</td>
1112
<td>name, photo, URL</td>
···
1121
1122
<div class="info-box">
1123
<strong>Note:</strong>
1124
+
Users can selectively approve scopes during authorization. Your app may receive fewer scopes than requested. The <code>openid</code> scope is only relevant for OIDC flows and enables ID token issuance.
1125
</div>
1126
</section>
1127
+21
-3
src/index.ts
+21
-3
src/index.ts
···
8
import indexHTML from "./html/index.html";
9
import loginHTML from "./html/login.html";
10
import { getLdapAccounts, updateOrphanedAccounts } from "./ldap-cleanup";
11
import {
12
deleteSelfAccount,
13
deleteUser,
···
155
);
156
},
157
"/.well-known/oauth-authorization-server": indieauthMetadata,
158
// OAuth/IndieAuth endpoints
159
"/userinfo": (req: Request) => {
160
if (req.method === "GET") return userinfo(req);
···
365
366
if (expiredOrphans.length > 0) {
367
if (action === "suspend") {
368
-
await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "suspend");
369
} else if (action === "deactivate") {
370
-
await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "deactivate");
371
} else if (action === "remove") {
372
-
await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "remove");
373
}
374
console.log(
375
`[LDAP Cleanup] ${action === "remove" ? "Removed" : action === "suspend" ? "Suspended" : "Deactivated"} ${expiredOrphans.length} LDAP orphan accounts (grace period: ${gracePeriod}s)`,
···
8
import indexHTML from "./html/index.html";
9
import loginHTML from "./html/login.html";
10
import { getLdapAccounts, updateOrphanedAccounts } from "./ldap-cleanup";
11
+
import { getDiscoveryDocument, getJWKS } from "./oidc";
12
import {
13
deleteSelfAccount,
14
deleteUser,
···
156
);
157
},
158
"/.well-known/oauth-authorization-server": indieauthMetadata,
159
+
"/.well-known/openid-configuration": () => {
160
+
const origin = process.env.ORIGIN as string;
161
+
return Response.json(getDiscoveryDocument(origin));
162
+
},
163
+
"/jwks": async () => {
164
+
const jwks = await getJWKS();
165
+
return Response.json(jwks);
166
+
},
167
// OAuth/IndieAuth endpoints
168
"/userinfo": (req: Request) => {
169
if (req.method === "GET") return userinfo(req);
···
374
375
if (expiredOrphans.length > 0) {
376
if (action === "suspend") {
377
+
await updateOrphanedAccounts(
378
+
{ ...result, orphanedUsers: expiredOrphans },
379
+
"suspend",
380
+
);
381
} else if (action === "deactivate") {
382
+
await updateOrphanedAccounts(
383
+
{ ...result, orphanedUsers: expiredOrphans },
384
+
"deactivate",
385
+
);
386
} else if (action === "remove") {
387
+
await updateOrphanedAccounts(
388
+
{ ...result, orphanedUsers: expiredOrphans },
389
+
"remove",
390
+
);
391
}
392
console.log(
393
`[LDAP Cleanup] ${action === "remove" ? "Removed" : action === "suspend" ? "Suspended" : "Deactivated"} ${expiredOrphans.length} LDAP orphan accounts (grace period: ${gracePeriod}s)`,
+16
src/migrations/008_add_oidc_keys.sql
+16
src/migrations/008_add_oidc_keys.sql
···
···
1
+
-- OIDC signing keys for ID Token generation
2
+
CREATE TABLE IF NOT EXISTS oidc_keys (
3
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
4
+
kid TEXT NOT NULL UNIQUE,
5
+
private_key TEXT NOT NULL,
6
+
public_key TEXT NOT NULL,
7
+
is_active INTEGER NOT NULL DEFAULT 1,
8
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
9
+
);
10
+
11
+
-- Add nonce and auth_time to authcodes for OIDC
12
+
ALTER TABLE authcodes ADD COLUMN nonce TEXT;
13
+
ALTER TABLE authcodes ADD COLUMN auth_time INTEGER;
14
+
15
+
CREATE INDEX IF NOT EXISTS idx_oidc_keys_kid ON oidc_keys(kid);
16
+
CREATE INDEX IF NOT EXISTS idx_oidc_keys_active ON oidc_keys(is_active);
+167
src/oidc.ts
+167
src/oidc.ts
···
···
1
+
import { exportJWK, generateKeyPair, importPKCS8, SignJWT } from "jose";
2
+
import { db } from "./db";
3
+
4
+
interface OIDCKey {
5
+
id: number;
6
+
kid: string;
7
+
private_key: string;
8
+
public_key: string;
9
+
is_active: number;
10
+
created_at: number;
11
+
}
12
+
13
+
interface JWK {
14
+
kty: string;
15
+
use: string;
16
+
alg: string;
17
+
kid: string;
18
+
n: string;
19
+
e: string;
20
+
}
21
+
22
+
async function generateAndStoreKey(): Promise<OIDCKey> {
23
+
const { privateKey, publicKey } = await generateKeyPair("RS256", {
24
+
modulusLength: 2048,
25
+
});
26
+
27
+
const privateKeyPem = await exportKeyToPem(privateKey);
28
+
const publicKeyPem = await exportKeyToPem(publicKey);
29
+
30
+
const kid = `indiko-oidc-key-${Date.now()}`;
31
+
32
+
db.query(
33
+
"INSERT INTO oidc_keys (kid, private_key, public_key, is_active) VALUES (?, ?, ?, 1)",
34
+
).run(kid, privateKeyPem, publicKeyPem);
35
+
36
+
const key = db
37
+
.query("SELECT * FROM oidc_keys WHERE kid = ?")
38
+
.get(kid) as OIDCKey;
39
+
40
+
return key;
41
+
}
42
+
43
+
async function exportKeyToPem(key: CryptoKey): Promise<string> {
44
+
const format = key.type === "private" ? "pkcs8" : "spki";
45
+
const exported = await crypto.subtle.exportKey(format, key);
46
+
const base64 = Buffer.from(exported).toString("base64");
47
+
const type = key.type === "private" ? "PRIVATE KEY" : "PUBLIC KEY";
48
+
49
+
const lines = base64.match(/.{1,64}/g) || [];
50
+
return `-----BEGIN ${type}-----\n${lines.join("\n")}\n-----END ${type}-----`;
51
+
}
52
+
53
+
export async function getActiveKey(): Promise<OIDCKey> {
54
+
let key = db
55
+
.query(
56
+
"SELECT * FROM oidc_keys WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
57
+
)
58
+
.get() as OIDCKey | undefined;
59
+
60
+
if (!key) {
61
+
key = await generateAndStoreKey();
62
+
}
63
+
64
+
return key;
65
+
}
66
+
67
+
export async function getJWKS(): Promise<{ keys: JWK[] }> {
68
+
const keys = db
69
+
.query("SELECT * FROM oidc_keys WHERE is_active = 1")
70
+
.all() as OIDCKey[];
71
+
72
+
const jwks: JWK[] = [];
73
+
74
+
for (const key of keys) {
75
+
const publicKey = await importPublicKey(key.public_key);
76
+
const jwk = await exportJWK(publicKey);
77
+
78
+
jwks.push({
79
+
kty: jwk.kty as string,
80
+
use: "sig",
81
+
alg: "RS256",
82
+
kid: key.kid,
83
+
n: jwk.n as string,
84
+
e: jwk.e as string,
85
+
});
86
+
}
87
+
88
+
return { keys: jwks };
89
+
}
90
+
91
+
async function importPublicKey(pem: string): Promise<CryptoKey> {
92
+
const pemContents = pem
93
+
.replace("-----BEGIN PUBLIC KEY-----", "")
94
+
.replace("-----END PUBLIC KEY-----", "")
95
+
.replace(/\n/g, "");
96
+
97
+
const binaryDer = Buffer.from(pemContents, "base64");
98
+
99
+
return await crypto.subtle.importKey(
100
+
"spki",
101
+
binaryDer,
102
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
103
+
true,
104
+
["verify"],
105
+
);
106
+
}
107
+
108
+
interface IDTokenClaims {
109
+
sub: string;
110
+
aud: string;
111
+
nonce?: string;
112
+
auth_time?: number;
113
+
name?: string;
114
+
email?: string;
115
+
picture?: string;
116
+
website?: string;
117
+
}
118
+
119
+
export async function signIDToken(
120
+
issuer: string,
121
+
claims: IDTokenClaims,
122
+
): Promise<string> {
123
+
const key = await getActiveKey();
124
+
const privateKey = await importPKCS8(key.private_key, "RS256");
125
+
126
+
const now = Math.floor(Date.now() / 1000);
127
+
const expiresIn = 3600; // 1 hour
128
+
129
+
const builder = new SignJWT({
130
+
...claims,
131
+
iss: issuer,
132
+
iat: now,
133
+
exp: now + expiresIn,
134
+
}).setProtectedHeader({ alg: "RS256", typ: "JWT", kid: key.kid });
135
+
136
+
return await builder.sign(privateKey);
137
+
}
138
+
139
+
export function getDiscoveryDocument(origin: string) {
140
+
return {
141
+
issuer: origin,
142
+
authorization_endpoint: `${origin}/auth/authorize`,
143
+
token_endpoint: `${origin}/auth/token`,
144
+
userinfo_endpoint: `${origin}/userinfo`,
145
+
jwks_uri: `${origin}/jwks`,
146
+
scopes_supported: ["openid", "profile", "email"],
147
+
response_types_supported: ["code"],
148
+
grant_types_supported: ["authorization_code", "refresh_token"],
149
+
subject_types_supported: ["public"],
150
+
id_token_signing_alg_values_supported: ["RS256"],
151
+
token_endpoint_auth_methods_supported: ["none", "client_secret_post"],
152
+
claims_supported: [
153
+
"sub",
154
+
"iss",
155
+
"aud",
156
+
"exp",
157
+
"iat",
158
+
"auth_time",
159
+
"nonce",
160
+
"name",
161
+
"email",
162
+
"picture",
163
+
"website",
164
+
],
165
+
code_challenge_methods_supported: ["S256"],
166
+
};
167
+
}
+15
-5
src/routes/api.ts
+15
-5
src/routes/api.ts
···
1
import { db } from "../db";
2
-
import { verifyDomain, validateProfileURL } from "./indieauth";
3
4
function getSessionUser(
5
req: Request,
6
-
): { username: string; userId: number; is_admin: boolean; tier: string } | Response {
7
const authHeader = req.headers.get("Authorization");
8
9
if (!authHeader || !authHeader.startsWith("Bearer ")) {
···
193
const origin = process.env.ORIGIN || "http://localhost:3000";
194
const indikoProfileUrl = `${origin}/u/${user.username}`;
195
196
-
const verification = await verifyDomain(validation.canonicalUrl!, indikoProfileUrl);
197
if (!verification.success) {
198
return Response.json(
199
{ error: verification.error || "Failed to verify domain" },
···
508
return Response.json({ success: true });
509
}
510
511
-
export async function updateUserTier(req: Request, userId: string): Promise<Response> {
512
const user = getSessionUser(req);
513
if (user instanceof Response) {
514
return user;
···
536
537
const targetUser = db
538
.query("SELECT id, username, tier FROM users WHERE id = ?")
539
-
.get(targetUserId) as { id: number; username: string; tier: string } | undefined;
540
541
if (!targetUser) {
542
return Response.json({ error: "User not found" }, { status: 404 });
···
1
import { db } from "../db";
2
+
import { validateProfileURL, verifyDomain } from "./indieauth";
3
4
function getSessionUser(
5
req: Request,
6
+
):
7
+
| { username: string; userId: number; is_admin: boolean; tier: string }
8
+
| Response {
9
const authHeader = req.headers.get("Authorization");
10
11
if (!authHeader || !authHeader.startsWith("Bearer ")) {
···
195
const origin = process.env.ORIGIN || "http://localhost:3000";
196
const indikoProfileUrl = `${origin}/u/${user.username}`;
197
198
+
const verification = await verifyDomain(
199
+
validation.canonicalUrl!,
200
+
indikoProfileUrl,
201
+
);
202
if (!verification.success) {
203
return Response.json(
204
{ error: verification.error || "Failed to verify domain" },
···
513
return Response.json({ success: true });
514
}
515
516
+
export async function updateUserTier(
517
+
req: Request,
518
+
userId: string,
519
+
): Promise<Response> {
520
const user = getSessionUser(req);
521
if (user instanceof Response) {
522
return user;
···
544
545
const targetUser = db
546
.query("SELECT id, username, tier FROM users WHERE id = ?")
547
+
.get(targetUserId) as
548
+
| { id: number; username: string; tier: string }
549
+
| undefined;
550
551
if (!targetUser) {
552
return Response.json({ error: "User not found" }, { status: 404 });
+78
-38
src/routes/auth.ts
+78
-38
src/routes/auth.ts
···
1
import {
2
type AuthenticationResponseJSON,
3
type PublicKeyCredentialCreationOptionsJSON,
4
type PublicKeyCredentialRequestOptionsJSON,
5
type RegistrationResponseJSON,
6
type VerifiedAuthenticationResponse,
7
type VerifiedRegistrationResponse,
8
-
generateAuthenticationOptions,
9
-
generateRegistrationOptions,
10
verifyAuthenticationResponse,
11
verifyRegistrationResponse,
12
} from "@simplewebauthn/server";
···
39
// Check if username already exists
40
const existingUser = db
41
.query("SELECT id FROM users WHERE username = ?")
42
-
.get(username);
43
44
if (existingUser) {
45
-
return Response.json(
46
-
{ error: "Username already taken" },
47
-
{ status: 400 },
48
-
);
49
}
50
51
// Check if this is bootstrap (first user)
···
156
157
// Check if username already exists
158
const existingUser = db
159
-
.query("SELECT id FROM users WHERE username = ?")
160
-
.get(username);
161
162
if (existingUser) {
163
-
return Response.json(
164
-
{ error: "Username already taken" },
165
-
{ status: 400 },
166
-
);
167
}
168
169
if (!expectedChallenge) {
···
275
invite?.ldap_username !== null && invite?.ldap_username !== undefined;
276
}
277
278
-
// Create user (bootstrap is always admin, invited users are regular users)
279
-
const insertUser = db.query(
280
-
"INSERT INTO users (username, name, is_admin, tier, role, provisioned_via_ldap) VALUES (?, ?, ?, ?, ?, ?) RETURNING id",
281
-
);
282
-
const user = insertUser.get(
283
-
username,
284
-
username,
285
-
isBootstrap ? 1 : 0,
286
-
isBootstrap ? "admin" : "user",
287
-
isBootstrap ? "admin" : "user",
288
-
isLdapProvisioned ? 1 : 0,
289
-
) as {
290
-
id: number;
291
-
};
292
293
// Store credential
294
// credential.id is a Uint8Array, convert to Buffer for storage
295
db.query(
296
"INSERT INTO credentials (user_id, credential_id, public_key, counter, name) VALUES (?, ?, ?, ?, ?)",
297
).run(
298
-
user.id,
299
Buffer.from(credential.id),
300
Buffer.from(credential.publicKey),
301
credential.counter,
302
-
"Primary Passkey",
303
);
304
305
// Mark invite as used if applicable
···
324
// Record this invite use
325
db.query(
326
"INSERT INTO invite_uses (invite_id, user_id, used_at) VALUES (?, ?, ?)",
327
-
).run(inviteId, user.id, usedAt);
328
329
-
// Assign app roles to the new user
330
-
if (inviteRoles.length > 0) {
331
const insertPermission = db.query(
332
"INSERT INTO permissions (user_id, app_id, role) VALUES (?, ?, ?)",
333
);
334
for (const { app_id, role } of inviteRoles) {
335
-
insertPermission.run(user.id, app_id, role);
336
}
337
}
338
}
···
347
const expiresAt = Math.floor(Date.now() / 1000) + 86400; // 24 hours
348
db.query(
349
"INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)",
350
-
).run(token, user.id, expiresAt);
351
352
const isProduction = process.env.NODE_ENV === "production";
353
const secureCookie = isProduction ? "; Secure" : "";
···
356
{
357
token,
358
username,
359
-
isAdmin: isBootstrap,
360
},
361
{
362
headers: {
···
381
382
// Check if user exists and is active
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;
386
387
if (!user) {
388
return Response.json({ error: "Invalid credentials" }, { status: 401 });
···
405
const existsInLdap = await checkLdapUser(username);
406
if (!existsInLdap) {
407
// User no longer exists in LDAP - suspend the account
408
-
db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run(user.id);
409
return Response.json(
410
{ error: "Invalid credentials" },
411
{ status: 401 },
···
1
import {
2
type AuthenticationResponseJSON,
3
+
generateAuthenticationOptions,
4
+
generateRegistrationOptions,
5
type PublicKeyCredentialCreationOptionsJSON,
6
type PublicKeyCredentialRequestOptionsJSON,
7
type RegistrationResponseJSON,
8
type VerifiedAuthenticationResponse,
9
type VerifiedRegistrationResponse,
10
verifyAuthenticationResponse,
11
verifyRegistrationResponse,
12
} from "@simplewebauthn/server";
···
39
// Check if username already exists
40
const existingUser = db
41
.query("SELECT id FROM users WHERE username = ?")
42
+
.get(username) as { id: number } | undefined;
43
44
+
// Allow re-registration if user exists but has no credentials (passkey reset case)
45
+
let isPasskeyReset = false;
46
if (existingUser) {
47
+
const credCount = db
48
+
.query("SELECT COUNT(*) as count FROM credentials WHERE user_id = ?")
49
+
.get(existingUser.id) as { count: number };
50
+
51
+
if (credCount.count > 0) {
52
+
return Response.json(
53
+
{ error: "Username already taken" },
54
+
{ status: 400 },
55
+
);
56
+
}
57
+
// User exists but has no credentials - this is a passkey reset
58
+
isPasskeyReset = true;
59
}
60
61
// Check if this is bootstrap (first user)
···
166
167
// Check if username already exists
168
const existingUser = db
169
+
.query("SELECT id, is_admin FROM users WHERE username = ?")
170
+
.get(username) as { id: number; is_admin: number } | undefined;
171
172
+
// Allow re-registration if user exists but has no credentials (passkey reset case)
173
+
let isPasskeyReset = false;
174
if (existingUser) {
175
+
const credCount = db
176
+
.query("SELECT COUNT(*) as count FROM credentials WHERE user_id = ?")
177
+
.get(existingUser.id) as { count: number };
178
+
179
+
if (credCount.count > 0) {
180
+
return Response.json(
181
+
{ error: "Username already taken" },
182
+
{ status: 400 },
183
+
);
184
+
}
185
+
// User exists but has no credentials - this is a passkey reset
186
+
isPasskeyReset = true;
187
}
188
189
if (!expectedChallenge) {
···
295
invite?.ldap_username !== null && invite?.ldap_username !== undefined;
296
}
297
298
+
let userId: number;
299
+
let userIsAdmin: boolean;
300
+
301
+
if (isPasskeyReset && existingUser) {
302
+
// Passkey reset: use existing user, just add credential
303
+
userId = existingUser.id;
304
+
userIsAdmin = existingUser.is_admin === 1;
305
+
} else {
306
+
// Create new user (bootstrap is always admin, invited users are regular users)
307
+
const insertUser = db.query(
308
+
"INSERT INTO users (username, name, is_admin, tier, role, provisioned_via_ldap) VALUES (?, ?, ?, ?, ?, ?) RETURNING id",
309
+
);
310
+
const user = insertUser.get(
311
+
username,
312
+
username,
313
+
isBootstrap ? 1 : 0,
314
+
isBootstrap ? "admin" : "user",
315
+
isBootstrap ? "admin" : "user",
316
+
isLdapProvisioned ? 1 : 0,
317
+
) as { id: number };
318
+
userId = user.id;
319
+
userIsAdmin = isBootstrap;
320
+
}
321
322
// Store credential
323
// credential.id is a Uint8Array, convert to Buffer for storage
324
db.query(
325
"INSERT INTO credentials (user_id, credential_id, public_key, counter, name) VALUES (?, ?, ?, ?, ?)",
326
).run(
327
+
userId,
328
Buffer.from(credential.id),
329
Buffer.from(credential.publicKey),
330
credential.counter,
331
+
isPasskeyReset ? "Reset Passkey" : "Primary Passkey",
332
);
333
334
// Mark invite as used if applicable
···
353
// Record this invite use
354
db.query(
355
"INSERT INTO invite_uses (invite_id, user_id, used_at) VALUES (?, ?, ?)",
356
+
).run(inviteId, userId, usedAt);
357
358
+
// Assign app roles to the new user (skip for passkey reset - they already have roles)
359
+
if (inviteRoles.length > 0 && !isPasskeyReset) {
360
const insertPermission = db.query(
361
"INSERT INTO permissions (user_id, app_id, role) VALUES (?, ?, ?)",
362
);
363
for (const { app_id, role } of inviteRoles) {
364
+
insertPermission.run(userId, app_id, role);
365
}
366
}
367
}
···
376
const expiresAt = Math.floor(Date.now() / 1000) + 86400; // 24 hours
377
db.query(
378
"INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)",
379
+
).run(token, userId, expiresAt);
380
381
const isProduction = process.env.NODE_ENV === "production";
382
const secureCookie = isProduction ? "; Secure" : "";
···
385
{
386
token,
387
username,
388
+
isAdmin: userIsAdmin,
389
},
390
{
391
headers: {
···
410
411
// Check if user exists and is active
412
const user = db
413
+
.query(
414
+
"SELECT id, status, provisioned_via_ldap, last_ldap_verified_at FROM users WHERE username = ?",
415
+
)
416
+
.get(username) as
417
+
| {
418
+
id: number;
419
+
status: string;
420
+
provisioned_via_ldap: number;
421
+
last_ldap_verified_at: number | null;
422
+
}
423
+
| undefined;
424
425
if (!user) {
426
return Response.json({ error: "Invalid credentials" }, { status: 401 });
···
443
const existsInLdap = await checkLdapUser(username);
444
if (!existsInLdap) {
445
// User no longer exists in LDAP - suspend the account
446
+
db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run(
447
+
user.id,
448
+
);
449
return Response.json(
450
{ error: "Invalid credentials" },
451
{ status: 401 },
+3
-1
src/routes/clients.ts
+3
-1
src/routes/clients.ts
+270
-80
src/routes/indieauth.ts
+270
-80
src/routes/indieauth.ts
···
1
import crypto from "crypto";
2
import { db } from "../db";
3
4
interface SessionUser {
5
username: string;
···
127
}
128
129
// Validate profile URL per IndieAuth spec
130
-
export function validateProfileURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } {
131
let url: URL;
132
try {
133
url = new URL(urlString);
···
152
153
// MUST NOT contain username/password
154
if (url.username || url.password) {
155
-
return { valid: false, error: "Profile URL must not contain username or password" };
156
}
157
158
// MUST NOT contain ports
···
164
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
165
const ipv6Regex = /^\[?[0-9a-fA-F:]+\]?$/;
166
if (ipv4Regex.test(url.hostname) || ipv6Regex.test(url.hostname)) {
167
-
return { valid: false, error: "Profile URL must use domain names, not IP addresses" };
168
}
169
170
// MUST NOT contain single-dot or double-dot path segments
171
const pathSegments = url.pathname.split("/");
172
if (pathSegments.includes(".") || pathSegments.includes("..")) {
173
-
return { valid: false, error: "Profile URL must not contain . or .. path segments" };
174
}
175
176
return { valid: true, canonicalUrl: canonicalizeURL(urlString) };
177
}
178
179
// Validate client URL per IndieAuth spec
180
-
function validateClientURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } {
181
let url: URL;
182
try {
183
url = new URL(urlString);
···
202
203
// MUST NOT contain username/password
204
if (url.username || url.password) {
205
-
return { valid: false, error: "Client URL must not contain username or password" };
206
}
207
208
// MUST NOT contain single-dot or double-dot path segments
209
const pathSegments = url.pathname.split("/");
210
if (pathSegments.includes(".") || pathSegments.includes("..")) {
211
-
return { valid: false, error: "Client URL must not contain . or .. path segments" };
212
}
213
214
// MAY use loopback interface, but not other IP addresses
···
217
if (ipv4Regex.test(url.hostname)) {
218
// Allow 127.0.0.1 (loopback), reject others
219
if (!url.hostname.startsWith("127.")) {
220
-
return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" };
221
}
222
} else if (ipv6Regex.test(url.hostname)) {
223
// Allow ::1 (loopback), reject others
224
const ipv6Match = url.hostname.match(ipv6Regex);
225
if (ipv6Match && ipv6Match[1] !== "::1") {
226
-
return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" };
227
}
228
}
229
···
234
function isLoopbackURL(urlString: string): boolean {
235
try {
236
const url = new URL(urlString);
237
-
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname.startsWith("127.");
238
} catch {
239
return false;
240
}
···
254
}> {
255
// MUST NOT fetch loopback addresses (security requirement)
256
if (isLoopbackURL(clientId)) {
257
-
return { success: false, error: "Cannot fetch metadata from loopback addresses" };
258
}
259
260
try {
···
273
clearTimeout(timeoutId);
274
275
if (!response.ok) {
276
-
return { success: false, error: `Failed to fetch client metadata: HTTP ${response.status}` };
277
}
278
279
const contentType = response.headers.get("content-type") || "";
···
284
285
// Verify client_id matches
286
if (metadata.client_id && metadata.client_id !== clientId) {
287
-
return { success: false, error: "client_id in metadata does not match URL" };
288
}
289
290
return { success: true, metadata };
···
295
const html = await response.text();
296
297
// Extract redirect URIs from link tags
298
-
const redirectUriRegex = /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
299
const redirectUris: string[] = [];
300
let match: RegExpExecArray | null;
301
···
304
}
305
306
// Also try reverse order (href before rel)
307
-
const redirectUriRegex2 = /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi;
308
while ((match = redirectUriRegex2.exec(html)) !== null) {
309
if (!redirectUris.includes(match[1])) {
310
redirectUris.push(match[1]);
···
321
};
322
}
323
324
-
return { success: false, error: "No client metadata or redirect_uri links found in HTML" };
325
}
326
327
return { success: false, error: "Unsupported content type" };
···
330
if (error.name === "AbortError") {
331
return { success: false, error: "Timeout fetching client metadata" };
332
}
333
-
return { success: false, error: `Failed to fetch client metadata: ${error.message}` };
334
}
335
return { success: false, error: "Failed to fetch client metadata" };
336
}
337
}
338
339
// Verify domain has rel="me" link back to user profile
340
-
export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): Promise<{
341
success: boolean;
342
error?: string;
343
}> {
···
359
360
if (!response.ok) {
361
const errorBody = await response.text();
362
-
console.error(`[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, {
363
-
status: response.status,
364
-
contentType: response.headers.get("content-type"),
365
-
bodyPreview: errorBody.substring(0, 200),
366
-
});
367
-
return { success: false, error: `Failed to fetch domain: HTTP ${response.status}` };
368
}
369
370
const html = await response.text();
···
413
414
// Check if any rel="me" link matches the indiko profile URL
415
const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl);
416
-
const hasRelMe = relMeLinks.some(link => {
417
try {
418
const normalizedLink = canonicalizeURL(link);
419
return normalizedLink === normalizedIndikoUrl;
···
423
});
424
425
if (!hasRelMe) {
426
-
console.error(`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, {
427
-
foundLinks: relMeLinks,
428
-
normalizedTarget: normalizedIndikoUrl,
429
-
});
430
return {
431
success: false,
432
error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`,
···
440
console.error(`[verifyDomain] Timeout verifying ${domainUrl}`);
441
return { success: false, error: "Timeout verifying domain" };
442
}
443
-
console.error(`[verifyDomain] Error verifying ${domainUrl}: ${error.message}`, {
444
-
name: error.name,
445
-
stack: error.stack,
446
-
});
447
-
return { success: false, error: `Failed to verify domain: ${error.message}` };
448
}
449
-
console.error(`[verifyDomain] Unknown error verifying ${domainUrl}:`, error);
450
return { success: false, error: "Failed to verify domain" };
451
}
452
}
···
457
redirectUri: string,
458
): Promise<{
459
error?: string;
460
-
app?: { name: string | null; redirect_uris: string; logo_url?: string | null };
461
}> {
462
const existing = db
463
.query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?")
···
550
551
// Fetch the newly created app
552
const newApp = db
553
-
.query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?")
554
-
.get(canonicalClientId) as { name: string | null; redirect_uris: string; logo_url?: string | null };
555
556
return { app: newApp };
557
}
···
579
const codeChallengeMethod = params.get("code_challenge_method");
580
const scope = params.get("scope") || "profile";
581
const me = params.get("me");
582
583
if (responseType !== "code") {
584
return new Response("Unsupported response_type", { status: 400 });
···
933
if (hasAllScopes) {
934
// Auto-approve - create auth code and redirect
935
const code = crypto.randomBytes(32).toString("base64url");
936
-
const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds
937
938
db.query(
939
-
"INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
940
).run(
941
code,
942
user.userId,
···
946
codeChallenge,
947
expiresAt,
948
me,
949
);
950
951
// Update permission last_used
···
954
).run(Math.floor(Date.now() / 1000), user.userId, clientId);
955
956
const origin = process.env.ORIGIN || "http://localhost:3000";
957
-
return Response.redirect(`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`);
958
}
959
}
960
···
967
codeChallenge,
968
requestedScopes,
969
me,
970
);
971
}
972
···
978
codeChallenge: string,
979
scopes: string[],
980
me: string | null,
981
): Response {
982
// Load app metadata if pre-registered
983
const appData = db
···
1296
<input type="hidden" name="state" value="${state}" />
1297
<input type="hidden" name="code_challenge" value="${codeChallenge}" />
1298
${me ? `<input type="hidden" name="me" value="${me}" />` : ""}
1299
<!-- Always include profile scope as it's required -->
1300
<input type="hidden" name="scope" value="profile" />
1301
···
1316
// POST /auth/authorize - Consent form submission
1317
export async function authorizePost(req: Request): Promise<Response> {
1318
const contentType = req.headers.get("Content-Type");
1319
-
1320
// Parse the request body
1321
let body: Record<string, string>;
1322
let formData: FormData;
···
1334
}
1335
1336
const grantType = body.grant_type;
1337
-
1338
// If grant_type is present, this is a token exchange request (IndieAuth profile scope only)
1339
if (grantType === "authorization_code") {
1340
// Create a mock request for token() function
1341
const mockReq = new Request(req.url, {
1342
method: "POST",
1343
headers: req.headers,
1344
-
body: contentType?.includes("application/x-www-form-urlencoded")
1345
? new URLSearchParams(body).toString()
1346
: JSON.stringify(body),
1347
});
···
1361
const state = body.state;
1362
const codeChallenge = body.code_challenge;
1363
const me = body.me || null;
1364
1365
if (!rawClientId || !rawRedirectUri || !state || !codeChallenge) {
1366
return new Response("Missing required parameters", { status: 400 });
···
1373
clientId = canonicalizeURL(rawClientId);
1374
redirectUri = canonicalizeURL(rawRedirectUri);
1375
} catch {
1376
-
return new Response("Invalid client_id or redirect_uri URL format", { status: 400 });
1377
}
1378
1379
if (action === "deny") {
···
1392
1393
// Create authorization code
1394
const code = crypto.randomBytes(32).toString("base64url");
1395
-
const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds
1396
1397
db.query(
1398
-
"INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1399
).run(
1400
code,
1401
user.userId,
···
1405
codeChallenge,
1406
expiresAt,
1407
me,
1408
);
1409
1410
// Store or update permission grant
···
1487
let redirect_uri: string | undefined;
1488
try {
1489
client_id = raw_client_id ? canonicalizeURL(raw_client_id) : undefined;
1490
-
redirect_uri = raw_redirect_uri ? canonicalizeURL(raw_redirect_uri) : undefined;
1491
} catch {
1492
return Response.json(
1493
{
···
1502
return Response.json(
1503
{
1504
error: "unsupported_grant_type",
1505
-
error_description: "Only authorization_code and refresh_token grant types are supported",
1506
},
1507
{ status: 400 },
1508
);
···
1577
const expiresAt = now + expiresIn;
1578
1579
// Update token (rotate access token, keep refresh token)
1580
-
db.query(
1581
-
"UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?",
1582
-
).run(newAccessToken, expiresAt, tokenData.id);
1583
1584
// Get user profile for me value
1585
const user = db
···
1614
headers: {
1615
"Content-Type": "application/json",
1616
"Cache-Control": "no-store",
1617
-
"Pragma": "no-cache",
1618
},
1619
},
1620
);
···
1670
}
1671
}
1672
1673
-
if (!code || !client_id || !redirect_uri) {
1674
-
console.error("Token endpoint: missing parameters", {
1675
code: !!code,
1676
client_id: !!client_id,
1677
-
redirect_uri: !!redirect_uri,
1678
});
1679
return Response.json(
1680
{
1681
error: "invalid_request",
1682
-
error_description: "Missing required parameters",
1683
},
1684
{ status: 400 },
1685
);
···
1699
// Look up authorization code
1700
const authcode = db
1701
.query(
1702
-
"SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me FROM authcodes WHERE code = ?",
1703
)
1704
.get(code) as
1705
| {
···
1711
expires_at: number;
1712
used: number;
1713
me: string | null;
1714
}
1715
| undefined;
1716
···
1727
1728
// Check if already used
1729
if (authcode.used) {
1730
-
console.error("Token endpoint: authorization code already used", { code });
1731
return Response.json(
1732
{
1733
error: "invalid_grant",
···
1740
// Check if expired
1741
const now = Math.floor(Date.now() / 1000);
1742
if (authcode.expires_at < now) {
1743
-
console.error("Token endpoint: authorization code expired", { code, expires_at: authcode.expires_at, now, diff: now - authcode.expires_at });
1744
return Response.json(
1745
{
1746
error: "invalid_grant",
···
1752
1753
// Verify client_id matches
1754
if (authcode.client_id !== client_id) {
1755
-
console.error("Token endpoint: client_id mismatch", { stored: authcode.client_id, received: client_id });
1756
return Response.json(
1757
{
1758
error: "invalid_grant",
···
1762
);
1763
}
1764
1765
-
// Verify redirect_uri matches
1766
-
if (authcode.redirect_uri !== redirect_uri) {
1767
-
console.error("Token endpoint: redirect_uri mismatch", { stored: authcode.redirect_uri, received: redirect_uri });
1768
return Response.json(
1769
{
1770
error: "invalid_grant",
···
1776
1777
// Verify PKCE code_verifier (required for all clients per IndieAuth spec)
1778
if (!verifyPKCE(code_verifier, authcode.code_challenge)) {
1779
-
console.error("Token endpoint: PKCE verification failed", { code_verifier, code_challenge: authcode.code_challenge });
1780
return Response.json(
1781
{
1782
error: "invalid_grant",
···
1839
1840
// Validate that the user controls the requested me parameter
1841
if (authcode.me && authcode.me !== meValue) {
1842
-
console.error("Token endpoint: me mismatch", { requested: authcode.me, actual: meValue });
1843
return Response.json(
1844
{
1845
error: "invalid_grant",
1846
-
error_description: "The requested identity does not match the user's verified domain",
1847
},
1848
{ status: 400 },
1849
);
1850
}
1851
1852
const origin = process.env.ORIGIN || "http://localhost:3000";
1853
-
1854
// Generate access token
1855
const accessToken = crypto.randomBytes(32).toString("base64url");
1856
const expiresIn = 3600; // 1 hour
···
1864
// Store token in database with refresh token
1865
db.query(
1866
"INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
1867
-
).run(accessToken, authcode.user_id, client_id, scopes.join(" "), expiresAt, refreshToken, refreshExpiresAt);
1868
1869
const response: Record<string, unknown> = {
1870
access_token: accessToken,
···
1882
response.role = permission.role;
1883
}
1884
1885
-
console.log("Token endpoint: success", { me: meValue, scopes: scopes.join(" ") });
1886
1887
return Response.json(response, {
1888
headers: {
1889
"Content-Type": "application/json",
1890
"Cache-Control": "no-store",
1891
-
"Pragma": "no-cache",
1892
},
1893
});
1894
} catch (error) {
···
2052
try {
2053
// Get access token from Authorization header
2054
const authHeader = req.headers.get("Authorization");
2055
-
2056
if (!authHeader || !authHeader.startsWith("Bearer ")) {
2057
return Response.json(
2058
{
···
2110
// Parse scopes
2111
const scopes = tokenData.scope.split(" ");
2112
2113
-
// Build response based on scopes
2114
const response: Record<string, string> = {};
2115
2116
if (scopes.includes("profile")) {
2117
response.name = tokenData.name;
2118
-
if (tokenData.photo) response.photo = tokenData.photo;
2119
if (tokenData.url) {
2120
-
response.url = tokenData.url;
2121
-
} else {
2122
-
const origin = process.env.ORIGIN || "http://localhost:3000";
2123
-
response.url = `${origin}/u/${tokenData.username}`;
2124
}
2125
}
2126
···
2128
response.email = tokenData.email;
2129
}
2130
2131
-
// Return empty object if no profile/email scopes
2132
-
if (Object.keys(response).length === 0) {
2133
return Response.json(
2134
{
2135
error: "insufficient_scope",
···
1
import crypto from "crypto";
2
import { db } from "../db";
3
+
import { signIDToken } from "../oidc";
4
5
interface SessionUser {
6
username: string;
···
128
}
129
130
// Validate profile URL per IndieAuth spec
131
+
export function validateProfileURL(urlString: string): {
132
+
valid: boolean;
133
+
error?: string;
134
+
canonicalUrl?: string;
135
+
} {
136
let url: URL;
137
try {
138
url = new URL(urlString);
···
157
158
// MUST NOT contain username/password
159
if (url.username || url.password) {
160
+
return {
161
+
valid: false,
162
+
error: "Profile URL must not contain username or password",
163
+
};
164
}
165
166
// MUST NOT contain ports
···
172
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
173
const ipv6Regex = /^\[?[0-9a-fA-F:]+\]?$/;
174
if (ipv4Regex.test(url.hostname) || ipv6Regex.test(url.hostname)) {
175
+
return {
176
+
valid: false,
177
+
error: "Profile URL must use domain names, not IP addresses",
178
+
};
179
}
180
181
// MUST NOT contain single-dot or double-dot path segments
182
const pathSegments = url.pathname.split("/");
183
if (pathSegments.includes(".") || pathSegments.includes("..")) {
184
+
return {
185
+
valid: false,
186
+
error: "Profile URL must not contain . or .. path segments",
187
+
};
188
}
189
190
return { valid: true, canonicalUrl: canonicalizeURL(urlString) };
191
}
192
193
// Validate client URL per IndieAuth spec
194
+
function validateClientURL(urlString: string): {
195
+
valid: boolean;
196
+
error?: string;
197
+
canonicalUrl?: string;
198
+
} {
199
let url: URL;
200
try {
201
url = new URL(urlString);
···
220
221
// MUST NOT contain username/password
222
if (url.username || url.password) {
223
+
return {
224
+
valid: false,
225
+
error: "Client URL must not contain username or password",
226
+
};
227
}
228
229
// MUST NOT contain single-dot or double-dot path segments
230
const pathSegments = url.pathname.split("/");
231
if (pathSegments.includes(".") || pathSegments.includes("..")) {
232
+
return {
233
+
valid: false,
234
+
error: "Client URL must not contain . or .. path segments",
235
+
};
236
}
237
238
// MAY use loopback interface, but not other IP addresses
···
241
if (ipv4Regex.test(url.hostname)) {
242
// Allow 127.0.0.1 (loopback), reject others
243
if (!url.hostname.startsWith("127.")) {
244
+
return {
245
+
valid: false,
246
+
error:
247
+
"Client URL must use domain names, not IP addresses (except loopback)",
248
+
};
249
}
250
} else if (ipv6Regex.test(url.hostname)) {
251
// Allow ::1 (loopback), reject others
252
const ipv6Match = url.hostname.match(ipv6Regex);
253
if (ipv6Match && ipv6Match[1] !== "::1") {
254
+
return {
255
+
valid: false,
256
+
error:
257
+
"Client URL must use domain names, not IP addresses (except loopback)",
258
+
};
259
}
260
}
261
···
266
function isLoopbackURL(urlString: string): boolean {
267
try {
268
const url = new URL(urlString);
269
+
return (
270
+
url.hostname === "localhost" ||
271
+
url.hostname === "127.0.0.1" ||
272
+
url.hostname === "[::1]" ||
273
+
url.hostname.startsWith("127.")
274
+
);
275
} catch {
276
return false;
277
}
···
291
}> {
292
// MUST NOT fetch loopback addresses (security requirement)
293
if (isLoopbackURL(clientId)) {
294
+
return {
295
+
success: false,
296
+
error: "Cannot fetch metadata from loopback addresses",
297
+
};
298
}
299
300
try {
···
313
clearTimeout(timeoutId);
314
315
if (!response.ok) {
316
+
return {
317
+
success: false,
318
+
error: `Failed to fetch client metadata: HTTP ${response.status}`,
319
+
};
320
}
321
322
const contentType = response.headers.get("content-type") || "";
···
327
328
// Verify client_id matches
329
if (metadata.client_id && metadata.client_id !== clientId) {
330
+
return {
331
+
success: false,
332
+
error: "client_id in metadata does not match URL",
333
+
};
334
}
335
336
return { success: true, metadata };
···
341
const html = await response.text();
342
343
// Extract redirect URIs from link tags
344
+
const redirectUriRegex =
345
+
/<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
346
const redirectUris: string[] = [];
347
let match: RegExpExecArray | null;
348
···
351
}
352
353
// Also try reverse order (href before rel)
354
+
const redirectUriRegex2 =
355
+
/<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi;
356
while ((match = redirectUriRegex2.exec(html)) !== null) {
357
if (!redirectUris.includes(match[1])) {
358
redirectUris.push(match[1]);
···
369
};
370
}
371
372
+
return {
373
+
success: false,
374
+
error: "No client metadata or redirect_uri links found in HTML",
375
+
};
376
}
377
378
return { success: false, error: "Unsupported content type" };
···
381
if (error.name === "AbortError") {
382
return { success: false, error: "Timeout fetching client metadata" };
383
}
384
+
return {
385
+
success: false,
386
+
error: `Failed to fetch client metadata: ${error.message}`,
387
+
};
388
}
389
return { success: false, error: "Failed to fetch client metadata" };
390
}
391
}
392
393
// Verify domain has rel="me" link back to user profile
394
+
export async function verifyDomain(
395
+
domainUrl: string,
396
+
indikoProfileUrl: string,
397
+
): Promise<{
398
success: boolean;
399
error?: string;
400
}> {
···
416
417
if (!response.ok) {
418
const errorBody = await response.text();
419
+
console.error(
420
+
`[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`,
421
+
{
422
+
status: response.status,
423
+
contentType: response.headers.get("content-type"),
424
+
bodyPreview: errorBody.substring(0, 200),
425
+
},
426
+
);
427
+
return {
428
+
success: false,
429
+
error: `Failed to fetch domain: HTTP ${response.status}`,
430
+
};
431
}
432
433
const html = await response.text();
···
476
477
// Check if any rel="me" link matches the indiko profile URL
478
const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl);
479
+
const hasRelMe = relMeLinks.some((link) => {
480
try {
481
const normalizedLink = canonicalizeURL(link);
482
return normalizedLink === normalizedIndikoUrl;
···
486
});
487
488
if (!hasRelMe) {
489
+
console.error(
490
+
`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`,
491
+
{
492
+
foundLinks: relMeLinks,
493
+
normalizedTarget: normalizedIndikoUrl,
494
+
},
495
+
);
496
return {
497
success: false,
498
error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`,
···
506
console.error(`[verifyDomain] Timeout verifying ${domainUrl}`);
507
return { success: false, error: "Timeout verifying domain" };
508
}
509
+
console.error(
510
+
`[verifyDomain] Error verifying ${domainUrl}: ${error.message}`,
511
+
{
512
+
name: error.name,
513
+
stack: error.stack,
514
+
},
515
+
);
516
+
return {
517
+
success: false,
518
+
error: `Failed to verify domain: ${error.message}`,
519
+
};
520
}
521
+
console.error(
522
+
`[verifyDomain] Unknown error verifying ${domainUrl}:`,
523
+
error,
524
+
);
525
return { success: false, error: "Failed to verify domain" };
526
}
527
}
···
532
redirectUri: string,
533
): Promise<{
534
error?: string;
535
+
app?: {
536
+
name: string | null;
537
+
redirect_uris: string;
538
+
logo_url?: string | null;
539
+
};
540
}> {
541
const existing = db
542
.query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?")
···
629
630
// Fetch the newly created app
631
const newApp = db
632
+
.query(
633
+
"SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?",
634
+
)
635
+
.get(canonicalClientId) as {
636
+
name: string | null;
637
+
redirect_uris: string;
638
+
logo_url?: string | null;
639
+
};
640
641
return { app: newApp };
642
}
···
664
const codeChallengeMethod = params.get("code_challenge_method");
665
const scope = params.get("scope") || "profile";
666
const me = params.get("me");
667
+
const nonce = params.get("nonce"); // OIDC nonce parameter
668
669
if (responseType !== "code") {
670
return new Response("Unsupported response_type", { status: 400 });
···
1019
if (hasAllScopes) {
1020
// Auto-approve - create auth code and redirect
1021
const code = crypto.randomBytes(32).toString("base64url");
1022
+
const now = Math.floor(Date.now() / 1000);
1023
+
const expiresAt = now + 60; // 60 seconds
1024
1025
db.query(
1026
+
"INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me, nonce, auth_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1027
).run(
1028
code,
1029
user.userId,
···
1033
codeChallenge,
1034
expiresAt,
1035
me,
1036
+
nonce,
1037
+
now, // auth_time - user already authenticated
1038
);
1039
1040
// Update permission last_used
···
1043
).run(Math.floor(Date.now() / 1000), user.userId, clientId);
1044
1045
const origin = process.env.ORIGIN || "http://localhost:3000";
1046
+
return Response.redirect(
1047
+
`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`,
1048
+
);
1049
}
1050
}
1051
···
1058
codeChallenge,
1059
requestedScopes,
1060
me,
1061
+
nonce,
1062
);
1063
}
1064
···
1070
codeChallenge: string,
1071
scopes: string[],
1072
me: string | null,
1073
+
nonce: string | null,
1074
): Response {
1075
// Load app metadata if pre-registered
1076
const appData = db
···
1389
<input type="hidden" name="state" value="${state}" />
1390
<input type="hidden" name="code_challenge" value="${codeChallenge}" />
1391
${me ? `<input type="hidden" name="me" value="${me}" />` : ""}
1392
+
${nonce ? `<input type="hidden" name="nonce" value="${nonce}" />` : ""}
1393
<!-- Always include profile scope as it's required -->
1394
<input type="hidden" name="scope" value="profile" />
1395
···
1410
// POST /auth/authorize - Consent form submission
1411
export async function authorizePost(req: Request): Promise<Response> {
1412
const contentType = req.headers.get("Content-Type");
1413
+
1414
// Parse the request body
1415
let body: Record<string, string>;
1416
let formData: FormData;
···
1428
}
1429
1430
const grantType = body.grant_type;
1431
+
1432
// If grant_type is present, this is a token exchange request (IndieAuth profile scope only)
1433
if (grantType === "authorization_code") {
1434
// Create a mock request for token() function
1435
const mockReq = new Request(req.url, {
1436
method: "POST",
1437
headers: req.headers,
1438
+
body: contentType?.includes("application/x-www-form-urlencoded")
1439
? new URLSearchParams(body).toString()
1440
: JSON.stringify(body),
1441
});
···
1455
const state = body.state;
1456
const codeChallenge = body.code_challenge;
1457
const me = body.me || null;
1458
+
const nonce = body.nonce || null; // OIDC nonce
1459
1460
if (!rawClientId || !rawRedirectUri || !state || !codeChallenge) {
1461
return new Response("Missing required parameters", { status: 400 });
···
1468
clientId = canonicalizeURL(rawClientId);
1469
redirectUri = canonicalizeURL(rawRedirectUri);
1470
} catch {
1471
+
return new Response("Invalid client_id or redirect_uri URL format", {
1472
+
status: 400,
1473
+
});
1474
}
1475
1476
if (action === "deny") {
···
1489
1490
// Create authorization code
1491
const code = crypto.randomBytes(32).toString("base64url");
1492
+
const now = Math.floor(Date.now() / 1000);
1493
+
const expiresAt = now + 60; // 60 seconds
1494
1495
db.query(
1496
+
"INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me, nonce, auth_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1497
).run(
1498
code,
1499
user.userId,
···
1503
codeChallenge,
1504
expiresAt,
1505
me,
1506
+
nonce,
1507
+
now, // auth_time
1508
);
1509
1510
// Store or update permission grant
···
1587
let redirect_uri: string | undefined;
1588
try {
1589
client_id = raw_client_id ? canonicalizeURL(raw_client_id) : undefined;
1590
+
redirect_uri = raw_redirect_uri
1591
+
? canonicalizeURL(raw_redirect_uri)
1592
+
: undefined;
1593
} catch {
1594
return Response.json(
1595
{
···
1604
return Response.json(
1605
{
1606
error: "unsupported_grant_type",
1607
+
error_description:
1608
+
"Only authorization_code and refresh_token grant types are supported",
1609
},
1610
{ status: 400 },
1611
);
···
1680
const expiresAt = now + expiresIn;
1681
1682
// Update token (rotate access token, keep refresh token)
1683
+
db.query("UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?").run(
1684
+
newAccessToken,
1685
+
expiresAt,
1686
+
tokenData.id,
1687
+
);
1688
1689
// Get user profile for me value
1690
const user = db
···
1719
headers: {
1720
"Content-Type": "application/json",
1721
"Cache-Control": "no-store",
1722
+
Pragma: "no-cache",
1723
},
1724
},
1725
);
···
1775
}
1776
}
1777
1778
+
if (!code || !client_id) {
1779
+
console.error("Token endpoint: missing required parameters", {
1780
code: !!code,
1781
client_id: !!client_id,
1782
});
1783
return Response.json(
1784
{
1785
error: "invalid_request",
1786
+
error_description: "Missing required parameters (code, client_id)",
1787
},
1788
{ status: 400 },
1789
);
···
1803
// Look up authorization code
1804
const authcode = db
1805
.query(
1806
+
"SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me, nonce, auth_time FROM authcodes WHERE code = ?",
1807
)
1808
.get(code) as
1809
| {
···
1815
expires_at: number;
1816
used: number;
1817
me: string | null;
1818
+
nonce: string | null;
1819
+
auth_time: number | null;
1820
}
1821
| undefined;
1822
···
1833
1834
// Check if already used
1835
if (authcode.used) {
1836
+
console.error("Token endpoint: authorization code already used", {
1837
+
code,
1838
+
});
1839
return Response.json(
1840
{
1841
error: "invalid_grant",
···
1848
// Check if expired
1849
const now = Math.floor(Date.now() / 1000);
1850
if (authcode.expires_at < now) {
1851
+
console.error("Token endpoint: authorization code expired", {
1852
+
code,
1853
+
expires_at: authcode.expires_at,
1854
+
now,
1855
+
diff: now - authcode.expires_at,
1856
+
});
1857
return Response.json(
1858
{
1859
error: "invalid_grant",
···
1865
1866
// Verify client_id matches
1867
if (authcode.client_id !== client_id) {
1868
+
console.error("Token endpoint: client_id mismatch", {
1869
+
stored: authcode.client_id,
1870
+
received: client_id,
1871
+
});
1872
return Response.json(
1873
{
1874
error: "invalid_grant",
···
1878
);
1879
}
1880
1881
+
// Verify redirect_uri matches if provided (per OAuth 2.0 RFC 6749 section 4.1.3)
1882
+
// redirect_uri is REQUIRED if it was included in the authorization request
1883
+
if (redirect_uri && authcode.redirect_uri !== redirect_uri) {
1884
+
console.error("Token endpoint: redirect_uri mismatch", {
1885
+
stored: authcode.redirect_uri,
1886
+
received: redirect_uri,
1887
+
});
1888
return Response.json(
1889
{
1890
error: "invalid_grant",
···
1896
1897
// Verify PKCE code_verifier (required for all clients per IndieAuth spec)
1898
if (!verifyPKCE(code_verifier, authcode.code_challenge)) {
1899
+
console.error("Token endpoint: PKCE verification failed", {
1900
+
code_verifier,
1901
+
code_challenge: authcode.code_challenge,
1902
+
});
1903
return Response.json(
1904
{
1905
error: "invalid_grant",
···
1962
1963
// Validate that the user controls the requested me parameter
1964
if (authcode.me && authcode.me !== meValue) {
1965
+
console.error("Token endpoint: me mismatch", {
1966
+
requested: authcode.me,
1967
+
actual: meValue,
1968
+
});
1969
return Response.json(
1970
{
1971
error: "invalid_grant",
1972
+
error_description:
1973
+
"The requested identity does not match the user's verified domain",
1974
},
1975
{ status: 400 },
1976
);
1977
}
1978
1979
const origin = process.env.ORIGIN || "http://localhost:3000";
1980
+
1981
// Generate access token
1982
const accessToken = crypto.randomBytes(32).toString("base64url");
1983
const expiresIn = 3600; // 1 hour
···
1991
// Store token in database with refresh token
1992
db.query(
1993
"INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
1994
+
).run(
1995
+
accessToken,
1996
+
authcode.user_id,
1997
+
client_id,
1998
+
scopes.join(" "),
1999
+
expiresAt,
2000
+
refreshToken,
2001
+
refreshExpiresAt,
2002
+
);
2003
2004
const response: Record<string, unknown> = {
2005
access_token: accessToken,
···
2017
response.role = permission.role;
2018
}
2019
2020
+
// Generate OIDC id_token if openid scope is requested
2021
+
if (scopes.includes("openid")) {
2022
+
const idTokenClaims: Record<string, unknown> = {
2023
+
sub: meValue,
2024
+
aud: client_id,
2025
+
};
2026
+
2027
+
// Add nonce if provided (OIDC replay protection)
2028
+
if (authcode.nonce) {
2029
+
idTokenClaims.nonce = authcode.nonce;
2030
+
}
2031
+
2032
+
// Add auth_time if available
2033
+
if (authcode.auth_time) {
2034
+
idTokenClaims.auth_time = authcode.auth_time;
2035
+
}
2036
+
2037
+
// Add profile claims if profile scope included
2038
+
if (scopes.includes("profile")) {
2039
+
idTokenClaims.name = user.name;
2040
+
if (user.photo) idTokenClaims.picture = user.photo;
2041
+
if (user.url) idTokenClaims.website = user.url;
2042
+
}
2043
+
2044
+
// Add email claim if email scope included
2045
+
if (scopes.includes("email") && user.email) {
2046
+
idTokenClaims.email = user.email;
2047
+
}
2048
+
2049
+
const idToken = await signIDToken(
2050
+
origin,
2051
+
idTokenClaims as {
2052
+
sub: string;
2053
+
aud: string;
2054
+
nonce?: string;
2055
+
auth_time?: number;
2056
+
name?: string;
2057
+
email?: string;
2058
+
picture?: string;
2059
+
website?: string;
2060
+
},
2061
+
);
2062
+
response.id_token = idToken;
2063
+
}
2064
+
2065
+
console.log("Token endpoint: success", {
2066
+
me: meValue,
2067
+
scopes: scopes.join(" "),
2068
+
});
2069
2070
return Response.json(response, {
2071
headers: {
2072
"Content-Type": "application/json",
2073
"Cache-Control": "no-store",
2074
+
Pragma: "no-cache",
2075
},
2076
});
2077
} catch (error) {
···
2235
try {
2236
// Get access token from Authorization header
2237
const authHeader = req.headers.get("Authorization");
2238
+
2239
if (!authHeader || !authHeader.startsWith("Bearer ")) {
2240
return Response.json(
2241
{
···
2293
// Parse scopes
2294
const scopes = tokenData.scope.split(" ");
2295
2296
+
// Build response based on scopes (OIDC-compliant claim names)
2297
+
const origin = process.env.ORIGIN || "http://localhost:3000";
2298
const response: Record<string, string> = {};
2299
2300
+
// sub claim is always required for OIDC userinfo
2301
+
if (tokenData.url) {
2302
+
response.sub = tokenData.url;
2303
+
} else {
2304
+
response.sub = `${origin}/u/${tokenData.username}`;
2305
+
}
2306
+
2307
if (scopes.includes("profile")) {
2308
response.name = tokenData.name;
2309
+
if (tokenData.photo) response.picture = tokenData.photo; // OIDC uses 'picture'
2310
if (tokenData.url) {
2311
+
response.website = tokenData.url; // OIDC uses 'website'
2312
}
2313
}
2314
···
2316
response.email = tokenData.email;
2317
}
2318
2319
+
// For OIDC, we always return at least sub
2320
+
// But for IndieAuth compatibility, check if we have meaningful claims
2321
+
if (Object.keys(response).length === 1 && !scopes.includes("openid")) {
2322
+
// Only sub, no openid scope - this is a pure IndieAuth request without claims
2323
return Response.json(
2324
{
2325
error: "insufficient_scope",
+6
-2
src/routes/passkeys.ts
+6
-2
src/routes/passkeys.ts
···
1
import {
2
-
type RegistrationResponseJSON,
3
generateRegistrationOptions,
4
type VerifiedRegistrationResponse,
5
verifyRegistrationResponse,
6
} from "@simplewebauthn/server";
···
133
}
134
135
const body = await req.json();
136
-
const { response, challenge: expectedChallenge, name } = body as {
137
response: RegistrationResponseJSON;
138
challenge: string;
139
name?: string;
···
1
import {
2
generateRegistrationOptions,
3
+
type RegistrationResponseJSON,
4
type VerifiedRegistrationResponse,
5
verifyRegistrationResponse,
6
} from "@simplewebauthn/server";
···
133
}
134
135
const body = await req.json();
136
+
const {
137
+
response,
138
+
challenge: expectedChallenge,
139
+
name,
140
+
} = body as {
141
response: RegistrationResponseJSON;
142
challenge: string;
143
name?: string;