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

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement oidc

+468 -74
+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
··· 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
··· 19 19 "@simplewebauthn/browser": "^13.2.2", 20 20 "@simplewebauthn/server": "^13.2.2", 21 21 "bun-sqlite-migrations": "^1.0.2", 22 + "jose": "^6.1.3", 22 23 "ldap-authentication": "^3.3.6", 23 24 "nanoid": "^5.1.6" 24 25 }
+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
··· 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
··· 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}/auth/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 + }
+132 -74
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; ··· 459 452 460 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 ··· 1796 1800 // Look up authorization code 1797 1801 const authcode = db 1798 1802 .query( 1799 - "SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me FROM authcodes WHERE code = ?", 1803 + "SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me, nonce, auth_time FROM authcodes WHERE code = ?", 1800 1804 ) 1801 1805 .get(code) as 1802 1806 | { ··· 1808 1812 expires_at: number; 1809 1813 used: number; 1810 1814 me: string | null; 1815 + nonce: string | null; 1816 + auth_time: number | null; 1811 1817 } 1812 1818 | undefined; 1813 1819 ··· 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",