- Updated 42 references from indieauth.spec.indieweb.org to www.w3.org/TR/indieauth - Ensures consistency across all documentation - Points to the authoritative W3C specification - No functional changes, documentation update only Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
537 lines
20 KiB
Markdown
537 lines
20 KiB
Markdown
# ADR-029: Micropub IndieAuth Integration Strategy
|
|
|
|
## Status
|
|
Accepted
|
|
|
|
## Context
|
|
|
|
The developer review of our Micropub design (ADR-028) revealed critical issues and questions about how IndieAuth and Micropub integrate. This ADR addresses all architectural decisions needed to proceed with implementation.
|
|
|
|
### Critical Issues Identified
|
|
|
|
1. **Token endpoint missing required `me` parameter** in the IndieAuth spec
|
|
2. **PKCE confusion** - it's not part of IndieAuth spec, but StarPunk uses it with IndieLogin.com
|
|
3. **Database security issue** - tokens stored in plain text
|
|
4. **Missing `authorization_codes` table** for token exchange
|
|
5. **Property mapping rules** undefined for Micropub to StarPunk conversion
|
|
6. **Authorization endpoint location** unclear
|
|
7. **Two authentication flows** need clarification
|
|
|
|
### V1 Scope Decision
|
|
|
|
The user has agreed to **simplify V1** by:
|
|
- ✅ Omitting update operations from V1
|
|
- ✅ Omitting delete operations from V1
|
|
- ✅ Focusing on create-only for V1 release
|
|
- Post-V1 features will be tracked separately
|
|
|
|
## Decision
|
|
|
|
We will implement a **hybrid IndieAuth architecture** that clearly separates admin authentication from Micropub authorization.
|
|
|
|
### Architectural Decisions
|
|
|
|
#### 1. Token Endpoint `me` Parameter (RESOLVED)
|
|
|
|
**Issue**: IndieAuth spec requires `me` parameter in token exchange, but our design missed it.
|
|
|
|
**Decision**: Add `me` parameter validation to token endpoint.
|
|
|
|
**Implementation**:
|
|
```python
|
|
# Token exchange request MUST include:
|
|
POST /auth/token
|
|
grant_type=authorization_code
|
|
code={code}
|
|
client_id={client_url}
|
|
redirect_uri={redirect_url}
|
|
me={user_profile_url} # REQUIRED by IndieAuth spec
|
|
```
|
|
|
|
**Validation**:
|
|
- Verify `me` matches the value stored with the authorization code
|
|
- Return error if mismatch (prevents code hijacking)
|
|
|
|
#### 2. PKCE Strategy (RESOLVED)
|
|
|
|
**Issue**: PKCE is not part of IndieAuth spec, but StarPunk uses it with IndieLogin.com.
|
|
|
|
**Decision**: Make PKCE **optional but recommended**.
|
|
|
|
**Implementation**:
|
|
- Check for `code_challenge` in authorization request
|
|
- If present, require `code_verifier` in token exchange
|
|
- If absent, proceed without PKCE (spec-compliant)
|
|
- Document as security enhancement beyond spec
|
|
|
|
**Rationale**:
|
|
- IndieLogin.com supports PKCE as an extension
|
|
- Other IndieAuth providers may not support it
|
|
- Making it optional ensures broader compatibility
|
|
|
|
#### 3. Token Storage Security (RESOLVED)
|
|
|
|
**Issue**: Current `tokens` table stores tokens in plain text (major security vulnerability).
|
|
|
|
**Decision**: Implement **immediate migration** to hashed token storage.
|
|
|
|
**Migration Strategy**:
|
|
```sql
|
|
-- Step 1: Create new secure tokens table
|
|
CREATE TABLE tokens_secure (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
token_hash TEXT UNIQUE NOT NULL, -- SHA256 hash
|
|
me TEXT NOT NULL,
|
|
client_id TEXT,
|
|
scope TEXT DEFAULT 'create',
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
last_used_at TIMESTAMP,
|
|
revoked_at TIMESTAMP
|
|
);
|
|
|
|
-- Step 2: Invalidate all existing tokens (security breach recovery)
|
|
-- Since we can't hash plain text tokens retroactively, all must be revoked
|
|
DROP TABLE IF EXISTS tokens;
|
|
|
|
-- Step 3: Rename secure table
|
|
ALTER TABLE tokens_secure RENAME TO tokens;
|
|
|
|
-- Step 4: Create indexes
|
|
CREATE INDEX idx_tokens_hash ON tokens(token_hash);
|
|
CREATE INDEX idx_tokens_me ON tokens(me);
|
|
CREATE INDEX idx_tokens_expires ON tokens(expires_at);
|
|
```
|
|
|
|
**Security Notice**: All existing tokens will be invalidated. Users must re-authenticate.
|
|
|
|
#### 4. Authorization Codes Table (RESOLVED)
|
|
|
|
**Issue**: Design references `authorization_codes` table that doesn't exist.
|
|
|
|
**Decision**: Create the table as part of Micropub implementation.
|
|
|
|
**Schema**:
|
|
```sql
|
|
CREATE TABLE authorization_codes (
|
|
code TEXT PRIMARY KEY,
|
|
code_hash TEXT UNIQUE NOT NULL, -- SHA256 hash for security
|
|
me TEXT NOT NULL,
|
|
client_id TEXT NOT NULL,
|
|
redirect_uri TEXT NOT NULL,
|
|
scope TEXT DEFAULT 'create',
|
|
code_challenge TEXT, -- Optional PKCE
|
|
code_challenge_method TEXT, -- S256 if PKCE used
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
used_at TIMESTAMP -- Prevent replay attacks
|
|
);
|
|
|
|
CREATE INDEX idx_auth_codes_hash ON authorization_codes(code_hash);
|
|
CREATE INDEX idx_auth_codes_expires ON authorization_codes(expires_at);
|
|
```
|
|
|
|
#### 5. Property Mapping Rules (RESOLVED)
|
|
|
|
**Issue**: Functions like `extract_title()` and `extract_content()` are undefined.
|
|
|
|
**Decision**: Define explicit mapping rules for V1.
|
|
|
|
**Micropub → StarPunk Mapping**:
|
|
```python
|
|
# Content mapping (required)
|
|
content = properties.get('content', [''])[0] # First content value
|
|
if not content:
|
|
return error_response("invalid_request", "Content is required")
|
|
|
|
# Title mapping (optional)
|
|
# Option 1: Use 'name' property if provided
|
|
title = properties.get('name', [''])[0]
|
|
# Option 2: If no name, extract from content (first line up to 50 chars)
|
|
if not title and content:
|
|
first_line = content.split('\n')[0]
|
|
title = first_line[:50] + ('...' if len(first_line) > 50 else '')
|
|
|
|
# Tags mapping
|
|
tags = properties.get('category', []) # All category values become tags
|
|
|
|
# Published date (respect if provided, otherwise use current time)
|
|
published = properties.get('published', [''])[0]
|
|
if published:
|
|
# Parse ISO 8601 date
|
|
created_at = parse_iso8601(published)
|
|
else:
|
|
created_at = datetime.now()
|
|
|
|
# Slug generation
|
|
mp_slug = properties.get('mp-slug', [''])[0]
|
|
if mp_slug:
|
|
slug = slugify(mp_slug)
|
|
else:
|
|
slug = generate_slug(title or content[:30])
|
|
```
|
|
|
|
### Q1: Authorization Endpoint Location (RESOLVED)
|
|
|
|
**Issue**: Design mentions `/auth/authorization` but it doesn't exist.
|
|
|
|
**Decision**: Create **NEW** `/auth/authorization` endpoint for Micropub clients.
|
|
|
|
**Rationale**:
|
|
- Keep admin login (`/auth/login`) separate from Micropub authorization
|
|
- Clear separation of concerns
|
|
- Follows IndieAuth spec naming conventions
|
|
|
|
**Implementation**:
|
|
```python
|
|
@bp.route("/auth/authorization", methods=["GET", "POST"])
|
|
def authorization_endpoint():
|
|
"""
|
|
IndieAuth authorization endpoint for Micropub clients
|
|
|
|
GET: Display authorization form
|
|
POST: Process authorization and redirect with code
|
|
"""
|
|
if request.method == "GET":
|
|
# Parse IndieAuth parameters
|
|
response_type = request.args.get('response_type')
|
|
client_id = request.args.get('client_id')
|
|
redirect_uri = request.args.get('redirect_uri')
|
|
state = request.args.get('state')
|
|
scope = request.args.get('scope', 'create')
|
|
me = request.args.get('me')
|
|
code_challenge = request.args.get('code_challenge')
|
|
|
|
# Validate parameters
|
|
if response_type != 'code':
|
|
return error_response("unsupported_response_type")
|
|
|
|
# Check if user is logged in (via admin session)
|
|
if not verify_admin_session():
|
|
# Redirect to login, then back here
|
|
session['pending_auth'] = request.url
|
|
return redirect(url_for('auth.login_form'))
|
|
|
|
# Display authorization form
|
|
return render_template('auth/authorize.html',
|
|
client_id=client_id,
|
|
scope=scope,
|
|
redirect_uri=redirect_uri)
|
|
|
|
else: # POST
|
|
# User approved/denied authorization
|
|
# Generate authorization code
|
|
# Store in authorization_codes table
|
|
# Redirect to client with code
|
|
```
|
|
|
|
### Q2: Two Authentication Flows Integration (RESOLVED)
|
|
|
|
**Decision**: Maintain **two separate flows** with clear boundaries.
|
|
|
|
**Flow 1: Admin Login** (Existing)
|
|
- Purpose: Admin access to StarPunk interface
|
|
- Path: `/auth/login` → IndieLogin.com → `/auth/callback`
|
|
- Result: Session cookie for admin panel
|
|
- No changes needed
|
|
|
|
**Flow 2: Micropub Authorization** (New)
|
|
- Purpose: Micropub client authorization
|
|
- Path: `/auth/authorization` → `/auth/token`
|
|
- Result: Bearer token for API access
|
|
|
|
**Integration Point**: The authorization endpoint checks for admin session:
|
|
```python
|
|
def authorization_endpoint():
|
|
# Check if admin is logged in
|
|
if not has_admin_session():
|
|
# Store authorization request
|
|
# Redirect to admin login
|
|
# After login, return to authorization
|
|
return redirect_to_login_with_return()
|
|
|
|
# Admin is logged in, show authorization form
|
|
return show_authorization_form()
|
|
```
|
|
|
|
**Key Design Choice**: We act as our **own authorization server** for Micropub, not delegating to IndieLogin.com for this flow. This is because:
|
|
1. IndieLogin.com doesn't issue access tokens
|
|
2. We need to control scopes and token lifetime
|
|
3. We already have admin authentication to verify the user
|
|
|
|
### Q3: Scope Validation Rules (RESOLVED)
|
|
|
|
**Issue**: What happens when client requests no scopes?
|
|
|
|
**Decision**: Implement **Option C** - Allow empty scope during authorization, reject at token endpoint.
|
|
|
|
**Rationale**: This matches the IndieAuth spec requirement exactly.
|
|
|
|
**Implementation**:
|
|
```python
|
|
def handle_authorization():
|
|
scope = request.args.get('scope', '')
|
|
|
|
# Store whatever scope was requested (even empty)
|
|
authorization_code = create_authorization_code(
|
|
scope=scope, # Can be empty string
|
|
# ... other parameters
|
|
)
|
|
|
|
def handle_token_exchange():
|
|
auth_code = get_authorization_code(code)
|
|
|
|
# IndieAuth spec: MUST NOT issue token if no scope
|
|
if not auth_code.scope:
|
|
return error_response("invalid_scope",
|
|
"Authorization code was issued without scope")
|
|
|
|
# Issue token with the authorized scope
|
|
token = create_access_token(scope=auth_code.scope)
|
|
```
|
|
|
|
### Q4: V1 Scope - Update/Delete Operations (RESOLVED)
|
|
|
|
**Decision**: Remove update/delete from V1 completely.
|
|
|
|
**Changes Required**:
|
|
1. Remove `handle_update()` and `handle_delete()` from design doc
|
|
2. Remove update/delete from supported scopes in V1
|
|
3. Return "invalid_request" if action=update or action=delete
|
|
4. Document in project plan for post-V1
|
|
|
|
**V1 Supported Actions**:
|
|
- ✅ action=create (or no action - default)
|
|
- ❌ action=update → error response
|
|
- ❌ action=delete → error response
|
|
|
|
### Q5: Token Storage Security Fix (RESOLVED)
|
|
|
|
**Decision**: Fix the security issue as part of Micropub implementation.
|
|
|
|
**Implementation Plan**:
|
|
1. Create migration to new secure schema
|
|
2. Hash all new tokens before storage
|
|
3. Document that existing tokens will be invalidated
|
|
4. Add security notice to changelog
|
|
|
|
## Implementation Architecture
|
|
|
|
### Complete Authorization Flow
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ Micropub Client │
|
|
└────────────────────┬────────────────────────────────────┘
|
|
│
|
|
│ 1. GET /auth/authorization?
|
|
│ response_type=code&
|
|
│ client_id=https://app.example&
|
|
│ redirect_uri=...&
|
|
│ state=...&
|
|
│ scope=create&
|
|
│ me=https://user.example
|
|
▼
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ StarPunk Authorization Endpoint │
|
|
│ /auth/authorization │
|
|
├─────────────────────────────────────────────────────────┤
|
|
│ if not admin_logged_in: │
|
|
│ redirect_to_login() │
|
|
│ else: │
|
|
│ show_authorization_form() │
|
|
└────────────────────┬────────────────────────────────────┘
|
|
│
|
|
│ 2. User approves
|
|
│ POST /auth/authorization
|
|
│
|
|
│ 3. Redirect with code
|
|
│ https://app.example/callback?
|
|
│ code=xxx&state=yyy
|
|
▼
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ Micropub Client │
|
|
└────────────────────┬────────────────────────────────────┘
|
|
│
|
|
│ 4. POST /auth/token
|
|
│ grant_type=authorization_code&
|
|
│ code=xxx&
|
|
│ client_id=https://app.example&
|
|
│ redirect_uri=...&
|
|
│ me=https://user.example&
|
|
│ code_verifier=... (if PKCE)
|
|
▼
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ StarPunk Token Endpoint │
|
|
│ /auth/token │
|
|
├─────────────────────────────────────────────────────────┤
|
|
│ 1. Verify authorization code │
|
|
│ 2. Check code not used │
|
|
│ 3. Verify client_id matches │
|
|
│ 4. Verify redirect_uri matches │
|
|
│ 5. Verify me matches │
|
|
│ 6. Verify PKCE if present │
|
|
│ 7. Check scope not empty │
|
|
│ 8. Generate access token │
|
|
│ 9. Store hashed token │
|
|
│ 10. Return token response │
|
|
└────────────────────┬────────────────────────────────────┘
|
|
│
|
|
│ 5. Response:
|
|
│ {
|
|
│ "access_token": "xxx",
|
|
│ "token_type": "Bearer",
|
|
│ "scope": "create",
|
|
│ "me": "https://user.example"
|
|
│ }
|
|
▼
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ Micropub Client │
|
|
└────────────────────┬────────────────────────────────────┘
|
|
│
|
|
│ 6. POST /micropub
|
|
│ Authorization: Bearer xxx
|
|
│ h=entry&content=Hello
|
|
▼
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ StarPunk Micropub Endpoint │
|
|
│ /micropub │
|
|
├─────────────────────────────────────────────────────────┤
|
|
│ 1. Extract bearer token │
|
|
│ 2. Hash token and lookup │
|
|
│ 3. Verify not expired │
|
|
│ 4. Check scope includes "create" │
|
|
│ 5. Parse Micropub properties │
|
|
│ 6. Create note via notes.py │
|
|
│ 7. Return 201 with Location header │
|
|
└─────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
## Consequences
|
|
|
|
### Positive
|
|
- ✅ All spec compliance issues resolved
|
|
- ✅ Clear separation between admin auth and Micropub auth
|
|
- ✅ Security vulnerability in token storage fixed
|
|
- ✅ Simplified V1 scope (create-only)
|
|
- ✅ PKCE optional for compatibility
|
|
- ✅ Clear property mapping rules
|
|
|
|
### Negative
|
|
- ⚠️ Existing tokens will be invalidated (security fix)
|
|
- ⚠️ More complex than initially designed
|
|
- ⚠️ Two authorization flows to maintain
|
|
|
|
### Neutral
|
|
- We become our own authorization server (for Micropub only)
|
|
- Admin must be logged in to authorize Micropub clients
|
|
- Update/delete deferred to post-V1
|
|
|
|
## Migration Requirements
|
|
|
|
### Database Migration Script
|
|
```sql
|
|
-- Migration: Fix token security and add authorization codes
|
|
-- Version: 0.10.0
|
|
|
|
-- 1. Create secure tokens table
|
|
CREATE TABLE tokens_new (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
token_hash TEXT UNIQUE NOT NULL,
|
|
me TEXT NOT NULL,
|
|
client_id TEXT,
|
|
scope TEXT DEFAULT 'create',
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
last_used_at TIMESTAMP,
|
|
revoked_at TIMESTAMP
|
|
);
|
|
|
|
-- 2. Drop insecure table (invalidates all tokens)
|
|
DROP TABLE IF EXISTS tokens;
|
|
|
|
-- 3. Rename to final name
|
|
ALTER TABLE tokens_new RENAME TO tokens;
|
|
|
|
-- 4. Create authorization codes table
|
|
CREATE TABLE authorization_codes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
code_hash TEXT UNIQUE NOT NULL,
|
|
me TEXT NOT NULL,
|
|
client_id TEXT NOT NULL,
|
|
redirect_uri TEXT NOT NULL,
|
|
scope TEXT,
|
|
state TEXT,
|
|
code_challenge TEXT,
|
|
code_challenge_method TEXT,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
used_at TIMESTAMP
|
|
);
|
|
|
|
-- 5. Create indexes
|
|
CREATE INDEX idx_tokens_hash ON tokens(token_hash);
|
|
CREATE INDEX idx_tokens_expires ON tokens(expires_at);
|
|
CREATE INDEX idx_auth_codes_hash ON authorization_codes(code_hash);
|
|
CREATE INDEX idx_auth_codes_expires ON authorization_codes(expires_at);
|
|
|
|
-- 6. Clean up expired auth state
|
|
DELETE FROM auth_state WHERE expires_at < datetime('now');
|
|
```
|
|
|
|
## Implementation Checklist
|
|
|
|
### Phase 1: Security & Database
|
|
- [ ] Create database migration script
|
|
- [ ] Implement token hashing functions
|
|
- [ ] Add authorization_codes table
|
|
- [ ] Update database.py schema
|
|
|
|
### Phase 2: Authorization Endpoint
|
|
- [ ] Create `/auth/authorization` route
|
|
- [ ] Implement authorization form template
|
|
- [ ] Add scope approval UI
|
|
- [ ] Generate and store authorization codes
|
|
|
|
### Phase 3: Token Endpoint
|
|
- [ ] Create `/auth/token` route
|
|
- [ ] Implement code exchange logic
|
|
- [ ] Add `me` parameter validation
|
|
- [ ] Optional PKCE verification
|
|
- [ ] Generate and store hashed tokens
|
|
|
|
### Phase 4: Micropub Endpoint (Create Only)
|
|
- [ ] Create `/micropub` route
|
|
- [ ] Bearer token extraction
|
|
- [ ] Token verification (hash lookup)
|
|
- [ ] Property normalization
|
|
- [ ] Content/title/tags mapping
|
|
- [ ] Note creation via notes.py
|
|
- [ ] Location header response
|
|
|
|
### Phase 5: Testing & Documentation
|
|
- [ ] Test with Indigenous app
|
|
- [ ] Test with Quill
|
|
- [ ] Update API documentation
|
|
- [ ] Security audit
|
|
- [ ] Performance testing
|
|
|
|
## References
|
|
|
|
- [IndieAuth Spec - Token Endpoint](https://www.w3.org/TR/indieauth/#token-endpoint)
|
|
- [IndieAuth Spec - Authorization Code](https://www.w3.org/TR/indieauth/#authorization-code)
|
|
- [Micropub Spec - Authentication](https://www.w3.org/TR/micropub/#authentication)
|
|
- [OAuth 2.0 Security Best Practices](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics)
|
|
|
|
## Related ADRs
|
|
|
|
- ADR-021: IndieAuth Provider Strategy (understanding flows)
|
|
- ADR-028: Micropub Implementation Strategy (original design)
|
|
- ADR-005: IndieLogin Authentication (admin auth flow)
|
|
|
|
---
|
|
|
|
**Date**: 2024-11-24
|
|
**Author**: StarPunk Architecture Team
|
|
**Status**: Accepted
|
|
**Version Impact**: Requires 0.10.0 (breaking change - token invalidation) |