+24
-3
README.md
+24
-3
README.md
···
130
130
131
131
Now you can sign in to IndieAuth-compatible sites using `https://your-domain.com/` as your identity.
132
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
+
133
151
## API Reference
134
152
135
-
### OAuth 2.0 Endpoints
153
+
### OAuth 2.0 / OpenID Connect Endpoints
136
154
137
-
- `GET /auth/authorize` - Authorization endpoint
138
-
- `POST /auth/token` - Token exchange endpoint
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
139
160
- `POST /auth/logout` - Session logout
140
161
141
162
### User Profile
+140
SPEC.md
+140
SPEC.md
···
497
497
// Create session for user
498
498
```
499
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
+
500
637
## Future Enhancements
501
638
502
639
- Token endpoint for longer-lived access tokens
···
509
646
- Audit log for admin
510
647
- Rate limiting
511
648
- Account recovery flow
649
+
- OIDC key rotation via admin interface
512
650
513
651
## Standards Compliance
514
652
···
516
654
- [WebAuthn/FIDO2](https://www.w3.org/TR/webauthn-2/)
517
655
- [OAuth 2.0 PKCE](https://tools.ietf.org/html/rfc7636)
518
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
8
"@simplewebauthn/browser": "^13.2.2",
9
9
"@simplewebauthn/server": "^13.2.2",
10
10
"bun-sqlite-migrations": "^1.0.2",
11
+
"jose": "^6.1.3",
11
12
"ldap-authentication": "^3.3.6",
12
13
"nanoid": "^5.1.6",
13
14
},
···
70
71
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
71
72
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=="],
73
76
74
77
"ldap-authentication": ["ldap-authentication@3.3.6", "", { "dependencies": { "ldapts": "^7.3.1" } }, "sha512-j8XxH5wGXhIQ3mMnoRCZTalSLmPhzEaGH4+5RIFP0Higc32fCKDRJBo+9wb5ysy9TnlaNtaf+rgdwYCD15OBpQ=="],
75
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";
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", {
+76
-1
src/html/docs.html
+76
-1
src/html/docs.html
···
577
577
<h3>table of contents</h3>
578
578
<ul>
579
579
<li><a href="#overview">overview</a></li>
580
+
<li><a href="#oidc">openid connect (oidc)</a></li>
580
581
<li><a href="#getting-started">getting started</a></li>
581
582
<li><a href="#button">sign in button</a></li>
582
583
<li><a href="#endpoints">endpoints</a></li>
···
612
613
<ul>
613
614
<li>Passwordless authentication via WebAuthn passkeys</li>
614
615
<li>Full IndieAuth and OAuth 2.0 support with PKCE</li>
616
+
<li>OpenID Connect (OIDC) support with ID tokens</li>
615
617
<li>Access tokens and refresh tokens for API access</li>
616
618
<li>Token introspection and revocation endpoints</li>
617
619
<li>UserInfo endpoint for profile data</li>
···
621
623
<li>User profile endpoints with h-card microformats</li>
622
624
<li>Invite-based user registration</li>
623
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>
624
694
</section>
625
695
626
696
<section id="getting-started" class="section">
···
1032
1102
</thead>
1033
1103
<tbody>
1034
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>
1035
1110
<td><code>profile</code></td>
1036
1111
<td>Basic profile information</td>
1037
1112
<td>name, photo, URL</td>
···
1046
1121
1047
1122
<div class="info-box">
1048
1123
<strong>Note:</strong>
1049
-
Users can selectively approve scopes during authorization. Your app may receive fewer scopes than requested.
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.
1050
1125
</div>
1051
1126
</section>
1052
1127
+21
-3
src/index.ts
+21
-3
src/index.ts
···
8
8
import indexHTML from "./html/index.html";
9
9
import loginHTML from "./html/login.html";
10
10
import { getLdapAccounts, updateOrphanedAccounts } from "./ldap-cleanup";
11
+
import { getDiscoveryDocument, getJWKS } from "./oidc";
11
12
import {
12
13
deleteSelfAccount,
13
14
deleteUser,
···
155
156
);
156
157
},
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
+
},
158
167
// OAuth/IndieAuth endpoints
159
168
"/userinfo": (req: Request) => {
160
169
if (req.method === "GET") return userinfo(req);
···
365
374
366
375
if (expiredOrphans.length > 0) {
367
376
if (action === "suspend") {
368
-
await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "suspend");
377
+
await updateOrphanedAccounts(
378
+
{ ...result, orphanedUsers: expiredOrphans },
379
+
"suspend",
380
+
);
369
381
} else if (action === "deactivate") {
370
-
await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "deactivate");
382
+
await updateOrphanedAccounts(
383
+
{ ...result, orphanedUsers: expiredOrphans },
384
+
"deactivate",
385
+
);
371
386
} else if (action === "remove") {
372
-
await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "remove");
387
+
await updateOrphanedAccounts(
388
+
{ ...result, orphanedUsers: expiredOrphans },
389
+
"remove",
390
+
);
373
391
}
374
392
console.log(
375
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
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" },
···
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 });
+78
-38
src/routes/auth.ts
+78
-38
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";
···
39
39
// Check if username already exists
40
40
const existingUser = db
41
41
.query("SELECT id FROM users WHERE username = ?")
42
-
.get(username);
42
+
.get(username) as { id: number } | undefined;
43
43
44
+
// Allow re-registration if user exists but has no credentials (passkey reset case)
45
+
let isPasskeyReset = false;
44
46
if (existingUser) {
45
-
return Response.json(
46
-
{ error: "Username already taken" },
47
-
{ status: 400 },
48
-
);
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;
49
59
}
50
60
51
61
// Check if this is bootstrap (first user)
···
156
166
157
167
// Check if username already exists
158
168
const existingUser = db
159
-
.query("SELECT id FROM users WHERE username = ?")
160
-
.get(username);
169
+
.query("SELECT id, is_admin FROM users WHERE username = ?")
170
+
.get(username) as { id: number; is_admin: number } | undefined;
161
171
172
+
// Allow re-registration if user exists but has no credentials (passkey reset case)
173
+
let isPasskeyReset = false;
162
174
if (existingUser) {
163
-
return Response.json(
164
-
{ error: "Username already taken" },
165
-
{ status: 400 },
166
-
);
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;
167
187
}
168
188
169
189
if (!expectedChallenge) {
···
275
295
invite?.ldap_username !== null && invite?.ldap_username !== undefined;
276
296
}
277
297
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
-
};
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
+
}
292
321
293
322
// Store credential
294
323
// credential.id is a Uint8Array, convert to Buffer for storage
295
324
db.query(
296
325
"INSERT INTO credentials (user_id, credential_id, public_key, counter, name) VALUES (?, ?, ?, ?, ?)",
297
326
).run(
298
-
user.id,
327
+
userId,
299
328
Buffer.from(credential.id),
300
329
Buffer.from(credential.publicKey),
301
330
credential.counter,
302
-
"Primary Passkey",
331
+
isPasskeyReset ? "Reset Passkey" : "Primary Passkey",
303
332
);
304
333
305
334
// Mark invite as used if applicable
···
324
353
// Record this invite use
325
354
db.query(
326
355
"INSERT INTO invite_uses (invite_id, user_id, used_at) VALUES (?, ?, ?)",
327
-
).run(inviteId, user.id, usedAt);
356
+
).run(inviteId, userId, usedAt);
328
357
329
-
// Assign app roles to the new user
330
-
if (inviteRoles.length > 0) {
358
+
// Assign app roles to the new user (skip for passkey reset - they already have roles)
359
+
if (inviteRoles.length > 0 && !isPasskeyReset) {
331
360
const insertPermission = db.query(
332
361
"INSERT INTO permissions (user_id, app_id, role) VALUES (?, ?, ?)",
333
362
);
334
363
for (const { app_id, role } of inviteRoles) {
335
-
insertPermission.run(user.id, app_id, role);
364
+
insertPermission.run(userId, app_id, role);
336
365
}
337
366
}
338
367
}
···
347
376
const expiresAt = Math.floor(Date.now() / 1000) + 86400; // 24 hours
348
377
db.query(
349
378
"INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)",
350
-
).run(token, user.id, expiresAt);
379
+
).run(token, userId, expiresAt);
351
380
352
381
const isProduction = process.env.NODE_ENV === "production";
353
382
const secureCookie = isProduction ? "; Secure" : "";
···
356
385
{
357
386
token,
358
387
username,
359
-
isAdmin: isBootstrap,
388
+
isAdmin: userIsAdmin,
360
389
},
361
390
{
362
391
headers: {
···
381
410
382
411
// Check if user exists and is active
383
412
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;
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;
386
424
387
425
if (!user) {
388
426
return Response.json({ error: "Invalid credentials" }, { status: 401 });
···
405
443
const existsInLdap = await checkLdapUser(username);
406
444
if (!existsInLdap) {
407
445
// User no longer exists in LDAP - suspend the account
408
-
db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run(user.id);
446
+
db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run(
447
+
user.id,
448
+
);
409
449
return Response.json(
410
450
{ error: "Invalid credentials" },
411
451
{ status: 401 },
+3
-1
src/routes/clients.ts
+3
-1
src/routes/clients.ts
···
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 ")) {
+270
-80
src/routes/indieauth.ts
+270
-80
src/routes/indieauth.ts
···
1
1
import crypto from "crypto";
2
2
import { db } from "../db";
3
+
import { signIDToken } from "../oidc";
3
4
4
5
interface SessionUser {
5
6
username: string;
···
127
128
}
128
129
129
130
// Validate profile URL per IndieAuth spec
130
-
export function validateProfileURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } {
131
+
export function validateProfileURL(urlString: string): {
132
+
valid: boolean;
133
+
error?: string;
134
+
canonicalUrl?: string;
135
+
} {
131
136
let url: URL;
132
137
try {
133
138
url = new URL(urlString);
···
152
157
153
158
// MUST NOT contain username/password
154
159
if (url.username || url.password) {
155
-
return { valid: false, error: "Profile URL must not contain username or password" };
160
+
return {
161
+
valid: false,
162
+
error: "Profile URL must not contain username or password",
163
+
};
156
164
}
157
165
158
166
// MUST NOT contain ports
···
164
172
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
165
173
const ipv6Regex = /^\[?[0-9a-fA-F:]+\]?$/;
166
174
if (ipv4Regex.test(url.hostname) || ipv6Regex.test(url.hostname)) {
167
-
return { valid: false, error: "Profile URL must use domain names, not IP addresses" };
175
+
return {
176
+
valid: false,
177
+
error: "Profile URL must use domain names, not IP addresses",
178
+
};
168
179
}
169
180
170
181
// MUST NOT contain single-dot or double-dot path segments
171
182
const pathSegments = url.pathname.split("/");
172
183
if (pathSegments.includes(".") || pathSegments.includes("..")) {
173
-
return { valid: false, error: "Profile URL must not contain . or .. path segments" };
184
+
return {
185
+
valid: false,
186
+
error: "Profile URL must not contain . or .. path segments",
187
+
};
174
188
}
175
189
176
190
return { valid: true, canonicalUrl: canonicalizeURL(urlString) };
177
191
}
178
192
179
193
// Validate client URL per IndieAuth spec
180
-
function validateClientURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } {
194
+
function validateClientURL(urlString: string): {
195
+
valid: boolean;
196
+
error?: string;
197
+
canonicalUrl?: string;
198
+
} {
181
199
let url: URL;
182
200
try {
183
201
url = new URL(urlString);
···
202
220
203
221
// MUST NOT contain username/password
204
222
if (url.username || url.password) {
205
-
return { valid: false, error: "Client URL must not contain username or password" };
223
+
return {
224
+
valid: false,
225
+
error: "Client URL must not contain username or password",
226
+
};
206
227
}
207
228
208
229
// MUST NOT contain single-dot or double-dot path segments
209
230
const pathSegments = url.pathname.split("/");
210
231
if (pathSegments.includes(".") || pathSegments.includes("..")) {
211
-
return { valid: false, error: "Client URL must not contain . or .. path segments" };
232
+
return {
233
+
valid: false,
234
+
error: "Client URL must not contain . or .. path segments",
235
+
};
212
236
}
213
237
214
238
// MAY use loopback interface, but not other IP addresses
···
217
241
if (ipv4Regex.test(url.hostname)) {
218
242
// Allow 127.0.0.1 (loopback), reject others
219
243
if (!url.hostname.startsWith("127.")) {
220
-
return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" };
244
+
return {
245
+
valid: false,
246
+
error:
247
+
"Client URL must use domain names, not IP addresses (except loopback)",
248
+
};
221
249
}
222
250
} else if (ipv6Regex.test(url.hostname)) {
223
251
// Allow ::1 (loopback), reject others
224
252
const ipv6Match = url.hostname.match(ipv6Regex);
225
253
if (ipv6Match && ipv6Match[1] !== "::1") {
226
-
return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" };
254
+
return {
255
+
valid: false,
256
+
error:
257
+
"Client URL must use domain names, not IP addresses (except loopback)",
258
+
};
227
259
}
228
260
}
229
261
···
234
266
function isLoopbackURL(urlString: string): boolean {
235
267
try {
236
268
const url = new URL(urlString);
237
-
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname.startsWith("127.");
269
+
return (
270
+
url.hostname === "localhost" ||
271
+
url.hostname === "127.0.0.1" ||
272
+
url.hostname === "[::1]" ||
273
+
url.hostname.startsWith("127.")
274
+
);
238
275
} catch {
239
276
return false;
240
277
}
···
254
291
}> {
255
292
// MUST NOT fetch loopback addresses (security requirement)
256
293
if (isLoopbackURL(clientId)) {
257
-
return { success: false, error: "Cannot fetch metadata from loopback addresses" };
294
+
return {
295
+
success: false,
296
+
error: "Cannot fetch metadata from loopback addresses",
297
+
};
258
298
}
259
299
260
300
try {
···
273
313
clearTimeout(timeoutId);
274
314
275
315
if (!response.ok) {
276
-
return { success: false, error: `Failed to fetch client metadata: HTTP ${response.status}` };
316
+
return {
317
+
success: false,
318
+
error: `Failed to fetch client metadata: HTTP ${response.status}`,
319
+
};
277
320
}
278
321
279
322
const contentType = response.headers.get("content-type") || "";
···
284
327
285
328
// Verify client_id matches
286
329
if (metadata.client_id && metadata.client_id !== clientId) {
287
-
return { success: false, error: "client_id in metadata does not match URL" };
330
+
return {
331
+
success: false,
332
+
error: "client_id in metadata does not match URL",
333
+
};
288
334
}
289
335
290
336
return { success: true, metadata };
···
295
341
const html = await response.text();
296
342
297
343
// Extract redirect URIs from link tags
298
-
const redirectUriRegex = /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
344
+
const redirectUriRegex =
345
+
/<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
299
346
const redirectUris: string[] = [];
300
347
let match: RegExpExecArray | null;
301
348
···
304
351
}
305
352
306
353
// Also try reverse order (href before rel)
307
-
const redirectUriRegex2 = /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi;
354
+
const redirectUriRegex2 =
355
+
/<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi;
308
356
while ((match = redirectUriRegex2.exec(html)) !== null) {
309
357
if (!redirectUris.includes(match[1])) {
310
358
redirectUris.push(match[1]);
···
321
369
};
322
370
}
323
371
324
-
return { success: false, error: "No client metadata or redirect_uri links found in HTML" };
372
+
return {
373
+
success: false,
374
+
error: "No client metadata or redirect_uri links found in HTML",
375
+
};
325
376
}
326
377
327
378
return { success: false, error: "Unsupported content type" };
···
330
381
if (error.name === "AbortError") {
331
382
return { success: false, error: "Timeout fetching client metadata" };
332
383
}
333
-
return { success: false, error: `Failed to fetch client metadata: ${error.message}` };
384
+
return {
385
+
success: false,
386
+
error: `Failed to fetch client metadata: ${error.message}`,
387
+
};
334
388
}
335
389
return { success: false, error: "Failed to fetch client metadata" };
336
390
}
337
391
}
338
392
339
393
// Verify domain has rel="me" link back to user profile
340
-
export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): Promise<{
394
+
export async function verifyDomain(
395
+
domainUrl: string,
396
+
indikoProfileUrl: string,
397
+
): Promise<{
341
398
success: boolean;
342
399
error?: string;
343
400
}> {
···
359
416
360
417
if (!response.ok) {
361
418
const errorBody = await response.text();
362
-
console.error(`[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, {
363
-
status: response.status,
364
-
contentType: response.headers.get("content-type"),
365
-
bodyPreview: errorBody.substring(0, 200),
366
-
});
367
-
return { success: false, error: `Failed to fetch domain: HTTP ${response.status}` };
419
+
console.error(
420
+
`[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`,
421
+
{
422
+
status: response.status,
423
+
contentType: response.headers.get("content-type"),
424
+
bodyPreview: errorBody.substring(0, 200),
425
+
},
426
+
);
427
+
return {
428
+
success: false,
429
+
error: `Failed to fetch domain: HTTP ${response.status}`,
430
+
};
368
431
}
369
432
370
433
const html = await response.text();
···
413
476
414
477
// Check if any rel="me" link matches the indiko profile URL
415
478
const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl);
416
-
const hasRelMe = relMeLinks.some(link => {
479
+
const hasRelMe = relMeLinks.some((link) => {
417
480
try {
418
481
const normalizedLink = canonicalizeURL(link);
419
482
return normalizedLink === normalizedIndikoUrl;
···
423
486
});
424
487
425
488
if (!hasRelMe) {
426
-
console.error(`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, {
427
-
foundLinks: relMeLinks,
428
-
normalizedTarget: normalizedIndikoUrl,
429
-
});
489
+
console.error(
490
+
`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`,
491
+
{
492
+
foundLinks: relMeLinks,
493
+
normalizedTarget: normalizedIndikoUrl,
494
+
},
495
+
);
430
496
return {
431
497
success: false,
432
498
error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`,
···
440
506
console.error(`[verifyDomain] Timeout verifying ${domainUrl}`);
441
507
return { success: false, error: "Timeout verifying domain" };
442
508
}
443
-
console.error(`[verifyDomain] Error verifying ${domainUrl}: ${error.message}`, {
444
-
name: error.name,
445
-
stack: error.stack,
446
-
});
447
-
return { success: false, error: `Failed to verify domain: ${error.message}` };
509
+
console.error(
510
+
`[verifyDomain] Error verifying ${domainUrl}: ${error.message}`,
511
+
{
512
+
name: error.name,
513
+
stack: error.stack,
514
+
},
515
+
);
516
+
return {
517
+
success: false,
518
+
error: `Failed to verify domain: ${error.message}`,
519
+
};
448
520
}
449
-
console.error(`[verifyDomain] Unknown error verifying ${domainUrl}:`, error);
521
+
console.error(
522
+
`[verifyDomain] Unknown error verifying ${domainUrl}:`,
523
+
error,
524
+
);
450
525
return { success: false, error: "Failed to verify domain" };
451
526
}
452
527
}
···
457
532
redirectUri: string,
458
533
): Promise<{
459
534
error?: string;
460
-
app?: { name: string | null; redirect_uris: string; logo_url?: string | null };
535
+
app?: {
536
+
name: string | null;
537
+
redirect_uris: string;
538
+
logo_url?: string | null;
539
+
};
461
540
}> {
462
541
const existing = db
463
542
.query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?")
···
550
629
551
630
// Fetch the newly created app
552
631
const newApp = db
553
-
.query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?")
554
-
.get(canonicalClientId) as { name: string | null; redirect_uris: string; logo_url?: string | null };
632
+
.query(
633
+
"SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?",
634
+
)
635
+
.get(canonicalClientId) as {
636
+
name: string | null;
637
+
redirect_uris: string;
638
+
logo_url?: string | null;
639
+
};
555
640
556
641
return { app: newApp };
557
642
}
···
579
664
const codeChallengeMethod = params.get("code_challenge_method");
580
665
const scope = params.get("scope") || "profile";
581
666
const me = params.get("me");
667
+
const nonce = params.get("nonce"); // OIDC nonce parameter
582
668
583
669
if (responseType !== "code") {
584
670
return new Response("Unsupported response_type", { status: 400 });
···
933
1019
if (hasAllScopes) {
934
1020
// Auto-approve - create auth code and redirect
935
1021
const code = crypto.randomBytes(32).toString("base64url");
936
-
const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds
1022
+
const now = Math.floor(Date.now() / 1000);
1023
+
const expiresAt = now + 60; // 60 seconds
937
1024
938
1025
db.query(
939
-
"INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1026
+
"INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me, nonce, auth_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
940
1027
).run(
941
1028
code,
942
1029
user.userId,
···
946
1033
codeChallenge,
947
1034
expiresAt,
948
1035
me,
1036
+
nonce,
1037
+
now, // auth_time - user already authenticated
949
1038
);
950
1039
951
1040
// Update permission last_used
···
954
1043
).run(Math.floor(Date.now() / 1000), user.userId, clientId);
955
1044
956
1045
const origin = process.env.ORIGIN || "http://localhost:3000";
957
-
return Response.redirect(`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`);
1046
+
return Response.redirect(
1047
+
`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`,
1048
+
);
958
1049
}
959
1050
}
960
1051
···
967
1058
codeChallenge,
968
1059
requestedScopes,
969
1060
me,
1061
+
nonce,
970
1062
);
971
1063
}
972
1064
···
978
1070
codeChallenge: string,
979
1071
scopes: string[],
980
1072
me: string | null,
1073
+
nonce: string | null,
981
1074
): Response {
982
1075
// Load app metadata if pre-registered
983
1076
const appData = db
···
1296
1389
<input type="hidden" name="state" value="${state}" />
1297
1390
<input type="hidden" name="code_challenge" value="${codeChallenge}" />
1298
1391
${me ? `<input type="hidden" name="me" value="${me}" />` : ""}
1392
+
${nonce ? `<input type="hidden" name="nonce" value="${nonce}" />` : ""}
1299
1393
<!-- Always include profile scope as it's required -->
1300
1394
<input type="hidden" name="scope" value="profile" />
1301
1395
···
1316
1410
// POST /auth/authorize - Consent form submission
1317
1411
export async function authorizePost(req: Request): Promise<Response> {
1318
1412
const contentType = req.headers.get("Content-Type");
1319
-
1413
+
1320
1414
// Parse the request body
1321
1415
let body: Record<string, string>;
1322
1416
let formData: FormData;
···
1334
1428
}
1335
1429
1336
1430
const grantType = body.grant_type;
1337
-
1431
+
1338
1432
// If grant_type is present, this is a token exchange request (IndieAuth profile scope only)
1339
1433
if (grantType === "authorization_code") {
1340
1434
// Create a mock request for token() function
1341
1435
const mockReq = new Request(req.url, {
1342
1436
method: "POST",
1343
1437
headers: req.headers,
1344
-
body: contentType?.includes("application/x-www-form-urlencoded")
1438
+
body: contentType?.includes("application/x-www-form-urlencoded")
1345
1439
? new URLSearchParams(body).toString()
1346
1440
: JSON.stringify(body),
1347
1441
});
···
1361
1455
const state = body.state;
1362
1456
const codeChallenge = body.code_challenge;
1363
1457
const me = body.me || null;
1458
+
const nonce = body.nonce || null; // OIDC nonce
1364
1459
1365
1460
if (!rawClientId || !rawRedirectUri || !state || !codeChallenge) {
1366
1461
return new Response("Missing required parameters", { status: 400 });
···
1373
1468
clientId = canonicalizeURL(rawClientId);
1374
1469
redirectUri = canonicalizeURL(rawRedirectUri);
1375
1470
} catch {
1376
-
return new Response("Invalid client_id or redirect_uri URL format", { status: 400 });
1471
+
return new Response("Invalid client_id or redirect_uri URL format", {
1472
+
status: 400,
1473
+
});
1377
1474
}
1378
1475
1379
1476
if (action === "deny") {
···
1392
1489
1393
1490
// Create authorization code
1394
1491
const code = crypto.randomBytes(32).toString("base64url");
1395
-
const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds
1492
+
const now = Math.floor(Date.now() / 1000);
1493
+
const expiresAt = now + 60; // 60 seconds
1396
1494
1397
1495
db.query(
1398
-
"INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1496
+
"INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me, nonce, auth_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1399
1497
).run(
1400
1498
code,
1401
1499
user.userId,
···
1405
1503
codeChallenge,
1406
1504
expiresAt,
1407
1505
me,
1506
+
nonce,
1507
+
now, // auth_time
1408
1508
);
1409
1509
1410
1510
// Store or update permission grant
···
1487
1587
let redirect_uri: string | undefined;
1488
1588
try {
1489
1589
client_id = raw_client_id ? canonicalizeURL(raw_client_id) : undefined;
1490
-
redirect_uri = raw_redirect_uri ? canonicalizeURL(raw_redirect_uri) : undefined;
1590
+
redirect_uri = raw_redirect_uri
1591
+
? canonicalizeURL(raw_redirect_uri)
1592
+
: undefined;
1491
1593
} catch {
1492
1594
return Response.json(
1493
1595
{
···
1502
1604
return Response.json(
1503
1605
{
1504
1606
error: "unsupported_grant_type",
1505
-
error_description: "Only authorization_code and refresh_token grant types are supported",
1607
+
error_description:
1608
+
"Only authorization_code and refresh_token grant types are supported",
1506
1609
},
1507
1610
{ status: 400 },
1508
1611
);
···
1577
1680
const expiresAt = now + expiresIn;
1578
1681
1579
1682
// 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);
1683
+
db.query("UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?").run(
1684
+
newAccessToken,
1685
+
expiresAt,
1686
+
tokenData.id,
1687
+
);
1583
1688
1584
1689
// Get user profile for me value
1585
1690
const user = db
···
1614
1719
headers: {
1615
1720
"Content-Type": "application/json",
1616
1721
"Cache-Control": "no-store",
1617
-
"Pragma": "no-cache",
1722
+
Pragma: "no-cache",
1618
1723
},
1619
1724
},
1620
1725
);
···
1670
1775
}
1671
1776
}
1672
1777
1673
-
if (!code || !client_id || !redirect_uri) {
1674
-
console.error("Token endpoint: missing parameters", {
1778
+
if (!code || !client_id) {
1779
+
console.error("Token endpoint: missing required parameters", {
1675
1780
code: !!code,
1676
1781
client_id: !!client_id,
1677
-
redirect_uri: !!redirect_uri,
1678
1782
});
1679
1783
return Response.json(
1680
1784
{
1681
1785
error: "invalid_request",
1682
-
error_description: "Missing required parameters",
1786
+
error_description: "Missing required parameters (code, client_id)",
1683
1787
},
1684
1788
{ status: 400 },
1685
1789
);
···
1699
1803
// Look up authorization code
1700
1804
const authcode = db
1701
1805
.query(
1702
-
"SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me FROM authcodes WHERE code = ?",
1806
+
"SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me, nonce, auth_time FROM authcodes WHERE code = ?",
1703
1807
)
1704
1808
.get(code) as
1705
1809
| {
···
1711
1815
expires_at: number;
1712
1816
used: number;
1713
1817
me: string | null;
1818
+
nonce: string | null;
1819
+
auth_time: number | null;
1714
1820
}
1715
1821
| undefined;
1716
1822
···
1727
1833
1728
1834
// Check if already used
1729
1835
if (authcode.used) {
1730
-
console.error("Token endpoint: authorization code already used", { code });
1836
+
console.error("Token endpoint: authorization code already used", {
1837
+
code,
1838
+
});
1731
1839
return Response.json(
1732
1840
{
1733
1841
error: "invalid_grant",
···
1740
1848
// Check if expired
1741
1849
const now = Math.floor(Date.now() / 1000);
1742
1850
if (authcode.expires_at < now) {
1743
-
console.error("Token endpoint: authorization code expired", { code, expires_at: authcode.expires_at, now, diff: now - authcode.expires_at });
1851
+
console.error("Token endpoint: authorization code expired", {
1852
+
code,
1853
+
expires_at: authcode.expires_at,
1854
+
now,
1855
+
diff: now - authcode.expires_at,
1856
+
});
1744
1857
return Response.json(
1745
1858
{
1746
1859
error: "invalid_grant",
···
1752
1865
1753
1866
// Verify client_id matches
1754
1867
if (authcode.client_id !== client_id) {
1755
-
console.error("Token endpoint: client_id mismatch", { stored: authcode.client_id, received: client_id });
1868
+
console.error("Token endpoint: client_id mismatch", {
1869
+
stored: authcode.client_id,
1870
+
received: client_id,
1871
+
});
1756
1872
return Response.json(
1757
1873
{
1758
1874
error: "invalid_grant",
···
1762
1878
);
1763
1879
}
1764
1880
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 });
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
+
});
1768
1888
return Response.json(
1769
1889
{
1770
1890
error: "invalid_grant",
···
1776
1896
1777
1897
// Verify PKCE code_verifier (required for all clients per IndieAuth spec)
1778
1898
if (!verifyPKCE(code_verifier, authcode.code_challenge)) {
1779
-
console.error("Token endpoint: PKCE verification failed", { code_verifier, code_challenge: authcode.code_challenge });
1899
+
console.error("Token endpoint: PKCE verification failed", {
1900
+
code_verifier,
1901
+
code_challenge: authcode.code_challenge,
1902
+
});
1780
1903
return Response.json(
1781
1904
{
1782
1905
error: "invalid_grant",
···
1839
1962
1840
1963
// Validate that the user controls the requested me parameter
1841
1964
if (authcode.me && authcode.me !== meValue) {
1842
-
console.error("Token endpoint: me mismatch", { requested: authcode.me, actual: meValue });
1965
+
console.error("Token endpoint: me mismatch", {
1966
+
requested: authcode.me,
1967
+
actual: meValue,
1968
+
});
1843
1969
return Response.json(
1844
1970
{
1845
1971
error: "invalid_grant",
1846
-
error_description: "The requested identity does not match the user's verified domain",
1972
+
error_description:
1973
+
"The requested identity does not match the user's verified domain",
1847
1974
},
1848
1975
{ status: 400 },
1849
1976
);
1850
1977
}
1851
1978
1852
1979
const origin = process.env.ORIGIN || "http://localhost:3000";
1853
-
1980
+
1854
1981
// Generate access token
1855
1982
const accessToken = crypto.randomBytes(32).toString("base64url");
1856
1983
const expiresIn = 3600; // 1 hour
···
1864
1991
// Store token in database with refresh token
1865
1992
db.query(
1866
1993
"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);
1994
+
).run(
1995
+
accessToken,
1996
+
authcode.user_id,
1997
+
client_id,
1998
+
scopes.join(" "),
1999
+
expiresAt,
2000
+
refreshToken,
2001
+
refreshExpiresAt,
2002
+
);
1868
2003
1869
2004
const response: Record<string, unknown> = {
1870
2005
access_token: accessToken,
···
1882
2017
response.role = permission.role;
1883
2018
}
1884
2019
1885
-
console.log("Token endpoint: success", { me: meValue, scopes: scopes.join(" ") });
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
+
});
1886
2069
1887
2070
return Response.json(response, {
1888
2071
headers: {
1889
2072
"Content-Type": "application/json",
1890
2073
"Cache-Control": "no-store",
1891
-
"Pragma": "no-cache",
2074
+
Pragma: "no-cache",
1892
2075
},
1893
2076
});
1894
2077
} catch (error) {
···
2052
2235
try {
2053
2236
// Get access token from Authorization header
2054
2237
const authHeader = req.headers.get("Authorization");
2055
-
2238
+
2056
2239
if (!authHeader || !authHeader.startsWith("Bearer ")) {
2057
2240
return Response.json(
2058
2241
{
···
2110
2293
// Parse scopes
2111
2294
const scopes = tokenData.scope.split(" ");
2112
2295
2113
-
// Build response based on scopes
2296
+
// Build response based on scopes (OIDC-compliant claim names)
2297
+
const origin = process.env.ORIGIN || "http://localhost:3000";
2114
2298
const response: Record<string, string> = {};
2115
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
+
2116
2307
if (scopes.includes("profile")) {
2117
2308
response.name = tokenData.name;
2118
-
if (tokenData.photo) response.photo = tokenData.photo;
2309
+
if (tokenData.photo) response.picture = tokenData.photo; // OIDC uses 'picture'
2119
2310
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}`;
2311
+
response.website = tokenData.url; // OIDC uses 'website'
2124
2312
}
2125
2313
}
2126
2314
···
2128
2316
response.email = tokenData.email;
2129
2317
}
2130
2318
2131
-
// Return empty object if no profile/email scopes
2132
-
if (Object.keys(response).length === 0) {
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
2133
2323
return Response.json(
2134
2324
{
2135
2325
error: "insufficient_scope",
+6
-2
src/routes/passkeys.ts
+6
-2
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";
···
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;