+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
+
});
+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
+9
src/index.ts
+9
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);
+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
+
}
+62
-33
src/routes/auth.ts
+62
-33
src/routes/auth.ts
···
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: {
+139
-81
src/routes/indieauth.ts
+139
-81
src/routes/indieauth.ts
···
1
1
import crypto from "crypto";
2
2
import { db } from "../db";
3
+
import { signIDToken } from "../oidc";
3
4
import { safeFetch, validateExternalURL } from "../lib/ssrf-safe-fetch";
4
5
5
6
interface SessionUser {
···
414
415
// Validate URL is safe to fetch (prevents SSRF attacks)
415
416
const urlValidation = validateExternalURL(domainUrl);
416
417
if (!urlValidation.safe) {
417
-
return {
418
-
success: false,
419
-
error: urlValidation.error || "Invalid domain URL",
420
-
};
418
+
return { success: false, error: urlValidation.error || "Invalid domain URL" };
421
419
}
422
420
423
421
// Use SSRF-safe fetch
···
430
428
});
431
429
432
430
if (!fetchResult.success) {
433
-
console.error(
434
-
`[verifyDomain] Failed to fetch ${domainUrl}: ${fetchResult.error}`,
435
-
);
436
-
return {
437
-
success: false,
438
-
error: `Failed to fetch domain: ${fetchResult.error}`,
439
-
};
431
+
console.error(`[verifyDomain] Failed to fetch ${domainUrl}: ${fetchResult.error}`);
432
+
return { success: false, error: `Failed to fetch domain: ${fetchResult.error}` };
440
433
}
441
434
442
435
const response = fetchResult.data;
···
457
450
};
458
451
}
459
452
460
-
const html = await response.text();
453
+
const html = await response.text();
461
454
462
-
// Extract rel="me" links using regex
463
-
// Matches both <link> and <a> tags with rel attribute containing "me"
464
-
const relMeLinks: string[] = [];
455
+
// Extract rel="me" links using regex
456
+
// Matches both <link> and <a> tags with rel attribute containing "me"
457
+
const relMeLinks: string[] = [];
465
458
466
-
// Simpler approach: find all link and a tags, then check if they have rel="me" and href
467
-
const linkRegex = /<link\s+[^>]*>/gi;
468
-
const aRegex = /<a\s+[^>]*>/gi;
459
+
// Simpler approach: find all link and a tags, then check if they have rel="me" and href
460
+
const linkRegex = /<link\s+[^>]*>/gi;
461
+
const aRegex = /<a\s+[^>]*>/gi;
469
462
470
-
const processTag = (tagHtml: string) => {
471
-
// Check if has rel containing "me" (handle quoted and unquoted attributes)
472
-
const relMatch = tagHtml.match(/rel=["']?([^"'\s>]+)["']?/i);
473
-
if (!relMatch) return null;
463
+
const processTag = (tagHtml: string) => {
464
+
// Check if has rel containing "me" (handle quoted and unquoted attributes)
465
+
const relMatch = tagHtml.match(/rel=["']?([^"'\s>]+)["']?/i);
466
+
if (!relMatch) return null;
474
467
475
-
const relValue = relMatch[1];
476
-
// Check if "me" is a separate word in the rel attribute
477
-
if (!relValue.split(/\s+/).includes("me")) return null;
468
+
const relValue = relMatch[1];
469
+
// Check if "me" is a separate word in the rel attribute
470
+
if (!relValue.split(/\s+/).includes("me")) return null;
478
471
479
-
// Extract href (handle quoted and unquoted attributes)
480
-
const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i);
481
-
if (!hrefMatch) return null;
472
+
// Extract href (handle quoted and unquoted attributes)
473
+
const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i);
474
+
if (!hrefMatch) return null;
482
475
483
-
return hrefMatch[1];
484
-
};
476
+
return hrefMatch[1];
477
+
};
485
478
486
-
// Process all link tags
487
-
let linkMatch;
488
-
while ((linkMatch = linkRegex.exec(html)) !== null) {
489
-
const href = processTag(linkMatch[0]);
490
-
if (href && !relMeLinks.includes(href)) {
491
-
relMeLinks.push(href);
479
+
// Process all link tags
480
+
let linkMatch;
481
+
while ((linkMatch = linkRegex.exec(html)) !== null) {
482
+
const href = processTag(linkMatch[0]);
483
+
if (href && !relMeLinks.includes(href)) {
484
+
relMeLinks.push(href);
485
+
}
492
486
}
493
-
}
494
487
495
-
// Process all a tags
496
-
let aMatch;
497
-
while ((aMatch = aRegex.exec(html)) !== null) {
498
-
const href = processTag(aMatch[0]);
499
-
if (href && !relMeLinks.includes(href)) {
500
-
relMeLinks.push(href);
488
+
// Process all a tags
489
+
let aMatch;
490
+
while ((aMatch = aRegex.exec(html)) !== null) {
491
+
const href = processTag(aMatch[0]);
492
+
if (href && !relMeLinks.includes(href)) {
493
+
relMeLinks.push(href);
494
+
}
501
495
}
502
-
}
503
496
504
-
// Check if any rel="me" link matches the indiko profile URL
505
-
const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl);
506
-
const hasRelMe = relMeLinks.some((link) => {
507
-
try {
508
-
const normalizedLink = canonicalizeURL(link);
509
-
return normalizedLink === normalizedIndikoUrl;
510
-
} catch {
511
-
return false;
512
-
}
513
-
});
497
+
// Check if any rel="me" link matches the indiko profile URL
498
+
const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl);
499
+
const hasRelMe = relMeLinks.some((link) => {
500
+
try {
501
+
const normalizedLink = canonicalizeURL(link);
502
+
return normalizedLink === normalizedIndikoUrl;
503
+
} catch {
504
+
return false;
505
+
}
506
+
});
514
507
515
-
if (!hasRelMe) {
516
-
console.error(
517
-
`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`,
518
-
{
519
-
foundLinks: relMeLinks,
520
-
normalizedTarget: normalizedIndikoUrl,
521
-
},
522
-
);
508
+
if (!hasRelMe) {
509
+
console.error(
510
+
`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`,
511
+
{
512
+
foundLinks: relMeLinks,
513
+
normalizedTarget: normalizedIndikoUrl,
514
+
},
515
+
);
523
516
return {
524
517
success: false,
525
518
error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`,
···
667
660
const codeChallengeMethod = params.get("code_challenge_method");
668
661
const scope = params.get("scope") || "profile";
669
662
const me = params.get("me");
663
+
const nonce = params.get("nonce"); // OIDC nonce parameter
670
664
671
665
if (responseType !== "code") {
672
666
return new Response("Unsupported response_type", { status: 400 });
···
1021
1015
if (hasAllScopes) {
1022
1016
// Auto-approve - create auth code and redirect
1023
1017
const code = crypto.randomBytes(32).toString("base64url");
1024
-
const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds
1018
+
const now = Math.floor(Date.now() / 1000);
1019
+
const expiresAt = now + 60; // 60 seconds
1025
1020
1026
1021
db.query(
1027
-
"INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1022
+
"INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me, nonce, auth_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1028
1023
).run(
1029
1024
code,
1030
1025
user.userId,
···
1034
1029
codeChallenge,
1035
1030
expiresAt,
1036
1031
me,
1032
+
nonce,
1033
+
now, // auth_time - user already authenticated
1037
1034
);
1038
1035
1039
1036
// Update permission last_used
···
1057
1054
codeChallenge,
1058
1055
requestedScopes,
1059
1056
me,
1057
+
nonce,
1060
1058
);
1061
1059
}
1062
1060
···
1068
1066
codeChallenge: string,
1069
1067
scopes: string[],
1070
1068
me: string | null,
1069
+
nonce: string | null,
1071
1070
): Response {
1072
1071
// Load app metadata if pre-registered
1073
1072
const appData = db
···
1386
1385
<input type="hidden" name="state" value="${state}" />
1387
1386
<input type="hidden" name="code_challenge" value="${codeChallenge}" />
1388
1387
${me ? `<input type="hidden" name="me" value="${me}" />` : ""}
1388
+
${nonce ? `<input type="hidden" name="nonce" value="${nonce}" />` : ""}
1389
1389
<!-- Always include profile scope as it's required -->
1390
1390
<input type="hidden" name="scope" value="profile" />
1391
1391
···
1451
1451
const state = body.state;
1452
1452
const codeChallenge = body.code_challenge;
1453
1453
const me = body.me || null;
1454
+
const nonce = body.nonce || null; // OIDC nonce
1454
1455
1455
1456
if (!rawClientId || !rawRedirectUri || !state || !codeChallenge) {
1456
1457
return new Response("Missing required parameters", { status: 400 });
···
1484
1485
1485
1486
// Create authorization code
1486
1487
const code = crypto.randomBytes(32).toString("base64url");
1487
-
const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds
1488
+
const now = Math.floor(Date.now() / 1000);
1489
+
const expiresAt = now + 60; // 60 seconds
1488
1490
1489
1491
db.query(
1490
-
"INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1492
+
"INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me, nonce, auth_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1491
1493
).run(
1492
1494
code,
1493
1495
user.userId,
···
1497
1499
codeChallenge,
1498
1500
expiresAt,
1499
1501
me,
1502
+
nonce,
1503
+
now, // auth_time
1500
1504
);
1501
1505
1502
1506
// Store or update permission grant
···
1767
1771
}
1768
1772
}
1769
1773
1770
-
if (!code || !client_id || !redirect_uri) {
1771
-
console.error("Token endpoint: missing parameters", {
1774
+
if (!code || !client_id) {
1775
+
console.error("Token endpoint: missing required parameters", {
1772
1776
code: !!code,
1773
1777
client_id: !!client_id,
1774
-
redirect_uri: !!redirect_uri,
1775
1778
});
1776
1779
return Response.json(
1777
1780
{
1778
1781
error: "invalid_request",
1779
-
error_description: "Missing required parameters",
1782
+
error_description: "Missing required parameters (code, client_id)",
1780
1783
},
1781
1784
{ status: 400 },
1782
1785
);
···
1796
1799
// Look up authorization code
1797
1800
const authcode = db
1798
1801
.query(
1799
-
"SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me FROM authcodes WHERE code = ?",
1802
+
"SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me, nonce, auth_time FROM authcodes WHERE code = ?",
1800
1803
)
1801
1804
.get(code) as
1802
1805
| {
···
1808
1811
expires_at: number;
1809
1812
used: number;
1810
1813
me: string | null;
1814
+
nonce: string | null;
1815
+
auth_time: number | null;
1811
1816
}
1812
1817
| undefined;
1813
1818
···
1869
1874
);
1870
1875
}
1871
1876
1872
-
// Verify redirect_uri matches
1873
-
if (authcode.redirect_uri !== redirect_uri) {
1877
+
// Verify redirect_uri matches if provided (per OAuth 2.0 RFC 6749 section 4.1.3)
1878
+
// redirect_uri is REQUIRED if it was included in the authorization request
1879
+
if (redirect_uri && authcode.redirect_uri !== redirect_uri) {
1874
1880
console.error("Token endpoint: redirect_uri mismatch", {
1875
1881
stored: authcode.redirect_uri,
1876
1882
received: redirect_uri,
···
2007
2013
response.role = permission.role;
2008
2014
}
2009
2015
2016
+
// Generate OIDC id_token if openid scope is requested
2017
+
if (scopes.includes("openid")) {
2018
+
const idTokenClaims: Record<string, unknown> = {
2019
+
sub: meValue,
2020
+
aud: client_id,
2021
+
};
2022
+
2023
+
// Add nonce if provided (OIDC replay protection)
2024
+
if (authcode.nonce) {
2025
+
idTokenClaims.nonce = authcode.nonce;
2026
+
}
2027
+
2028
+
// Add auth_time if available
2029
+
if (authcode.auth_time) {
2030
+
idTokenClaims.auth_time = authcode.auth_time;
2031
+
}
2032
+
2033
+
// Add profile claims if profile scope included
2034
+
if (scopes.includes("profile")) {
2035
+
idTokenClaims.name = user.name;
2036
+
if (user.photo) idTokenClaims.picture = user.photo;
2037
+
if (user.url) idTokenClaims.website = user.url;
2038
+
}
2039
+
2040
+
// Add email claim if email scope included
2041
+
if (scopes.includes("email") && user.email) {
2042
+
idTokenClaims.email = user.email;
2043
+
}
2044
+
2045
+
const idToken = await signIDToken(
2046
+
origin,
2047
+
idTokenClaims as {
2048
+
sub: string;
2049
+
aud: string;
2050
+
nonce?: string;
2051
+
auth_time?: number;
2052
+
name?: string;
2053
+
email?: string;
2054
+
picture?: string;
2055
+
website?: string;
2056
+
},
2057
+
);
2058
+
response.id_token = idToken;
2059
+
}
2060
+
2010
2061
console.log("Token endpoint: success", {
2011
2062
me: meValue,
2012
2063
scopes: scopes.join(" "),
···
2238
2289
// Parse scopes
2239
2290
const scopes = tokenData.scope.split(" ");
2240
2291
2241
-
// Build response based on scopes
2292
+
// Build response based on scopes (OIDC-compliant claim names)
2293
+
const origin = process.env.ORIGIN || "http://localhost:3000";
2242
2294
const response: Record<string, string> = {};
2243
2295
2296
+
// sub claim is always required for OIDC userinfo
2297
+
if (tokenData.url) {
2298
+
response.sub = tokenData.url;
2299
+
} else {
2300
+
response.sub = `${origin}/u/${tokenData.username}`;
2301
+
}
2302
+
2244
2303
if (scopes.includes("profile")) {
2245
2304
response.name = tokenData.name;
2246
-
if (tokenData.photo) response.photo = tokenData.photo;
2305
+
if (tokenData.photo) response.picture = tokenData.photo; // OIDC uses 'picture'
2247
2306
if (tokenData.url) {
2248
-
response.url = tokenData.url;
2249
-
} else {
2250
-
const origin = process.env.ORIGIN || "http://localhost:3000";
2251
-
response.url = `${origin}/u/${tokenData.username}`;
2307
+
response.website = tokenData.url; // OIDC uses 'website'
2252
2308
}
2253
2309
}
2254
2310
···
2256
2312
response.email = tokenData.email;
2257
2313
}
2258
2314
2259
-
// Return empty object if no profile/email scopes
2260
-
if (Object.keys(response).length === 0) {
2315
+
// For OIDC, we always return at least sub
2316
+
// But for IndieAuth compatibility, check if we have meaningful claims
2317
+
if (Object.keys(response).length === 1 && !scopes.includes("openid")) {
2318
+
// Only sub, no openid scope - this is a pure IndieAuth request without claims
2261
2319
return Response.json(
2262
2320
{
2263
2321
error: "insufficient_scope",
History
3 rounds
0 comments
dunkirk.sh
submitted
#2
6 commits
expand
collapse
feat: implement oidc
feat: allow re-registration to reset passkey
chore: fix userinfo endpoint
docs: add oidc
bug: allow not sending redirect url
security: add SSRF protection for client metadata and domain verification fetches
expand 0 comments
closed without merging
expand 0 comments
dunkirk.sh
submitted
#0