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: add client metadata endpoint and improve consent screen scope descriptions

Adds /.well-known/oauth-client endpoint exposing RFC-compliant client metadata
(name, redirect URIs, grant types, logo, etc.) with CORS support, and expands
authorization server metadata with jwks_uri, token_endpoint_auth_methods_supported,
and client_id_metadata_document_supported. Consent screen now shows human-readable
descriptions for profile, email, and openid scopes (including a description for the
openid scope that was previously unhandled). Also fixes buttons missing type='button'
across HTML templates and applies minor code quality improvements throughout.

authored by

avycado13 and committed by
Tangled
fb00747c 1288b2b8

+156 -51
+1 -5
src/client/admin-clients.ts
··· 569 569 // If creating a new client, show the credentials in modal 570 570 if (!isEdit) { 571 571 const result = await response.json(); 572 - if ( 573 - result.client && 574 - result.client.clientId && 575 - result.client.clientSecret 576 - ) { 572 + if (result.client?.clientId && result.client.clientSecret) { 577 573 const secretModal = document.getElementById( 578 574 "secretModal", 579 575 ) as HTMLElement;
+1 -1
src/client/admin-invites.ts
··· 193 193 ) as HTMLSelectElement; 194 194 195 195 let role = ""; 196 - if (roleSelect && roleSelect.value) { 196 + if (roleSelect?.value) { 197 197 role = roleSelect.value; 198 198 } 199 199
+1 -1
src/client/docs.ts
··· 48 48 ); 49 49 } 50 50 51 - result += attrs + ">"; 51 + result += `${attrs}>`; 52 52 return result; 53 53 }, 54 54 );
+5 -5
src/html/admin-clients.html
··· 613 613 <main> 614 614 <div class="actions"> 615 615 <h2>oauth clients</h2> 616 - <button class="btn" id="createClientBtn">create client</button> 616 + <button type="button" class="btn" id="createClientBtn">create client</button> 617 617 </div> 618 618 <div id="clientsList" class="clients-list"> 619 619 <div class="loading">loading clients...</div> ··· 631 631 <div class="modal-content"> 632 632 <div class="modal-header"> 633 633 <h3 class="modal-title" id="modalTitle">Create OAuth Client</h3> 634 - <button class="modal-close" id="modalClose">&times;</button> 634 + <button type="button" class="modal-close" id="modalClose">&times;</button> 635 635 </div> 636 636 <form id="clientForm"> 637 637 <input type="hidden" id="editClientId" /> ··· 679 679 <div class="modal-content"> 680 680 <div class="modal-header"> 681 681 <h3 class="modal-title">Client Credentials Generated</h3> 682 - <button class="modal-close" id="secretModalClose">&times;</button> 682 + <button type="button" class="modal-close" id="secretModalClose">&times;</button> 683 683 </div> 684 684 <div style="margin-bottom: 1.5rem;"> 685 685 <p style="color: var(--rosewood); font-weight: 600; margin-bottom: 1rem;"> ··· 690 690 <div style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;"> 691 691 <code id="generatedClientId" style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code> 692 692 </div> 693 - <button class="btn" id="copyClientIdBtn" style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy client id</button> 693 + <button type="button" class="btn" id="copyClientIdBtn" style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy client id</button> 694 694 </div> 695 695 <div style="margin-bottom: 1rem;"> 696 696 <label style="display: block; color: var(--old-rose); font-size: 0.875rem; margin-bottom: 0.5rem;">Client Secret</label> 697 697 <div style="background: rgba(0, 0, 0, 0.3); padding: 1rem; border: 1px solid var(--old-rose); margin-bottom: 0.5rem;"> 698 698 <code id="generatedSecret" style="color: var(--lavender); font-size: 0.875rem; word-break: break-all; display: block;"></code> 699 699 </div> 700 - <button class="btn" id="copySecretBtn" style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy secret</button> 700 + <button type="button" class="btn" id="copySecretBtn" style="width: 100%; background: rgba(188, 141, 160, 0.2);">copy secret</button> 701 701 </div> 702 702 </div> 703 703 </div>
+7 -7
src/html/admin-invites.html
··· 295 295 <div class="invites-section"> 296 296 <h2>invites</h2> 297 297 <div class="invite-actions"> 298 - <button class="invite-btn" id="createInviteBtn">create invite link</button> 298 + <button type="button" class="invite-btn" id="createInviteBtn">create invite link</button> 299 299 </div> 300 300 <div id="invitesList" class="invite-list"> 301 301 <div class="loading">loading invites...</div> ··· 313 313 <div class="modal-content"> 314 314 <div class="modal-header"> 315 315 <h3>create invite</h3> 316 - <button class="modal-close" onclick="closeCreateInviteModal()">&times;</button> 316 + <button type="button" class="modal-close" onclick="closeCreateInviteModal()">&times;</button> 317 317 </div> 318 318 319 319 <div class="form-group"> ··· 349 349 </div> 350 350 351 351 <div class="modal-actions"> 352 - <button class="modal-btn modal-btn-secondary" onclick="closeCreateInviteModal()">cancel</button> 353 - <button class="modal-btn modal-btn-primary" id="submitInviteBtn" onclick="submitCreateInvite()">create invite</button> 352 + <button type="button" class="modal-btn modal-btn-secondary" onclick="closeCreateInviteModal()">cancel</button> 353 + <button type="button" class="modal-btn modal-btn-primary" id="submitInviteBtn" onclick="submitCreateInvite()">create invite</button> 354 354 </div> 355 355 </div> 356 356 </div> ··· 360 360 <div class="modal-content"> 361 361 <div class="modal-header"> 362 362 <h3>edit invite</h3> 363 - <button class="modal-close" onclick="closeEditInviteModal()">&times;</button> 363 + <button type="button" class="modal-close" onclick="closeEditInviteModal()">&times;</button> 364 364 </div> 365 365 366 366 <div class="form-group"> ··· 388 388 </div> 389 389 390 390 <div class="modal-actions"> 391 - <button class="modal-btn modal-btn-secondary" onclick="closeEditInviteModal()">cancel</button> 392 - <button class="modal-btn modal-btn-primary" id="submitEditInviteBtn" onclick="submitEditInvite()">save changes</button> 391 + <button type="button" class="modal-btn modal-btn-secondary" onclick="closeEditInviteModal()">cancel</button> 392 + <button type="button" class="modal-btn modal-btn-primary" id="submitEditInviteBtn" onclick="submitEditInvite()">save changes</button> 393 393 </div> 394 394 </div> 395 395 </div>
+15 -15
src/html/docs.html
··· 2 2 <html lang="en"> 3 3 4 4 <head> 5 - <meta charset="UTF-8" /> 6 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 5 + <meta charset="UTF-8" > 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" > 7 7 <title>documentation • indiko</title> 8 - <meta name="description" content="IndieAuth/OAuth 2.0 server documentation and interactive API testing" /> 9 - <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 8 + <meta name="description" content="IndieAuth/OAuth 2.0 server documentation and interactive API testing" > 9 + <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" > 10 10 11 11 <!-- Open Graph / Facebook --> 12 - <meta property="og:type" content="website" /> 13 - <meta property="og:title" content="Documentation • Indiko" /> 14 - <meta property="og:description" content="IndieAuth/OAuth 2.0 server documentation and interactive API testing" /> 12 + <meta property="og:type" content="website"> 13 + <meta property="og:title" content="Documentation • Indiko" > 14 + <meta property="og:description" content="IndieAuth/OAuth 2.0 server documentation and interactive API testing" > 15 15 16 16 <!-- Twitter --> 17 - <meta name="twitter:card" content="summary" /> 18 - <meta name="twitter:title" content="Documentation • Indiko" /> 19 - <meta name="twitter:description" content="IndieAuth/OAuth 2.0 server documentation and interactive API testing" /> 17 + <meta name="twitter:card" content="summary" > 18 + <meta name="twitter:title" content="Documentation • Indiko" > 19 + <meta name="twitter:description" content="IndieAuth/OAuth 2.0 server documentation and interactive API testing" > 20 20 <link rel="preconnect" href="https://fonts.googleapis.com"> 21 21 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 22 22 <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> ··· 571 571 <p class="subtitle">IndieAuth/OAuth 2.0 server with passkey authentication</p> 572 572 </header> 573 573 574 - <button id="copyMarkdownBtn" class="copy-btn">copy as markdown</button> 574 + <button type="button" id="copyMarkdownBtn" class="copy-btn">copy as markdown</button> 575 575 576 576 <nav class="toc"> 577 577 <h3>table of contents</h3> ··· 761 761 <h3>HTML + CSS</h3> 762 762 <pre><code id="buttonCode"></code></pre> 763 763 764 - <button id="copyButtonCode" class="copy-btn">copy button code</button> 764 + <button type="button" id="copyButtonCode" class="copy-btn">copy button code</button> 765 765 766 766 <div class="info-box"> 767 767 <strong>Customization:</strong> ··· 1256 1256 </label> 1257 1257 </div> 1258 1258 1259 - <button id="startBtn">start oauth flow</button> 1259 + <button type="button" id="startBtn">start oauth flow</button> 1260 1260 </div> 1261 1261 1262 1262 <div id="callbackSection" style="display: none;"> ··· 1265 1265 You've been redirected back with an authorization code. Click below to exchange it for user data. 1266 1266 </div> 1267 1267 <div id="callbackInfo"></div> 1268 - <button id="exchangeBtn">exchange code for profile</button> 1268 + <button type="button" id="exchangeBtn">exchange code for profile</button> 1269 1269 </div> 1270 1270 1271 1271 <div id="resultSection" style="display: none;"> ··· 1288 1288 <script type="module" src="../client/docs.ts"></script> 1289 1289 </body> 1290 1290 1291 - </html> 1291 + </html>
+1 -1
src/html/login.html
··· 28 28 } 29 29 30 30 main { 31 - max-width: 28rem !important; 31 + max-width: 28rem; 32 32 width: 100%; 33 33 text-align: center; 34 34 }
+2 -2
src/html/oauth-test.html
··· 282 282 </label> 283 283 </div> 284 284 285 - <button id="startBtn">start oauth flow</button> 285 + <button type="button" id="startBtn">start oauth flow</button> 286 286 </div> 287 287 288 288 <div class="section" id="callbackSection" style="display: none;"> ··· 294 294 295 295 <div id="callbackInfo"></div> 296 296 297 - <button id="exchangeBtn">exchange code for profile</button> 297 + <button type="button" id="exchangeBtn">exchange code for profile</button> 298 298 </div> 299 299 300 300 <div class="section" id="resultSection" style="display: none;">
+13
src/index.ts
··· 45 45 import { 46 46 authorizeGet, 47 47 authorizePost, 48 + clientMetadata, 48 49 createInvite, 49 50 deleteInvite, 50 51 indieauthMetadata, ··· 156 157 ); 157 158 }, 158 159 "/.well-known/oauth-authorization-server": indieauthMetadata, 160 + "/.well-known/oauth-client": (req: Request) => { 161 + if (req.method === "GET") return clientMetadata(req); 162 + if (req.method === "OPTIONS") 163 + return new Response(null, { 164 + status: 204, 165 + headers: { 166 + "Access-Control-Allow-Origin": "*", 167 + "Access-Control-Allow-Methods": "GET, OPTIONS", 168 + }, 169 + }); 170 + return new Response("Method not allowed", { status: 405 }); 171 + }, 159 172 "/.well-known/openid-configuration": () => { 160 173 const origin = process.env.ORIGIN as string; 161 174 return Response.json(getDiscoveryDocument(origin));
+1 -1
src/ldap-cleanup.ts
··· 35 35 verifyUserExists: true, 36 36 }); 37 37 return !!user; 38 - } catch (error) { 38 + } catch (_error) { 39 39 // User not found or invalid credentials (expected for non-existence check) 40 40 return false; 41 41 }
+1 -1
src/routes/api.ts
··· 8 8 | Response { 9 9 const authHeader = req.headers.get("Authorization"); 10 10 11 - if (!authHeader || !authHeader.startsWith("Bearer ")) { 11 + if (!authHeader?.startsWith("Bearer ")) { 12 12 return Response.json({ error: "Unauthorized" }, { status: 401 }); 13 13 } 14 14
+2 -2
src/routes/auth.ts
··· 42 42 .get(username) as { id: number } | undefined; 43 43 44 44 // Allow re-registration if user exists but has no credentials (passkey reset case) 45 - let isPasskeyReset = false; 45 + let _isPasskeyReset = false; 46 46 if (existingUser) { 47 47 const credCount = db 48 48 .query("SELECT COUNT(*) as count FROM credentials WHERE user_id = ?") ··· 55 55 ); 56 56 } 57 57 // User exists but has no credentials - this is a passkey reset 58 - isPasskeyReset = true; 58 + _isPasskeyReset = true; 59 59 } 60 60 61 61 // Check if this is bootstrap (first user)
+1 -1
src/routes/clients.ts
··· 21 21 | Response { 22 22 const authHeader = req.headers.get("Authorization"); 23 23 24 - if (!authHeader || !authHeader.startsWith("Bearer ")) { 24 + if (!authHeader?.startsWith("Bearer ")) { 25 25 return Response.json({ error: "Unauthorized" }, { status: 401 }); 26 26 } 27 27
+105 -9
src/routes/indieauth.ts
··· 26 26 function getSessionUser(req: Request): SessionUser | Response { 27 27 const authHeader = req.headers.get("Authorization"); 28 28 29 - if (!authHeader || !authHeader.startsWith("Bearer ")) { 29 + if (!authHeader?.startsWith("Bearer ")) { 30 30 return Response.json({ error: "Unauthorized" }, { status: 401 }); 31 31 } 32 32 ··· 82 82 }), 83 83 ); 84 84 85 - const sessionToken = cookies["indiko_session"]; 85 + const sessionToken = cookies.indiko_session; 86 86 if (!sessionToken) return null; 87 87 88 88 const session = db ··· 1415 1415 ${scopes 1416 1416 .map((scope) => { 1417 1417 const isProfile = scope === "profile"; 1418 - const description = 1419 - scope === "profile" 1420 - ? "Your profile (name, photo, URL)" 1421 - : scope === "email" 1422 - ? "Your email address" 1423 - : scope; 1418 + const scopeDescriptions: Record<string, string> = { 1419 + profile: "Your profile (name, photo, URL)", 1420 + email: "Your email address", 1421 + openid: "Authenticate with OpenID Connect (issues an id_token)", 1422 + }; 1423 + const description = scopeDescriptions[scope] ?? scope; 1424 1424 const required = isProfile 1425 1425 ? ' <span style="color: var(--old-rose); font-size: 0.875rem; margin-left: 0.5rem;">(required)</span>' 1426 1426 : ""; ··· 2330 2330 // Get access token from Authorization header 2331 2331 const authHeader = req.headers.get("Authorization"); 2332 2332 2333 - if (!authHeader || !authHeader.startsWith("Bearer ")) { 2333 + if (!authHeader?.startsWith("Bearer ")) { 2334 2334 return unauthorizedResponse( 2335 2335 "invalid_request", 2336 2336 "Missing or invalid Authorization header", ··· 2900 2900 return Response.json({ success: true }); 2901 2901 } 2902 2902 2903 + export function clientMetadata(req: Request): Response { 2904 + const url = new URL(req.url); 2905 + const clientId = url.searchParams.get("client_id"); 2906 + 2907 + if (!clientId) { 2908 + return Response.json( 2909 + { 2910 + error: "invalid_request", 2911 + error_description: "client_id parameter is required", 2912 + }, 2913 + { status: 400 }, 2914 + ); 2915 + } 2916 + 2917 + const client = db 2918 + .query( 2919 + `SELECT 2920 + client_id, 2921 + name, 2922 + logo_url, 2923 + description, 2924 + redirect_uris, 2925 + is_preregistered, 2926 + available_roles, 2927 + default_role, 2928 + first_seen, 2929 + last_used 2930 + FROM apps 2931 + WHERE client_id = ?`, 2932 + ) 2933 + .get(clientId) as 2934 + | { 2935 + client_id: string; 2936 + name: string | null; 2937 + logo_url: string | null; 2938 + description: string | null; 2939 + redirect_uris: string; 2940 + is_preregistered: number; 2941 + available_roles: string | null; 2942 + default_role: string | null; 2943 + first_seen: number; 2944 + last_used: number; 2945 + } 2946 + | undefined; 2947 + 2948 + if (!client) { 2949 + return Response.json( 2950 + { error: "not_found", error_description: "Client not found" }, 2951 + { status: 404 }, 2952 + ); 2953 + } 2954 + 2955 + const redirectUris = JSON.parse(client.redirect_uris) as string[]; 2956 + const availableRoles = client.available_roles 2957 + ? (JSON.parse(client.available_roles) as string[]) 2958 + : undefined; 2959 + 2960 + // Derive client_uri from the client_id when it's a URL (auto-registered apps) 2961 + let clientUri: string | undefined; 2962 + try { 2963 + new URL(client.client_id); 2964 + clientUri = client.client_id; 2965 + } catch { 2966 + // Pre-registered clients use opaque IDs, not URLs 2967 + } 2968 + 2969 + const metadata: Record<string, unknown> = { 2970 + client_id: client.client_id, 2971 + client_name: 2972 + client.name || 2973 + (clientUri ? new URL(clientUri).hostname : client.client_id), 2974 + redirect_uris: redirectUris, 2975 + grant_types: ["authorization_code"], 2976 + response_types: ["code"], 2977 + token_endpoint_auth_method: client.is_preregistered 2978 + ? "client_secret_post" 2979 + : "none", 2980 + }; 2981 + 2982 + if (clientUri) metadata.client_uri = clientUri; 2983 + if (client.logo_url) metadata.logo_uri = client.logo_url; 2984 + if (client.description) metadata.client_description = client.description; 2985 + if (availableRoles) metadata.roles = availableRoles; 2986 + if (client.default_role) metadata.default_role = client.default_role; 2987 + 2988 + return Response.json(metadata, { 2989 + headers: { 2990 + "Content-Type": "application/json", 2991 + "Access-Control-Allow-Origin": "*", 2992 + }, 2993 + }); 2994 + } 2995 + 2903 2996 // GET /.well-known/oauth-authorization-server - IndieAuth metadata endpoint 2904 2997 export function indieauthMetadata(): Response { 2905 2998 const origin = process.env.ORIGIN || "http://localhost:3000"; ··· 2913 3006 revocation_endpoint: `${origin}/auth/token/revoke`, 2914 3007 revocation_endpoint_auth_methods_supported: ["none"], 2915 3008 userinfo_endpoint: `${origin}/userinfo`, 3009 + jwks_uri: `${origin}/jwks`, 2916 3010 code_challenge_methods_supported: ["S256"], 2917 3011 scopes_supported: ["profile", "email"], 2918 3012 response_types_supported: ["code"], 2919 3013 grant_types_supported: ["authorization_code", "refresh_token"], 3014 + token_endpoint_auth_methods_supported: ["none", "client_secret_post"], 2920 3015 service_documentation: `${origin}/docs`, 3016 + client_id_metadata_document_supported: true, 2921 3017 }; 2922 3018 2923 3019 return Response.json(metadata, {