my own indieAuth provider!
indiko.dunkirk.sh/docs
indieauth
oauth2-server
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 <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
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" />
15
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" />
20 <link rel="preconnect" href="https://fonts.googleapis.com">
21 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
22 <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
23 <style>
24 :root {
25 --mahogany: #26242b;
26 --lavender: #d9d0de;
27 --old-rose: #bc8da0;
28 --rosewood: #a04668;
29 --berry-crush: #ab4967;
30 }
31
32 * {
33 margin: 0;
34 padding: 0;
35 box-sizing: border-box;
36 }
37
38 body {
39 font-family: "Space Grotesk", sans-serif;
40 background: var(--mahogany);
41 color: var(--lavender);
42 min-height: 100vh;
43 padding: 2.5rem 1.25rem;
44 }
45
46 .container {
47 max-width: 56.25rem;
48 margin: 0 auto;
49 }
50
51 header {
52 margin-bottom: 3rem;
53 }
54
55 h1 {
56 font-size: 2.5rem;
57 font-weight: 700;
58 background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood));
59 -webkit-background-clip: text;
60 -webkit-text-fill-color: transparent;
61 background-clip: text;
62 letter-spacing: -0.125rem;
63 margin-bottom: 0.5rem;
64 }
65
66 .subtitle {
67 color: var(--old-rose);
68 margin-bottom: 2rem;
69 font-size: 1.125rem;
70 font-weight: 300;
71 }
72
73 h2 {
74 font-size: 1.75rem;
75 font-weight: 600;
76 color: var(--lavender);
77 margin-top: 0;
78 margin-bottom: 1rem;
79 letter-spacing: -0.05rem;
80 }
81
82 h3 {
83 font-size: 1.25rem;
84 font-weight: 600;
85 color: var(--lavender);
86 margin-top: 0;
87 margin-bottom: 1rem;
88 }
89
90 p {
91 line-height: 1.8;
92 margin-bottom: 1rem;
93 color: var(--lavender);
94 }
95
96 .section {
97 background: rgba(188, 141, 160, 0.05);
98 border: 1px solid var(--old-rose);
99 padding: 2rem;
100 margin-bottom: 2rem;
101 }
102
103 .info-box {
104 background: rgba(188, 141, 160, 0.1);
105 border-left: 4px solid var(--berry-crush);
106 padding: 1.25rem;
107 margin: 1.5rem 0;
108 font-size: 0.9375rem;
109 color: var(--old-rose);
110 line-height: 1.8;
111 }
112
113 .info-box strong {
114 color: var(--lavender);
115 display: block;
116 margin-bottom: 0.5rem;
117 }
118
119 code {
120 background: rgba(12, 23, 19, 0.8);
121 padding: 0.25rem 0.5rem;
122 font-family: monospace;
123 color: var(--berry-crush);
124 font-size: 0.875rem;
125 border-radius: 2px;
126 }
127
128 pre {
129 background: rgba(12, 23, 19, 0.8);
130 border: 1px solid var(--rosewood);
131 padding: 1.5rem;
132 margin: 1.5rem 0;
133 overflow-x: auto;
134 line-height: 1.6;
135 }
136
137 pre code {
138 background: none;
139 padding: 0;
140 font-size: 0.875rem;
141 color: inherit;
142 }
143
144 /* Override Prism theme to match our colors */
145 pre[class*="language-"] {
146 background: rgba(12, 23, 19, 0.8);
147 border: 1px solid var(--rosewood);
148 }
149
150 /* HTTP syntax highlighting */
151 .http-method {
152 color: var(--berry-crush);
153 font-weight: 700;
154 }
155
156 .http-url {
157 color: #a5d6a7;
158 }
159
160 .http-header {
161 color: var(--old-rose);
162 }
163
164 .http-param {
165 color: #81c784;
166 }
167
168 /* JSON syntax highlighting */
169 .json-key {
170 color: var(--berry-crush);
171 }
172
173 .json-string {
174 color: #a5d6a7;
175 }
176
177 .json-number {
178 color: #81c784;
179 }
180
181 .json-boolean {
182 color: var(--old-rose);
183 }
184
185 /* HTML/CSS syntax highlighting */
186 .html-tag {
187 color: var(--berry-crush);
188 }
189
190 .html-attr {
191 color: var(--old-rose);
192 }
193
194 .html-string {
195 color: #a5d6a7;
196 }
197
198 .html-comment {
199 color: #7a7a7a;
200 font-style: italic;
201 }
202
203 .css-selector {
204 color: var(--berry-crush);
205 }
206
207 .css-property {
208 color: var(--old-rose);
209 }
210
211 .css-value {
212 color: #a5d6a7;
213 }
214
215 .css-unit {
216 color: #81c784;
217 }
218
219 .token.property,
220 .token.tag,
221 .token.boolean,
222 .token.number,
223 .token.constant,
224 .token.symbol {
225 color: #81c784;
226 }
227
228 .token.selector,
229 .token.attr-name,
230 .token.string,
231 .token.char,
232 .token.builtin {
233 color: #a5d6a7;
234 }
235
236 .token.punctuation {
237 color: var(--lavender);
238 }
239
240 .token.operator,
241 .token.entity,
242 .token.url,
243 .language-css .token.string,
244 .style .token.string {
245 color: var(--old-rose);
246 }
247
248 ul,
249 ol {
250 margin-left: 1.5rem;
251 margin-bottom: 1rem;
252 line-height: 1.8;
253 color: var(--lavender);
254 }
255
256 li {
257 margin-bottom: 0.5rem;
258 }
259
260 a {
261 color: var(--berry-crush);
262 text-decoration: none;
263 font-weight: 500;
264 }
265
266 a:hover {
267 text-decoration: underline;
268 }
269
270 table {
271 width: 100%;
272 border-collapse: collapse;
273 margin: 1.5rem 0;
274 }
275
276 th {
277 background: rgba(188, 141, 160, 0.2);
278 padding: 0.75rem;
279 text-align: left;
280 color: var(--lavender);
281 font-weight: 600;
282 border: 1px solid var(--old-rose);
283 }
284
285 td {
286 padding: 0.75rem;
287 border: 1px solid var(--old-rose);
288 color: var(--lavender);
289 }
290
291 tr:nth-child(even) {
292 background: rgba(188, 141, 160, 0.05);
293 }
294
295 .toc {
296 background: rgba(188, 141, 160, 0.05);
297 border: 1px solid var(--old-rose);
298 padding: 1.5rem;
299 margin-bottom: 2rem;
300 }
301
302 .toc h3 {
303 margin-top: 0;
304 margin-bottom: 1rem;
305 font-size: 1rem;
306 color: var(--old-rose);
307 text-transform: uppercase;
308 letter-spacing: 0.05rem;
309 }
310
311 .toc ul {
312 list-style: none;
313 margin: 0;
314 padding: 0;
315 }
316
317 .toc li {
318 margin-bottom: 0.5rem;
319 }
320
321 .toc a {
322 color: var(--lavender);
323 text-decoration: none;
324 transition: color 0.2s;
325 }
326
327 .toc a:hover {
328 color: var(--berry-crush);
329 text-decoration: underline;
330 }
331
332 .copy-btn {
333 display: block;
334 width: auto;
335 padding: 0.75rem 1.5rem;
336 font-size: 0.875rem;
337 margin: 2rem auto;
338 }
339
340 .back-link {
341 text-align: center;
342 margin-top: 3rem;
343 padding-top: 2rem;
344 border-top: 1px solid var(--old-rose);
345 }
346
347 /* OAuth Tester Styles */
348 label {
349 display: block;
350 color: var(--old-rose);
351 font-size: 0.875rem;
352 font-weight: 500;
353 margin-bottom: 0.5rem;
354 text-transform: uppercase;
355 letter-spacing: 0.05rem;
356 }
357
358 input[type="text"],
359 input[type="url"] {
360 width: 100%;
361 padding: 0.875rem 1rem;
362 background: rgba(12, 23, 19, 0.6);
363 border: 2px solid var(--rosewood);
364 border-radius: 0;
365 color: var(--lavender);
366 font-size: 1rem;
367 font-family: "Space Grotesk", sans-serif;
368 margin-bottom: 1.5rem;
369 transition: border-color 0.2s;
370 }
371
372 input:focus {
373 outline: none;
374 border-color: var(--berry-crush);
375 background: rgba(12, 23, 19, 0.8);
376 }
377
378 .checkbox-group {
379 margin-bottom: 1.5rem;
380 }
381
382 .checkbox-group label {
383 display: flex;
384 align-items: center;
385 gap: 0.75rem;
386 text-transform: none;
387 font-weight: 400;
388 margin-bottom: 0.75rem;
389 cursor: pointer;
390 padding: 0.5rem;
391 transition: background 0.2s;
392 }
393
394 .checkbox-group label:hover {
395 background: rgba(188, 141, 160, 0.1);
396 }
397
398 input[type="checkbox"] {
399 appearance: none;
400 width: 1.5rem;
401 height: 1.5rem;
402 border: 2px solid var(--old-rose);
403 background: rgba(12, 23, 19, 0.6);
404 cursor: pointer;
405 flex-shrink: 0;
406 position: relative;
407 transition: all 0.2s;
408 }
409
410 input[type="checkbox"]:checked {
411 background: var(--berry-crush);
412 border-color: var(--berry-crush);
413 }
414
415 input[type="checkbox"]:checked::after {
416 content: "✓";
417 position: absolute;
418 top: 50%;
419 left: 50%;
420 transform: translate(-50%, -50%);
421 color: var(--lavender);
422 font-size: 1rem;
423 font-weight: 700;
424 }
425
426 button {
427 position: relative;
428 padding: 1rem 2rem;
429 background: var(--berry-crush);
430 color: var(--lavender);
431 border: 4px solid var(--mahogany);
432 border-radius: 0;
433 font-size: 1rem;
434 font-weight: 700;
435 cursor: pointer;
436 font-family: "Space Grotesk", sans-serif;
437 transition: all 0.15s ease;
438 text-transform: uppercase;
439 letter-spacing: 0.1rem;
440 box-shadow: 6px 6px 0 var(--mahogany);
441 width: 100%;
442 }
443
444 button::before {
445 content: '';
446 position: absolute;
447 top: -4px;
448 left: -4px;
449 right: -4px;
450 bottom: -4px;
451 background: transparent;
452 border: 4px solid var(--rosewood);
453 pointer-events: none;
454 transition: all 0.15s ease;
455 }
456
457 button:hover:not(:disabled) {
458 transform: translate(3px, 3px);
459 box-shadow: 3px 3px 0 var(--mahogany);
460 }
461
462 button:hover:not(:disabled)::before {
463 top: -7px;
464 left: -7px;
465 right: -7px;
466 bottom: -7px;
467 }
468
469 button:active:not(:disabled) {
470 transform: translate(6px, 6px);
471 box-shadow: 0 0 0 var(--mahogany);
472 }
473
474 button:disabled {
475 opacity: 0.5;
476 cursor: not-allowed;
477 }
478
479 .result {
480 background: rgba(12, 23, 19, 0.6);
481 border: 2px solid var(--rosewood);
482 padding: 1.5rem;
483 margin-top: 1.5rem;
484 font-family: monospace;
485 font-size: 0.875rem;
486 white-space: pre-wrap;
487 word-break: break-all;
488 display: none;
489 }
490
491 .result.show {
492 display: block;
493 }
494
495 .result.success {
496 border-color: #81c784;
497 background: rgba(139, 195, 74, 0.1);
498 }
499
500 .result.error {
501 border-color: var(--rosewood);
502 background: rgba(160, 70, 104, 0.1);
503 }
504
505 /* Demo button styles */
506 .demo-button-wrapper {
507 background: rgba(12, 23, 19, 0.6);
508 padding: 2rem;
509 margin: 1.5rem 0;
510 display: flex;
511 justify-content: center;
512 }
513
514 .indiko-demo-button {
515 position: relative;
516 display: inline-block;
517 padding: 1rem 2rem;
518 background: var(--berry-crush);
519 color: var(--lavender);
520 border: 4px solid var(--mahogany);
521 font-size: 1rem;
522 font-weight: 700;
523 text-decoration: none;
524 font-family: "Space Grotesk", sans-serif;
525 text-transform: uppercase;
526 letter-spacing: 0.1rem;
527 box-shadow: 6px 6px 0 var(--mahogany);
528 transition: all 0.15s ease;
529 }
530
531 .indiko-demo-button::before {
532 content: '';
533 position: absolute;
534 top: -4px;
535 left: -4px;
536 right: -4px;
537 bottom: -4px;
538 background: transparent;
539 border: 4px solid var(--rosewood);
540 pointer-events: none;
541 transition: all 0.15s ease;
542 }
543
544 .indiko-demo-button:hover {
545 transform: translate(3px, 3px);
546 box-shadow: 3px 3px 0 var(--mahogany);
547 }
548
549 .indiko-demo-button:hover::before {
550 top: -7px;
551 left: -7px;
552 right: -7px;
553 bottom: -7px;
554 }
555
556 .indiko-demo-button:active {
557 transform: translate(6px, 6px);
558 box-shadow: 0 0 0 var(--mahogany);
559 }
560
561 .indiko-demo-button:hover {
562 text-decoration: none;
563 }
564 </style>
565</head>
566
567<body>
568 <div class="container">
569 <header>
570 <h1>indiko documentation</h1>
571 <p class="subtitle">IndieAuth/OAuth 2.0 server with passkey authentication</p>
572 </header>
573
574 <button id="copyMarkdownBtn" class="copy-btn">copy as markdown</button>
575
576 <nav class="toc">
577 <h3>table of contents</h3>
578 <ul>
579 <li><a href="#overview">overview</a></li>
580 <li><a href="#getting-started">getting started</a></li>
581 <li><a href="#button">sign in button</a></li>
582 <li><a href="#endpoints">endpoints</a></li>
583 <li><a href="#authorization">authorization flow</a>
584 <ul style="margin-top: 0.5rem; margin-left: 1.5rem;">
585 <li><a href="#authorization" style="font-size: 0.9rem;">discovery</a></li>
586 </ul>
587 </li>
588 <li><a href="#tokens">token management</a>
589 <ul style="margin-top: 0.5rem; margin-left: 1.5rem;">
590 <li><a href="#tokens-refresh" style="font-size: 0.9rem;">refresh tokens</a></li>
591 <li><a href="#tokens-introspect" style="font-size: 0.9rem;">introspection</a></li>
592 <li><a href="#tokens-revoke" style="font-size: 0.9rem;">revocation</a></li>
593 <li><a href="#tokens-userinfo" style="font-size: 0.9rem;">userinfo</a></li>
594 </ul>
595 </li>
596 <li><a href="#scopes">scopes</a></li>
597 <li><a href="#roles">roles</a></li>
598 <li><a href="#clients">client types</a></li>
599 <li><a href="#tester">oauth tester</a></li>
600 </ul>
601 </nav>
602
603 <section id="overview" class="section">
604 <h2>overview</h2>
605 <p>
606 Indiko is a self-hosted IndieAuth/OAuth 2.0 authorization server with passwordless authentication using WebAuthn
607 passkeys.
608 It provides single sign-on (SSO) for your apps and services.
609 </p>
610
611 <h3>key features</h3>
612 <ul>
613 <li>Passwordless authentication via WebAuthn passkeys</li>
614 <li>Full IndieAuth and OAuth 2.0 support with PKCE</li>
615 <li>Access tokens and refresh tokens for API access</li>
616 <li>Token introspection and revocation endpoints</li>
617 <li>UserInfo endpoint for profile data</li>
618 <li>Auto-registration of OAuth clients</li>
619 <li>Pre-registered clients with secrets and role management</li>
620 <li>Session-based SSO (authenticate once, authorize many apps)</li>
621 <li>User profile endpoints with h-card microformats</li>
622 <li>Invite-based user registration</li>
623 </ul>
624 </section>
625
626 <section id="getting-started" class="section">
627 <h2>getting started</h2>
628
629 <h3>for app developers</h3>
630 <p>
631 To integrate with Indiko as an OAuth client, you'll need:
632 </p>
633 <ol>
634 <li>A <strong>client ID</strong> (any valid URL, e.g., <code>https://myapp.example.com</code>)</li>
635 <li>A <strong>redirect URI</strong> (where users return after authorization)</li>
636 <li>Support for PKCE (code challenge/verifier)</li>
637 </ol>
638
639 <div class="info-box">
640 <strong>Auto-registration:</strong>
641 Apps are automatically registered on first use. You don't need admin approval to get started.
642 During registration, Indiko fetches your client metadata from your <code>client_id</code> URL to validate redirect URIs and display your app name/logo.
643 For advanced features like client secrets and role assignment, contact your Indiko admin to pre-register your app.
644 </div>
645
646 <h3>publishing client metadata (recommended)</h3>
647 <p>
648 To help Indiko verify your app and display proper branding, publish client metadata as JSON at your <code>client_id</code> URL:
649 </p>
650 <pre><code>{
651 "client_id": "https://myapp.example.com/",
652 "client_name": "My App",
653 "logo_uri": "https://myapp.example.com/logo.png",
654 "redirect_uris": [
655 "https://myapp.example.com/callback",
656 "https://myapp.example.com/auth/callback"
657 ]
658}</code></pre>
659 <p>
660 Alternatively, you can publish redirect URIs as HTML <code><link></code> tags:
661 </p>
662 <pre><code><link rel="redirect_uri" href="https://myapp.example.com/callback" /></code></pre>
663
664 <div class="info-box">
665 <strong>Security:</strong>
666 If your <code>redirect_uri</code> uses a different host than your <code>client_id</code>, you MUST publish <code>redirect_uris</code> in your client metadata. This prevents unauthorized apps from hijacking your client_id.
667 </div>
668
669 <h3>for users</h3>
670 <p>
671 You'll need an invite code to create an account. Once registered:
672 </p>
673 <ul>
674 <li>Set up your passkey (fingerprint, face ID, or security key)</li>
675 <li>Complete your profile (name, photo, website)</li>
676 <li>Authorize apps to access your profile</li>
677 <li>Manage app permissions from your dashboard</li>
678 </ul>
679 </section>
680
681 <section id="button" class="section">
682 <h2>sign in button</h2>
683 <p>
684 Copy this themed button for your app's login page. It matches Indiko's visual style:
685 </p>
686
687 <div class="demo-button-wrapper">
688 <a href="#" id="demoButton" class="indiko-demo-button">Sign in with Indiko</a>
689 </div>
690
691 <h3>HTML + CSS</h3>
692 <pre><code id="buttonCode"></code></pre>
693
694 <button id="copyButtonCode" class="copy-btn">copy button code</button>
695
696 <div class="info-box">
697 <strong>Customization:</strong>
698 Replace <code>YOUR_OAUTH_URL_HERE</code> with your authorization URL (see <a href="#authorization">authorization flow</a> below). You can also change the button text or adjust colors to match your app's theme.
699 </div>
700 </section>
701
702 <section id="endpoints" class="section">
703 <h2>API endpoints</h2>
704
705 <h3>authorization endpoints</h3>
706 <table>
707 <thead>
708 <tr>
709 <th>Endpoint</th>
710 <th>Method</th>
711 <th>Description</th>
712 </tr>
713 </thead>
714 <tbody>
715 <tr>
716 <td><code>/.well-known/oauth-authorization-server</code></td>
717 <td>GET</td>
718 <td>IndieAuth server metadata (discovery endpoint)</td>
719 </tr>
720 <tr>
721 <td><code>/auth/authorize</code></td>
722 <td>GET</td>
723 <td>Start OAuth authorization flow</td>
724 </tr>
725 <tr>
726 <td><code>/auth/authorize</code></td>
727 <td>POST</td>
728 <td>Submit consent/scope approval</td>
729 </tr>
730 <tr>
731 <td><code>/auth/token</code></td>
732 <td>POST</td>
733 <td>Exchange code for access token and refresh token</td>
734 </tr>
735 <tr>
736 <td><code>/auth/token/introspect</code></td>
737 <td>POST</td>
738 <td>Verify access token validity</td>
739 </tr>
740 <tr>
741 <td><code>/auth/token/revoke</code></td>
742 <td>POST</td>
743 <td>Revoke access or refresh token</td>
744 </tr>
745 <tr>
746 <td><code>/userinfo</code></td>
747 <td>GET</td>
748 <td>Get user profile data with bearer token</td>
749 </tr>
750 <tr>
751 <td><code>/u/:username</code></td>
752 <td>GET</td>
753 <td>Public user profile (h-card with discovery links)</td>
754 </tr>
755 </tbody>
756 </table>
757
758 <h3>authentication endpoints</h3>
759 <table>
760 <thead>
761 <tr>
762 <th>Endpoint</th>
763 <th>Method</th>
764 <th>Description</th>
765 </tr>
766 </thead>
767 <tbody>
768 <tr>
769 <td><code>/auth/can-register</code></td>
770 <td>POST</td>
771 <td>Check if invite code is valid</td>
772 </tr>
773 <tr>
774 <td><code>/auth/register/options</code></td>
775 <td>POST</td>
776 <td>Get WebAuthn registration options</td>
777 </tr>
778 <tr>
779 <td><code>/auth/register/verify</code></td>
780 <td>POST</td>
781 <td>Complete passkey registration</td>
782 </tr>
783 <tr>
784 <td><code>/auth/login/options</code></td>
785 <td>POST</td>
786 <td>Get WebAuthn login options</td>
787 </tr>
788 <tr>
789 <td><code>/auth/login/verify</code></td>
790 <td>POST</td>
791 <td>Complete passkey login</td>
792 </tr>
793 <tr>
794 <td><code>/auth/logout</code></td>
795 <td>POST</td>
796 <td>End current session</td>
797 </tr>
798 </tbody>
799 </table>
800 </section>
801
802 <section id="authorization" class="section">
803 <h2>authorization flow</h2>
804
805 <h3>0. discovery (recommended)</h3>
806 <p>
807 Before starting authorization, clients should discover the authorization server's endpoints from the user's profile URL:
808 </p>
809 <ol>
810 <li>Fetch the user's profile URL (e.g., <code id="discoveryUrl">http://localhost:3000/u/username</code>)</li>
811 <li>Look for <code><link rel="indieauth-metadata"></code> tag or HTTP <code>Link:</code> header</li>
812 <li>Fetch the metadata endpoint to get <code>authorization_endpoint</code> and <code>token_endpoint</code></li>
813 </ol>
814 <p>
815 The metadata endpoint returns:
816 </p>
817 <pre><code>{
818 <span class="json-key">"issuer"</span>: <span class="json-string" id="metadataIssuer">"http://localhost:3000"</span>,
819 <span class="json-key">"authorization_endpoint"</span>: <span class="json-string" id="metadataAuthEndpoint">"http://localhost:3000/auth/authorize"</span>,
820 <span class="json-key">"token_endpoint"</span>: <span class="json-string" id="metadataTokenEndpoint">"http://localhost:3000/auth/token"</span>,
821 <span class="json-key">"code_challenge_methods_supported"</span>: [<span class="json-string">"S256"</span>],
822 <span class="json-key">"scopes_supported"</span>: [<span class="json-string">"profile"</span>, <span class="json-string">"email"</span>]
823}</code></pre>
824
825 <h3>1. redirect to authorization endpoint</h3>
826 <pre><code><span class="http-method">GET</span> <span class="http-url" id="authUrl">http://localhost:3000/auth/authorize</span>?<span
827 class="http-param">response_type</span>=code
828 &<span class="http-param">client_id</span>=https://myapp.example.com
829 &<span class="http-param">redirect_uri</span>=https://myapp.example.com/callback
830 &<span class="http-param">state</span>=random_state_string
831 &<span class="http-param">code_challenge</span>=base64url_encoded_challenge
832 &<span class="http-param">code_challenge_method</span>=S256
833 &<span class="http-param">scope</span>=profile email</code></pre>
834
835 <div class="info-box">
836 <strong>PKCE is required:</strong>
837 Generate a random <code>code_verifier</code> (43-128 characters), then create <code>code_challenge</code> by
838 hashing it with SHA-256 and base64url encoding.
839 </div>
840
841 <h3>2. user authenticates and approves</h3>
842 <p>
843 Indiko will:
844 </p>
845 <ul>
846 <li>Check if user has an active session (if not, prompt for passkey login)</li>
847 <li>Show consent screen with requested scopes</li>
848 <li>Auto-approve if user previously authorized this app</li>
849 </ul>
850
851 <h3>3. redirect back with code</h3>
852 <pre><code><span class="http-url">https://myapp.example.com/callback</span>?<span
853 class="http-param">code</span>=short_lived_authorization_code
854 &<span class="http-param">state</span>=random_state_string
855 &<span class="http-param">iss</span>=<span class="http-url" id="issuerUrl">http://localhost:3000</span></code></pre>
856
857 <div class="info-box">
858 <strong>Security:</strong>
859 The <code>iss</code> (issuer) parameter allows you to verify the response came from the expected authorization server. Compare it to the <code>issuer</code> from the metadata endpoint.
860 </div>
861
862 <h3>4. exchange code for token</h3>
863 <pre><code><span class="http-method">POST</span> <span class="http-url" id="tokenUrl">http://localhost:3000/auth/token</span>
864 <span class="http-header">Content-Type</span>: application/x-www-form-urlencoded
865
866 <span class="http-param">grant_type</span>=authorization_code
867 &<span class="http-param">code</span>=authorization_code
868 &<span class="http-param">client_id</span>=https://myapp.example.com
869 &<span class="http-param">redirect_uri</span>=https://myapp.example.com/callback
870 &<span class="http-param">code_verifier</span>=original_code_verifier
871 &<span class="http-param">client_secret</span>=your_client_secret (if pre-registered)</code></pre>
872
873 <div class="info-box">
874 <strong>Client authentication:</strong>
875 All clients MUST use PKCE (code_verifier) per the IndieAuth specification. Pre-registered confidential clients should also include <code>client_secret</code> in the token request for additional security.
876 </div>
877
878 <h3>5. receive tokens and user profile</h3>
879 <pre><code>{
880 <span class="json-key">"access_token"</span>: <span class="json-string">"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."</span>,
881 <span class="json-key">"token_type"</span>: <span class="json-string">"Bearer"</span>,
882 <span class="json-key">"expires_in"</span>: <span class="json-number">3600</span>,
883 <span class="json-key">"refresh_token"</span>: <span class="json-string">"RT_abc123xyz..."</span>,
884 <span class="json-key">"me"</span>: <span class="json-string" id="profileMeUrl">"http://localhost:3000/u/username"</span>,
885 <span class="json-key">"profile"</span>: {
886 <span class="json-key">"name"</span>: <span class="json-string">"Jane Doe"</span>,
887 <span class="json-key">"email"</span>: <span class="json-string">"jane@example.com"</span>,
888 <span class="json-key">"photo"</span>: <span class="json-string">"https://example.com/photo.jpg"</span>,
889 <span class="json-key">"url"</span>: <span class="json-string">"https://jane.example.com"</span>
890 },
891 <span class="json-key">"scope"</span>: <span class="json-string">"profile email"</span>,
892 <span class="json-key">"iss"</span>: <span class="json-string" id="issuerUrl2">"http://localhost:3000"</span>,
893 <span class="json-key">"role"</span>: <span class="json-string">"admin"</span>
894}</code></pre>
895
896 <div class="info-box">
897 <strong>Token types:</strong>
898 <ul style="margin-top: 0.5rem; margin-bottom: 0;">
899 <li><code>access_token</code> - Short-lived token (1 hour) for API access</li>
900 <li><code>refresh_token</code> - Long-lived token (30 days) for getting new access tokens</li>
901 </ul>
902 </div>
903
904 <div class="info-box">
905 <strong>Roles:</strong>
906 If an admin has assigned a role to this user for your app, it will be included in the response. Roles are
907 arbitrary strings that you can use for role-based access control (RBAC) in your application.
908 </div>
909 </section>
910
911 <section id="tokens" class="section">
912 <h2>token management</h2>
913 <p>
914 Indiko provides a complete OAuth 2.0 token management system with access tokens, refresh tokens, introspection, and revocation.
915 </p>
916
917 <h3 id="tokens-refresh">refresh tokens</h3>
918 <p>
919 Access tokens expire after 1 hour. Use the refresh token to get a new access token without requiring user re-authentication:
920 </p>
921
922 <pre><code><span class="http-method">POST</span> <span class="http-url" id="tokenRefreshUrl">http://localhost:3000/auth/token</span>
923<span class="http-header">Content-Type</span>: application/x-www-form-urlencoded
924
925<span class="http-param">grant_type</span>=refresh_token
926&<span class="http-param">refresh_token</span>=RT_abc123xyz...
927&<span class="http-param">client_id</span>=https://myapp.example.com</code></pre>
928
929 <p>Response:</p>
930 <pre><code>{
931 <span class="json-key">"access_token"</span>: <span class="json-string">"new_access_token..."</span>,
932 <span class="json-key">"token_type"</span>: <span class="json-string">"Bearer"</span>,
933 <span class="json-key">"expires_in"</span>: <span class="json-number">3600</span>,
934 <span class="json-key">"me"</span>: <span class="json-string">"http://localhost:3000/u/username"</span>,
935 <span class="json-key">"scope"</span>: <span class="json-string">"profile email"</span>,
936 <span class="json-key">"iss"</span>: <span class="json-string">"http://localhost:3000"</span>
937}</code></pre>
938
939 <div class="info-box">
940 <strong>Important:</strong>
941 <ul style="margin-top: 0.5rem; margin-bottom: 0;">
942 <li>Refresh tokens are valid for 30 days</li>
943 <li>Each refresh request generates a new access token</li>
944 <li>The refresh token itself remains valid (no rotation)</li>
945 <li>Store refresh tokens securely - they provide long-term access</li>
946 </ul>
947 </div>
948
949 <h3 id="tokens-introspect">token introspection</h3>
950 <p>
951 Resource servers can verify access tokens by calling the introspection endpoint:
952 </p>
953
954 <pre><code><span class="http-method">POST</span> <span class="http-url" id="tokenIntrospectUrl">http://localhost:3000/auth/token/introspect</span>
955<span class="http-header">Content-Type</span>: application/json
956
957{
958 <span class="json-key">"token"</span>: <span class="json-string">"access_token_here"</span>
959}</code></pre>
960
961 <p>Response (valid token):</p>
962 <pre><code>{
963 <span class="json-key">"active"</span>: <span class="json-boolean">true</span>,
964 <span class="json-key">"me"</span>: <span class="json-string">"http://localhost:3000/u/username"</span>,
965 <span class="json-key">"client_id"</span>: <span class="json-string">"https://myapp.example.com"</span>,
966 <span class="json-key">"scope"</span>: <span class="json-string">"profile email"</span>,
967 <span class="json-key">"exp"</span>: <span class="json-number">1640000000</span>,
968 <span class="json-key">"iat"</span>: <span class="json-number">1639996400</span>
969}</code></pre>
970
971 <p>Response (invalid token):</p>
972 <pre><code>{
973 <span class="json-key">"active"</span>: <span class="json-boolean">false</span>
974}</code></pre>
975
976 <div class="info-box">
977 <strong>Use case:</strong>
978 Introspection is useful for resource servers (like Micropub endpoints) that need to verify tokens issued by Indiko.
979 </div>
980
981 <h3 id="tokens-revoke">token revocation</h3>
982 <p>
983 Apps can revoke access or refresh tokens when users log out:
984 </p>
985
986 <pre><code><span class="http-method">POST</span> <span class="http-url" id="tokenRevokeUrl">http://localhost:3000/auth/token/revoke</span>
987<span class="http-header">Content-Type</span>: application/json
988
989{
990 <span class="json-key">"token"</span>: <span class="json-string">"access_or_refresh_token_here"</span>
991}</code></pre>
992
993 <p>Response: HTTP 200 (always returns success, even if token doesn't exist)</p>
994
995 <div class="info-box">
996 <strong>Best practice:</strong>
997 Always revoke tokens when users explicitly log out to prevent unauthorized access.
998 </div>
999
1000 <h3 id="tokens-userinfo">userinfo endpoint</h3>
1001 <p>
1002 Fetch updated user profile information using an access token:
1003 </p>
1004
1005 <pre><code><span class="http-method">GET</span> <span class="http-url" id="userinfoUrl">http://localhost:3000/userinfo</span>
1006<span class="http-header">Authorization</span>: Bearer access_token_here</code></pre>
1007
1008 <p>Response (with <code>profile</code> and <code>email</code> scopes):</p>
1009 <pre><code>{
1010 <span class="json-key">"name"</span>: <span class="json-string">"Jane Doe"</span>,
1011 <span class="json-key">"photo"</span>: <span class="json-string">"https://example.com/photo.jpg"</span>,
1012 <span class="json-key">"url"</span>: <span class="json-string">"https://jane.example.com"</span>,
1013 <span class="json-key">"email"</span>: <span class="json-string">"jane@example.com"</span>
1014}</code></pre>
1015
1016 <div class="info-box">
1017 <strong>Note:</strong>
1018 The response only includes data for scopes granted to the token. A token with only <code>profile</code> scope will not include email.
1019 </div>
1020 </section>
1021
1022 <section id="scopes" class="section">
1023 <h2>scopes</h2>
1024
1025 <table>
1026 <thead>
1027 <tr>
1028 <th>Scope</th>
1029 <th>Description</th>
1030 <th>Data Included</th>
1031 </tr>
1032 </thead>
1033 <tbody>
1034 <tr>
1035 <td><code>profile</code></td>
1036 <td>Basic profile information</td>
1037 <td>name, photo, URL</td>
1038 </tr>
1039 <tr>
1040 <td><code>email</code></td>
1041 <td>Email address</td>
1042 <td>email</td>
1043 </tr>
1044 </tbody>
1045 </table>
1046
1047 <div class="info-box">
1048 <strong>Note:</strong>
1049 Users can selectively approve scopes during authorization. Your app may receive fewer scopes than requested.
1050 </div>
1051 </section>
1052
1053 <section id="roles" class="section">
1054 <h2>roles</h2>
1055 <p>
1056 Roles enable role-based access control (RBAC) in your applications. <strong>Only pre-registered clients with client secrets support role assignment.</strong>
1057 </p>
1058
1059 <div class="info-box">
1060 <strong>Pre-registration required:</strong>
1061 To use roles, contact your Indiko admin to pre-register your app with a client secret. Auto-registered (public) clients cannot use roles.
1062 </div>
1063
1064 <h3>how roles work</h3>
1065 <ul>
1066 <li>Roles are assigned by admins for specific user-app combinations</li>
1067 <li>Role strings are arbitrary (e.g., <code>"admin"</code>, <code>"editor"</code>, <code>"viewer"</code>)</li>
1068 <li>Only one role per user per app</li>
1069 <li>Included in token response if assigned</li>
1070 <li>Your app interprets the role string and enforces permissions</li>
1071 </ul>
1072
1073 <div class="info-box">
1074 <strong>Example use case:</strong>
1075 A CMS app could use roles like <code>"admin"</code>, <code>"editor"</code>, and <code>"viewer"</code>. When
1076 users authenticate via Indiko, the app checks their role and grants appropriate permissions.
1077 </div>
1078
1079 <h3>defining app roles</h3>
1080 <p>
1081 Apps can define available roles in three ways:
1082 </p>
1083 <ul>
1084 <li><strong>Disabled (default):</strong> Leave "Available Roles" empty. No roles can be assigned.</li>
1085 <li><strong>Predefined roles:</strong> Specify allowed roles (one per line). Creates a dropdown for role selection, preventing typos.</li>
1086 <li><strong>Default role:</strong> Automatically assign a role when users first authorize your app.</li>
1087 </ul>
1088
1089 <h3>assigning roles</h3>
1090 <p>
1091 Roles can be assigned in multiple ways:
1092 </p>
1093 <ol>
1094 <li><strong>Default role (automatic):</strong> If configured, users automatically receive the default role on first authorization.</li>
1095 <li><strong>Via invite codes:</strong> Admins can create invites with pre-assigned roles for specific apps. New
1096 users automatically get those roles on signup.</li>
1097 <li><strong>Via admin dashboard:</strong> Admins can assign or change roles for existing users in the clients
1098 management interface.</li>
1099 </ol>
1100
1101 <div class="info-box">
1102 <strong>Note:</strong>
1103 Roles are optional. If no role is assigned, the <code>role</code> field will not appear in the token response.
1104 </div>
1105 </section>
1106
1107 <section id="clients" class="section">
1108 <h2>client types</h2>
1109
1110 <h3>auto-registered clients</h3>
1111 <p>
1112 Any app can use Indiko without pre-registration. On first authorization, Indiko will:
1113 </p>
1114 <ul>
1115 <li>Validate the client ID (must be a valid URL per IndieAuth spec)</li>
1116 <li>Fetch client metadata from the client_id URL (if available)</li>
1117 <li>Validate redirect_uri against published redirect_uris (if different host)</li>
1118 <li>Extract and store client name and logo (if provided)</li>
1119 <li>Automatically register the client for future use</li>
1120 </ul>
1121 <p>
1122 Auto-registered clients:
1123 </p>
1124 <ul>
1125 <li><strong>Client ID format:</strong> Any valid URL (e.g., <code>https://myapp.example.com</code>)</li>
1126 <li><strong>Authentication:</strong> MUST use PKCE only (no client secret)</li>
1127 <li><strong>Limitations:</strong> Cannot use client secrets or role assignment</li>
1128 </ul>
1129
1130 <div class="info-box">
1131 <strong>Security:</strong>
1132 For redirect URIs on different hosts than your client_id, you must publish redirect_uris in your client metadata. See <a href="#getting-started">getting started</a> for details.
1133 </div>
1134
1135 <h3>pre-registered clients</h3>
1136 <p>
1137 Admins can pre-register clients for advanced features. <strong>All pre-registered clients require a client secret and must also use PKCE.</strong>
1138 </p>
1139 <p>
1140 Pre-registered clients:
1141 </p>
1142 <ul>
1143 <li><strong>Client ID format:</strong> Generated with <code>ikc_</code> prefix (e.g., <code>ikc_xxxxxxxxxxxxxxxxxxxxx</code>)</li>
1144 <li><strong>Client secret format:</strong> Generated with <code>iks_</code> prefix (shown once on creation)</li>
1145 <li><strong>Authentication:</strong> MUST use both PKCE AND client_secret in token requests</li>
1146 <li><strong>Role assignment:</strong> Admins can assign per-user roles for RBAC</li>
1147 <li><strong>Available roles:</strong> Define which roles can be assigned (enforces dropdown selection)</li>
1148 <li><strong>Default role:</strong> Automatically assigned to users on first authorization</li>
1149 <li><strong>Metadata:</strong> Custom name, logo, description</li>
1150 </ul>
1151
1152 <div class="info-box">
1153 <strong>Tip:</strong>
1154 Contact your Indiko admin to pre-register your app if you need client authentication or role-based access
1155 control.
1156 </div>
1157 </section>
1158
1159 <section id="tester" class="section">
1160 <h2>OAuth tester</h2>
1161 <p>
1162 Test the OAuth flow with a live interactive client. This simulates how your app would integrate with Indiko.
1163 </p>
1164
1165 <div id="testerForm">
1166 <label for="clientId">client id (your app's URL)</label>
1167 <input type="url" id="clientId" value="" placeholder="https://example.com" />
1168
1169 <label for="redirectUri">redirect uri (callback URL)</label>
1170 <input type="url" id="redirectUri" value="" placeholder="https://example.com/callback" />
1171
1172 <div class="checkbox-group">
1173 <label>scopes to request:</label>
1174 <label>
1175 <input type="checkbox" name="scope" value="profile" checked />
1176 <span>profile (name, photo, URL)</span>
1177 </label>
1178 <label>
1179 <input type="checkbox" name="scope" value="email" />
1180 <span>email</span>
1181 </label>
1182 </div>
1183
1184 <button id="startBtn">start oauth flow</button>
1185 </div>
1186
1187 <div id="callbackSection" style="display: none;">
1188 <h3>callback received</h3>
1189 <div class="info-box">
1190 You've been redirected back with an authorization code. Click below to exchange it for user data.
1191 </div>
1192 <div id="callbackInfo"></div>
1193 <button id="exchangeBtn">exchange code for profile</button>
1194 </div>
1195
1196 <div id="resultSection" style="display: none;">
1197 <h3>result</h3>
1198 <div id="result" class="result"></div>
1199 </div>
1200
1201 <div class="info-box" style="margin-top: 2rem;">
1202 <strong>How it works:</strong>
1203 This page uses the current URL as the redirect URI. After authorization, the code is automatically detected and
1204 you can exchange it for user profile data.
1205 </div>
1206 </section>
1207
1208 <div class="back-link">
1209 <a href="/">← back to dashboard</a>
1210 </div>
1211 </div>
1212
1213 <script type="module" src="../client/docs.ts"></script>
1214</body>
1215
1216</html>