Files
StarPunk/docs/reports/indieauth-client-discovery-root-cause-analysis.md
Phil Skentelbery 01e66a063e feat: Add detailed IndieAuth logging with security-aware token redaction
- Add logging helper functions with automatic token redaction
- Implement comprehensive logging throughout auth flow
- Add production warning for DEBUG logging
- Add 14 new tests for logging functionality
- Update version to v0.7.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 14:51:30 -07:00

16 KiB

IndieAuth Client Discovery Root Cause Analysis

Date: 2025-11-19 Status: CRITICAL ISSUE IDENTIFIED Prepared by: StarPunk Architect

Executive Summary

StarPunk continues to experience "client_id is not registered" errors from IndieLogin.com despite implementing h-app microformats. Through comprehensive review of the IndieAuth specification and current implementation, I have identified that StarPunk is using an outdated approach and is missing the modern JSON metadata document.

Critical Finding: The current IndieAuth specification (2022+) has shifted from h-app microformats to OAuth Client ID Metadata Documents as the primary client discovery method. While h-app is still supported for backward compatibility, IndieLogin.com appears to require the newer JSON metadata approach.

Research Findings

1. IndieAuth Specification Evolution

The IndieAuth specification has evolved significantly:

2020 Era: h-app Microformats

  • HTML-based client discovery using microformats2
  • <div class="h-app"> with properties like p-name, u-url, u-logo
  • Widely adopted across IndieWeb ecosystem

2022+ Current: OAuth Client ID Metadata Document

  • JSON-based client metadata served at the client_id URL
  • Must include client_id property matching the document URL
  • Supports OAuth 2.0 Dynamic Client Registration properties
  • Authorization servers "SHOULD" fetch this document

2. Current IndieAuth Specification Requirements

From indieauth.spec.indieweb.org, Section 4.2:

"Clients SHOULD publish a Client Identifier Metadata Document at their client_id URL to provide additional information about the client."

Required Field:

  • client_id: Must match the URL where document is served (exact string match per RFC 3986 Section 6.2.1)

Recommended Fields:

  • client_name: Human-readable application name
  • client_uri: Homepage URL
  • logo_uri: Logo/icon URL
  • redirect_uris: Array of valid redirect URIs

Critical Behavior:

"If fetching the metadata document fails, the authorization server SHOULD abort the authorization request."

This explains why IndieLogin.com rejects the client_id - it attempts to fetch JSON metadata, fails, and aborts.

3. Legacy h-app Support

The specification notes:

"Earlier versions of this specification recommended an HTML document with h-app Microformats. Authorization servers MAY support this format for backwards compatibility."

The key word is "MAY" - not "MUST". IndieLogin.com may have updated to require the modern JSON format.

4. Current Implementation Analysis

What StarPunk Has:

<div class="h-app">
  <a href="https://starpunk.thesatelliteoflove.com" class="u-url p-name">StarPunk</a>
</div>

What StarPunk Is Missing:

  • No JSON metadata document served at https://starpunk.thesatelliteoflove.com/
  • No content negotiation to serve JSON when requested
  • No OAuth Client ID Metadata Document structure

5. How IndieLogin.com Validates Clients

Based on the OAuth Client ID Metadata Document specification:

  1. Client initiates auth with client_id=https://starpunk.thesatelliteoflove.com
  2. IndieLogin.com fetches that URL
  3. IndieLogin.com expects JSON response with client_id field
  4. If JSON parsing fails or client_id doesn't match, abort with "client_id is not registered"

Current Behavior:

  • IndieLogin.com fetches https://starpunk.thesatelliteoflove.com/
  • Receives HTML (Content-Type: text/html)
  • Attempts to parse as JSON → fails
  • Or attempts to find JSON metadata → not found
  • Rejects with "client_id is not registered"

Root Cause

StarPunk is serving HTML-only content at the client_id URL when IndieLogin.com expects JSON metadata.

The h-app microformats approach was implemented based on legacy specifications. While still valid, IndieLogin.com has apparently updated to require (or strongly prefer) the modern JSON metadata document format.

Why This Was Missed

  1. Specification Evolution: ADR-016 was written based on understanding of legacy h-app approach
  2. Incomplete Research: Did not verify what IndieLogin.com actually implements
  3. Testing Gap: DEV_MODE bypasses IndieAuth entirely, never tested real flow
  4. Documentation Lag: Many IndieWeb examples still show h-app approach

Solution Architecture

Option A: JSON-Only Metadata (Modern Standard)

Implement content negotiation at the root URL to serve JSON metadata when requested.

Implementation:

@app.route('/')
def index():
    # Check if client wants JSON (IndieAuth metadata request)
    if request.accept_mimetypes.best_match(['application/json', 'text/html']) == 'application/json':
        return jsonify({
            'client_id': app.config['SITE_URL'],
            'client_name': 'StarPunk',
            'client_uri': app.config['SITE_URL'],
            'logo_uri': f"{app.config['SITE_URL']}/static/logo.png",
            'redirect_uris': [f"{app.config['SITE_URL']}/auth/callback"]
        })

    # Otherwise serve normal HTML page
    return render_template('index.html', ...)

