my own indieAuth provider!
indiko.dunkirk.sh/docs
indieauth
oauth2-server
1# indiko - IndieAuth Server Specification
2
3## Overview
4
5**indiko** is a centralized authentication and user management system for personal projects. It provides:
6- Passkey-based authentication (WebAuthn)
7- IndieAuth server implementation
8- User profile management
9- Per-app access control
10- Invite-based user registration
11
12## Core Concepts
13
14### Single Source of Truth
15- Authentication via passkeys
16- User profiles (name, email, picture, URL)
17- Authorization with per-app scoping
18- User management (admin + invite system)
19
20### Trust Model
21- First user becomes admin
22- Admin can create invite links
23- Apps auto-register on first use
24- Users grant/revoke app access via consent
25
26## User Identifier Format
27
28Users are identified by: `https://indiko.yourdomain.com/u/{username}`
29
30## Data Structures
31
32### Users
33```
34user:{username} -> {
35 credential: {
36 credentialID: Uint8Array,
37 publicKey: Uint8Array,
38 counter: number
39 },
40 isAdmin: boolean,
41 profile: {
42 name: string,
43 email: string,
44 photo: string, // URL
45 url: string // personal website
46 },
47 createdAt: timestamp
48}
49```
50
51### Admin Marker
52```
53admin:user -> username // marks first/admin user
54```
55
56### Sessions
57```
58session:{token} -> {
59 username: string,
60 expiresAt: timestamp
61}
62// TTL: 24 hours
63```
64
65### Apps
66
67There are two types of OAuth clients in indiko:
68
69#### Auto-registered Apps (IndieAuth)
70```
71app:{client_id} -> {
72 client_id: string, // e.g. "https://blog.kierank.dev" (any valid URL)
73 redirect_uris: string[],
74 is_preregistered: 0, // indicates auto-registered
75 first_seen: timestamp,
76 last_used: timestamp,
77 name?: string, // optional, from client metadata
78 logo_url?: string // optional, from client metadata
79}
80```
81
82**Features:**
83- Client ID is any valid URL per IndieAuth spec
84- No client secret (public client)
85- MUST use PKCE (code_verifier)
86- Automatically registered on first authorization
87- Metadata fetched from client_id URL
88- Cannot use role-based access control
89
90#### Pre-registered Apps (OAuth 2.0 with secrets)
91```
92app:{client_id} -> {
93 client_id: string, // e.g. "ikc_xxxxxxxxxxxxxxxxxxxxx" (generated ID)
94 redirect_uris: string[],
95 is_preregistered: 1, // indicates pre-registered
96 client_secret_hash: string, // SHA-256 hash of client secret
97 available_roles?: string[], // optional list of allowed roles
98 default_role?: string, // optional role auto-assigned on first auth
99 first_seen: timestamp,
100 last_used: timestamp,
101 name?: string,
102 logo_url?: string,
103 description?: string
104}
105```
106
107**Features:**
108- Client ID format: `ikc_` + 21 character nanoid
109- Client secret format: `iks_` + 43 character nanoid (shown once on creation)
110- MUST use PKCE (code_verifier) AND client_secret
111- Supports role-based access control
112- Admin-managed metadata
113- Created via admin interface
114
115### User Permissions (Per-App)
116```
117permission:{user_id}:{client_id} -> {
118 scopes: string[], // e.g. ["profile", "email"]
119 role?: string, // optional, only for pre-registered clients
120 granted_at: timestamp,
121 last_used: timestamp
122}
123```
124
125### Authorization Codes (Short-lived)
126```
127authcode:{code} -> {
128 username: string,
129 client_id: string,
130 redirect_uri: string,
131 scopes: string[],
132 code_challenge: string, // PKCE
133 expires_at: timestamp,
134 used: boolean
135}
136// TTL: 60 seconds
137// Single-use only
138```
139
140### Invites
141```
142invite:{code} -> {
143 code: string,
144 created_by: string, // admin username
145 created_at: timestamp,
146 used: boolean,
147 used_by?: string,
148 used_at?: timestamp
149}
150```
151
152### Challenges (WebAuthn)
153```
154challenge:{challenge} -> {
155 username: string,
156 type: "registration" | "authentication",
157 expires_at: timestamp
158}
159// TTL: 5 minutes
160```
161
162## Supported Scopes
163
164- `profile` - Name, photo, URL
165- `email` - Email address
166- (Future: custom scopes as needed)
167
168## Routes
169
170### Authentication (WebAuthn/Passkey)
171
172#### `GET /login`
173- Login/registration page
174- Shows passkey auth interface
175- First user: admin registration flow
176- With `?invite=CODE`: invite-based registration
177
178#### `GET /auth/can-register`
179- Check if open registration allowed
180- Returns `{ canRegister: boolean }`
181
182#### `POST /auth/register/options`
183- Generate WebAuthn registration options
184- Body: `{ username: string, inviteCode?: string }`
185- Validates invite code if not first user
186- Returns registration options
187
188#### `POST /auth/register/verify`
189- Verify WebAuthn registration response
190- Body: `{ username: string, response: RegistrationResponseJSON, inviteCode?: string }`
191- Creates user, stores credential
192- First user marked as admin
193- Returns `{ token: string, username: string }`
194
195#### `POST /auth/login/options`
196- Generate WebAuthn authentication options
197- Body: `{ username: string }`
198- Returns authentication options
199
200#### `POST /auth/login/verify`
201- Verify WebAuthn authentication response
202- Body: `{ username: string, response: AuthenticationResponseJSON }`
203- Creates session
204- Returns `{ token: string, username: string }`
205
206#### `POST /auth/logout`
207- Clear session
208- Requires: `Authorization: Bearer {token}`
209- Returns `{ success: true }`
210
211### IndieAuth Endpoints
212
213#### `GET /auth/authorize`
214Authorization request from client app
215
216**Query Parameters:**
217- `response_type=code` (required)
218- `client_id` (required) - App's URL
219- `redirect_uri` (required) - Callback URL
220- `state` (required) - CSRF protection
221- `code_challenge` (required) - PKCE challenge
222- `code_challenge_method=S256` (required)
223- `scope` (optional) - Space-separated scopes (default: "profile")
224- `me` (optional) - User's URL (hint)
225
226**Flow:**
2271. Validate parameters
2282. Auto-register app if not exists
2293. If no session → redirect to `/login`
2304. If session exists → show consent screen
2315. Check if user previously approved this app
232 - If yes → auto-approve (skip consent)
233 - If no → show consent screen
234
235**Response:**
236- HTML consent screen
237- Shows: app name, requested scopes
238- Buttons: "Allow" / "Deny"
239
240#### `POST /auth/authorize`
241Consent form submission (CSRF protected)
242
243**Body:**
244- `client_id` (required)
245- `redirect_uri` (required)
246- `state` (required)
247- `code_challenge` (required)
248- `scopes` (required)
249- `action` (required) - "allow" | "deny"
250
251**Flow:**
2521. Validate CSRF token
2532. Validate session
2543. If denied → redirect with error
2554. If allowed:
256 - Create authorization code
257 - Store permission grant
258 - Update app last_used
259 - Redirect to redirect_uri with code & state
260
261**Success Response:**
262```
263HTTP/1.1 302 Found
264Location: {redirect_uri}?code={authcode}&state={state}
265```
266
267**Error Response:**
268```
269HTTP/1.1 302 Found
270Location: {redirect_uri}?error=access_denied&state={state}
271```
272
273#### `POST /auth/token`
274Exchange authorization code for user identity (NOT CSRF protected)
275
276**Headers:**
277- `Content-Type: application/json`
278
279**Body:**
280```json
281{
282 "grant_type": "authorization_code",
283 "code": "authcode123",
284 "client_id": "https://blog.kierank.dev",
285 "redirect_uri": "https://blog.kierank.dev/auth/callback",
286 "code_verifier": "pkce_verifier_string"
287}
288```
289
290**Flow:**
2911. Validate authorization code exists
2922. Verify code not expired
2933. Verify code not already used
2944. Verify client_id matches
2955. Verify redirect_uri matches
2966. Verify PKCE code_verifier
2977. Mark code as used
2988. Return user identity + profile
299
300**Success Response:**
301```json
302{
303 "me": "https://indiko.yourdomain.com/u/kieran",
304 "profile": {
305 "name": "Kieran Klukas",
306 "email": "kieran@example.com",
307 "photo": "https://...",
308 "url": "https://kierank.dev"
309 }
310}
311```
312
313**Error Response:**
314```json
315{
316 "error": "invalid_grant",
317 "error_description": "Authorization code expired"
318}
319```
320
321#### `GET /auth/userinfo` (Optional)
322Get current user profile with bearer token
323
324**Headers:**
325- `Authorization: Bearer {access_token}`
326
327**Response:**
328```json
329{
330 "sub": "https://indiko.yourdomain.com/u/kieran",
331 "name": "Kieran Klukas",
332 "email": "kieran@example.com",
333 "picture": "https://...",
334 "website": "https://kierank.dev"
335}
336```
337
338### User Profile & Settings
339
340#### `GET /settings`
341User settings page (requires session)
342
343**Shows:**
344- Profile form (name, email, photo, URL)
345- Connected apps list
346- Revoke access buttons
347- (Admin only) Invite generation
348
349#### `POST /settings/profile`
350Update user profile
351
352**Body:**
353```json
354{
355 "name": "Kieran Klukas",
356 "email": "kieran@example.com",
357 "photo": "https://...",
358 "url": "https://kierank.dev"
359}
360```
361
362**Response:**
363```json
364{
365 "success": true,
366 "profile": { ... }
367}
368```
369
370#### `POST /settings/apps/:client_id/revoke`
371Revoke app access
372
373**Response:**
374```json
375{
376 "success": true
377}
378```
379
380#### `GET /u/:username`
381Public user profile page (h-card)
382
383**Response:**
384HTML page with microformats h-card:
385```html
386<div class="h-card">
387 <img class="u-photo" src="...">
388 <a class="p-name u-url" href="...">Kieran Klukas</a>
389 <a class="u-email" href="mailto:...">email</a>
390</div>
391```
392
393### Admin Endpoints
394
395#### `POST /api/invites/create`
396Create invite link (admin only)
397
398**Headers:**
399- `Authorization: Bearer {token}`
400
401**Response:**
402```json
403{
404 "inviteCode": "abc123xyz"
405}
406```
407
408Usage: `https://indiko.yourdomain.com/login?invite=abc123xyz`
409
410### Dashboard
411
412#### `GET /`
413Main dashboard (requires session)
414
415**Shows:**
416- User info
417- Test API button
418- (Admin only) Admin controls section
419 - Generate invite link button
420 - Invite display
421
422#### `GET /api/hello`
423Test endpoint (requires session)
424
425**Headers:**
426- `Authorization: Bearer {token}`
427
428**Response:**
429```json
430{
431 "message": "Hello kieran! You're authenticated with passkeys.",
432 "username": "kieran",
433 "isAdmin": true
434}
435```
436
437## Session Behavior
438
439### Single Sign-On
440- Once logged into indiko (valid session), subsequent app authorization requests:
441 - Skip passkey authentication
442 - Show consent screen directly
443 - If app previously approved, auto-approve
444- Session duration: 24 hours
445- Passkey required only when session expires
446
447### Security
448- PKCE required for all authorization flows
449- Authorization codes:
450 - Single-use only
451 - 60-second expiration
452 - Bound to client_id and redirect_uri
453- State parameter required for CSRF protection
454
455## Client Integration Example
456
457### 1. Initiate Authorization
458```javascript
459const params = new URLSearchParams({
460 response_type: 'code',
461 client_id: 'https://blog.kierank.dev',
462 redirect_uri: 'https://blog.kierank.dev/auth/callback',
463 state: generateRandomState(),
464 code_challenge: generatePKCEChallenge(verifier),
465 code_challenge_method: 'S256',
466 scope: 'profile email'
467});
468
469window.location.href = `https://indiko.yourdomain.com/auth/authorize?${params}`;
470```
471
472### 2. Handle Callback
473```javascript
474// At https://blog.kierank.dev/auth/callback?code=...&state=...
475const code = new URLSearchParams(window.location.search).get('code');
476const state = new URLSearchParams(window.location.search).get('state');
477
478// Verify state matches
479
480// Exchange code for profile
481const response = await fetch('https://indiko.yourdomain.com/auth/token', {
482 method: 'POST',
483 headers: { 'Content-Type': 'application/json' },
484 body: JSON.stringify({
485 grant_type: 'authorization_code',
486 code,
487 client_id: 'https://blog.kierank.dev',
488 redirect_uri: 'https://blog.kierank.dev/auth/callback',
489 code_verifier: storedVerifier
490 })
491});
492
493const { me, profile } = await response.json();
494// me: "https://indiko.yourdomain.com/u/kieran"
495// profile: { name, email, photo, url }
496
497// Create session for user
498```
499
500## OpenID Connect (OIDC) Support
501
502Indiko implements OpenID Connect Core 1.0 as an identity layer on top of OAuth 2.0, enabling "Sign in with Indiko" for any OIDC-compatible application.
503
504### Overview
505
506OIDC extends the existing OAuth 2.0 authorization flow by:
507- Adding the `openid` scope to request identity information
508- Returning an **ID Token** (signed JWT) alongside the authorization code exchange
509- Providing a standardized `/userinfo` endpoint
510- Publishing discovery metadata at `/.well-known/openid-configuration`
511
512### Supported Scopes
513
514| Scope | Claims Returned |
515|-------|-----------------|
516| `openid` | `sub`, `iss`, `aud`, `exp`, `iat`, `auth_time` |
517| `profile` | `name`, `picture`, `website` |
518| `email` | `email` |
519
520### OIDC Endpoints
521
522#### `GET /.well-known/openid-configuration`
523Discovery document for OIDC clients.
524
525**Response:**
526```json
527{
528 "issuer": "https://indiko.yourdomain.com",
529 "authorization_endpoint": "https://indiko.yourdomain.com/auth/authorize",
530 "token_endpoint": "https://indiko.yourdomain.com/auth/token",
531 "userinfo_endpoint": "https://indiko.yourdomain.com/auth/userinfo",
532 "jwks_uri": "https://indiko.yourdomain.com/jwks",
533 "scopes_supported": ["openid", "profile", "email"],
534 "response_types_supported": ["code"],
535 "grant_types_supported": ["authorization_code"],
536 "subject_types_supported": ["public"],
537 "id_token_signing_alg_values_supported": ["RS256"],
538 "token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
539 "claims_supported": ["sub", "iss", "aud", "exp", "iat", "auth_time", "name", "email", "picture", "website"],
540 "code_challenge_methods_supported": ["S256"]
541}
542```
543
544#### `GET /jwks`
545JSON Web Key Set containing the public key for ID Token verification.
546
547**Response:**
548```json
549{
550 "keys": [
551 {
552 "kty": "RSA",
553 "use": "sig",
554 "alg": "RS256",
555 "kid": "indiko-oidc-key-1",
556 "n": "...",
557 "e": "AQAB"
558 }
559 ]
560}
561```
562
563### ID Token
564
565When the `openid` scope is requested, the token endpoint returns an `id_token` JWT:
566
567**Token Endpoint Response (with openid scope):**
568```json
569{
570 "me": "https://indiko.yourdomain.com/u/kieran",
571 "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImluZGlrby1vaWRjLWtleS0xIn0...",
572 "profile": {
573 "name": "Kieran Klukas",
574 "email": "kieran@example.com",
575 "photo": "https://...",
576 "url": "https://kierank.dev"
577 }
578}
579```
580
581**ID Token Claims:**
582```json
583{
584 "iss": "https://indiko.yourdomain.com",
585 "sub": "https://indiko.yourdomain.com/u/kieran",
586 "aud": "https://blog.kierank.dev",
587 "exp": 1234567890,
588 "iat": 1234567800,
589 "auth_time": 1234567700,
590 "nonce": "abc123",
591 "name": "Kieran Klukas",
592 "email": "kieran@example.com",
593 "picture": "https://...",
594 "website": "https://kierank.dev"
595}
596```
597
598### OIDC Authorization Flow
599
6001. Client initiates authorization with `scope=openid profile email`
6012. User authenticates and consents (same as IndieAuth)
6023. Client receives authorization code
6034. Client exchanges code at `/auth/token` with `code_verifier`
6045. Token endpoint returns `id_token` JWT + profile data
6056. Client verifies `id_token` signature using keys from `/jwks`
606
607### Key Management
608
609- RSA 2048-bit key pair generated on first OIDC request
610- Private key stored in database (`oidc_keys` table)
611- Key rotation: manual via admin interface (future)
612- Key ID format: `indiko-oidc-key-{version}`
613
614### Data Structures
615
616#### OIDC Keys
617```
618oidc_keys -> {
619 id: number,
620 kid: string, // e.g. "indiko-oidc-key-1"
621 private_key: string, // PEM-encoded RSA private key
622 public_key: string, // PEM-encoded RSA public key
623 created_at: timestamp,
624 is_active: boolean
625}
626```
627
628#### Authorization Code (Extended)
629```
630authcode:{code} -> {
631 ...existing fields...,
632 nonce?: string, // OIDC nonce for replay protection
633 auth_time: timestamp // when user authenticated
634}
635```
636
637## Future Enhancements
638
639- Token endpoint for longer-lived access tokens
640- Refresh tokens
641- Client metadata endpoint discovery
642- Micropub support
643- WebSub notifications
644- Multiple passkey support per user
645- Email notifications for new logins
646- Audit log for admin
647- Rate limiting
648- Account recovery flow
649- OIDC key rotation via admin interface
650
651## Standards Compliance
652
653- [IndieAuth Specification](https://indieauth.spec.indieweb.org/)
654- [WebAuthn/FIDO2](https://www.w3.org/TR/webauthn-2/)
655- [OAuth 2.0 PKCE](https://tools.ietf.org/html/rfc7636)
656- [Microformats h-card](http://microformats.org/wiki/h-card)
657- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
658- [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html)