- Redirect OAuth errors to client per RFC 6749 §4.1.2.1 after validating redirect_uri
- Rotate refresh tokens on use to prevent replay attacks (RFC 6749 §10.4)
- Revoke both access and refresh tokens together per RFC 7009 §2.1
- Require redirect_uri at token endpoint per RFC 6749 §4.1.3
- Add WWW-Authenticate headers to 401 responses per RFC 6750
- Add sub and username to token introspection response
+227
-128
Diff
round #0
+27
-10
scripts/reset-passkey.ts
+27
-10
scripts/reset-passkey.ts
···
52
52
53
53
function getUser(username: string): User | null {
54
54
return db
55
-
.query("SELECT id, username, name, email, status, is_admin FROM users WHERE username = ?")
55
+
.query(
56
+
"SELECT id, username, name, email, status, is_admin FROM users WHERE username = ?",
57
+
)
56
58
.get(username) as User | null;
57
59
}
58
60
···
70
72
}
71
73
72
74
function deleteSessions(userId: number): number {
73
-
const result = db
74
-
.query("DELETE FROM sessions WHERE user_id = ?")
75
-
.run(userId);
75
+
const result = db.query("DELETE FROM sessions WHERE user_id = ?").run(userId);
76
76
return result.changes;
77
77
}
78
78
79
-
function createResetInvite(adminUserId: number, targetUsername: string): string {
79
+
function createResetInvite(
80
+
adminUserId: number,
81
+
targetUsername: string,
82
+
): string {
80
83
const code = crypto.randomBytes(16).toString("base64url");
81
84
const now = Math.floor(Date.now() / 1000);
82
85
const expiresAt = now + 86400; // 24 hours
83
86
84
87
// Check if there's a reset_username column, if not we'll use the note field
85
88
const hasResetColumn = db
86
-
.query("SELECT name FROM pragma_table_info('invites') WHERE name = 'reset_username'")
89
+
.query(
90
+
"SELECT name FROM pragma_table_info('invites') WHERE name = 'reset_username'",
91
+
)
87
92
.get();
88
93
89
94
if (hasResetColumn) {
90
95
db.query(
91
96
"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);
97
+
).run(
98
+
code,
99
+
adminUserId,
100
+
expiresAt,
101
+
`Passkey reset for ${targetUsername}`,
102
+
targetUsername,
103
+
);
93
104
} else {
94
105
// Use a special note format to indicate this is a reset invite
95
106
// Format: PASSKEY_RESET:username
···
109
120
110
121
function getAdminUser(): User | null {
111
122
return db
112
-
.query("SELECT id, username, name, email, status, is_admin FROM users WHERE is_admin = 1 LIMIT 1")
123
+
.query(
124
+
"SELECT id, username, name, email, status, is_admin FROM users WHERE is_admin = 1 LIMIT 1",
125
+
)
113
126
.get() as User | null;
114
127
}
115
128
···
169
182
});
170
183
171
184
if (credentials.length === 0) {
172
-
console.log("\n⚠️ User has no passkeys registered. Creating reset link anyway...");
185
+
console.log(
186
+
"\n⚠️ User has no passkeys registered. Creating reset link anyway...",
187
+
);
173
188
}
174
189
175
190
if (dryRun) {
···
184
199
// Confirmation prompt (unless --force)
185
200
if (!force) {
186
201
console.log("\n⚠️ This will:");
187
-
console.log(` • Delete ALL ${credentials.length} passkey(s) for this user`);
202
+
console.log(
203
+
` • Delete ALL ${credentials.length} passkey(s) for this user`,
204
+
);
188
205
console.log(" • Log them out of all sessions");
189
206
console.log(" • Generate a 24-hour reset link\n");
190
207
+2
-2
src/routes/clients.ts
+2
-2
src/routes/clients.ts
···
1
-
import crypto from "crypto";
1
+
import crypto from "node:crypto";
2
2
import { nanoid } from "nanoid";
3
3
import { db } from "../db";
4
4
···
121
121
if (!rolesByApp.has(app_id)) {
122
122
rolesByApp.set(app_id, []);
123
123
}
124
-
rolesByApp.get(app_id)!.push(role);
124
+
rolesByApp.get(app_id)?.push(role);
125
125
}
126
126
127
127
return Response.json({
+198
-116
src/routes/indieauth.ts
+198
-116
src/routes/indieauth.ts
···
1
-
import crypto from "crypto";
1
+
import crypto from "node:crypto";
2
2
import { db } from "../db";
3
-
import { signIDToken } from "../oidc";
4
3
import { safeFetch, validateExternalURL } from "../lib/ssrf-safe-fetch";
4
+
import { signIDToken } from "../oidc";
5
5
6
6
interface SessionUser {
7
7
username: string;
···
10
10
tier: string;
11
11
}
12
12
13
+
function unauthorizedResponse(error: string, description: string): Response {
14
+
return Response.json(
15
+
{ error, error_description: description },
16
+
{
17
+
status: 401,
18
+
headers: {
19
+
"WWW-Authenticate": `Bearer realm="indiko", error="${error}", error_description="${description}"`,
20
+
},
21
+
},
22
+
);
23
+
}
24
+
13
25
// Helper to get authenticated user from session token
14
26
function getSessionUser(req: Request): SessionUser | Response {
15
27
const authHeader = req.headers.get("Authorization");
···
415
427
// Validate URL is safe to fetch (prevents SSRF attacks)
416
428
const urlValidation = validateExternalURL(domainUrl);
417
429
if (!urlValidation.safe) {
418
-
return { success: false, error: urlValidation.error || "Invalid domain URL" };
430
+
return {
431
+
success: false,
432
+
error: urlValidation.error || "Invalid domain URL",
433
+
};
419
434
}
420
435
421
436
// Use SSRF-safe fetch
···
428
443
});
429
444
430
445
if (!fetchResult.success) {
431
-
console.error(`[verifyDomain] Failed to fetch ${domainUrl}: ${fetchResult.error}`);
432
-
return { success: false, error: `Failed to fetch domain: ${fetchResult.error}` };
446
+
console.error(
447
+
`[verifyDomain] Failed to fetch ${domainUrl}: ${fetchResult.error}`,
448
+
);
449
+
return {
450
+
success: false,
451
+
error: `Failed to fetch domain: ${fetchResult.error}`,
452
+
};
433
453
}
434
454
435
455
const response = fetchResult.data;
···
452
472
453
473
const html = await response.text();
454
474
455
-
// Extract rel="me" links using regex
456
-
// Matches both <link> and <a> tags with rel attribute containing "me"
457
-
const relMeLinks: string[] = [];
475
+
// Extract rel="me" links using regex
476
+
// Matches both <link> and <a> tags with rel attribute containing "me"
477
+
const relMeLinks: string[] = [];
458
478
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;
479
+
// Simpler approach: find all link and a tags, then check if they have rel="me" and href
480
+
const linkRegex = /<link\s+[^>]*>/gi;
481
+
const aRegex = /<a\s+[^>]*>/gi;
462
482
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;
483
+
const processTag = (tagHtml: string) => {
484
+
// Check if has rel containing "me" (handle quoted and unquoted attributes)
485
+
const relMatch = tagHtml.match(/rel=["']?([^"'\s>]+)["']?/i);
486
+
if (!relMatch) return null;
467
487
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;
488
+
const relValue = relMatch[1];
489
+
// Check if "me" is a separate word in the rel attribute
490
+
if (!relValue.split(/\s+/).includes("me")) return null;
471
491
472
-
// Extract href (handle quoted and unquoted attributes)
473
-
const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i);
474
-
if (!hrefMatch) return null;
492
+
// Extract href (handle quoted and unquoted attributes)
493
+
const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i);
494
+
if (!hrefMatch) return null;
475
495
476
-
return hrefMatch[1];
477
-
};
496
+
return hrefMatch[1];
497
+
};
478
498
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
-
}
499
+
// Process all link tags
500
+
let linkMatch;
501
+
while ((linkMatch = linkRegex.exec(html)) !== null) {
502
+
const href = processTag(linkMatch[0]);
503
+
if (href && !relMeLinks.includes(href)) {
504
+
relMeLinks.push(href);
486
505
}
506
+
}
487
507
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
-
}
508
+
// Process all a tags
509
+
let aMatch;
510
+
while ((aMatch = aRegex.exec(html)) !== null) {
511
+
const href = processTag(aMatch[0]);
512
+
if (href && !relMeLinks.includes(href)) {
513
+
relMeLinks.push(href);
495
514
}
515
+
}
496
516
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
-
});
517
+
// Check if any rel="me" link matches the indiko profile URL
518
+
const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl);
519
+
const hasRelMe = relMeLinks.some((link) => {
520
+
try {
521
+
const normalizedLink = canonicalizeURL(link);
522
+
return normalizedLink === normalizedIndikoUrl;
523
+
} catch {
524
+
return false;
525
+
}
526
+
});
507
527
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
-
);
528
+
if (!hasRelMe) {
529
+
console.error(
530
+
`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`,
531
+
{
532
+
foundLinks: relMeLinks,
533
+
normalizedTarget: normalizedIndikoUrl,
534
+
},
535
+
);
516
536
return {
517
537
success: false,
518
538
error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`,
···
662
682
const me = params.get("me");
663
683
const nonce = params.get("nonce"); // OIDC nonce parameter
664
684
665
-
if (responseType !== "code") {
666
-
return new Response("Unsupported response_type", { status: 400 });
667
-
}
668
-
669
-
if (!rawClientId || !rawRedirectUri || !state || !codeChallenge) {
670
-
return new Response("Missing required parameters", { status: 400 });
685
+
// Step 1: Validate client_id and redirect_uri exist (can't redirect without them)
686
+
if (!rawClientId || !rawRedirectUri) {
687
+
return new Response(
688
+
"Missing required parameters: client_id and redirect_uri",
689
+
{ status: 400 },
690
+
);
671
691
}
672
692
673
-
// Validate and canonicalize URLs for consistent storage and comparison
693
+
// Step 2: Canonicalize URLs (if they're malformed, can't trust redirect_uri)
674
694
let clientId: string;
675
695
let redirectUri: string;
676
696
try {
···
772
792
);
773
793
}
774
794
775
-
if (codeChallengeMethod && codeChallengeMethod !== "S256") {
776
-
return new Response("Only S256 code_challenge_method supported", {
777
-
status: 400,
778
-
});
779
-
}
780
-
781
-
// Verify app is registered
795
+
// Step 3: Verify app is registered (can't trust redirect_uri if client is invalid)
782
796
const appResult = await ensureApp(clientId, redirectUri);
783
797
784
798
if (appResult.error) {
···
982
996
);
983
997
}
984
998
999
+
// Step 5: redirect_uri is now trusted — validate remaining params via redirect (RFC 6749 §4.1.2.1)
1000
+
if (responseType !== "code") {
1001
+
const errorUrl = new URL(redirectUri);
1002
+
errorUrl.searchParams.set("error", "unsupported_response_type");
1003
+
errorUrl.searchParams.set(
1004
+
"error_description",
1005
+
"Only response_type=code is supported",
1006
+
);
1007
+
if (state) errorUrl.searchParams.set("state", state);
1008
+
return Response.redirect(errorUrl.toString(), 302);
1009
+
}
1010
+
1011
+
if (!codeChallenge) {
1012
+
const errorUrl = new URL(redirectUri);
1013
+
errorUrl.searchParams.set("error", "invalid_request");
1014
+
errorUrl.searchParams.set(
1015
+
"error_description",
1016
+
"Missing required parameter: code_challenge",
1017
+
);
1018
+
if (state) errorUrl.searchParams.set("state", state);
1019
+
return Response.redirect(errorUrl.toString(), 302);
1020
+
}
1021
+
1022
+
if (codeChallengeMethod && codeChallengeMethod !== "S256") {
1023
+
const errorUrl = new URL(redirectUri);
1024
+
errorUrl.searchParams.set("error", "invalid_request");
1025
+
errorUrl.searchParams.set(
1026
+
"error_description",
1027
+
"Only S256 code_challenge_method is supported",
1028
+
);
1029
+
if (state) errorUrl.searchParams.set("state", state);
1030
+
return Response.redirect(errorUrl.toString(), 302);
1031
+
}
1032
+
1033
+
if (!state) {
1034
+
const errorUrl = new URL(redirectUri);
1035
+
errorUrl.searchParams.set("error", "invalid_request");
1036
+
errorUrl.searchParams.set(
1037
+
"error_description",
1038
+
"Missing required parameter: state",
1039
+
);
1040
+
return Response.redirect(errorUrl.toString(), 302);
1041
+
}
1042
+
985
1043
// Check if user is logged in
986
1044
const user = getUserFromCookie(req);
987
1045
···
1675
1733
const expiresIn = 3600; // 1 hour
1676
1734
const expiresAt = now + expiresIn;
1677
1735
1678
-
// Update token (rotate access token, keep refresh token)
1679
-
db.query("UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?").run(
1736
+
// Rotate refresh token to prevent replay attacks
1737
+
const newRefreshToken = crypto.randomBytes(32).toString("base64url");
1738
+
const refreshExpiresAt = now + 2592000; // 30 days
1739
+
1740
+
// Update token (rotate both access and refresh tokens)
1741
+
db.query(
1742
+
"UPDATE tokens SET token = ?, expires_at = ?, refresh_token = ?, refresh_expires_at = ? WHERE id = ?",
1743
+
).run(
1680
1744
newAccessToken,
1681
1745
expiresAt,
1746
+
newRefreshToken,
1747
+
refreshExpiresAt,
1682
1748
tokenData.id,
1683
1749
);
1684
1750
···
1707
1773
access_token: newAccessToken,
1708
1774
token_type: "Bearer",
1709
1775
expires_in: expiresIn,
1776
+
refresh_token: newRefreshToken,
1710
1777
me: meValue,
1711
1778
scope: tokenData.scope,
1712
1779
iss: origin,
···
1731
1798
| { is_preregistered: number; client_secret_hash: string | null }
1732
1799
| undefined;
1733
1800
1801
+
// If client_secret provided but client not found - unknown client (400, not 401)
1802
+
if (client_secret && !app) {
1803
+
return Response.json(
1804
+
{ error: "invalid_client", error_description: "Unknown client" },
1805
+
{ status: 400 },
1806
+
);
1807
+
}
1808
+
1734
1809
// If client is pre-registered, verify client secret
1735
1810
if (app && app.is_preregistered === 1) {
1736
1811
if (!client_secret) {
1737
-
return Response.json(
1738
-
{
1739
-
error: "invalid_client",
1740
-
error_description:
1741
-
"client_secret is required for pre-registered clients",
1742
-
},
1743
-
{ status: 401 },
1812
+
return unauthorizedResponse(
1813
+
"invalid_client",
1814
+
"client_secret is required for pre-registered clients",
1744
1815
);
1745
1816
}
1746
1817
···
1761
1832
.digest("hex");
1762
1833
1763
1834
if (providedSecretHash !== app.client_secret_hash) {
1764
-
return Response.json(
1765
-
{
1766
-
error: "invalid_client",
1767
-
error_description: "Invalid client_secret",
1768
-
},
1769
-
{ status: 401 },
1770
-
);
1835
+
return unauthorizedResponse("invalid_client", "Invalid client_secret");
1771
1836
}
1772
1837
}
1773
1838
···
1874
1939
);
1875
1940
}
1876
1941
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) {
1942
+
// redirect_uri is REQUIRED since it's always included in the authorization request
1943
+
// (per OAuth 2.0 RFC 6749 §4.1.3)
1944
+
if (!redirect_uri) {
1945
+
return Response.json(
1946
+
{
1947
+
error: "invalid_request",
1948
+
error_description: "redirect_uri is required",
1949
+
},
1950
+
{ status: 400 },
1951
+
);
1952
+
}
1953
+
1954
+
if (authcode.redirect_uri !== redirect_uri) {
1880
1955
console.error("Token endpoint: redirect_uri mismatch", {
1881
1956
stored: authcode.redirect_uri,
1882
1957
received: redirect_uri,
···
2156
2231
// Token is active - return metadata
2157
2232
return Response.json({
2158
2233
active: true,
2234
+
sub: meValue,
2159
2235
me: meValue,
2160
2236
client_id: tokenData.client_id,
2161
2237
scope: tokenData.scope,
2162
2238
exp: tokenData.expires_at,
2163
2239
iat: tokenData.created_at,
2240
+
username: tokenData.username,
2164
2241
});
2165
2242
} catch (error) {
2166
2243
console.error("Token introspection error:", error);
···
2209
2286
);
2210
2287
}
2211
2288
2212
-
// Mark token as revoked (per spec, return 200 even if token doesn't exist)
2213
-
db.query("UPDATE tokens SET revoked = 1 WHERE token = ?").run(token);
2289
+
// Check if it's a refresh token first (RFC 7009 §2.1: revoking a refresh token
2290
+
// must also invalidate associated access tokens, and vice versa)
2291
+
const refreshTokenData = db
2292
+
.query("SELECT id FROM tokens WHERE refresh_token = ?")
2293
+
.get(token) as { id: number } | undefined;
2294
+
2295
+
if (refreshTokenData) {
2296
+
// Revoking refresh token — revoke the entire token record (access + refresh)
2297
+
db.query("UPDATE tokens SET revoked = 1 WHERE id = ?").run(
2298
+
refreshTokenData.id,
2299
+
);
2300
+
} else {
2301
+
// Check if it's an access token — also invalidates the associated refresh token
2302
+
const accessTokenData = db
2303
+
.query("SELECT id FROM tokens WHERE token = ?")
2304
+
.get(token) as { id: number } | undefined;
2305
+
2306
+
if (accessTokenData) {
2307
+
db.query("UPDATE tokens SET revoked = 1 WHERE id = ?").run(
2308
+
accessTokenData.id,
2309
+
);
2310
+
}
2311
+
}
2214
2312
2215
-
// Return 200 with empty body per RFC 7009
2313
+
// Per RFC 7009, return 200 even if token doesn't exist
2216
2314
return new Response(null, { status: 200 });
2217
2315
} catch (error) {
2218
2316
console.error("Token revocation error:", error);
···
2233
2331
const authHeader = req.headers.get("Authorization");
2234
2332
2235
2333
if (!authHeader || !authHeader.startsWith("Bearer ")) {
2236
-
return Response.json(
2237
-
{
2238
-
error: "invalid_request",
2239
-
error_description: "Missing or invalid Authorization header",
2240
-
},
2241
-
{ status: 401 },
2334
+
return unauthorizedResponse(
2335
+
"invalid_request",
2336
+
"Missing or invalid Authorization header",
2242
2337
);
2243
2338
}
2244
2339
···
2265
2360
2266
2361
// Token not found or revoked
2267
2362
if (!tokenData || tokenData.revoked === 1) {
2268
-
return Response.json(
2269
-
{
2270
-
error: "invalid_token",
2271
-
error_description: "Invalid or revoked access token",
2272
-
},
2273
-
{ status: 401 },
2363
+
return unauthorizedResponse(
2364
+
"invalid_token",
2365
+
"Invalid or revoked access token",
2274
2366
);
2275
2367
}
2276
2368
2277
2369
// Check if expired
2278
2370
const now = Math.floor(Date.now() / 1000);
2279
2371
if (tokenData.expires_at < now) {
2280
-
return Response.json(
2281
-
{
2282
-
error: "invalid_token",
2283
-
error_description: "Access token expired",
2284
-
},
2285
-
{ status: 401 },
2286
-
);
2372
+
return unauthorizedResponse("invalid_token", "Access token expired");
2287
2373
}
2288
2374
2289
2375
// Parse scopes
···
2293
2379
const origin = process.env.ORIGIN || "http://localhost:3000";
2294
2380
const response: Record<string, string> = {};
2295
2381
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
-
}
2382
+
// sub claim - use stable canonical profile URL (OIDC Core §2)
2383
+
response.sub = `${origin}/u/${tokenData.username}`;
2302
2384
2303
2385
if (scopes.includes("profile")) {
2304
2386
response.name = tokenData.name;
History
1 round
0 comments
avycado13.tngl.sh
submitted
#0
1 commit
expand
collapse
b1b06e70
Improve OAuth 2.0/OIDC spec compliance and harden token handling
- Redirect OAuth errors to client per RFC 6749 §4.1.2.1 after validating redirect_uri
- Rotate refresh tokens on use to prevent replay attacks (RFC 6749 §10.4)
- Revoke both access and refresh tokens together per RFC 7009 §2.1
- Require redirect_uri at token endpoint per RFC 6749 §4.1.3
- Add WWW-Authenticate headers to 401 responses per RFC 6750
- Add sub and username to token introspection response
expand 0 comments
pull request successfully merged