my own indieAuth provider!
indiko.dunkirk.sh/docs
indieauth
oauth2-server
1// JSON syntax highlighter
2function highlightJSON(json: string): string {
3 return json
4 .replace(/&/g, "&")
5 .replace(/</g, "<")
6 .replace(/>/g, ">")
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, "&")
18 .replace(/</g, "<")
19 .replace(/>/g, ">");
20
21 // HTML comments
22 highlighted = highlighted.replace(
23 /<!--(.*?)-->/g,
24 '<span class="html-comment"><!--$1--></span>',
25 );
26
27 // Split by <style> tags to handle CSS separately
28 const parts = highlighted.split(/(<style>[\s\S]*?<\/style>)/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 /<(\/?)([\w-]+)([\s\S]*?)>/g,
37 (_match, slash, tag, attrs) => {
38 let result = `<${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 + ">";
52 return result;
53 },
54 );
55 } else {
56 // Process CSS (inside <style> tags)
57 return (
58 part
59 .replace(
60 /<style>/g,
61 '<<span class="html-tag">style</span>>',
62 )
63 .replace(
64 /<\/style>/g,
65 '</<span class="html-tag">style</span>>',
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});