my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server
at main 587 lines 17 kB view raw
1// JSON syntax highlighter 2function highlightJSON(json: string): string { 3 return json 4 .replace(/&/g, "&amp;") 5 .replace(/</g, "&lt;") 6 .replace(/>/g, "&gt;") 7 .replace(/"([^"]+)":/g, '<span class="json-key">"$1"</span>:') 8 .replace(/: "([^"]*)"/g, ': <span class="json-string">"$1"</span>') 9 .replace(/: (\d+\.?\d*)/g, ': <span class="json-number">$1</span>') 10 .replace(/: (true|false|null)/g, ': <span class="json-boolean">$1</span>'); 11} 12 13// HTML/CSS syntax highlighter 14function highlightHTMLCSS(code: string): string { 15 // First escape HTML entities 16 let highlighted = code 17 .replace(/&/g, "&amp;") 18 .replace(/</g, "&lt;") 19 .replace(/>/g, "&gt;"); 20 21 // HTML comments 22 highlighted = highlighted.replace( 23 /&lt;!--(.*?)--&gt;/g, 24 '<span class="html-comment">&lt;!--$1--&gt;</span>', 25 ); 26 27 // Split by <style> tags to handle CSS separately 28 const parts = highlighted.split(/(&lt;style&gt;[\s\S]*?&lt;\/style&gt;)/g); 29 30 highlighted = parts 31 .map((part, index) => { 32 // Even indices are HTML, odd indices are CSS blocks 33 if (index % 2 === 0) { 34 // Process HTML 35 return part.replace( 36 /&lt;(\/?)([\w-]+)([\s\S]*?)&gt;/g, 37 (_match, slash, tag, attrs) => { 38 let result = `&lt;${slash}<span class="html-tag">${tag}</span>`; 39 40 if (attrs) { 41 attrs = attrs.replace( 42 /([\w-]+)="([^"]*)"/g, 43 '<span class="html-attr">$1</span>="<span class="html-string">$2</span>"', 44 ); 45 attrs = attrs.replace( 46 /(?<=\s)([\w-]+)(?=\s|$)/g, 47 '<span class="html-attr">$1</span>', 48 ); 49 } 50 51 result += attrs + "&gt;"; 52 return result; 53 }, 54 ); 55 } else { 56 // Process CSS (inside <style> tags) 57 return ( 58 part 59 .replace( 60 /&lt;style&gt;/g, 61 '&lt;<span class="html-tag">style</span>&gt;', 62 ) 63 .replace( 64 /&lt;\/style&gt;/g, 65 '&lt;/<span class="html-tag">style</span>&gt;', 66 ) 67 // CSS selectors (anything before { including pseudo-selectors) 68 .replace( 69 /^(\s*)([\w.-]+(?::+[\w-]+(?:\([^)]*\))?)*)\s*\{/gm, 70 '$1<span class="css-selector">$2</span> {', 71 ) 72 // CSS properties (word followed by colon, but not :: for pseudo-elements) 73 .replace( 74 /^(\s+)([\w-]+):\s+/gm, 75 '$1<span class="css-property">$2</span>: ', 76 ) 77 // CSS values (everything between property: and ;) 78 .replace( 79 /(<span class="css-property">[\w-]+<\/span>:\s+)([^;]+);/g, 80 (_match, prop, value) => { 81 const highlightedValue = value 82 .replace( 83 /(#[0-9a-fA-F]{3,6})/g, 84 '<span class="css-value">$1</span>', 85 ) 86 .replace( 87 /([\d.]+(?:px|rem|em|s|%))/g, 88 '<span class="css-value">$1</span>', 89 ) 90 .replace(/('.*?')/g, '<span class="css-value">$1</span>') 91 .replace( 92 /([\w-]+\([^)]*\))/g, 93 '<span class="css-value">$1</span>', 94 ); 95 return `${prop}${highlightedValue};`; 96 }, 97 ) 98 ); 99 } 100 }) 101 .join(""); 102 103 return highlighted; 104} 105 106// PKCE helper functions 107function generateRandomString(length: number): string { 108 const array = new Uint8Array(length); 109 crypto.getRandomValues(array); 110 return btoa(String.fromCharCode(...array)) 111 .replace(/\+/g, "-") 112 .replace(/\//g, "_") 113 .replace(/=/g, ""); 114} 115 116async function sha256(plain: string): Promise<string> { 117 const encoder = new TextEncoder(); 118 const data = encoder.encode(plain); 119 const hash = await crypto.subtle.digest("SHA-256", data); 120 const hashArray = Array.from(new Uint8Array(hash)); 121 return btoa(String.fromCharCode(...hashArray)) 122 .replace(/\+/g, "-") 123 .replace(/\//g, "_") 124 .replace(/=/g, ""); 125} 126 127// Elements 128const clientIdInput = document.getElementById("clientId") as HTMLInputElement; 129const redirectUriInput = document.getElementById( 130 "redirectUri", 131) as HTMLInputElement; 132const startBtn = document.getElementById("startBtn") as HTMLButtonElement; 133const callbackSection = document.getElementById( 134 "callbackSection", 135) as HTMLElement; 136const callbackInfo = document.getElementById("callbackInfo") as HTMLElement; 137const exchangeBtn = document.getElementById("exchangeBtn") as HTMLButtonElement; 138const resultSection = document.getElementById("resultSection") as HTMLElement; 139const resultDiv = document.getElementById("result") as HTMLElement; 140const copyMarkdownBtn = document.getElementById( 141 "copyMarkdownBtn", 142) as HTMLButtonElement; 143const copyButtonCodeBtn = document.getElementById( 144 "copyButtonCode", 145) as HTMLButtonElement; 146const demoButton = document.getElementById("demoButton") as HTMLAnchorElement; 147const buttonCodeEl = document.getElementById("buttonCode") as HTMLElement; 148 149// Populate and highlight button code 150const buttonCodeRaw = `<!-- Add Google Fonts to your <head> --> 151<link rel="preconnect" href="https://fonts.googleapis.com"> 152<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 153<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> 154 155<!-- Button HTML --> 156<a href="https://your-indiko-server.com/auth/authorize?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&state=RANDOM_STATE&code_challenge=CODE_CHALLENGE&code_challenge_method=S256&scope=profile%20email" class="indiko-button"> 157 Sign in with Indiko 158</a> 159 160<style> 161 .indiko-button { 162 position: relative; 163 display: inline-block; 164 padding: 1rem 2rem; 165 background: #ab4967; 166 color: #d9d0de; 167 border: 4px solid #26242b; 168 font-size: 1rem; 169 font-weight: 700; 170 text-decoration: none; 171 font-family: 'Space Grotesk', sans-serif; 172 text-transform: uppercase; 173 letter-spacing: 0.1rem; 174 box-shadow: 6px 6px 0 #26242b; 175 transition: all 0.15s ease; 176 } 177 178 .indiko-button::before { 179 content: ''; 180 position: absolute; 181 top: -4px; 182 left: -4px; 183 right: -4px; 184 bottom: -4px; 185 background: transparent; 186 border: 4px solid #a04668; 187 pointer-events: none; 188 transition: all 0.15s ease; 189 } 190 191 .indiko-button:hover { 192 transform: translate(3px, 3px); 193 box-shadow: 3px 3px 0 #26242b; 194 } 195 196 .indiko-button:hover::before { 197 top: -7px; 198 left: -7px; 199 right: -7px; 200 bottom: -7px; 201 } 202 203 .indiko-button:active { 204 transform: translate(6px, 6px); 205 box-shadow: 0 0 0 #26242b; 206 } 207</style>`; 208 209if (buttonCodeEl) { 210 const highlighted = highlightHTMLCSS(buttonCodeRaw); 211 buttonCodeEl.innerHTML = highlighted; 212} 213 214// Auto-fill redirect URI with current page URL 215const currentUrl = window.location.origin + window.location.pathname; 216redirectUriInput.value = currentUrl; 217 218// Auto-fill client ID with a test URL 219clientIdInput.value = window.location.origin; 220 221// Update documentation examples with current origin 222const origin = window.location.origin; 223const authUrlEl = document.getElementById("authUrl"); 224const tokenUrlEl = document.getElementById("tokenUrl"); 225const profileMeUrlEl = document.getElementById("profileMeUrl"); 226 227if (authUrlEl) authUrlEl.textContent = `${origin}/auth/authorize`; 228if (tokenUrlEl) tokenUrlEl.textContent = `${origin}/auth/token`; 229if (profileMeUrlEl) profileMeUrlEl.textContent = `"${origin}/u/username"`; 230 231// Check if we're handling a callback 232const urlParams = new URLSearchParams(window.location.search); 233const code = urlParams.get("code"); 234const state = urlParams.get("state"); 235const error = urlParams.get("error"); 236 237if (error) { 238 // OAuth error response 239 showResult( 240 `Error: ${error}\n${urlParams.get("error_description") || ""}`, 241 "error", 242 ); 243 resultSection.style.display = "block"; 244} else if (code && state) { 245 // We have a callback with authorization code 246 handleCallback(code, state); 247} 248 249// Start OAuth flow 250startBtn.addEventListener("click", async () => { 251 const clientId = clientIdInput.value.trim(); 252 const redirectUri = redirectUriInput.value.trim(); 253 254 if (!clientId || !redirectUri) { 255 alert("Please fill in client ID and redirect URI"); 256 return; 257 } 258 259 // Get selected scopes 260 const scopeCheckboxes = document.querySelectorAll( 261 'input[name="scope"]:checked', 262 ); 263 const scopes = Array.from(scopeCheckboxes).map( 264 (cb) => (cb as HTMLInputElement).value, 265 ); 266 267 if (scopes.length === 0) { 268 alert("Please select at least one scope"); 269 return; 270 } 271 272 // Generate PKCE parameters 273 const codeVerifier = generateRandomString(64); 274 const codeChallenge = await sha256(codeVerifier); 275 const state = generateRandomString(32); 276 277 // Store PKCE values in localStorage for callback 278 localStorage.setItem("oauth_code_verifier", codeVerifier); 279 localStorage.setItem("oauth_state", state); 280 localStorage.setItem("oauth_client_id", clientId); 281 localStorage.setItem("oauth_redirect_uri", redirectUri); 282 283 // Build authorization URL 284 const authUrl = new URL("/auth/authorize", window.location.origin); 285 authUrl.searchParams.set("response_type", "code"); 286 authUrl.searchParams.set("client_id", clientId); 287 authUrl.searchParams.set("redirect_uri", redirectUri); 288 authUrl.searchParams.set("state", state); 289 authUrl.searchParams.set("code_challenge", codeChallenge); 290 authUrl.searchParams.set("code_challenge_method", "S256"); 291 authUrl.searchParams.set("scope", scopes.join(" ")); 292 293 // Redirect to authorization endpoint 294 window.location.href = authUrl.toString(); 295}); 296 297// Handle OAuth callback 298function handleCallback(code: string, state: string) { 299 const storedState = localStorage.getItem("oauth_state"); 300 301 if (state !== storedState) { 302 showResult("Error: State mismatch (CSRF attack?)", "error"); 303 resultSection.style.display = "block"; 304 return; 305 } 306 307 callbackSection.style.display = "block"; 308 callbackInfo.innerHTML = ` 309 <p style="margin-bottom: 1rem;"><strong>Authorization Code:</strong><br><code style="word-break: break-all;">${code}</code></p> 310 <p><strong>State:</strong> <code>${state}</code> ✓ (verified)</p> 311 `; 312 313 // Scroll to callback section 314 callbackSection.scrollIntoView({ behavior: "smooth" }); 315} 316 317// Exchange authorization code for user profile 318exchangeBtn.addEventListener("click", async () => { 319 const code = urlParams.get("code"); 320 const codeVerifier = localStorage.getItem("oauth_code_verifier"); 321 const clientId = localStorage.getItem("oauth_client_id"); 322 const redirectUri = localStorage.getItem("oauth_redirect_uri"); 323 324 if (!code || !codeVerifier || !clientId || !redirectUri) { 325 showResult("Error: Missing OAuth parameters", "error"); 326 resultSection.style.display = "block"; 327 return; 328 } 329 330 exchangeBtn.disabled = true; 331 exchangeBtn.textContent = "exchanging..."; 332 333 try { 334 const response = await fetch("/auth/token", { 335 method: "POST", 336 headers: { 337 "Content-Type": "application/json", 338 }, 339 body: JSON.stringify({ 340 grant_type: "authorization_code", 341 code, 342 client_id: clientId, 343 redirect_uri: redirectUri, 344 code_verifier: codeVerifier, 345 }), 346 }); 347 348 const data = await response.json(); 349 350 if (!response.ok) { 351 showResult( 352 `Error: ${data.error}\n${data.error_description || ""}`, 353 "error", 354 ); 355 } else { 356 showResult( 357 `Success! User authenticated:\n\n${JSON.stringify(data, null, 2)}`, 358 "success", 359 ); 360 361 // Clean up localStorage 362 localStorage.removeItem("oauth_code_verifier"); 363 localStorage.removeItem("oauth_state"); 364 localStorage.removeItem("oauth_client_id"); 365 localStorage.removeItem("oauth_redirect_uri"); 366 } 367 } catch (error) { 368 showResult(`Error: ${(error as Error).message}`, "error"); 369 } finally { 370 exchangeBtn.disabled = false; 371 exchangeBtn.textContent = "exchange code for profile"; 372 resultSection.style.display = "block"; 373 resultSection.scrollIntoView({ behavior: "smooth" }); 374 } 375}); 376 377function showResult(text: string, type: "success" | "error") { 378 if (type === "success" && text.includes("{")) { 379 // Extract and parse JSON from success message 380 const jsonStart = text.indexOf("{"); 381 const jsonStr = text.substring(jsonStart); 382 const prefix = text.substring(0, jsonStart).trim(); 383 384 try { 385 const data = JSON.parse(jsonStr); 386 const formattedJson = JSON.stringify(data, null, 2); 387 388 // Apply custom JSON syntax highlighting 389 const highlightedJson = highlightJSON(formattedJson); 390 391 resultDiv.innerHTML = `<strong style="color: var(--berry-crush); font-size: 1.125rem; display: block; margin-bottom: 1rem;">${prefix}</strong><pre style="margin: 0;"><code>${highlightedJson}</code></pre>`; 392 } catch { 393 resultDiv.textContent = text; 394 } 395 } else { 396 resultDiv.textContent = text; 397 } 398 resultDiv.className = `result show ${type}`; 399} 400 401// Convert HTML documentation to Markdown by parsing the DOM 402function extractMarkdown(): string { 403 const lines: string[] = []; 404 405 // Get title and subtitle from header 406 const h1 = document.querySelector("header h1"); 407 const subtitle = document.querySelector("header .subtitle"); 408 409 if (h1) { 410 lines.push(`# ${h1.textContent}`); 411 lines.push(""); 412 } 413 414 if (subtitle) { 415 lines.push(subtitle.textContent || ""); 416 lines.push(""); 417 } 418 419 // Process each section (skip TOC and OAuth tester) 420 const sections = document.querySelectorAll(".section"); 421 422 sections.forEach((section) => { 423 // Skip the OAuth tester section 424 if (section.id === "tester") return; 425 426 processElement(section, lines); 427 lines.push(""); 428 }); 429 430 return lines.join("\n"); 431} 432 433function processElement(el: Element, lines: string[], indent = 0): void { 434 const tag = el.tagName.toLowerCase(); 435 436 // Headers 437 if (tag === "h2") { 438 lines.push(`## ${el.textContent}`); 439 lines.push(""); 440 } else if (tag === "h3") { 441 lines.push(`### ${el.textContent}`); 442 lines.push(""); 443 } 444 // Paragraphs 445 else if (tag === "p") { 446 lines.push(el.textContent || ""); 447 lines.push(""); 448 } 449 // Lists 450 else if (tag === "ul" || tag === "ol") { 451 const items = el.querySelectorAll(":scope > li"); 452 items.forEach((li, i) => { 453 const prefix = tag === "ol" ? `${i + 1}. ` : "- "; 454 const text = getTextContent(li); 455 lines.push(`${prefix}${text}`); 456 }); 457 lines.push(""); 458 } 459 // Tables 460 else if (tag === "table") { 461 const headers: string[] = []; 462 const rows: string[][] = []; 463 464 // Get headers 465 el.querySelectorAll("thead th").forEach((th) => { 466 headers.push(th.textContent?.trim() || ""); 467 }); 468 469 // Get rows 470 el.querySelectorAll("tbody tr").forEach((tr) => { 471 const row: string[] = []; 472 tr.querySelectorAll("td").forEach((td) => { 473 row.push(td.textContent?.trim() || ""); 474 }); 475 rows.push(row); 476 }); 477 478 // Format as markdown table 479 if (headers.length > 0) { 480 lines.push(`| ${headers.join(" | ")} |`); 481 lines.push(`|${headers.map(() => "-------").join("|")}|`); 482 rows.forEach((row) => { 483 lines.push(`| ${row.join(" | ")} |`); 484 }); 485 lines.push(""); 486 } 487 } 488 // Code blocks 489 else if (tag === "pre") { 490 const code = el.querySelector("code"); 491 if (code) { 492 // Detect language from class or content 493 let lang = ""; 494 const text = code.textContent || ""; 495 496 if (text.includes("GET ") || text.includes("POST ")) { 497 lang = "http"; 498 } else if (text.includes("{") && text.includes('"')) { 499 lang = "json"; 500 } 501 502 lines.push(`\`\`\`${lang}`); 503 lines.push(text.trim()); 504 lines.push("```"); 505 lines.push(""); 506 } 507 } 508 // Info boxes 509 else if (el.classList.contains("info-box")) { 510 const strong = el.querySelector("strong"); 511 const text = el.textContent?.trim() || ""; 512 513 if (strong) { 514 // Extract content after the strong tag 515 const afterStrong = text 516 .substring(strong.textContent?.length || 0) 517 .trim(); 518 lines.push(`> **${strong.textContent}** ${afterStrong}`); 519 } else { 520 lines.push(`> ${text}`); 521 } 522 lines.push(""); 523 } 524 // Process children for sections and divs 525 else if (tag === "section" || tag === "div") { 526 Array.from(el.children).forEach((child) => { 527 processElement(child, lines, indent); 528 }); 529 } 530} 531 532// Get text content, preserving inline code formatting 533function getTextContent(el: Element): string { 534 let text = ""; 535 536 el.childNodes.forEach((node) => { 537 if (node.nodeType === Node.TEXT_NODE) { 538 text += node.textContent; 539 } else if (node.nodeType === Node.ELEMENT_NODE) { 540 const elem = node as Element; 541 if (elem.tagName.toLowerCase() === "code") { 542 text += `\`${elem.textContent}\``; 543 } else if (elem.tagName.toLowerCase() === "strong") { 544 text += `**${elem.textContent}**`; 545 } else { 546 text += elem.textContent; 547 } 548 } 549 }); 550 551 return text.trim(); 552} 553 554// Copy markdown to clipboard 555copyMarkdownBtn.addEventListener("click", async () => { 556 const markdown = extractMarkdown(); 557 558 try { 559 await navigator.clipboard.writeText(markdown); 560 copyMarkdownBtn.textContent = "copied! ✓"; 561 setTimeout(() => { 562 copyMarkdownBtn.textContent = "copy as markdown"; 563 }, 2000); 564 } catch (error) { 565 console.error("Failed to copy:", error); 566 alert("Failed to copy to clipboard"); 567 } 568}); 569 570// Copy button code to clipboard 571copyButtonCodeBtn.addEventListener("click", async () => { 572 try { 573 await navigator.clipboard.writeText(buttonCodeRaw); 574 copyButtonCodeBtn.textContent = "copied! ✓"; 575 setTimeout(() => { 576 copyButtonCodeBtn.textContent = "copy button code"; 577 }, 2000); 578 } catch (error) { 579 console.error("Failed to copy:", error); 580 alert("Failed to copy to clipboard"); 581 } 582}); 583 584// Add interactive hover effect to demo button 585demoButton.addEventListener("click", (e) => { 586 e.preventDefault(); 587});