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="#oidc">openid connect (oidc)</a></li>
581 <li><a href="#getting-started">getting started</a></li>
582 <li><a href="#button">sign in button</a></li>
583 <li><a href="#endpoints">endpoints</a></li>
584 <li><a href="#authorization">authorization flow</a>
585 <ul style="margin-top: 0.5rem; margin-left: 1.5rem;">
586 <li><a href="#authorization" style="font-size: 0.9rem;">discovery</a></li>
587 </ul>
588 </li>
589 <li><a href="#tokens">token management</a>
590 <ul style="margin-top: 0.5rem; margin-left: 1.5rem;">
591 <li><a href="#tokens-refresh" style="font-size: 0.9rem;">refresh tokens</a></li>
592 <li><a href="#tokens-introspect" style="font-size: 0.9rem;">introspection</a></li>
593 <li><a href="#tokens-revoke" style="font-size: 0.9rem;">revocation</a></li>
594 <li><a href="#tokens-userinfo" style="font-size: 0.9rem;">userinfo</a></li>
595 </ul>
596 </li>
597 <li><a href="#scopes">scopes</a></li>
598 <li><a href="#roles">roles</a></li>
599 <li><a href="#clients">client types</a></li>
600 <li><a href="#tester">oauth tester</a></li>
601 </ul>
602 </nav>
603
604 <section id="overview" class="section">
605 <h2>overview</h2>
606 <p>
607 Indiko is a self-hosted IndieAuth/OAuth 2.0 authorization server with passwordless authentication using WebAuthn
608 passkeys.
609 It provides single sign-on (SSO) for your apps and services.
610 </p>
611
612 <h3>key features</h3>
613 <ul>
614 <li>Passwordless authentication via WebAuthn passkeys</li>
615 <li>Full IndieAuth and OAuth 2.0 support with PKCE</li>
616 <li>OpenID Connect (OIDC) support with ID tokens</li>
617 <li>Access tokens and refresh tokens for API access</li>
618 <li>Token introspection and revocation endpoints</li>
619 <li>UserInfo endpoint for profile data</li>
620 <li>Auto-registration of OAuth clients</li>
621 <li>Pre-registered clients with secrets and role management</li>
622 <li>Session-based SSO (authenticate once, authorize many apps)</li>
623 <li>User profile endpoints with h-card microformats</li>
624 <li>Invite-based user registration</li>
625 </ul>
626 </section>
627
628 <section id="oidc" class="section">
629 <h2>openid connect (oidc)</h2>
630 <p>
631 Indiko supports OpenID Connect (OIDC) for modern authentication flows, enabling "Sign in with Indiko" for any OIDC-compatible application.
632 </p>
633
634 <h3>oidc endpoints</h3>
635 <table>
636 <thead>
637 <tr>
638 <th>Endpoint</th>
639 <th>Description</th>
640 </tr>
641 </thead>
642 <tbody>
643 <tr>
644 <td><code>/.well-known/openid-configuration</code></td>
645 <td>OIDC discovery document</td>
646 </tr>
647 <tr>
648 <td><code>/jwks</code></td>
649 <td>JSON Web Key Set for ID token verification</td>
650 </tr>
651 <tr>
652 <td><code>/auth/authorize</code></td>
653 <td>Authorization endpoint (same as OAuth 2.0)</td>
654 </tr>
655 <tr>
656 <td><code>/auth/token</code></td>
657 <td>Token endpoint (returns ID token when <code>openid</code> scope requested)</td>
658 </tr>
659 <tr>
660 <td><code>/userinfo</code></td>
661 <td>OIDC userinfo endpoint</td>
662 </tr>
663 </tbody>
664 </table>
665
666 <h3>key features</h3>
667 <ul>
668 <li>Authorization Code Flow with PKCE</li>
669 <li>ID Token with RS256 signing</li>
670 <li>Support for <code>openid</code>, <code>profile</code>, and <code>email</code> scopes</li>
671 <li>Automatic key generation and management</li>
672 <li>Standards-compliant discovery document</li>
673 </ul>
674
675 <h3>id token claims</h3>
676 <p>
677 When the <code>openid</code> scope is requested, the token endpoint returns an ID token (JWT) containing:
678 </p>
679 <ul>
680 <li><code>iss</code> - Issuer (Indiko server URL)</li>
681 <li><code>sub</code> - Subject (user identifier)</li>
682 <li><code>aud</code> - Audience (client ID)</li>
683 <li><code>exp</code> - Expiration time</li>
684 <li><code>iat</code> - Issued at time</li>
685 <li><code>auth_time</code> - Authentication time</li>
686 <li><code>nonce</code> - Nonce (if provided in authorization request)</li>
687 <li><code>name</code>, <code>email</code>, <code>picture</code>, <code>website</code> - User claims (based on granted scopes)</li>
688 </ul>
689
690 <div class="info-box">
691 <strong>Testing:</strong>
692 You can test your OIDC setup using the <a href="https://oidcdebugger.com/" target="_blank" rel="noopener noreferrer">OIDC Debugger</a>. Set the discovery endpoint and use PKCE with SHA-256.
693 </div>
694 </section>
695
696 <section id="getting-started" class="section">
697 <h2>getting started</h2>
698
699 <h3>for app developers</h3>
700 <p>
701 To integrate with Indiko as an OAuth client, you'll need:
702 </p>
703 <ol>
704 <li>A <strong>client ID</strong> (any valid URL, e.g., <code>https://myapp.example.com</code>)</li>
705 <li>A <strong>redirect URI</strong> (where users return after authorization)</li>
706 <li>Support for PKCE (code challenge/verifier)</li>
707 </ol>
708
709 <div class="info-box">
710 <strong>Auto-registration:</strong>
711 Apps are automatically registered on first use. You don't need admin approval to get started.
712 During registration, Indiko fetches your client metadata from your <code>client_id</code> URL to validate redirect URIs and display your app name/logo.
713 For advanced features like client secrets and role assignment, contact your Indiko admin to pre-register your app.
714 </div>
715
716 <h3>publishing client metadata (recommended)</h3>
717 <p>
718 To help Indiko verify your app and display proper branding, publish client metadata as JSON at your <code>client_id</code> URL:
719 </p>
720 <pre><code>{
721 "client_id": "https://myapp.example.com/",
722 "client_name": "My App",
723 "logo_uri": "https://myapp.example.com/logo.png",
724 "redirect_uris": [
725 "https://myapp.example.com/callback",
726 "https://myapp.example.com/auth/callback"
727 ]
728}</code></pre>
729 <p>
730 Alternatively, you can publish redirect URIs as HTML <code><link></code> tags:
731 </p>
732 <pre><code><link rel="redirect_uri" href="https://myapp.example.com/callback" /></code></pre>
733
734 <div class="info-box">
735 <strong>Security:</strong>
736 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.
737 </div>
738
739 <h3>for users</h3>
740 <p>
741 You'll need an invite code to create an account. Once registered:
742 </p>
743 <ul>
744 <li>Set up your passkey (fingerprint, face ID, or security key)</li>
745 <li>Complete your profile (name, photo, website)</li>
746 <li>Authorize apps to access your profile</li>
747 <li>Manage app permissions from your dashboard</li>
748 </ul>
749 </section>
750
751 <section id="button" class="section">
752 <h2>sign in button</h2>
753 <p>
754 Copy this themed button for your app's login page. It matches Indiko's visual style:
755 </p>
756
757 <div class="demo-button-wrapper">
758 <a href="#" id="demoButton" class="indiko-demo-button">Sign in with Indiko</a>
759 </div>
760
761 <h3>HTML + CSS</h3>
762 <pre><code id="buttonCode"></code></pre>
763
764 <button id="copyButtonCode" class="copy-btn">copy button code</button>
765
766 <div class="info-box">
767 <strong>Customization:</strong>
768 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.
769 </div>
770 </section>
771
772 <section id="endpoints" class="section">
773 <h2>API endpoints</h2>
774
775 <h3>authorization endpoints</h3>
776 <table>
777 <thead>
778 <tr>
779 <th>Endpoint</th>
780 <th>Method</th>
781 <th>Description</th>
782 </tr>
783 </thead>
784 <tbody>
785 <tr>
786 <td><code>/.well-known/oauth-authorization-server</code></td>
787 <td>GET</td>
788 <td>IndieAuth server metadata (discovery endpoint)</td>
789 </tr>
790 <tr>
791 <td><code>/auth/authorize</code></td>
792 <td>GET</td>
793 <td>Start OAuth authorization flow</td>
794 </tr>
795 <tr>
796 <td><code>/auth/authorize</code></td>
797 <td>POST</td>
798 <td>Submit consent/scope approval</td>
799 </tr>
800 <tr>
801 <td><code>/auth/token</code></td>
802 <td>POST</td>
803 <td>Exchange code for access token and refresh token</td>
804 </tr>
805 <tr>
806 <td><code>/auth/token/introspect</code></td>
807 <td>POST</td>
808 <td>Verify access token validity</td>
809 </tr>
810 <tr>
811 <td><code>/auth/token/revoke</code></td>
812 <td>POST</td>
813 <td>Revoke access or refresh token</td>
814 </tr>
815 <tr>
816 <td><code>/userinfo</code></td>
817 <td>GET</td>
818 <td>Get user profile data with bearer token</td>
819 </tr>
820 <tr>
821 <td><code>/u/:username</code></td>
822 <td>GET</td>
823 <td>Public user profile (h-card with discovery links)</td>
824 </tr>
825 </tbody>
826 </table>
827
828 <h3>authentication endpoints</h3>
829 <table>
830 <thead>
831 <tr>
832 <th>Endpoint</th>
833 <th>Method</th>
834 <th>Description</th>
835 </tr>
836 </thead>
837 <tbody>
838 <tr>
839 <td><code>/auth/can-register</code></td>
840 <td>POST</td>
841 <td>Check if invite code is valid</td>
842 </tr>
843 <tr>
844 <td><code>/auth/register/options</code></td>
845 <td>POST</td>
846 <td>Get WebAuthn registration options</td>
847 </tr>
848 <tr>
849 <td><code>/auth/register/verify</code></td>
850 <td>POST</td>
851 <td>Complete passkey registration</td>
852 </tr>
853 <tr>
854 <td><code>/auth/login/options</code></td>
855 <td>POST</td>
856 <td>Get WebAuthn login options</td>
857 </tr>
858 <tr>
859 <td><code>/auth/login/verify</code></td>
860 <td>POST</td>
861 <td>Complete passkey login</td>
862 </tr>
863 <tr>
864 <td><code>/auth/logout</code></td>
865 <td>POST</td>
866 <td>End current session</td>
867 </tr>
868 </tbody>
869 </table>
870 </section>
871
872 <section id="authorization" class="section">
873 <h2>authorization flow</h2>
874
875 <h3>0. discovery (recommended)</h3>
876 <p>
877 Before starting authorization, clients should discover the authorization server's endpoints from the user's profile URL:
878 </p>
879 <ol>
880 <li>Fetch the user's profile URL (e.g., <code id="discoveryUrl">http://localhost:3000/u/username</code>)</li>
881 <li>Look for <code><link rel="indieauth-metadata"></code> tag or HTTP <code>Link:</code> header</li>
882 <li>Fetch the metadata endpoint to get <code>authorization_endpoint</code> and <code>token_endpoint</code></li>
883 </ol>
884 <p>
885 The metadata endpoint returns:
886 </p>
887 <pre><code>{
888 <span class="json-key">"issuer"</span>: <span class="json-string" id="metadataIssuer">"http://localhost:3000"</span>,
889 <span class="json-key">"authorization_endpoint"</span>: <span class="json-string" id="metadataAuthEndpoint">"http://localhost:3000/auth/authorize"</span>,
890 <span class="json-key">"token_endpoint"</span>: <span class="json-string" id="metadataTokenEndpoint">"http://localhost:3000/auth/token"</span>,
891 <span class="json-key">"code_challenge_methods_supported"</span>: [<span class="json-string">"S256"</span>],
892 <span class="json-key">"scopes_supported"</span>: [<span class="json-string">"profile"</span>, <span class="json-string">"email"</span>]
893}</code></pre>
894
895 <h3>1. redirect to authorization endpoint</h3>
896 <pre><code><span class="http-method">GET</span> <span class="http-url" id="authUrl">http://localhost:3000/auth/authorize</span>?<span
897 class="http-param">response_type</span>=code
898 &<span class="http-param">client_id</span>=https://myapp.example.com
899 &<span class="http-param">redirect_uri</span>=https://myapp.example.com/callback
900 &<span class="http-param">state</span>=random_state_string
901 &<span class="http-param">code_challenge</span>=base64url_encoded_challenge
902 &<span class="http-param">code_challenge_method</span>=S256
903 &<span class="http-param">scope</span>=profile email</code></pre>
904
905 <div class="info-box">
906 <strong>PKCE is required:</strong>
907 Generate a random <code>code_verifier</code> (43-128 characters), then create <code>code_challenge</code> by
908 hashing it with SHA-256 and base64url encoding.
909 </div>
910
911 <h3>2. user authenticates and approves</h3>
912 <p>
913 Indiko will:
914 </p>
915 <ul>
916 <li>Check if user has an active session (if not, prompt for passkey login)</li>
917 <li>Show consent screen with requested scopes</li>
918 <li>Auto-approve if user previously authorized this app</li>
919 </ul>
920
921 <h3>3. redirect back with code</h3>
922 <pre><code><span class="http-url">https://myapp.example.com/callback</span>?<span
923 class="http-param">code</span>=short_lived_authorization_code
924 &<span class="http-param">state</span>=random_state_string
925 &<span class="http-param">iss</span>=<span class="http-url" id="issuerUrl">http://localhost:3000</span></code></pre>
926
927 <div class="info-box">
928 <strong>Security:</strong>
929 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.
930 </div>
931
932 <h3>4. exchange code for token</h3>
933 <pre><code><span class="http-method">POST</span> <span class="http-url" id="tokenUrl">http://localhost:3000/auth/token</span>
934 <span class="http-header">Content-Type</span>: application/x-www-form-urlencoded
935
936 <span class="http-param">grant_type</span>=authorization_code
937 &<span class="http-param">code</span>=authorization_code
938 &<span class="http-param">client_id</span>=https://myapp.example.com
939 &<span class="http-param">redirect_uri</span>=https://myapp.example.com/callback
940 &<span class="http-param">code_verifier</span>=original_code_verifier
941 &<span class="http-param">client_secret</span>=your_client_secret (if pre-registered)</code></pre>
942
943 <div class="info-box">
944 <strong>Client authentication:</strong>
945 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.
946 </div>
947
948 <h3>5. receive tokens and user profile</h3>
949 <pre><code>{
950 <span class="json-key">"access_token"</span>: <span class="json-string">"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."</span>,
951 <span class="json-key">"token_type"</span>: <span class="json-string">"Bearer"</span>,
952 <span class="json-key">"expires_in"</span>: <span class="json-number">3600</span>,
953 <span class="json-key">"refresh_token"</span>: <span class="json-string">"RT_abc123xyz..."</span>,
954 <span class="json-key">"me"</span>: <span class="json-string" id="profileMeUrl">"http://localhost:3000/u/username"</span>,
955 <span class="json-key">"profile"</span>: {
956 <span class="json-key">"name"</span>: <span class="json-string">"Jane Doe"</span>,
957 <span class="json-key">"email"</span>: <span class="json-string">"jane@example.com"</span>,
958 <span class="json-key">"photo"</span>: <span class="json-string">"https://example.com/photo.jpg"</span>,
959 <span class="json-key">"url"</span>: <span class="json-string">"https://jane.example.com"</span>
960 },
961 <span class="json-key">"scope"</span>: <span class="json-string">"profile email"</span>,
962 <span class="json-key">"iss"</span>: <span class="json-string" id="issuerUrl2">"http://localhost:3000"</span>,
963 <span class="json-key">"role"</span>: <span class="json-string">"admin"</span>
964}</code></pre>
965
966 <div class="info-box">
967 <strong>Token types:</strong>
968 <ul style="margin-top: 0.5rem; margin-bottom: 0;">
969 <li><code>access_token</code> - Short-lived token (1 hour) for API access</li>
970 <li><code>refresh_token</code> - Long-lived token (30 days) for getting new access tokens</li>
971 </ul>
972 </div>
973
974 <div class="info-box">
975 <strong>Roles:</strong>
976 If an admin has assigned a role to this user for your app, it will be included in the response. Roles are
977 arbitrary strings that you can use for role-based access control (RBAC) in your application.
978 </div>
979 </section>
980
981 <section id="tokens" class="section">
982 <h2>token management</h2>
983 <p>
984 Indiko provides a complete OAuth 2.0 token management system with access tokens, refresh tokens, introspection, and revocation.
985 </p>
986
987 <h3 id="tokens-refresh">refresh tokens</h3>
988 <p>
989 Access tokens expire after 1 hour. Use the refresh token to get a new access token without requiring user re-authentication:
990 </p>
991
992 <pre><code><span class="http-method">POST</span> <span class="http-url" id="tokenRefreshUrl">http://localhost:3000/auth/token</span>
993<span class="http-header">Content-Type</span>: application/x-www-form-urlencoded
994
995<span class="http-param">grant_type</span>=refresh_token
996&<span class="http-param">refresh_token</span>=RT_abc123xyz...
997&<span class="http-param">client_id</span>=https://myapp.example.com</code></pre>
998
999 <p>Response:</p>
1000 <pre><code>{
1001 <span class="json-key">"access_token"</span>: <span class="json-string">"new_access_token..."</span>,
1002 <span class="json-key">"token_type"</span>: <span class="json-string">"Bearer"</span>,
1003 <span class="json-key">"expires_in"</span>: <span class="json-number">3600</span>,
1004 <span class="json-key">"me"</span>: <span class="json-string">"http://localhost:3000/u/username"</span>,
1005 <span class="json-key">"scope"</span>: <span class="json-string">"profile email"</span>,
1006 <span class="json-key">"iss"</span>: <span class="json-string">"http://localhost:3000"</span>
1007}</code></pre>
1008
1009 <div class="info-box">
1010 <strong>Important:</strong>
1011 <ul style="margin-top: 0.5rem; margin-bottom: 0;">
1012 <li>Refresh tokens are valid for 30 days</li>
1013 <li>Each refresh request generates a new access token</li>
1014 <li>The refresh token itself remains valid (no rotation)</li>
1015 <li>Store refresh tokens securely - they provide long-term access</li>
1016 </ul>
1017 </div>
1018
1019 <h3 id="tokens-introspect">token introspection</h3>
1020 <p>
1021 Resource servers can verify access tokens by calling the introspection endpoint:
1022 </p>
1023
1024 <pre><code><span class="http-method">POST</span> <span class="http-url" id="tokenIntrospectUrl">http://localhost:3000/auth/token/introspect</span>
1025<span class="http-header">Content-Type</span>: application/json
1026
1027{
1028 <span class="json-key">"token"</span>: <span class="json-string">"access_token_here"</span>
1029}</code></pre>
1030
1031 <p>Response (valid token):</p>
1032 <pre><code>{
1033 <span class="json-key">"active"</span>: <span class="json-boolean">true</span>,
1034 <span class="json-key">"me"</span>: <span class="json-string">"http://localhost:3000/u/username"</span>,
1035 <span class="json-key">"client_id"</span>: <span class="json-string">"https://myapp.example.com"</span>,
1036 <span class="json-key">"scope"</span>: <span class="json-string">"profile email"</span>,
1037 <span class="json-key">"exp"</span>: <span class="json-number">1640000000</span>,
1038 <span class="json-key">"iat"</span>: <span class="json-number">1639996400</span>
1039}</code></pre>
1040
1041 <p>Response (invalid token):</p>
1042 <pre><code>{
1043 <span class="json-key">"active"</span>: <span class="json-boolean">false</span>
1044}</code></pre>
1045
1046 <div class="info-box">
1047 <strong>Use case:</strong>
1048 Introspection is useful for resource servers (like Micropub endpoints) that need to verify tokens issued by Indiko.
1049 </div>
1050
1051 <h3 id="tokens-revoke">token revocation</h3>
1052 <p>
1053 Apps can revoke access or refresh tokens when users log out:
1054 </p>
1055
1056 <pre><code><span class="http-method">POST</span> <span class="http-url" id="tokenRevokeUrl">http://localhost:3000/auth/token/revoke</span>
1057<span class="http-header">Content-Type</span>: application/json
1058
1059{
1060 <span class="json-key">"token"</span>: <span class="json-string">"access_or_refresh_token_here"</span>
1061}</code></pre>
1062
1063 <p>Response: HTTP 200 (always returns success, even if token doesn't exist)</p>
1064
1065 <div class="info-box">
1066 <strong>Best practice:</strong>
1067 Always revoke tokens when users explicitly log out to prevent unauthorized access.
1068 </div>
1069
1070 <h3 id="tokens-userinfo">userinfo endpoint</h3>
1071 <p>
1072 Fetch updated user profile information using an access token:
1073 </p>
1074
1075 <pre><code><span class="http-method">GET</span> <span class="http-url" id="userinfoUrl">http://localhost:3000/userinfo</span>
1076<span class="http-header">Authorization</span>: Bearer access_token_here</code></pre>
1077
1078 <p>Response (with <code>profile</code> and <code>email</code> scopes):</p>
1079 <pre><code>{
1080 <span class="json-key">"name"</span>: <span class="json-string">"Jane Doe"</span>,
1081 <span class="json-key">"photo"</span>: <span class="json-string">"https://example.com/photo.jpg"</span>,
1082 <span class="json-key">"url"</span>: <span class="json-string">"https://jane.example.com"</span>,
1083 <span class="json-key">"email"</span>: <span class="json-string">"jane@example.com"</span>
1084}</code></pre>
1085
1086 <div class="info-box">
1087 <strong>Note:</strong>
1088 The response only includes data for scopes granted to the token. A token with only <code>profile</code> scope will not include email.
1089 </div>
1090 </section>
1091
1092 <section id="scopes" class="section">
1093 <h2>scopes</h2>
1094
1095 <table>
1096 <thead>
1097 <tr>
1098 <th>Scope</th>
1099 <th>Description</th>
1100 <th>Data Included</th>
1101 </tr>
1102 </thead>
1103 <tbody>
1104 <tr>
1105 <td><code>openid</code></td>
1106 <td>OpenID Connect authentication</td>
1107 <td>Triggers ID token issuance (OIDC only)</td>
1108 </tr>
1109 <tr>
1110 <td><code>profile</code></td>
1111 <td>Basic profile information</td>
1112 <td>name, photo, URL</td>
1113 </tr>
1114 <tr>
1115 <td><code>email</code></td>
1116 <td>Email address</td>
1117 <td>email</td>
1118 </tr>
1119 </tbody>
1120 </table>
1121
1122 <div class="info-box">
1123 <strong>Note:</strong>
1124 Users can selectively approve scopes during authorization. Your app may receive fewer scopes than requested. The <code>openid</code> scope is only relevant for OIDC flows and enables ID token issuance.
1125 </div>
1126 </section>
1127
1128 <section id="roles" class="section">
1129 <h2>roles</h2>
1130 <p>
1131 Roles enable role-based access control (RBAC) in your applications. <strong>Only pre-registered clients with client secrets support role assignment.</strong>
1132 </p>
1133
1134 <div class="info-box">
1135 <strong>Pre-registration required:</strong>
1136 To use roles, contact your Indiko admin to pre-register your app with a client secret. Auto-registered (public) clients cannot use roles.
1137 </div>
1138
1139 <h3>how roles work</h3>
1140 <ul>
1141 <li>Roles are assigned by admins for specific user-app combinations</li>
1142 <li>Role strings are arbitrary (e.g., <code>"admin"</code>, <code>"editor"</code>, <code>"viewer"</code>)</li>
1143 <li>Only one role per user per app</li>
1144 <li>Included in token response if assigned</li>
1145 <li>Your app interprets the role string and enforces permissions</li>
1146 </ul>
1147
1148 <div class="info-box">
1149 <strong>Example use case:</strong>
1150 A CMS app could use roles like <code>"admin"</code>, <code>"editor"</code>, and <code>"viewer"</code>. When
1151 users authenticate via Indiko, the app checks their role and grants appropriate permissions.
1152 </div>
1153
1154 <h3>defining app roles</h3>
1155 <p>
1156 Apps can define available roles in three ways:
1157 </p>
1158 <ul>
1159 <li><strong>Disabled (default):</strong> Leave "Available Roles" empty. No roles can be assigned.</li>
1160 <li><strong>Predefined roles:</strong> Specify allowed roles (one per line). Creates a dropdown for role selection, preventing typos.</li>
1161 <li><strong>Default role:</strong> Automatically assign a role when users first authorize your app.</li>
1162 </ul>
1163
1164 <h3>assigning roles</h3>
1165 <p>
1166 Roles can be assigned in multiple ways:
1167 </p>
1168 <ol>
1169 <li><strong>Default role (automatic):</strong> If configured, users automatically receive the default role on first authorization.</li>
1170 <li><strong>Via invite codes:</strong> Admins can create invites with pre-assigned roles for specific apps. New
1171 users automatically get those roles on signup.</li>
1172 <li><strong>Via admin dashboard:</strong> Admins can assign or change roles for existing users in the clients
1173 management interface.</li>
1174 </ol>
1175
1176 <div class="info-box">
1177 <strong>Note:</strong>
1178 Roles are optional. If no role is assigned, the <code>role</code> field will not appear in the token response.
1179 </div>
1180 </section>
1181
1182 <section id="clients" class="section">
1183 <h2>client types</h2>
1184
1185 <h3>auto-registered clients</h3>
1186 <p>
1187 Any app can use Indiko without pre-registration. On first authorization, Indiko will:
1188 </p>
1189 <ul>
1190 <li>Validate the client ID (must be a valid URL per IndieAuth spec)</li>
1191 <li>Fetch client metadata from the client_id URL (if available)</li>
1192 <li>Validate redirect_uri against published redirect_uris (if different host)</li>
1193 <li>Extract and store client name and logo (if provided)</li>
1194 <li>Automatically register the client for future use</li>
1195 </ul>
1196 <p>
1197 Auto-registered clients:
1198 </p>
1199 <ul>
1200 <li><strong>Client ID format:</strong> Any valid URL (e.g., <code>https://myapp.example.com</code>)</li>
1201 <li><strong>Authentication:</strong> MUST use PKCE only (no client secret)</li>
1202 <li><strong>Limitations:</strong> Cannot use client secrets or role assignment</li>
1203 </ul>
1204
1205 <div class="info-box">
1206 <strong>Security:</strong>
1207 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.
1208 </div>
1209
1210 <h3>pre-registered clients</h3>
1211 <p>
1212 Admins can pre-register clients for advanced features. <strong>All pre-registered clients require a client secret and must also use PKCE.</strong>
1213 </p>
1214 <p>
1215 Pre-registered clients:
1216 </p>
1217 <ul>
1218 <li><strong>Client ID format:</strong> Generated with <code>ikc_</code> prefix (e.g., <code>ikc_xxxxxxxxxxxxxxxxxxxxx</code>)</li>
1219 <li><strong>Client secret format:</strong> Generated with <code>iks_</code> prefix (shown once on creation)</li>
1220 <li><strong>Authentication:</strong> MUST use both PKCE AND client_secret in token requests</li>
1221 <li><strong>Role assignment:</strong> Admins can assign per-user roles for RBAC</li>
1222 <li><strong>Available roles:</strong> Define which roles can be assigned (enforces dropdown selection)</li>
1223 <li><strong>Default role:</strong> Automatically assigned to users on first authorization</li>
1224 <li><strong>Metadata:</strong> Custom name, logo, description</li>
1225 </ul>
1226
1227 <div class="info-box">
1228 <strong>Tip:</strong>
1229 Contact your Indiko admin to pre-register your app if you need client authentication or role-based access
1230 control.
1231 </div>
1232 </section>
1233
1234 <section id="tester" class="section">
1235 <h2>OAuth tester</h2>
1236 <p>
1237 Test the OAuth flow with a live interactive client. This simulates how your app would integrate with Indiko.
1238 </p>
1239
1240 <div id="testerForm">
1241 <label for="clientId">client id (your app's URL)</label>
1242 <input type="url" id="clientId" value="" placeholder="https://example.com" />
1243
1244 <label for="redirectUri">redirect uri (callback URL)</label>
1245 <input type="url" id="redirectUri" value="" placeholder="https://example.com/callback" />
1246
1247 <div class="checkbox-group">
1248 <label>scopes to request:</label>
1249 <label>
1250 <input type="checkbox" name="scope" value="profile" checked />
1251 <span>profile (name, photo, URL)</span>
1252 </label>
1253 <label>
1254 <input type="checkbox" name="scope" value="email" />
1255 <span>email</span>
1256 </label>
1257 </div>
1258
1259 <button id="startBtn">start oauth flow</button>
1260 </div>
1261
1262 <div id="callbackSection" style="display: none;">
1263 <h3>callback received</h3>
1264 <div class="info-box">
1265 You've been redirected back with an authorization code. Click below to exchange it for user data.
1266 </div>
1267 <div id="callbackInfo"></div>
1268 <button id="exchangeBtn">exchange code for profile</button>
1269 </div>
1270
1271 <div id="resultSection" style="display: none;">
1272 <h3>result</h3>
1273 <div id="result" class="result"></div>
1274 </div>
1275
1276 <div class="info-box" style="margin-top: 2rem;">
1277 <strong>How it works:</strong>
1278 This page uses the current URL as the redirect URI. After authorization, the code is automatically detected and
1279 you can exchange it for user profile data.
1280 </div>
1281 </section>
1282
1283 <div class="back-link">
1284 <a href="/">← back to dashboard</a>
1285 </div>
1286 </div>
1287
1288 <script type="module" src="../client/docs.ts"></script>
1289</body>
1290
1291</html>