indiko - IndieAuth Server Specification#
Overview#
indiko is a centralized authentication and user management system for personal projects. It provides:
- Passkey-based authentication (WebAuthn)
- IndieAuth server implementation
- User profile management
- Per-app access control
- Invite-based user registration
Core Concepts#
Single Source of Truth#
- Authentication via passkeys
- User profiles (name, email, picture, URL)
- Authorization with per-app scoping
- User management (admin + invite system)
Trust Model#
- First user becomes admin
- Admin can create invite links
- Apps auto-register on first use
- Users grant/revoke app access via consent
User Identifier Format#
Users are identified by: https://indiko.yourdomain.com/u/{username}
Data Structures#
Users#
user:{username} -> {
credential: {
credentialID: Uint8Array,
publicKey: Uint8Array,
counter: number
},
isAdmin: boolean,
profile: {
name: string,
email: string,
photo: string, // URL
url: string // personal website
},
createdAt: timestamp
}
Admin Marker#
admin:user -> username // marks first/admin user
Sessions#
session:{token} -> {
username: string,
expiresAt: timestamp
}
// TTL: 24 hours
Apps#
There are two types of OAuth clients in indiko:
Auto-registered Apps (IndieAuth)#
app:{client_id} -> {
client_id: string, // e.g. "https://blog.kierank.dev" (any valid URL)
redirect_uris: string[],
is_preregistered: 0, // indicates auto-registered
first_seen: timestamp,
last_used: timestamp,
name?: string, // optional, from client metadata
logo_url?: string // optional, from client metadata
}
Features:
- Client ID is any valid URL per IndieAuth spec
- No client secret (public client)
- MUST use PKCE (code_verifier)
- Automatically registered on first authorization
- Metadata fetched from client_id URL
- Cannot use role-based access control
Pre-registered Apps (OAuth 2.0 with secrets)#
app:{client_id} -> {
client_id: string, // e.g. "ikc_xxxxxxxxxxxxxxxxxxxxx" (generated ID)
redirect_uris: string[],
is_preregistered: 1, // indicates pre-registered
client_secret_hash: string, // SHA-256 hash of client secret
available_roles?: string[], // optional list of allowed roles
default_role?: string, // optional role auto-assigned on first auth
first_seen: timestamp,
last_used: timestamp,
name?: string,
logo_url?: string,
description?: string
}
Features:
- Client ID format:
ikc_+ 21 character nanoid - Client secret format:
iks_+ 43 character nanoid (shown once on creation) - MUST use PKCE (code_verifier) AND client_secret
- Supports role-based access control
- Admin-managed metadata
- Created via admin interface
User Permissions (Per-App)#
permission:{user_id}:{client_id} -> {
scopes: string[], // e.g. ["profile", "email"]
role?: string, // optional, only for pre-registered clients
granted_at: timestamp,
last_used: timestamp
}
Authorization Codes (Short-lived)#
authcode:{code} -> {
username: string,
client_id: string,
redirect_uri: string,
scopes: string[],
code_challenge: string, // PKCE
expires_at: timestamp,
used: boolean
}
// TTL: 60 seconds
// Single-use only
Invites#
invite:{code} -> {
code: string,
created_by: string, // admin username
created_at: timestamp,
used: boolean,
used_by?: string,
used_at?: timestamp
}
Challenges (WebAuthn)#
challenge:{challenge} -> {
username: string,
type: "registration" | "authentication",
expires_at: timestamp
}
// TTL: 5 minutes
Supported Scopes#
profile- Name, photo, URLemail- Email address- (Future: custom scopes as needed)
Routes#
Authentication (WebAuthn/Passkey)#
GET /login#
- Login/registration page
- Shows passkey auth interface
- First user: admin registration flow
- With
?invite=CODE: invite-based registration
GET /auth/can-register#
- Check if open registration allowed
- Returns
{ canRegister: boolean }
POST /auth/register/options#
- Generate WebAuthn registration options
- Body:
{ username: string, inviteCode?: string } - Validates invite code if not first user
- Returns registration options
POST /auth/register/verify#
- Verify WebAuthn registration response
- Body:
{ username: string, response: RegistrationResponseJSON, inviteCode?: string } - Creates user, stores credential
- First user marked as admin
- Returns
{ token: string, username: string }
POST /auth/login/options#
- Generate WebAuthn authentication options
- Body:
{ username: string } - Returns authentication options
POST /auth/login/verify#
- Verify WebAuthn authentication response
- Body:
{ username: string, response: AuthenticationResponseJSON } - Creates session
- Returns
{ token: string, username: string }
POST /auth/logout#
- Clear session
- Requires:
Authorization: Bearer {token} - Returns
{ success: true }
IndieAuth Endpoints#
GET /auth/authorize#
Authorization request from client app
Query Parameters:
response_type=code(required)client_id(required) - App's URLredirect_uri(required) - Callback URLstate(required) - CSRF protectioncode_challenge(required) - PKCE challengecode_challenge_method=S256(required)scope(optional) - Space-separated scopes (default: "profile")me(optional) - User's URL (hint)
Flow:
- Validate parameters
- Auto-register app if not exists
- If no session → redirect to
/login - If session exists → show consent screen
- Check if user previously approved this app
- If yes → auto-approve (skip consent)
- If no → show consent screen
Response:
- HTML consent screen
- Shows: app name, requested scopes
- Buttons: "Allow" / "Deny"
POST /auth/authorize#
Consent form submission (CSRF protected)
Body:
client_id(required)redirect_uri(required)state(required)code_challenge(required)scopes(required)action(required) - "allow" | "deny"
Flow:
- Validate CSRF token
- Validate session
- If denied → redirect with error
- If allowed:
- Create authorization code
- Store permission grant
- Update app last_used
- Redirect to redirect_uri with code & state
Success Response:
HTTP/1.1 302 Found
Location: {redirect_uri}?code={authcode}&state={state}
Error Response:
HTTP/1.1 302 Found
Location: {redirect_uri}?error=access_denied&state={state}
POST /auth/token#
Exchange authorization code for user identity (NOT CSRF protected)
Headers:
Content-Type: application/json
Body:
{
"grant_type": "authorization_code",
"code": "authcode123",
"client_id": "https://blog.kierank.dev",
"redirect_uri": "https://blog.kierank.dev/auth/callback",
"code_verifier": "pkce_verifier_string"
}
Flow:
- Validate authorization code exists
- Verify code not expired
- Verify code not already used
- Verify client_id matches
- Verify redirect_uri matches
- Verify PKCE code_verifier
- Mark code as used
- Return user identity + profile
Success Response:
{
"me": "https://indiko.yourdomain.com/u/kieran",
"profile": {
"name": "Kieran Klukas",
"email": "kieran@example.com",
"photo": "https://...",
"url": "https://kierank.dev"
}
}
Error Response:
{
"error": "invalid_grant",
"error_description": "Authorization code expired"
}
GET /auth/userinfo (Optional)#
Get current user profile with bearer token
Headers:
Authorization: Bearer {access_token}
Response:
{
"sub": "https://indiko.yourdomain.com/u/kieran",
"name": "Kieran Klukas",
"email": "kieran@example.com",
"picture": "https://...",
"website": "https://kierank.dev"
}
User Profile & Settings#
GET /settings#
User settings page (requires session)
Shows:
- Profile form (name, email, photo, URL)
- Connected apps list
- Revoke access buttons
- (Admin only) Invite generation
POST /settings/profile#
Update user profile
Body:
{
"name": "Kieran Klukas",
"email": "kieran@example.com",
"photo": "https://...",
"url": "https://kierank.dev"
}
Response:
{
"success": true,
"profile": { ... }
}
POST /settings/apps/:client_id/revoke#
Revoke app access
Response:
{
"success": true
}
GET /u/:username#
Public user profile page (h-card)
Response: HTML page with microformats h-card:
<div class="h-card">
<img class="u-photo" src="...">
<a class="p-name u-url" href="...">Kieran Klukas</a>
<a class="u-email" href="mailto:...">email</a>
</div>
Admin Endpoints#
POST /api/invites/create#
Create invite link (admin only)
Headers:
Authorization: Bearer {token}
Response:
{
"inviteCode": "abc123xyz"
}
Usage: https://indiko.yourdomain.com/login?invite=abc123xyz
Dashboard#
GET /#
Main dashboard (requires session)
Shows:
- User info
- Test API button
- (Admin only) Admin controls section
- Generate invite link button
- Invite display
GET /api/hello#
Test endpoint (requires session)
Headers:
Authorization: Bearer {token}
Response:
{
"message": "Hello kieran! You're authenticated with passkeys.",
"username": "kieran",
"isAdmin": true
}
Session Behavior#
Single Sign-On#
- Once logged into indiko (valid session), subsequent app authorization requests:
- Skip passkey authentication
- Show consent screen directly
- If app previously approved, auto-approve
- Session duration: 24 hours
- Passkey required only when session expires
Security#
- PKCE required for all authorization flows
- Authorization codes:
- Single-use only
- 60-second expiration
- Bound to client_id and redirect_uri
- State parameter required for CSRF protection
Client Integration Example#
1. Initiate Authorization#
const params = new URLSearchParams({
response_type: 'code',
client_id: 'https://blog.kierank.dev',
redirect_uri: 'https://blog.kierank.dev/auth/callback',
state: generateRandomState(),
code_challenge: generatePKCEChallenge(verifier),
code_challenge_method: 'S256',
scope: 'profile email'
});
window.location.href = `https://indiko.yourdomain.com/auth/authorize?${params}`;
2. Handle Callback#
// At https://blog.kierank.dev/auth/callback?code=...&state=...
const code = new URLSearchParams(window.location.search).get('code');
const state = new URLSearchParams(window.location.search).get('state');
// Verify state matches
// Exchange code for profile
const response = await fetch('https://indiko.yourdomain.com/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code,
client_id: 'https://blog.kierank.dev',
redirect_uri: 'https://blog.kierank.dev/auth/callback',
code_verifier: storedVerifier
})
});
const { me, profile } = await response.json();
// me: "https://indiko.yourdomain.com/u/kieran"
// profile: { name, email, photo, url }
// Create session for user
Future Enhancements#
- Token endpoint for longer-lived access tokens
- Refresh tokens
- Client metadata endpoint discovery
- Micropub support
- WebSub notifications
- Multiple passkey support per user
- Email notifications for new logins
- Audit log for admin
- Rate limiting
- Account recovery flow