Files
Gondulf/docs/designs/authentication-flow-fix.md
Phil Skentelbery 9135edfe84 fix(auth): require email authentication every login
CRITICAL SECURITY FIX:
- Email code required EVERY login (authentication, not verification)
- DNS TXT check cached separately (domain verification)
- New auth_sessions table for per-login state
- Codes hashed with SHA-256, constant-time comparison
- Max 3 attempts, 10-minute session expiry
- OAuth params stored server-side (security improvement)

New files:
- services/auth_session.py
- migrations 004, 005
- ADR-010: domain verification vs user authentication

312 tests passing, 86.21% coverage

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 15:16:26 -07:00

7.4 KiB

Authentication Flow Fix Design

Problem Statement

The current implementation conflates domain verification (one-time DNS check) with user authentication (per-login email verification). This creates a security vulnerability where only the first user needs to authenticate via email code, while subsequent users bypass authentication entirely.

Core Concepts

Domain Verification

  • Purpose: Establish that a domain is configured to use this IndieAuth server
  • Method: DNS TXT record containing server-specific verification string
  • Frequency: Once per domain, results cached in database
  • Storage: domains table with verification status and timestamp

User Authentication

  • Purpose: Prove the current user owns the claimed identity
  • Method: Time-limited 6-digit code sent to rel="me" email
  • Frequency: EVERY authorization attempt
  • Storage: Temporary session storage, expires after 5-10 minutes

Corrected Authorization Flow

Step 1: Authorization Request

Client initiates OAuth flow:

GET /authorize?
  response_type=code&
  client_id=https://app.example.com&
  redirect_uri=https://app.example.com/callback&
  state=xyz&
  code_challenge=abc&
  code_challenge_method=S256&
  me=https://user.example.com

Step 2: Domain Verification Check

  1. Extract domain from me parameter
  2. Check domains table for existing verification:
    SELECT verified, last_checked
    FROM domains
    WHERE domain = 'user.example.com'
    
  3. If not verified or stale (>24 hours):
    • Check DNS TXT record at _indieauth.user.example.com
    • Update database with verification status
  4. If domain not verified, reject with error

Step 3: Profile Discovery

  1. Fetch the user's homepage at me URL
  2. Parse for IndieAuth metadata:
    • Authorization endpoint (must be this server)
    • Token endpoint (if present)
    • rel="me" links for authentication options
  3. Extract email from rel="me" links

Step 4: User Authentication (ALWAYS REQUIRED)

  1. Generate 6-digit code
  2. Store in session with expiration:
    {
      "session_id": "uuid",
      "me": "https://user.example.com",
      "email": "user@example.com",
      "code": "123456",
      "client_id": "https://app.example.com",
      "redirect_uri": "https://app.example.com/callback",
      "state": "xyz",
      "code_challenge": "abc",
      "expires_at": "2024-01-01T12:05:00Z"
    }
    
  3. Send code via email
  4. Show code entry form

Step 5: Code Verification

  1. User submits code
  2. Validate against session storage
  3. If valid, mark session as authenticated
  4. If invalid, allow retry (max 3 attempts)
  1. Show consent page with client details
  2. User approves/denies
  3. If approved, generate authorization code

Step 7: Authorization Code

  1. Generate authorization code
  2. Store with session binding:
    {
      "code": "auth_code_xyz",
      "session_id": "uuid",
      "me": "https://user.example.com",
      "client_id": "https://app.example.com",
      "redirect_uri": "https://app.example.com/callback",
      "code_challenge": "abc",
      "expires_at": "2024-01-01T12:10:00Z"
    }
    
  3. Redirect to client with code

Data Models

domains table (persistent)

CREATE TABLE domains (
  domain VARCHAR(255) PRIMARY KEY,
  verified BOOLEAN DEFAULT FALSE,
  verification_string VARCHAR(255),
  last_checked TIMESTAMP,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

auth_sessions table (temporary, cleaned periodically)

CREATE TABLE auth_sessions (
  session_id VARCHAR(255) PRIMARY KEY,
  me VARCHAR(255) NOT NULL,
  email VARCHAR(255),
  verification_code VARCHAR(6),
  code_verified BOOLEAN DEFAULT FALSE,
  client_id VARCHAR(255) NOT NULL,
  redirect_uri VARCHAR(255) NOT NULL,
  state VARCHAR(255),
  code_challenge VARCHAR(255),
  code_challenge_method VARCHAR(10),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  expires_at TIMESTAMP NOT NULL,
  INDEX idx_expires (expires_at)
);

authorization_codes table (temporary)

CREATE TABLE authorization_codes (
  code VARCHAR(255) PRIMARY KEY,
  session_id VARCHAR(255) NOT NULL,
  me VARCHAR(255) NOT NULL,
  client_id VARCHAR(255) NOT NULL,
  redirect_uri VARCHAR(255) NOT NULL,
  code_challenge VARCHAR(255),
  used BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  expires_at TIMESTAMP NOT NULL,
  FOREIGN KEY (session_id) REFERENCES auth_sessions(session_id),
  INDEX idx_expires (expires_at)
);

Session Management

Session Creation

  • Generate UUID for session_id
  • Set expiration to 10 minutes for email verification
  • Store all OAuth parameters in session

Session Validation

  • Check expiration on every access
  • Verify session_id matches throughout flow
  • Clear expired sessions periodically (cron job)

Security Considerations

  • Session IDs must be cryptographically random
  • Email codes must be 6 random digits
  • Authorization codes must be unguessable
  • All temporary data expires and is cleaned up

Error Handling

Domain Not Verified

{
  "error": "unauthorized_client",
  "error_description": "Domain not configured for this IndieAuth server"
}

Invalid Email Code

{
  "error": "access_denied",
  "error_description": "Invalid verification code"
}

Session Expired

{
  "error": "invalid_request",
  "error_description": "Session expired, please start over"
}

Migration from Current Implementation

  1. Immediate: Disable caching of email verification
  2. Add auth_sessions table: Track per-login authentication state
  3. Modify verification flow: Always require email code
  4. Update domain verification: Separate from user authentication
  5. Clean up old code: Remove improper caching logic

Testing Requirements

Unit Tests

  • Domain verification logic (DNS lookup, caching)
  • Session management (creation, expiration, cleanup)
  • Email code generation and validation
  • Authorization code generation and exchange

Integration Tests

  • Full authorization flow with email verification
  • Multiple concurrent users for same domain
  • Session expiration during flow
  • Domain verification caching behavior

Security Tests

  • Ensure email verification required every login
  • Verify sessions properly isolated between users
  • Test rate limiting on code attempts
  • Verify all codes are single-use

Acceptance Criteria

  1. ✓ Domain verification via DNS TXT is cached appropriately
  2. ✓ Email verification code is required for EVERY login attempt
  3. ✓ Multiple users can authenticate for the same domain independently
  4. ✓ Sessions expire and are cleaned up properly
  5. ✓ Authorization codes are single-use
  6. ✓ Clear separation between domain verification and user authentication
  7. ✓ No security regression from current (broken) implementation

Implementation Priority

CRITICAL: This is a security vulnerability that must be fixed immediately. The current implementation allows unauthenticated access after the first user logs in for a domain.

Notes

The confusion between domain verification and user authentication is a fundamental architectural error. This fix properly separates these concerns:

  • Domain verification establishes trust in the domain configuration (one-time)
  • User authentication establishes trust in the current user (every time)

This aligns with the IndieAuth specification where the authorization endpoint MUST authenticate the user, not just verify the domain.