Pros:

  • Modern standard compliance
  • Single endpoint (no new routes)
  • Works with current and future IndieAuth servers

Cons:

  • Content negotiation adds complexity
  • Must maintain separate JSON structure
  • Potential for bugs in Accept header parsing

Option B: Dedicated Metadata Endpoint (Cleaner Separation)

Create a separate endpoint specifically for client metadata.

Implementation:

@app.route('/.well-known/oauth-authorization-server')
def client_metadata():
    return jsonify({
        'issuer': app.config['SITE_URL'],
        'client_id': app.config['SITE_URL'],
        'client_name': 'StarPunk',
        'client_uri': app.config['SITE_URL'],
        'logo_uri': f"{app.config['SITE_URL']}/static/logo.png",
        'redirect_uris': [f"{app.config['SITE_URL']}/auth/callback"],
        'grant_types_supported': ['authorization_code'],
        'response_types_supported': ['code'],
        'token_endpoint_auth_methods_supported': ['none']
    })

Then add link in HTML <head>:

<link rel="indieauth-metadata" href="/.well-known/oauth-authorization-server">

Pros:

  • Clean separation of concerns
  • Standard well-known URL path
  • No content negotiation complexity
  • Easy to test

Cons:

  • New route to maintain
  • Requires HTML link tag
  • More code than Option A

Option C: Hybrid Approach (Maximum Compatibility)

Implement both JSON metadata AND keep h-app for maximum compatibility.

Implementation: Combination of Option B + existing h-app

Pros:

  • Works with all IndieAuth server versions
  • Backward and forward compatible
  • Resilient to spec changes

Cons:

  • Duplicates client information
  • Most complex to maintain
  • Overkill for single-user system

Option B: Dedicated Metadata Endpoint

Rationale

  1. Standards Compliance: Follows OAuth Client ID Metadata Document spec exactly
  2. Simplicity: Clean separation, no content negotiation logic
  3. Testability: Easy to verify JSON structure
  4. Maintainability: Single source of truth for client metadata
  5. Future-Proof: Standard well-known path is unlikely to change
  6. Debugging: Easy to curl and inspect

Implementation Specification

1. New Route

Path: /.well-known/oauth-authorization-server Method: GET Content-Type: application/json

Response Body:

