my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server
at main 658 lines 16 kB view raw view rendered
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)