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 docs and nice consent screen

+1044 -62
+214
src/client/docs.ts
··· 1 + // PKCE helper functions 2 + function generateRandomString(length: number): string { 3 + const array = new Uint8Array(length); 4 + crypto.getRandomValues(array); 5 + return btoa(String.fromCharCode(...array)) 6 + .replace(/\+/g, '-') 7 + .replace(/\//g, '_') 8 + .replace(/=/g, ''); 9 + } 10 + 11 + async function sha256(plain: string): Promise<string> { 12 + const encoder = new TextEncoder(); 13 + const data = encoder.encode(plain); 14 + const hash = await crypto.subtle.digest('SHA-256', data); 15 + const hashArray = Array.from(new Uint8Array(hash)); 16 + return btoa(String.fromCharCode(...hashArray)) 17 + .replace(/\+/g, '-') 18 + .replace(/\//g, '_') 19 + .replace(/=/g, ''); 20 + } 21 + 22 + // Elements 23 + const clientIdInput = document.getElementById('clientId') as HTMLInputElement; 24 + const redirectUriInput = document.getElementById('redirectUri') as HTMLInputElement; 25 + const startBtn = document.getElementById('startBtn') as HTMLButtonElement; 26 + const callbackSection = document.getElementById('callbackSection') as HTMLElement; 27 + const callbackInfo = document.getElementById('callbackInfo') as HTMLElement; 28 + const exchangeBtn = document.getElementById('exchangeBtn') as HTMLButtonElement; 29 + const resultSection = document.getElementById('resultSection') as HTMLElement; 30 + const resultDiv = document.getElementById('result') as HTMLElement; 31 + 32 + // Auto-fill redirect URI with current page URL 33 + const currentUrl = window.location.origin + window.location.pathname; 34 + redirectUriInput.value = currentUrl; 35 + 36 + // Auto-fill client ID with a test URL 37 + clientIdInput.value = window.location.origin; 38 + 39 + // Check if we're handling a callback 40 + const urlParams = new URLSearchParams(window.location.search); 41 + const code = urlParams.get('code'); 42 + const state = urlParams.get('state'); 43 + const error = urlParams.get('error'); 44 + 45 + if (error) { 46 + // OAuth error response 47 + showResult(`Error: ${error}\n${urlParams.get('error_description') || ''}`, 'error'); 48 + resultSection.style.display = 'block'; 49 + } else if (code && state) { 50 + // We have a callback with authorization code 51 + handleCallback(code, state); 52 + } 53 + 54 + // Start OAuth flow 55 + startBtn.addEventListener('click', async () => { 56 + const clientId = clientIdInput.value.trim(); 57 + const redirectUri = redirectUriInput.value.trim(); 58 + 59 + if (!clientId || !redirectUri) { 60 + alert('Please fill in client ID and redirect URI'); 61 + return; 62 + } 63 + 64 + // Get selected scopes 65 + const scopeCheckboxes = document.querySelectorAll('input[name="scope"]:checked'); 66 + const scopes = Array.from(scopeCheckboxes).map((cb) => (cb as HTMLInputElement).value); 67 + 68 + if (scopes.length === 0) { 69 + alert('Please select at least one scope'); 70 + return; 71 + } 72 + 73 + // Generate PKCE parameters 74 + const codeVerifier = generateRandomString(64); 75 + const codeChallenge = await sha256(codeVerifier); 76 + const state = generateRandomString(32); 77 + 78 + // Store PKCE values in localStorage for callback 79 + localStorage.setItem('oauth_code_verifier', codeVerifier); 80 + localStorage.setItem('oauth_state', state); 81 + localStorage.setItem('oauth_client_id', clientId); 82 + localStorage.setItem('oauth_redirect_uri', redirectUri); 83 + 84 + // Build authorization URL 85 + const authUrl = new URL('/auth/authorize', window.location.origin); 86 + authUrl.searchParams.set('response_type', 'code'); 87 + authUrl.searchParams.set('client_id', clientId); 88 + authUrl.searchParams.set('redirect_uri', redirectUri); 89 + authUrl.searchParams.set('state', state); 90 + authUrl.searchParams.set('code_challenge', codeChallenge); 91 + authUrl.searchParams.set('code_challenge_method', 'S256'); 92 + authUrl.searchParams.set('scope', scopes.join(' ')); 93 + 94 + // Redirect to authorization endpoint 95 + window.location.href = authUrl.toString(); 96 + }); 97 + 98 + // Handle OAuth callback 99 + function handleCallback(code: string, state: string) { 100 + const storedState = localStorage.getItem('oauth_state'); 101 + 102 + if (state !== storedState) { 103 + showResult('Error: State mismatch (CSRF attack?)', 'error'); 104 + resultSection.style.display = 'block'; 105 + return; 106 + } 107 + 108 + callbackSection.style.display = 'block'; 109 + callbackInfo.innerHTML = ` 110 + <p style="margin-bottom: 1rem;"><strong>Authorization Code:</strong><br><code style="word-break: break-all;">${code}</code></p> 111 + <p><strong>State:</strong> <code>${state}</code> ✓ (verified)</p> 112 + `; 113 + 114 + // Scroll to callback section 115 + callbackSection.scrollIntoView({ behavior: 'smooth' }); 116 + } 117 + 118 + // Exchange authorization code for user profile 119 + exchangeBtn.addEventListener('click', async () => { 120 + const code = urlParams.get('code'); 121 + const codeVerifier = localStorage.getItem('oauth_code_verifier'); 122 + const clientId = localStorage.getItem('oauth_client_id'); 123 + const redirectUri = localStorage.getItem('oauth_redirect_uri'); 124 + 125 + if (!code || !codeVerifier || !clientId || !redirectUri) { 126 + showResult('Error: Missing OAuth parameters', 'error'); 127 + resultSection.style.display = 'block'; 128 + return; 129 + } 130 + 131 + exchangeBtn.disabled = true; 132 + exchangeBtn.textContent = 'exchanging...'; 133 + 134 + try { 135 + const response = await fetch('/auth/token', { 136 + method: 'POST', 137 + headers: { 138 + 'Content-Type': 'application/json', 139 + }, 140 + body: JSON.stringify({ 141 + grant_type: 'authorization_code', 142 + code, 143 + client_id: clientId, 144 + redirect_uri: redirectUri, 145 + code_verifier: codeVerifier, 146 + }), 147 + }); 148 + 149 + const data = await response.json(); 150 + 151 + if (!response.ok) { 152 + showResult( 153 + `Error: ${data.error}\n${data.error_description || ''}`, 154 + 'error' 155 + ); 156 + } else { 157 + showResult( 158 + `Success! User authenticated:\n\n${JSON.stringify(data, null, 2)}`, 159 + 'success' 160 + ); 161 + 162 + // Clean up localStorage 163 + localStorage.removeItem('oauth_code_verifier'); 164 + localStorage.removeItem('oauth_state'); 165 + localStorage.removeItem('oauth_client_id'); 166 + localStorage.removeItem('oauth_redirect_uri'); 167 + } 168 + } catch (error) { 169 + showResult(`Error: ${(error as Error).message}`, 'error'); 170 + } finally { 171 + exchangeBtn.disabled = false; 172 + exchangeBtn.textContent = 'exchange code for profile'; 173 + resultSection.style.display = 'block'; 174 + resultSection.scrollIntoView({ behavior: 'smooth' }); 175 + } 176 + }); 177 + 178 + function showResult(text: string, type: 'success' | 'error') { 179 + if (type === 'success' && text.includes('{')) { 180 + // Extract and parse JSON from success message 181 + const jsonStart = text.indexOf('{'); 182 + const jsonStr = text.substring(jsonStart); 183 + const prefix = text.substring(0, jsonStart); 184 + 185 + try { 186 + const data = JSON.parse(jsonStr); 187 + resultDiv.innerHTML = `${prefix}<pre style="margin: 0; font-family: 'Space Grotesk', monospace;">${syntaxHighlightJSON(data)}</pre>`; 188 + } catch { 189 + resultDiv.textContent = text; 190 + } 191 + } else { 192 + resultDiv.textContent = text; 193 + } 194 + resultDiv.className = `result show ${type}`; 195 + } 196 + 197 + function syntaxHighlightJSON(obj: any): string { 198 + const json = JSON.stringify(obj, null, 2); 199 + return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (match) => { 200 + let cls = 'json-number'; 201 + if (/^"/.test(match)) { 202 + if (/:$/.test(match)) { 203 + cls = 'json-key'; 204 + } else { 205 + cls = 'json-string'; 206 + } 207 + } else if (/true|false/.test(match)) { 208 + cls = 'json-boolean'; 209 + } else if (/null/.test(match)) { 210 + cls = 'json-null'; 211 + } 212 + return `<span class="${cls}">${match}</span>`; 213 + }); 214 + }
+1 -1
src/client/index.ts
··· 76 76 77 77 // Build footer with conditional admin link 78 78 const adminLink = data.isAdmin ? ' • <a href="/admin">admin</a>' : ''; 79 - footer.innerHTML = `signed in as <strong><a href="/u/${data.username}">${data.username}</a></strong> • <a href="/apps">apps</a> • <a href="/oauth-test">test oauth</a>${adminLink} • <a href="/login" id="logoutLink">sign out</a>`; 79 + footer.innerHTML = `signed in as <strong><a href="/u/${data.username}">${data.username}</a></strong> • <a href="/apps">apps</a> • <a href="/docs">docs</a>${adminLink} • <a href="/login" id="logoutLink">sign out</a>`; 80 80 81 81 // Handle logout 82 82 document.getElementById('logoutLink')?.addEventListener('click', async (e) => {
+679
src/html/docs.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <title>documentation • indiko</title> 8 + <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 9 + <link rel="preconnect" href="https://fonts.googleapis.com"> 10 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 11 + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> 12 + <style> 13 + :root { 14 + --mahogany: #26242b; 15 + --lavender: #d9d0de; 16 + --old-rose: #bc8da0; 17 + --rosewood: #a04668; 18 + --berry-crush: #ab4967; 19 + } 20 + 21 + * { 22 + margin: 0; 23 + padding: 0; 24 + box-sizing: border-box; 25 + } 26 + 27 + body { 28 + font-family: "Space Grotesk", sans-serif; 29 + background: var(--mahogany); 30 + color: var(--lavender); 31 + min-height: 100vh; 32 + padding: 2.5rem 1.25rem; 33 + } 34 + 35 + .container { 36 + max-width: 56.25rem; 37 + margin: 0 auto; 38 + } 39 + 40 + header { 41 + margin-bottom: 3rem; 42 + } 43 + 44 + h1 { 45 + font-size: 2.5rem; 46 + font-weight: 700; 47 + background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 48 + -webkit-background-clip: text; 49 + -webkit-text-fill-color: transparent; 50 + background-clip: text; 51 + letter-spacing: -0.125rem; 52 + margin-bottom: 0.5rem; 53 + } 54 + 55 + .subtitle { 56 + color: var(--old-rose); 57 + margin-bottom: 2rem; 58 + font-size: 1.125rem; 59 + font-weight: 300; 60 + } 61 + 62 + h2 { 63 + font-size: 1.75rem; 64 + font-weight: 600; 65 + color: var(--lavender); 66 + margin-top: 0; 67 + margin-bottom: 1rem; 68 + letter-spacing: -0.05rem; 69 + } 70 + 71 + h3 { 72 + font-size: 1.25rem; 73 + font-weight: 600; 74 + color: var(--lavender); 75 + margin-top: 0; 76 + margin-bottom: 1rem; 77 + } 78 + 79 + p { 80 + line-height: 1.8; 81 + margin-bottom: 1rem; 82 + color: var(--lavender); 83 + } 84 + 85 + .section { 86 + background: rgba(188, 141, 160, 0.05); 87 + border: 1px solid var(--old-rose); 88 + padding: 2rem; 89 + margin-bottom: 2rem; 90 + } 91 + 92 + .info-box { 93 + background: rgba(188, 141, 160, 0.1); 94 + border-left: 4px solid var(--berry-crush); 95 + padding: 1.25rem; 96 + margin: 1.5rem 0; 97 + font-size: 0.9375rem; 98 + color: var(--old-rose); 99 + line-height: 1.8; 100 + } 101 + 102 + .info-box strong { 103 + color: var(--lavender); 104 + display: block; 105 + margin-bottom: 0.5rem; 106 + } 107 + 108 + code { 109 + background: rgba(12, 23, 19, 0.8); 110 + padding: 0.25rem 0.5rem; 111 + font-family: monospace; 112 + color: var(--berry-crush); 113 + font-size: 0.875rem; 114 + border-radius: 2px; 115 + } 116 + 117 + pre { 118 + background: rgba(12, 23, 19, 0.8); 119 + border: 1px solid var(--rosewood); 120 + padding: 1.5rem; 121 + margin: 1.5rem 0; 122 + overflow-x: auto; 123 + line-height: 1.6; 124 + } 125 + 126 + pre code { 127 + background: none; 128 + padding: 0; 129 + font-size: 0.875rem; 130 + } 131 + 132 + ul, ol { 133 + margin-left: 1.5rem; 134 + margin-bottom: 1rem; 135 + line-height: 1.8; 136 + color: var(--lavender); 137 + } 138 + 139 + li { 140 + margin-bottom: 0.5rem; 141 + } 142 + 143 + a { 144 + color: var(--berry-crush); 145 + text-decoration: none; 146 + font-weight: 500; 147 + } 148 + 149 + a:hover { 150 + text-decoration: underline; 151 + } 152 + 153 + table { 154 + width: 100%; 155 + border-collapse: collapse; 156 + margin: 1.5rem 0; 157 + } 158 + 159 + th { 160 + background: rgba(188, 141, 160, 0.2); 161 + padding: 0.75rem; 162 + text-align: left; 163 + color: var(--lavender); 164 + font-weight: 600; 165 + border: 1px solid var(--old-rose); 166 + } 167 + 168 + td { 169 + padding: 0.75rem; 170 + border: 1px solid var(--old-rose); 171 + color: var(--lavender); 172 + } 173 + 174 + tr:nth-child(even) { 175 + background: rgba(188, 141, 160, 0.05); 176 + } 177 + 178 + .toc { 179 + background: rgba(188, 141, 160, 0.05); 180 + border: 1px solid var(--old-rose); 181 + padding: 1.5rem; 182 + margin-bottom: 2rem; 183 + } 184 + 185 + .toc h3 { 186 + margin-top: 0; 187 + margin-bottom: 1rem; 188 + font-size: 1rem; 189 + color: var(--old-rose); 190 + text-transform: uppercase; 191 + letter-spacing: 0.05rem; 192 + } 193 + 194 + .toc ul { 195 + list-style: none; 196 + margin: 0; 197 + padding: 0; 198 + } 199 + 200 + .toc li { 201 + margin-bottom: 0.5rem; 202 + } 203 + 204 + .toc a { 205 + color: var(--lavender); 206 + text-decoration: none; 207 + transition: color 0.2s; 208 + } 209 + 210 + .toc a:hover { 211 + color: var(--berry-crush); 212 + text-decoration: underline; 213 + } 214 + 215 + .back-link { 216 + text-align: center; 217 + margin-top: 3rem; 218 + padding-top: 2rem; 219 + border-top: 1px solid var(--old-rose); 220 + } 221 + 222 + /* OAuth Tester Styles */ 223 + label { 224 + display: block; 225 + color: var(--old-rose); 226 + font-size: 0.875rem; 227 + font-weight: 500; 228 + margin-bottom: 0.5rem; 229 + text-transform: uppercase; 230 + letter-spacing: 0.05rem; 231 + } 232 + 233 + input[type="text"], 234 + input[type="url"] { 235 + width: 100%; 236 + padding: 0.875rem 1rem; 237 + background: rgba(12, 23, 19, 0.6); 238 + border: 2px solid var(--rosewood); 239 + border-radius: 0; 240 + color: var(--lavender); 241 + font-size: 1rem; 242 + font-family: "Space Grotesk", sans-serif; 243 + margin-bottom: 1.5rem; 244 + transition: border-color 0.2s; 245 + } 246 + 247 + input:focus { 248 + outline: none; 249 + border-color: var(--berry-crush); 250 + background: rgba(12, 23, 19, 0.8); 251 + } 252 + 253 + .checkbox-group { 254 + margin-bottom: 1.5rem; 255 + } 256 + 257 + .checkbox-group label { 258 + display: flex; 259 + align-items: center; 260 + gap: 0.5rem; 261 + text-transform: none; 262 + font-weight: 400; 263 + margin-bottom: 0.75rem; 264 + cursor: pointer; 265 + } 266 + 267 + input[type="checkbox"] { 268 + width: 1.25rem; 269 + height: 1.25rem; 270 + cursor: pointer; 271 + } 272 + 273 + button { 274 + position: relative; 275 + padding: 1rem 2rem; 276 + background: var(--berry-crush); 277 + color: var(--lavender); 278 + border: 4px solid var(--mahogany); 279 + border-radius: 0; 280 + font-size: 1rem; 281 + font-weight: 700; 282 + cursor: pointer; 283 + font-family: "Space Grotesk", sans-serif; 284 + transition: all 0.15s ease; 285 + text-transform: uppercase; 286 + letter-spacing: 0.1rem; 287 + box-shadow: 6px 6px 0 var(--mahogany); 288 + width: 100%; 289 + } 290 + 291 + button::before { 292 + content: ''; 293 + position: absolute; 294 + top: -4px; 295 + left: -4px; 296 + right: -4px; 297 + bottom: -4px; 298 + background: transparent; 299 + border: 4px solid var(--rosewood); 300 + pointer-events: none; 301 + transition: all 0.15s ease; 302 + } 303 + 304 + button:hover:not(:disabled) { 305 + transform: translate(3px, 3px); 306 + box-shadow: 3px 3px 0 var(--mahogany); 307 + } 308 + 309 + button:hover:not(:disabled)::before { 310 + top: -7px; 311 + left: -7px; 312 + right: -7px; 313 + bottom: -7px; 314 + } 315 + 316 + button:active:not(:disabled) { 317 + transform: translate(6px, 6px); 318 + box-shadow: 0 0 0 var(--mahogany); 319 + } 320 + 321 + button:disabled { 322 + opacity: 0.5; 323 + cursor: not-allowed; 324 + } 325 + 326 + .result { 327 + background: rgba(12, 23, 19, 0.6); 328 + border: 2px solid var(--rosewood); 329 + padding: 1.5rem; 330 + margin-top: 1.5rem; 331 + font-family: monospace; 332 + font-size: 0.875rem; 333 + white-space: pre-wrap; 334 + word-break: break-all; 335 + display: none; 336 + } 337 + 338 + .result.show { 339 + display: block; 340 + } 341 + 342 + .result.success { 343 + border-color: #81c784; 344 + background: rgba(139, 195, 74, 0.1); 345 + } 346 + 347 + .result.error { 348 + border-color: var(--rosewood); 349 + background: rgba(160, 70, 104, 0.1); 350 + } 351 + </style> 352 + </head> 353 + 354 + <body> 355 + <div class="container"> 356 + <header> 357 + <h1>indiko documentation</h1> 358 + <p class="subtitle">IndieAuth/OAuth 2.0 server with passkey authentication</p> 359 + </header> 360 + 361 + <nav class="toc"> 362 + <h3>table of contents</h3> 363 + <ul> 364 + <li><a href="#overview">overview</a></li> 365 + <li><a href="#getting-started">getting started</a></li> 366 + <li><a href="#endpoints">endpoints</a></li> 367 + <li><a href="#authorization">authorization flow</a></li> 368 + <li><a href="#scopes">scopes</a></li> 369 + <li><a href="#clients">client types</a></li> 370 + <li><a href="#tester">oauth tester</a></li> 371 + </ul> 372 + </nav> 373 + 374 + <section id="overview" class="section"> 375 + <h2>overview</h2> 376 + <p> 377 + Indiko is a self-hosted IndieAuth/OAuth 2.0 authorization server with passwordless authentication using WebAuthn passkeys. 378 + It provides single sign-on (SSO) for your apps and services. 379 + </p> 380 + 381 + <h3>key features</h3> 382 + <ul> 383 + <li>Passwordless authentication via WebAuthn passkeys</li> 384 + <li>Full IndieAuth and OAuth 2.0 support with PKCE</li> 385 + <li>Auto-registration of OAuth clients</li> 386 + <li>Pre-registered clients with secrets and role management</li> 387 + <li>Session-based SSO (authenticate once, authorize many apps)</li> 388 + <li>User profile endpoints with h-card microformats</li> 389 + <li>Invite-based user registration</li> 390 + </ul> 391 + </section> 392 + 393 + <section id="getting-started" class="section"> 394 + <h2>getting started</h2> 395 + 396 + <h3>for app developers</h3> 397 + <p> 398 + To integrate with Indiko as an OAuth client, you'll need: 399 + </p> 400 + <ol> 401 + <li>A <strong>client ID</strong> (any valid URL, e.g., <code>https://myapp.example.com</code>)</li> 402 + <li>A <strong>redirect URI</strong> (where users return after authorization)</li> 403 + <li>Support for PKCE (code challenge/verifier)</li> 404 + </ol> 405 + 406 + <div class="info-box"> 407 + <strong>Auto-registration:</strong> 408 + Apps are automatically registered on first use. You don't need admin approval to get started. 409 + For advanced features like client secrets and role assignment, contact your Indiko admin to pre-register your app. 410 + </div> 411 + 412 + <h3>for users</h3> 413 + <p> 414 + You'll need an invite code to create an account. Once registered: 415 + </p> 416 + <ul> 417 + <li>Set up your passkey (fingerprint, face ID, or security key)</li> 418 + <li>Complete your profile (name, photo, website)</li> 419 + <li>Authorize apps to access your profile</li> 420 + <li>Manage app permissions from your dashboard</li> 421 + </ul> 422 + </section> 423 + 424 + <section id="endpoints" class="section"> 425 + <h2>API endpoints</h2> 426 + 427 + <h3>authorization endpoints</h3> 428 + <table> 429 + <thead> 430 + <tr> 431 + <th>Endpoint</th> 432 + <th>Method</th> 433 + <th>Description</th> 434 + </tr> 435 + </thead> 436 + <tbody> 437 + <tr> 438 + <td><code>/auth/authorize</code></td> 439 + <td>GET</td> 440 + <td>Start OAuth authorization flow</td> 441 + </tr> 442 + <tr> 443 + <td><code>/auth/authorize</code></td> 444 + <td>POST</td> 445 + <td>Submit consent/scope approval</td> 446 + </tr> 447 + <tr> 448 + <td><code>/auth/token</code></td> 449 + <td>POST</td> 450 + <td>Exchange code for access token</td> 451 + </tr> 452 + <tr> 453 + <td><code>/u/:username</code></td> 454 + <td>GET</td> 455 + <td>Public user profile (h-card)</td> 456 + </tr> 457 + </tbody> 458 + </table> 459 + 460 + <h3>authentication endpoints</h3> 461 + <table> 462 + <thead> 463 + <tr> 464 + <th>Endpoint</th> 465 + <th>Method</th> 466 + <th>Description</th> 467 + </tr> 468 + </thead> 469 + <tbody> 470 + <tr> 471 + <td><code>/auth/can-register</code></td> 472 + <td>POST</td> 473 + <td>Check if invite code is valid</td> 474 + </tr> 475 + <tr> 476 + <td><code>/auth/register/options</code></td> 477 + <td>POST</td> 478 + <td>Get WebAuthn registration options</td> 479 + </tr> 480 + <tr> 481 + <td><code>/auth/register/verify</code></td> 482 + <td>POST</td> 483 + <td>Complete passkey registration</td> 484 + </tr> 485 + <tr> 486 + <td><code>/auth/login/options</code></td> 487 + <td>POST</td> 488 + <td>Get WebAuthn login options</td> 489 + </tr> 490 + <tr> 491 + <td><code>/auth/login/verify</code></td> 492 + <td>POST</td> 493 + <td>Complete passkey login</td> 494 + </tr> 495 + <tr> 496 + <td><code>/auth/logout</code></td> 497 + <td>POST</td> 498 + <td>End current session</td> 499 + </tr> 500 + </tbody> 501 + </table> 502 + </section> 503 + 504 + <section id="authorization" class="section"> 505 + <h2>authorization flow</h2> 506 + 507 + <h3>1. redirect to authorization endpoint</h3> 508 + <pre><code>GET /auth/authorize? 509 + response_type=code 510 + &client_id=https://myapp.example.com 511 + &redirect_uri=https://myapp.example.com/callback 512 + &state=random_state_string 513 + &code_challenge=base64url_encoded_challenge 514 + &code_challenge_method=S256 515 + &scope=profile email</code></pre> 516 + 517 + <div class="info-box"> 518 + <strong>PKCE is required:</strong> 519 + Generate a random <code>code_verifier</code> (43-128 characters), then create <code>code_challenge</code> by hashing it with SHA-256 and base64url encoding. 520 + </div> 521 + 522 + <h3>2. user authenticates and approves</h3> 523 + <p> 524 + Indiko will: 525 + </p> 526 + <ul> 527 + <li>Check if user has an active session (if not, prompt for passkey login)</li> 528 + <li>Show consent screen with requested scopes</li> 529 + <li>Auto-approve if user previously authorized this app</li> 530 + </ul> 531 + 532 + <h3>3. redirect back with code</h3> 533 + <pre><code>https://myapp.example.com/callback? 534 + code=short_lived_authorization_code 535 + &state=random_state_string</code></pre> 536 + 537 + <h3>4. exchange code for token</h3> 538 + <pre><code>POST /auth/token 539 + Content-Type: application/x-www-form-urlencoded 540 + 541 + grant_type=authorization_code 542 + &code=authorization_code 543 + &client_id=https://myapp.example.com 544 + &redirect_uri=https://myapp.example.com/callback 545 + &code_verifier=original_code_verifier</code></pre> 546 + 547 + <h3>5. receive user profile</h3> 548 + <pre><code>{ 549 + "me": "https://indiko.example.com/u/username", 550 + "profile": { 551 + "name": "Jane Doe", 552 + "email": "jane@example.com", 553 + "photo": "https://example.com/photo.jpg", 554 + "url": "https://jane.example.com" 555 + }, 556 + "scope": "profile email" 557 + }</code></pre> 558 + </section> 559 + 560 + <section id="scopes" class="section"> 561 + <h2>scopes</h2> 562 + 563 + <table> 564 + <thead> 565 + <tr> 566 + <th>Scope</th> 567 + <th>Description</th> 568 + <th>Data Included</th> 569 + </tr> 570 + </thead> 571 + <tbody> 572 + <tr> 573 + <td><code>profile</code></td> 574 + <td>Basic profile information</td> 575 + <td>name, photo, URL</td> 576 + </tr> 577 + <tr> 578 + <td><code>email</code></td> 579 + <td>Email address</td> 580 + <td>email</td> 581 + </tr> 582 + </tbody> 583 + </table> 584 + 585 + <div class="info-box"> 586 + <strong>Note:</strong> 587 + Users can selectively approve scopes during authorization. Your app may receive fewer scopes than requested. 588 + </div> 589 + </section> 590 + 591 + <section id="clients" class="section"> 592 + <h2>client types</h2> 593 + 594 + <h3>auto-registered clients</h3> 595 + <p> 596 + Any app can use Indiko without pre-registration. On first authorization, the client is automatically registered with: 597 + </p> 598 + <ul> 599 + <li>Client ID (must be a valid URL)</li> 600 + <li>Redirect URIs</li> 601 + <li>Last used timestamp</li> 602 + </ul> 603 + <p> 604 + Auto-registered clients <strong>cannot</strong> use client secrets or assign user roles. 605 + </p> 606 + 607 + <h3>pre-registered clients</h3> 608 + <p> 609 + Admins can pre-register clients for advanced features: 610 + </p> 611 + <ul> 612 + <li><strong>Client secret:</strong> Confidential clients can authenticate with secrets</li> 613 + <li><strong>Role assignment:</strong> Admins can assign per-user roles for RBAC</li> 614 + <li><strong>Metadata:</strong> Custom name, logo, description</li> 615 + </ul> 616 + 617 + <div class="info-box"> 618 + <strong>Tip:</strong> 619 + Contact your Indiko admin to pre-register your app if you need client authentication or role-based access control. 620 + </div> 621 + </section> 622 + 623 + <section id="tester" class="section"> 624 + <h2>OAuth tester</h2> 625 + <p> 626 + Test the OAuth flow with a live interactive client. This simulates how your app would integrate with Indiko. 627 + </p> 628 + 629 + <div id="testerForm"> 630 + <label for="clientId">client id (your app's URL)</label> 631 + <input type="url" id="clientId" value="" placeholder="https://example.com" /> 632 + 633 + <label for="redirectUri">redirect uri (callback URL)</label> 634 + <input type="url" id="redirectUri" value="" placeholder="https://example.com/callback" /> 635 + 636 + <div class="checkbox-group"> 637 + <label>scopes to request:</label> 638 + <label> 639 + <input type="checkbox" name="scope" value="profile" checked /> 640 + <span>profile (name, photo, URL)</span> 641 + </label> 642 + <label> 643 + <input type="checkbox" name="scope" value="email" /> 644 + <span>email</span> 645 + </label> 646 + </div> 647 + 648 + <button id="startBtn">start oauth flow</button> 649 + </div> 650 + 651 + <div id="callbackSection" style="display: none;"> 652 + <h3>callback received</h3> 653 + <div class="info-box"> 654 + You've been redirected back with an authorization code. Click below to exchange it for user data. 655 + </div> 656 + <div id="callbackInfo"></div> 657 + <button id="exchangeBtn">exchange code for profile</button> 658 + </div> 659 + 660 + <div id="resultSection" style="display: none;"> 661 + <h3>result</h3> 662 + <div id="result" class="result"></div> 663 + </div> 664 + 665 + <div class="info-box" style="margin-top: 2rem;"> 666 + <strong>How it works:</strong> 667 + This page uses the current URL as the redirect URI. After authorization, the code is automatically detected and you can exchange it for user profile data. 668 + </div> 669 + </section> 670 + 671 + <div class="back-link"> 672 + <a href="/">← back to dashboard</a> 673 + </div> 674 + </div> 675 + 676 + <script type="module" src="../client/docs.ts"></script> 677 + </body> 678 + 679 + </html>
+2 -2
src/index.ts
··· 5 5 import adminClientsHTML from "./html/admin-clients.html"; 6 6 import loginHTML from "./html/login.html"; 7 7 import profileHTML from "./html/profile.html"; 8 - import oauthTestHTML from "./html/oauth-test.html"; 8 + import docsHTML from "./html/docs.html"; 9 9 import appsHTML from "./html/apps.html"; 10 10 import { 11 11 canRegister, ··· 66 66 "/admin/clients": adminClientsHTML, 67 67 "/login": loginHTML, 68 68 "/profile": profileHTML, 69 - "/oauth-test": oauthTestHTML, 69 + "/docs": docsHTML, 70 70 "/apps": appsHTML, 71 71 // API endpoints 72 72 "/api/hello": hello,
+148 -59
src/routes/indieauth.ts
··· 246 246 padding: 2rem 1rem; 247 247 } 248 248 .consent-box { 249 - max-width: 28rem; 249 + max-width: 32rem; 250 250 width: 100%; 251 251 background: rgba(188, 141, 160, 0.05); 252 252 border: 1px solid var(--old-rose); 253 - padding: 2rem; 253 + padding: 2.5rem; 254 254 } 255 255 .app-header { 256 256 display: flex; 257 - gap: 1rem; 258 - align-items: center; 259 - margin-bottom: 1rem; 257 + gap: 1.5rem; 258 + align-items: flex-start; 259 + margin-bottom: 2rem; 260 + padding-bottom: 2rem; 261 + border-bottom: 1px solid var(--old-rose); 260 262 } 261 263 .app-logo { 262 - width: 4rem; 263 - height: 4rem; 264 + width: 5rem; 265 + height: 5rem; 264 266 border-radius: 0.5rem; 265 267 background: rgba(188, 141, 160, 0.2); 266 268 display: flex; ··· 268 270 justify-content: center; 269 271 flex-shrink: 0; 270 272 overflow: hidden; 273 + font-size: 2rem; 271 274 } 272 275 .app-logo img { 273 276 width: 100%; 274 277 height: 100%; 275 278 object-fit: cover; 276 279 } 277 - h1 { 280 + .app-info { 281 + flex: 1; 282 + } 283 + .app-name { 278 284 font-size: 1.5rem; 279 285 font-weight: 700; 280 286 color: var(--lavender); 281 - margin-bottom: 0.25rem; 287 + margin-bottom: 0.5rem; 282 288 } 283 - .app-name { 284 - color: var(--berry-crush); 285 - font-weight: 700; 289 + .app-url { 290 + font-size: 0.875rem; 291 + color: var(--old-rose); 292 + font-family: monospace; 293 + margin-bottom: 0.75rem; 286 294 } 287 295 .app-description { 296 + font-size: 0.9375rem; 297 + color: var(--old-rose); 298 + line-height: 1.6; 299 + } 300 + .user-badge { 301 + display: inline-block; 302 + background: rgba(188, 141, 160, 0.1); 303 + border-left: 3px solid var(--berry-crush); 304 + padding: 0.75rem 1rem; 288 305 font-size: 0.875rem; 289 306 color: var(--old-rose); 290 - margin-top: 0.5rem; 307 + margin-bottom: 2rem; 308 + } 309 + .user-badge strong { 310 + color: var(--lavender); 311 + } 312 + .request-text { 313 + font-size: 1.125rem; 314 + color: var(--lavender); 315 + margin-bottom: 1.5rem; 316 + line-height: 1.6; 291 317 } 292 318 .scopes { 293 - margin: 1.5rem 0; 294 - padding: 1rem; 295 - background: rgba(12, 23, 19, 0.6); 296 - border: 1px solid var(--rosewood); 319 + margin-bottom: 2rem; 320 + padding: 1.5rem; 321 + background: rgba(12, 23, 19, 0.4); 322 + border: 1px solid var(--old-rose); 297 323 } 298 324 .scope-title { 299 - font-size: 0.875rem; 325 + font-size: 0.75rem; 300 326 color: var(--old-rose); 301 327 text-transform: uppercase; 302 - letter-spacing: 0.05rem; 303 - margin-bottom: 0.75rem; 328 + letter-spacing: 0.1rem; 329 + margin-bottom: 1rem; 304 330 } 305 331 .scope-list { 306 332 list-style: none; ··· 310 336 } 311 337 .scope-list li { 312 338 color: var(--lavender); 313 - padding-left: 1.5rem; 339 + font-size: 0.9375rem; 340 + line-height: 1.5; 341 + } 342 + .scope-list label { 343 + display: flex; 344 + align-items: center; 345 + gap: 0.75rem; 346 + cursor: pointer; 347 + padding: 0.75rem; 348 + transition: background 0.2s; 349 + border: 1px solid transparent; 350 + } 351 + .scope-list label:hover { 352 + background: rgba(188, 141, 160, 0.1); 353 + border-color: var(--old-rose); 354 + } 355 + .scope-list input[type="checkbox"] { 356 + appearance: none; 357 + width: 1.5rem; 358 + height: 1.5rem; 359 + border: 2px solid var(--old-rose); 360 + background: rgba(12, 23, 19, 0.6); 361 + cursor: pointer; 362 + flex-shrink: 0; 314 363 position: relative; 364 + transition: all 0.2s; 315 365 } 316 - .scope-list li::before { 366 + .scope-list input[type="checkbox"]:checked { 367 + background: var(--berry-crush); 368 + border-color: var(--berry-crush); 369 + } 370 + .scope-list input[type="checkbox"]:checked::after { 317 371 content: "✓"; 318 372 position: absolute; 319 - left: 0; 320 - color: var(--berry-crush); 373 + top: 50%; 374 + left: 50%; 375 + transform: translate(-50%, -50%); 376 + color: var(--lavender); 377 + font-size: 1rem; 378 + font-weight: 700; 379 + } 380 + .scope-list input[type="checkbox"]:disabled { 381 + cursor: not-allowed; 382 + } 383 + .scope-required { 384 + font-size: 0.75rem; 385 + color: var(--old-rose); 386 + margin-left: 2.25rem; 387 + margin-top: -0.25rem; 388 + margin-bottom: 0.25rem; 321 389 } 322 390 .buttons { 323 391 display: flex; 324 392 gap: 1rem; 325 - margin-top: 1.5rem; 326 393 } 327 394 button { 328 395 flex: 1; 329 - padding: 1rem; 396 + padding: 1rem 1.5rem; 330 397 border: 4px solid var(--mahogany); 331 398 font-family: "Space Grotesk", sans-serif; 332 399 font-size: 1rem; ··· 343 410 position: absolute; 344 411 top: -4px; left: -4px; right: -4px; bottom: -4px; 345 412 background: transparent; 413 + border: 4px solid; 346 414 pointer-events: none; 347 415 transition: all 0.15s ease; 348 416 } 349 417 button:hover { 350 418 transform: translate(3px, 3px); 351 419 box-shadow: 3px 3px 0 var(--mahogany); 420 + } 421 + button:hover::before { 422 + top: -7px; 423 + left: -7px; 424 + right: -7px; 425 + bottom: -7px; 426 + } 427 + button:active { 428 + transform: translate(6px, 6px); 429 + box-shadow: 0 0 0 var(--mahogany); 352 430 } 353 431 .allow { 354 432 background: var(--berry-crush); 355 433 color: var(--lavender); 356 434 } 357 435 .allow::before { 358 - border: 4px solid var(--rosewood); 436 + border-color: var(--rosewood); 359 437 } 360 438 .deny { 361 439 background: transparent; 362 440 color: var(--old-rose); 363 - box-shadow: 4px 4px 0 var(--mahogany); 364 441 } 365 442 .deny::before { 366 - border: 4px solid var(--old-rose); 367 - } 368 - .user-info { 369 - margin-bottom: 1.5rem; 370 - padding: 1rem; 371 - background: rgba(188, 141, 160, 0.1); 372 - border-left: 3px solid var(--berry-crush); 373 - font-size: 0.875rem; 374 - color: var(--old-rose); 443 + border-color: var(--old-rose); 375 444 } 376 445 </style> 377 446 </head> 378 447 <body> 379 448 <div class="consent-box"> 449 + <div class="user-badge"> 450 + <span>Signing in as</span> 451 + <strong>${user.username}</strong> 452 + </div> 453 + 380 454 <div class="app-header"> 381 - ${appLogo ? `<div class="app-logo"><img src="${appLogo}" alt="${appName}" /></div>` : ''} 382 - <div> 383 - <h1>authorize app</h1> 384 - <div class="user-info"> 385 - Signing in as <strong>${user.username}</strong> 386 - </div> 455 + <div class="app-logo"> 456 + ${appLogo ? `<img src="${appLogo}" alt="${appName}" />` : '🔐'} 457 + </div> 458 + <div class="app-info"> 459 + <div class="app-name">${appName}</div> 460 + <div class="app-url">${new URL(clientId).hostname}</div> 461 + ${appDescription ? `<div class="app-description">${appDescription}</div>` : ''} 387 462 </div> 388 463 </div> 389 464 390 - <p style="margin-bottom: 1rem;"> 391 - <span class="app-name">${appName}</span> is requesting access to: 392 - </p> 393 - 394 - ${appDescription ? `<p class="app-description">${appDescription}</p>` : ''} 465 + <div class="request-text"> 466 + This app would like to access the following information: 467 + </div> 395 468 396 469 <div class="scopes"> 397 - <div class="scope-title">permissions</div> 470 + <div class="scope-title">requested permissions</div> 398 471 <ul class="scope-list"> 399 472 ${scopes 400 473 .map( 401 - (scope) => ` 402 - <li>${scope === "profile" ? "Your profile (name, photo, URL)" : scope === "email" ? "Your email address" : scope}</li> 403 - `, 474 + (scope) => { 475 + const isProfile = scope === "profile"; 476 + const description = scope === "profile" ? "Your profile (name, photo, URL)" : scope === "email" ? "Your email address" : scope; 477 + const required = isProfile ? ' <span style="color: var(--old-rose); font-size: 0.875rem; margin-left: 0.5rem;">(required)</span>' : ''; 478 + return ` 479 + <li> 480 + <label> 481 + <input type="checkbox" name="scope" value="${scope}" ${isProfile ? 'checked disabled' : 'checked'} /> 482 + <span>${description}${required}</span> 483 + </label> 484 + </li> 485 + `; 486 + }, 404 487 ) 405 488 .join("")} 406 489 </ul> ··· 411 494 <input type="hidden" name="redirect_uri" value="${redirectUri}" /> 412 495 <input type="hidden" name="state" value="${state}" /> 413 496 <input type="hidden" name="code_challenge" value="${codeChallenge}" /> 414 - <input type="hidden" name="scopes" value="${scopes.join(" ")}" /> 497 + <!-- Always include profile scope as it's required --> 498 + <input type="hidden" name="scope" value="profile" /> 415 499 416 500 <div class="buttons"> 417 501 <button type="submit" name="action" value="deny" class="deny">deny</button> ··· 441 525 const redirectUri = formData.get("redirect_uri") as string; 442 526 const state = formData.get("state") as string; 443 527 const codeChallenge = formData.get("code_challenge") as string; 444 - const scopesStr = formData.get("scopes") as string; 445 528 446 - if (!clientId || !redirectUri || !state || !codeChallenge || !scopesStr) { 529 + if (!clientId || !redirectUri || !state || !codeChallenge) { 447 530 return new Response("Missing required parameters", { status: 400 }); 448 531 } 449 532 ··· 453 536 ); 454 537 } 455 538 456 - const scopes = scopesStr.split(" ").filter(Boolean); 539 + // Get the scopes the user actually approved (from checkboxes) 540 + const approvedScopes = formData.getAll("scope") as string[]; 541 + 542 + // Profile scope is always required and included via hidden input 543 + if (approvedScopes.length === 0 || !approvedScopes.includes("profile")) { 544 + return new Response("Invalid scope selection", { status: 400 }); 545 + } 457 546 458 547 // Create authorization code 459 548 const code = crypto.randomBytes(32).toString("base64url"); ··· 466 555 user.userId, 467 556 clientId, 468 557 redirectUri, 469 - JSON.stringify(scopes), 558 + JSON.stringify(approvedScopes), 470 559 codeChallenge, 471 560 expiresAt, 472 561 ); ··· 479 568 if (existing) { 480 569 db.query( 481 570 "UPDATE permissions SET scopes = ?, last_used = ? WHERE user_id = ? AND client_id = ?", 482 - ).run(JSON.stringify(scopes), Math.floor(Date.now() / 1000), user.userId, clientId); 571 + ).run(JSON.stringify(approvedScopes), Math.floor(Date.now() / 1000), user.userId, clientId); 483 572 } else { 484 573 db.query( 485 574 "INSERT INTO permissions (user_id, client_id, scopes) VALUES (?, ?, ?)", 486 - ).run(user.userId, clientId, JSON.stringify(scopes)); 575 + ).run(user.userId, clientId, JSON.stringify(approvedScopes)); 487 576 } 488 577 489 578 // Update app last_used