my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server

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

authored by

avycado13 and committed by
Tangled
e03aeaf8 77b8d838

+227 -128
+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
··· 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
··· 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; 8 8 userId: number; 9 9 isAdmin: boolean; 10 10 tier: string; 11 + } 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 + ); 11 23 } 12 24 13 25 // Helper to get authenticated user from session token ··· 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 }); 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 + ); 667 691 } 668 692 669 - if (!rawClientId || !rawRedirectUri || !state || !codeChallenge) { 670 - return new Response("Missing required parameters", { status: 400 }); 671 - } 672 - 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; 2214 2294 2215 - // Return 200 with empty body per RFC 7009 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 + } 2312 + 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;