# 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)