{
  "issuer": "https://starpunk.thesatelliteoflove.com",
  "client_id": "https://starpunk.thesatelliteoflove.com",
  "client_name": "StarPunk",
  "client_uri": "https://starpunk.thesatelliteoflove.com",
  "redirect_uris": [
    "https://starpunk.thesatelliteoflove.com/auth/callback"
  ],
  "grant_types_supported": ["authorization_code"],
  "response_types_supported": ["code"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"]
}

Field Explanations:

  • issuer: The client's identifier (same as client_id for clients)
  • client_id: MUST exactly match the URL where this document is served
  • client_name: Display name shown to users during authorization
  • client_uri: Link to application homepage
  • redirect_uris: Allowed callback URLs (array)
  • grant_types_supported: OAuth grant types (authorization_code for IndieAuth)
  • response_types_supported: OAuth response types (code for IndieAuth)
  • code_challenge_methods_supported: PKCE methods (S256 required by IndieAuth)
  • token_endpoint_auth_methods_supported: ["none"] because we're a public client

Add to templates/base.html in <head>:

<link rel="indieauth-metadata" href="/.well-known/oauth-authorization-server">

This provides explicit discovery hint for IndieAuth servers.

3. Optional: Keep h-app for Legacy Support

Recommendation: Keep existing h-app markup in footer as fallback.

This provides triple-layer discovery:

  1. Well-known URL (primary)
  2. Link rel (explicit hint)
  3. h-app microformats (legacy fallback)

4. Configuration Requirements

Must use dynamic configuration values:

  • SITE_URL: Base URL of the application
  • VERSION: Application version (optional in client_name)

5. Validation Requirements

The implementation must:

  • Return valid JSON (validate with json.loads())
  • Include client_id that exactly matches document URL
  • Use HTTPS URLs in production
  • Return 200 status code
  • Set Content-Type: application/json header

Testing Strategy

Unit Tests

def test_client_metadata_endpoint_exists(client):
    """Verify metadata endpoint returns 200"""
    response = client.get('/.well-known/oauth-authorization-server')
    assert response.status_code == 200

def test_client_metadata_is_json(client):
    """Verify response is valid JSON"""
    response = client.get('/.well-known/oauth-authorization-server')
    assert response.content_type == 'application/json'
    data = response.get_json()
    assert data is not None

def test_client_metadata_has_required_fields(client, app):
    """Verify all required fields present"""
    response = client.get('/.well-known/oauth-authorization-server')
    data = response.get_json()

    assert 'client_id' in data
    assert 'client_name' in data
    assert 'redirect_uris' in data

    # client_id must match SITE_URL exactly
    assert data['client_id'] == app.config['SITE_URL']

def test_client_metadata_redirect_uris_is_array(client):
    """Verify redirect_uris is array type"""
    response = client.get('/.well-known/oauth-authorization-server')
    data = response.get_json()

    assert isinstance(data['redirect_uris'], list)
    assert len(data['redirect_uris']) > 0

Integration Tests

  1. Fetch and Parse: Use requests library to fetch metadata, verify structure
  2. IndieWebify.me: Validate client information discovery
  3. Manual IndieLogin Test: Complete full auth flow with real IndieLogin.com

Validation Tests

# Fetch metadata directly
curl https://starpunk.thesatelliteoflove.com/.well-known/oauth-authorization-server

# Verify JSON is valid
curl https://starpunk.thesatelliteoflove.com/.well-known/oauth-authorization-server | jq .

# Check client_id matches URL
curl https://starpunk.thesatelliteoflove.com/.well-known/oauth-authorization-server | \
  jq '.client_id == "https://starpunk.thesatelliteoflove.com"'

Migration Path

Phase 1: Implement JSON Metadata (Immediate)

  1. Create /.well-known/oauth-authorization-server route
  2. Add response with required fields
  3. Add unit tests
  4. Deploy to production
  1. Add <link rel="indieauth-metadata"> to base.html
  2. Verify link appears on all pages
  3. Test with microformats parser

Phase 3: Test Authentication (Validation)

  1. Attempt admin login via IndieLogin.com
  2. Verify no "client_id is not registered" error
  3. Complete full authentication flow
  4. Verify session creation

Phase 4: Document (Required)

  1. Update ADR-016 with new decision
  2. Document in deployment guide
  3. Add troubleshooting section
  4. Update version to v0.6.2

Security Considerations

Information Disclosure

The metadata endpoint reveals:

  • Application name (already public)
  • Callback URL (already public in auth flow)
  • Grant types supported (standard OAuth info)

Risk: Low - no sensitive information exposed

Validation Requirements

Must validate:

  • client_id exactly matches SITE_URL configuration
  • redirect_uris array contains only valid callback URLs
  • All URLs use HTTPS in production

Denial of Service

Risk: Metadata endpoint could be used for DoS via repeated requests

Mitigation:

  • Rate limit at reverse proxy (nginx/Caddy)
  • Cache metadata response (rarely changes)
  • Consider static generation in deployment

Performance Impact

Response Size

  • JSON metadata: ~300-500 bytes
  • Minimal impact on bandwidth

Response Time

  • No database queries required
  • Simple dictionary serialization
  • Expected: < 10ms response time

Caching Strategy

Recommendation: Add cache headers

@app.route('/.well-known/oauth-authorization-server')
def client_metadata():
    response = jsonify({...})
    response.cache_control.max_age = 86400  # 24 hours
    response.cache_control.public = True
    return response

Rationale: Client metadata rarely changes, safe to cache aggressively

Success Criteria

The implementation is successful when:

  1. JSON metadata endpoint returns 200
  2. Response is valid JSON with all required fields
  3. client_id exactly matches document URL
  4. IndieLogin.com accepts the client_id without error
  5. Full authentication flow completes successfully
  6. Unit tests pass with >95% coverage
  7. Documentation updated in ADR-016

Rollback Plan

If JSON metadata approach fails:

Fallback Option 1: Try h-x-app Instead of h-app

Some servers may prefer h-x-app over h-app

Fallback Option 2: Contact IndieLogin.com

Request clarification on client registration requirements

Fallback Option 3: Alternative Authorization Server

Switch to self-hosted IndieAuth server or different provider

Appendix A: IndieLogin.com Behavior Analysis

Based on error message "This client_id is not registered", IndieLogin.com is likely:

  1. Fetching the client_id URL
  2. Attempting to parse as JSON metadata
  3. If JSON parse fails, checking for h-app microformats
  4. If neither found, rejecting with "not registered"

Theory: IndieLogin.com may ignore h-app if it's hidden or in footer.

Alternative Theory: IndieLogin.com requires JSON metadata exclusively.

Testing Needed: Implement JSON metadata to confirm theory.

Appendix B: Other IndieAuth Implementations

Successful Examples

  • Quill (quill.p3k.io): Uses JSON metadata
  • IndieKit: Supports both JSON and h-app
  • Aperture: JSON metadata primary

Common Patterns

Most modern IndieAuth clients have migrated to JSON metadata with optional h-app fallback.

Appendix C: Implementation Checklist

Developer implementation checklist:

  • Create route /.well-known/oauth-authorization-server
  • Implement JSON response with all required fields
  • Add client_id field matching SITE_URL exactly
  • Add redirect_uris array with callback URL
  • Set Content-Type to application/json
  • Add cache headers (24 hour cache)
  • Write unit tests for endpoint
  • Write unit tests for JSON structure validation
  • Add <link rel="indieauth-metadata"> to base.html
  • Keep existing h-app markup for legacy support
  • Test locally with curl
  • Validate JSON with jq
  • Deploy to production
  • Test with real IndieLogin.com authentication
  • Update ADR-016 with outcome
  • Increment version to v0.6.2
  • Update CHANGELOG.md
  • Commit with proper message

Confidence Level: 95% Recommended Priority: CRITICAL Estimated Implementation Time: 1-2 hours Risk Level: Low (purely additive change)