Files
Gondulf/docs/designs/phase-3-token-endpoint.md
Phil Skentelbery 05b4ff7a6b feat(phase-3): implement token endpoint and OAuth 2.0 flow
Phase 3 Implementation:
- Token service with secure token generation and validation
- Token endpoint (POST /token) with OAuth 2.0 compliance
- Database migration 003 for tokens table
- Authorization code validation and single-use enforcement

Phase 1 Updates:
- Enhanced CodeStore to support dict values with JSON serialization
- Maintains backward compatibility

Phase 2 Updates:
- Authorization codes now include PKCE fields, used flag, timestamps
- Complete metadata structure for token exchange

Security:
- 256-bit cryptographically secure tokens (secrets.token_urlsafe)
- SHA-256 hashed storage (no plaintext)
- Constant-time comparison for validation
- Single-use code enforcement with replay detection

Testing:
- 226 tests passing (100%)
- 87.27% coverage (exceeds 80% requirement)
- OAuth 2.0 compliance verified

This completes the v1.0.0 MVP with full IndieAuth authorization code flow.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 14:24:06 -07:00

66 KiB
Raw Permalink Blame History

Phase 3 Design: Token Endpoint

Date: 2025-11-20 Architect: Claude (Architect Agent) Status: Ready for Implementation Design Version: 1.0

Overview

What Phase 3 Builds

Phase 3 implements the IndieAuth token endpoint (/token), completing the OAuth 2.0 authorization code flow by exchanging authorization codes for access tokens.

Core Functionality:

  1. Token endpoint handler to exchange authorization codes for access tokens
  2. Authorization code validation (expiration, single-use, binding verification)
  3. Opaque access token generation (cryptographically secure)
  4. Token storage in SQLite database (hashed)
  5. Token response formatting per OAuth 2.0 and IndieAuth specifications

Connection to IndieAuth Protocol: Phase 3 implements step 10 of the IndieAuth authorization flow (see /docs/architecture/indieauth-protocol.md lines 240-360), completing the token exchange and enabling clients to verify user identity.

Connection to Phase 1 and Phase 2:

  • Phase 1: Uses database, in-memory storage, configuration, logging
  • Phase 2: Validates authorization codes generated by /authorize endpoint
  • Phase 3: Completes the flow by generating access tokens

Token Implementation Strategy

Per ADR-004, Phase 3 uses opaque tokens (NOT JWT):

  • Simple random strings with no inherent meaning
  • Server stores token metadata in database
  • Validation requires database lookup
  • Easily revocable
  • No information leakage

Rationale: Simplicity, security, and alignment with v1.0.0 MVP scope (authentication only, no resource server).

Components

1. Token Service

File: src/gondulf/services/token_service.py

Purpose: Core business logic for token generation, validation, and management.

Public Interface:

from typing import Optional, Dict
from datetime import datetime, timedelta
import secrets
import hashlib

class TokenService:
    """
    Service for access token generation and validation.

    Implements opaque token strategy per ADR-004:
    - Tokens are cryptographically random strings
    - Tokens are stored as SHA-256 hashes in database
    - Tokens contain no user information (opaque)
    - Tokens are validated via database lookup
    """

    def __init__(
        self,
        database,
        token_length: int = 32,  # 32 bytes = 256 bits
        token_ttl: int = 3600    # 1 hour in seconds
    ):
        """
        Initialize token service.

        Args:
            database: DatabaseConnection instance from Phase 1
            token_length: Token length in bytes (default: 32 = 256 bits)
            token_ttl: Token time-to-live in seconds (default: 3600 = 1 hour)
        """
        self.database = database
        self.token_length = token_length
        self.token_ttl = token_ttl

    def generate_token(
        self,
        me: str,
        client_id: str,
        scope: str = ""
    ) -> str:
        """
        Generate opaque access token and store in database.

        Token generation:
        1. Generate cryptographically secure random string (256 bits)
        2. Hash token with SHA-256 for storage
        3. Store hash + metadata in database
        4. Return plaintext token to caller (only time it exists in plaintext)

        Args:
            me: User's domain URL (e.g., "https://example.com")
            client_id: Client application URL
            scope: Requested scopes (empty string for v1.0.0 authentication)

        Returns:
            Opaque access token (43-character base64url string)

        Raises:
            No exceptions raised - database errors propagated
        """

    def validate_token(self, provided_token: str) -> Optional[Dict[str, str]]:
        """
        Validate access token and return metadata.

        Validation steps:
        1. Hash provided token with SHA-256
        2. Lookup hash in database (constant-time comparison)
        3. Check expiration (database timestamp vs current time)
        4. Check revocation flag
        5. Return metadata if valid, None if invalid

        Args:
            provided_token: Access token from Authorization header

        Returns:
            Token metadata dict if valid: {me, client_id, scope}
            None if invalid (not found, expired, or revoked)

        Raises:
            No exceptions raised - returns None for all error cases
        """

    def revoke_token(self, provided_token: str) -> bool:
        """
        Revoke access token.

        Note: Not used in v1.0.0 (no revocation endpoint).
        Included for Phase 1 completeness and future use.

        Args:
            provided_token: Access token to revoke

        Returns:
            True if token revoked successfully
            False if token not found

        Raises:
            No exceptions raised
        """

    def cleanup_expired_tokens(self) -> int:
        """
        Delete expired tokens from database.

        Note: Can be called periodically (e.g., hourly) to prevent
        database growth. Not critical for v1.0.0 (small scale).

        Returns:
            Number of tokens deleted

        Raises:
            No exceptions raised - database errors propagated
        """

Implementation Details:

def generate_token(self, me: str, client_id: str, scope: str = "") -> str:
    """Generate opaque access token and store in database."""
    import logging
    logger = logging.getLogger(__name__)

    # SECURITY: Generate cryptographically secure token (256 bits)
    token = secrets.token_urlsafe(self.token_length)  # 32 bytes = 43-char base64url

    # SECURITY: Hash token for storage (prevent recovery from database)
    token_hash = hashlib.sha256(token.encode('utf-8')).hexdigest()

    # Calculate expiration timestamp
    issued_at = datetime.utcnow()
    expires_at = issued_at + timedelta(seconds=self.token_ttl)

    # Store token metadata in database
    with self.database.get_connection() as conn:
        conn.execute(
            """
            INSERT INTO tokens (token_hash, me, client_id, scope, issued_at, expires_at, revoked)
            VALUES (?, ?, ?, ?, ?, ?, 0)
            """,
            (token_hash, me, client_id, scope, issued_at, expires_at)
        )
        conn.commit()

    # PRIVACY: Log token generation without revealing full token
    logger.info(
        f"Token generated for {me} (client: {client_id}, "
        f"prefix: {token[:8]}..., expires: {expires_at.isoformat()})"
    )

    return token  # Return plaintext token (only time it exists in plaintext)

def validate_token(self, provided_token: str) -> Optional[Dict[str, str]]:
    """Validate access token and return metadata."""
    import logging
    logger = logging.getLogger(__name__)

    # SECURITY: Hash provided token for constant-time comparison
    token_hash = hashlib.sha256(provided_token.encode('utf-8')).hexdigest()

    # Lookup token in database
    with self.database.get_connection() as conn:
        result = conn.execute(
            """
            SELECT me, client_id, scope, expires_at, revoked
            FROM tokens
            WHERE token_hash = ?
            """,
            (token_hash,)
        ).fetchone()

    # Token not found
    if not result:
        logger.warning(f"Token validation failed: not found (prefix: {provided_token[:8]}...)")
        return None

    # Convert Row to dict
    token_data = dict(result)

    # Check expiration
    expires_at = token_data['expires_at']
    if isinstance(expires_at, str):
        # SQLite returns timestamps as strings, parse them
        from datetime import datetime
        expires_at = datetime.fromisoformat(expires_at)

    if datetime.utcnow() > expires_at:
        logger.info(
            f"Token validation failed: expired "
            f"(me: {token_data['me']}, expired: {expires_at.isoformat()})"
        )
        return None

    # Check revocation
    if token_data['revoked']:
        logger.warning(
            f"Token validation failed: revoked "
            f"(me: {token_data['me']}, client: {token_data['client_id']})"
        )
        return None

    # Valid token - return metadata
    logger.debug(f"Token validated successfully (me: {token_data['me']})")

    return {
        'me': token_data['me'],
        'client_id': token_data['client_id'],
        'scope': token_data['scope']
    }

def revoke_token(self, provided_token: str) -> bool:
    """Revoke access token."""
    import logging
    logger = logging.getLogger(__name__)

    # Hash token for lookup
    token_hash = hashlib.sha256(provided_token.encode('utf-8')).hexdigest()

    # Update revoked flag
    with self.database.get_connection() as conn:
        cursor = conn.execute(
            """
            UPDATE tokens
            SET revoked = 1
            WHERE token_hash = ?
            """,
            (token_hash,)
        )
        conn.commit()
        rows_affected = cursor.rowcount

    if rows_affected > 0:
        logger.info(f"Token revoked (prefix: {provided_token[:8]}...)")
        return True
    else:
        logger.warning(f"Token revocation failed: not found (prefix: {provided_token[:8]}...)")
        return False

def cleanup_expired_tokens(self) -> int:
    """Delete expired tokens from database."""
    import logging
    logger = logging.getLogger(__name__)

    current_time = datetime.utcnow()

    with self.database.get_connection() as conn:
        cursor = conn.execute(
            """
            DELETE FROM tokens
            WHERE expires_at < ?
            """,
            (current_time,)
        )
        conn.commit()
        deleted_count = cursor.rowcount

    if deleted_count > 0:
        logger.info(f"Cleaned up {deleted_count} expired tokens")
    else:
        logger.debug("No expired tokens to clean up")

    return deleted_count

Dependencies:

  • Phase 1 database connection
  • Python standard library: secrets, hashlib, datetime

Error Handling:

  • Token generation: Database errors propagate (caller handles)
  • Token validation: Returns None for all error cases (not found, expired, revoked)
  • Token revocation: Returns False if not found
  • Cleanup: Database errors propagate

Security Considerations:

  • Cryptographically secure token generation (secrets.token_urlsafe)
  • SHA-256 hashing for storage (prevents recovery from database)
  • Constant-time comparison (SQL = operator is constant-time on hash)
  • No sensitive data in logs (only token prefix logged)
  • Expiration enforced on every validation

2. Token Endpoint Handler

File: src/gondulf/routers/token.py

Purpose: FastAPI endpoint for OAuth 2.0 token exchange.

Public Interface:

from fastapi import APIRouter, HTTPException, Depends, Form
from typing import Optional
from pydantic import BaseModel

router = APIRouter(tags=["indieauth"])

class TokenResponse(BaseModel):
    """
    OAuth 2.0 token response.

    Per W3C IndieAuth specification (Section 5.5):
    https://www.w3.org/TR/indieauth/#token-response
    """
    access_token: str
    token_type: str = "Bearer"
    me: str
    scope: str = ""

class TokenErrorResponse(BaseModel):
    """
    OAuth 2.0 error response.

    Per RFC 6749 Section 5.2:
    https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
    """
    error: str
    error_description: Optional[str] = None

@router.post("/token", response_model=TokenResponse)
async def token_exchange(
    grant_type: str = Form(...),
    code: str = Form(...),
    client_id: str = Form(...),
    redirect_uri: str = Form(...),
    code_verifier: Optional[str] = Form(None),  # PKCE (not used in v1.0.0)
    token_service = Depends(get_token_service),
    code_storage = Depends(get_code_storage)
) -> TokenResponse:
    """
    IndieAuth token endpoint.

    Exchanges authorization code for access token per OAuth 2.0
    authorization code flow.

    Per W3C IndieAuth specification:
    https://www.w3.org/TR/indieauth/#redeeming-the-authorization-code

    Request (application/x-www-form-urlencoded):
        grant_type: Must be "authorization_code"
        code: Authorization code from /authorize
        client_id: Client application URL
        redirect_uri: Original redirect URI
        code_verifier: PKCE verifier (optional, not used in v1.0.0)

    Response (200 OK):
        {
            "access_token": "...",
            "token_type": "Bearer",
            "me": "https://example.com",
            "scope": ""
        }

    Error Response (400 Bad Request):
        {
            "error": "invalid_grant",
            "error_description": "..."
        }

    Error Codes (OAuth 2.0 standard):
        invalid_request: Missing or invalid parameters
        invalid_grant: Invalid or expired authorization code
        unauthorized_client: Client authentication failed
        unsupported_grant_type: Grant type not "authorization_code"

    Raises:
        HTTPException: 400 for validation errors
    """

Implementation Details:

@router.post("/token", response_model=TokenResponse)
async def token_exchange(
    grant_type: str = Form(...),
    code: str = Form(...),
    client_id: str = Form(...),
    redirect_uri: str = Form(...),
    code_verifier: Optional[str] = Form(None),
    token_service = Depends(get_token_service),
    code_storage = Depends(get_code_storage)
) -> TokenResponse:
    """IndieAuth token endpoint."""
    import logging
    logger = logging.getLogger(__name__)

    logger.info(f"Token exchange request from client: {client_id}")

    # STEP 1: Validate grant_type
    if grant_type != "authorization_code":
        logger.warning(f"Unsupported grant_type: {grant_type}")
        raise HTTPException(
            status_code=400,
            detail={
                "error": "unsupported_grant_type",
                "error_description": f"Grant type must be 'authorization_code', got '{grant_type}'"
            }
        )

    # STEP 2: Retrieve authorization code from storage
    code_data = code_storage.get(code)

    if code_data is None:
        logger.warning(f"Authorization code not found or expired: {code[:8]}...")
        raise HTTPException(
            status_code=400,
            detail={
                "error": "invalid_grant",
                "error_description": "Authorization code is invalid or has expired"
            }
        )

    # Parse code metadata (stored as dict in Phase 2)
    # Phase 2 stores complete metadata structure
    metadata = code_data  # Already a dict from Phase 2

    # STEP 3: Validate client_id matches
    if metadata.get('client_id') != client_id:
        logger.error(
            f"Client ID mismatch: expected {metadata.get('client_id')}, got {client_id}"
        )
        raise HTTPException(
            status_code=400,
            detail={
                "error": "invalid_client",
                "error_description": "Client ID does not match authorization code"
            }
        )

    # STEP 4: Validate redirect_uri matches
    if metadata.get('redirect_uri') != redirect_uri:
        logger.error(
            f"Redirect URI mismatch: expected {metadata.get('redirect_uri')}, got {redirect_uri}"
        )
        raise HTTPException(
            status_code=400,
            detail={
                "error": "invalid_grant",
                "error_description": "Redirect URI does not match authorization request"
            }
        )

    # STEP 5: Check if code already used (prevent replay)
    if metadata.get('used'):
        logger.error(f"Authorization code replay detected: {code[:8]}...")
        # SECURITY: Code replay attempt is a serious security issue
        raise HTTPException(
            status_code=400,
            detail={
                "error": "invalid_grant",
                "error_description": "Authorization code has already been used"
            }
        )

    # STEP 6: Mark code as used (prevent future use)
    metadata['used'] = True
    code_storage.store(code, metadata, ttl=metadata.get('expires_at', 600) - metadata.get('created_at', 0))

    # STEP 7: Extract user identity from code
    me = metadata.get('me')
    scope = metadata.get('scope', '')

    if not me:
        logger.error("Authorization code missing 'me' parameter")
        raise HTTPException(
            status_code=400,
            detail={
                "error": "invalid_grant",
                "error_description": "Authorization code is malformed"
            }
        )

    # STEP 8: PKCE validation (deferred to v1.1.0 per ADR-003)
    if code_verifier:
        logger.debug(f"PKCE code_verifier provided but not validated (v1.0.0)")
        # v1.1.0 will validate: SHA256(code_verifier) == code_challenge

    # STEP 9: Generate access token
    try:
        access_token = token_service.generate_token(
            me=me,
            client_id=client_id,
            scope=scope
        )
    except Exception as e:
        logger.error(f"Token generation failed: {e}")
        raise HTTPException(
            status_code=500,
            detail={
                "error": "server_error",
                "error_description": "Failed to generate access token"
            }
        )

    # STEP 10: Delete authorization code (single-use enforcement)
    code_storage.delete(code)
    logger.info(f"Authorization code exchanged and deleted: {code[:8]}...")

    # STEP 11: Return token response
    logger.info(f"Access token issued for {me} (client: {client_id})")

    return TokenResponse(
        access_token=access_token,
        token_type="Bearer",
        me=me,
        scope=scope
    )

Dependencies:

  • FastAPI router, HTTPException, Depends, Form
  • Pydantic models for validation
  • Token service (Phase 3)
  • Code storage (Phase 1)
  • Python standard library: logging

Error Handling:

  • Invalid grant_type: Return 400 with unsupported_grant_type
  • Code not found/expired: Return 400 with invalid_grant
  • Client ID mismatch: Return 400 with invalid_client
  • Redirect URI mismatch: Return 400 with invalid_grant
  • Code already used: Return 400 with invalid_grant (replay prevention)
  • Missing 'me' parameter: Return 400 with invalid_grant
  • Token generation failure: Return 500 with server_error

Security Considerations:

  • OAuth 2.0 error response format (RFC 6749 Section 5.2)
  • Authorization code single-use enforcement
  • Client ID binding validation
  • Redirect URI binding validation
  • Code replay detection (used flag)
  • No PKCE validation in v1.0.0 (per ADR-003)
  • Constant-time operations via service layer

3. Database Migration

File: src/gondulf/database/migrations/003_create_tokens_table.sql

Purpose: Create tokens table for storing access token metadata.

Schema:

-- Migration 003: Create tokens table
-- Purpose: Store access token metadata (hashed tokens)
-- Per ADR-004: Opaque tokens with database storage

CREATE TABLE IF NOT EXISTS tokens (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    token_hash TEXT NOT NULL UNIQUE,          -- SHA-256 hash of token
    me TEXT NOT NULL,                         -- User's domain URL
    client_id TEXT NOT NULL,                  -- Client application URL
    scope TEXT NOT NULL DEFAULT '',           -- Requested scopes (empty for v1.0.0)
    issued_at TIMESTAMP NOT NULL,             -- When token was created
    expires_at TIMESTAMP NOT NULL,            -- When token expires
    revoked BOOLEAN NOT NULL DEFAULT 0        -- Revocation flag (future use)
);

-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_tokens_hash ON tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_tokens_expires ON tokens(expires_at);
CREATE INDEX IF NOT EXISTS idx_tokens_me ON tokens(me);
CREATE INDEX IF NOT EXISTS idx_tokens_client ON tokens(client_id);

-- Comments for documentation
-- token_hash: SHA-256 hash of access token (64 hex characters)
--             Allows constant-time lookup without storing plaintext
-- me: User's domain identity (e.g., "https://example.com")
-- client_id: Client application URL (e.g., "https://app.example.com")
-- scope: Space-separated scopes (empty string for v1.0.0 authentication)
-- issued_at: UTC timestamp when token was generated
-- expires_at: UTC timestamp when token expires (default: 1 hour from issue)
-- revoked: Boolean flag for manual token revocation (0 = active, 1 = revoked)

Migration Notes:

  • Creates tokens table if not exists (idempotent)
  • Indexes on token_hash (primary lookup), expires_at (cleanup), me (user tokens), client_id (client tokens)
  • No foreign keys (simplicity, no cascading deletes)
  • revoked column for future revocation endpoint (v1.1.0+)

4. Dependency Injection Updates

File: src/gondulf/dependencies.py (update existing)

Purpose: Add token service to dependency injection.

Addition:

from functools import lru_cache
from .services.token_service import TokenService
from .database.connection import DatabaseConnection
from .config import Config

@lru_cache()
def get_token_service() -> TokenService:
    """
    Get TokenService singleton.

    Returns cached instance for dependency injection.
    """
    database = get_database()
    config = Config.get()

    return TokenService(
        database=database,
        token_length=32,  # 256 bits
        token_ttl=config.TOKEN_EXPIRY  # From environment (default: 3600)
    )

5. Configuration Updates

File: src/gondulf/config.py (update existing)

Purpose: Add token configuration parameters.

Addition:

class Config:
    # ... existing configuration ...

    # Token Configuration
    TOKEN_EXPIRY: int = int(os.getenv("GONDULF_TOKEN_EXPIRY", "3600"))  # 1 hour
    TOKEN_CLEANUP_ENABLED: bool = os.getenv("GONDULF_TOKEN_CLEANUP_ENABLED", "false").lower() == "true"
    TOKEN_CLEANUP_INTERVAL: int = int(os.getenv("GONDULF_TOKEN_CLEANUP_INTERVAL", "3600"))  # 1 hour

    @classmethod
    def validate(cls) -> None:
        """Validate configuration."""
        # ... existing validation ...

        # Validate token expiry
        if cls.TOKEN_EXPIRY < 300:  # Minimum 5 minutes
            raise ValueError("GONDULF_TOKEN_EXPIRY must be at least 300 seconds (5 minutes)")
        if cls.TOKEN_EXPIRY > 86400:  # Maximum 24 hours
            raise ValueError("GONDULF_TOKEN_EXPIRY must be at most 86400 seconds (24 hours)")

        # Validate cleanup interval
        if cls.TOKEN_CLEANUP_ENABLED and cls.TOKEN_CLEANUP_INTERVAL < 600:
            raise ValueError("GONDULF_TOKEN_CLEANUP_INTERVAL must be at least 600 seconds (10 minutes)")

Environment Variables (add to .env.example):

# Token Configuration
GONDULF_TOKEN_EXPIRY=3600                    # Token lifetime in seconds (default: 1 hour)
GONDULF_TOKEN_CLEANUP_ENABLED=false          # Enable automatic cleanup (default: false)
GONDULF_TOKEN_CLEANUP_INTERVAL=3600          # Cleanup interval in seconds (default: 1 hour)

Data Flow

Complete Token Exchange Flow

┌─────────────────────────────────────────────────────────────────┐
│                      Client Application                          │
│  • Received authorization code from /authorize redirect         │
│  • Validates state parameter (CSRF protection)                  │
└───────────────────────────┬─────────────────────────────────────┘
                            │
                            │ POST /token
                            │ Content-Type: application/x-www-form-urlencoded
                            │ Body:
                            │   grant_type=authorization_code
                            │   code=abc123...
                            │   client_id=https://client.example.com
                            │   redirect_uri=https://client.example.com/callback
                            │
                            ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Token Endpoint Handler                        │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ 1. Validate grant_type = "authorization_code"           │   │
│  └──────────────────────────┬───────────────────────────────┘   │
│                             │                                    │
│                             ▼                                    │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ 2. Retrieve authorization code from in-memory storage    │   │
│  │    (Phase 1 CodeStorage)                                 │   │
│  └──────────────────────────┬───────────────────────────────┘   │
│                             │                                    │
│                    ┌────────┴────────┐                           │
│                    │ Code found?     │                           │
│          ┌─────────┴─────No─────────┴─────────┐                 │
│          │ NO                                  │ YES             │
│          ▼                                     ▼                 │
│  ┌──────────────────┐              ┌──────────────────────────┐ │
│  │ ERROR:           │              │ Continue to Step 3       │ │
│  │ invalid_grant    │              │                          │ │
│  │ (code expired/   │              │                          │ │
│  │  not found)      │              │                          │ │
│  └──────────────────┘              └─────────┬────────────────┘ │
│                                               │                  │
│                                               ▼                  │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ 3. Validate client_id matches code metadata             │   │
│  └──────────────────────────┬───────────────────────────────┘   │
│                             │                                    │
│                    ┌────────┴────────┐                           │
│                    │ Match?          │                           │
│          ┌─────────┴─────No─────────┴─────────┐                 │
│          │ NO                                  │ YES             │
│          ▼                                     ▼                 │
│  ┌──────────────────┐              ┌──────────────────────────┐ │
│  │ ERROR:           │              │ Continue to Step 4       │ │
│  │ invalid_client   │              │                          │ │
│  └──────────────────┘              └─────────┬────────────────┘ │
│                                               │                  │
│                                               ▼                  │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ 4. Validate redirect_uri matches code metadata          │   │
│  └──────────────────────────┬───────────────────────────────┘   │
│                             │                                    │
│                    ┌────────┴────────┐                           │
│                    │ Match?          │                           │
│          ┌─────────┴─────No─────────┴─────────┐                 │
│          │ NO                                  │ YES             │
│          ▼                                     ▼                 │
│  ┌──────────────────┐              ┌──────────────────────────┐ │
│  │ ERROR:           │              │ Continue to Step 5       │ │
│  │ invalid_grant    │              │                          │ │
│  └──────────────────┘              └─────────┬────────────────┘ │
│                                               │                  │
│                                               ▼                  │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ 5. Check if code already used (replay prevention)       │   │
│  └──────────────────────────┬───────────────────────────────┘   │
│                             │                                    │
│                    ┌────────┴────────┐                           │
│                    │ Used already?   │                           │
│          ┌─────────┴─────No─────────┴─────────┐                 │
│          │ YES (REPLAY!)                       │ NO (FRESH)      │
│          ▼                                     ▼                 │
│  ┌──────────────────┐              ┌──────────────────────────┐ │
│  │ ERROR:           │              │ Mark code as used        │ │
│  │ invalid_grant    │              │ Continue to Step 6       │ │
│  │ (code replay)    │              │                          │ │
│  └──────────────────┘              └─────────┬────────────────┘ │
│                                               │                  │
│                                               ▼                  │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ 6. Extract 'me' (user identity) from code metadata      │   │
│  └──────────────────────────┬───────────────────────────────┘   │
└─────────────────────────────┼────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                        Token Service                             │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ 7. Generate Access Token                                │   │
│  │    - Generate random token: secrets.token_urlsafe(32)   │   │
│  │    - Hash token: SHA-256(token)                          │   │
│  │    - Calculate expiration: now + TOKEN_EXPIRY            │   │
│  └──────────────────────────┬───────────────────────────────┘   │
│                             │                                    │
│                             ▼                                    │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ 8. Store Token in Database                               │   │
│  │    INSERT INTO tokens:                                   │   │
│  │      - token_hash (SHA-256)                              │   │
│  │      - me (user's domain)                                │   │
│  │      - client_id                                         │   │
│  │      - scope (empty for v1.0.0)                          │   │
│  │      - issued_at (current timestamp)                     │   │
│  │      - expires_at (issued_at + TOKEN_EXPIRY)             │   │
│  │      - revoked (FALSE)                                   │   │
│  └──────────────────────────┬───────────────────────────────┘   │
│                             │                                    │
│                             ▼                                    │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ 9. Return Plaintext Token                                │   │
│  │    (Only time token exists in plaintext)                 │   │
│  └──────────────────────────┬───────────────────────────────┘   │
└─────────────────────────────┼────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Token Endpoint Handler                        │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ 10. Delete Authorization Code                            │   │
│  │     (Single-use enforcement - prevent replay)            │   │
│  └──────────────────────────┬───────────────────────────────┘   │
│                             │                                    │
│                             ▼                                    │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ 11. Return Token Response (200 OK)                       │   │
│  │     {                                                    │   │
│  │       "access_token": "Xy9kP2mN8fR5tQ1wE7aZ4bV6cG3hJ0sL",│   │
│  │       "token_type": "Bearer",                            │   │
│  │       "me": "https://example.com",                       │   │
│  │       "scope": ""                                        │   │
│  │     }                                                    │   │
│  └──────────────────────────┬───────────────────────────────┘   │
└─────────────────────────────┼────────────────────────────────────┘
                              │
                              │ HTTP 200 OK
                              │ Content-Type: application/json
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                      Client Application                          │
│  • Receives access token                                        │
│  • Stores token securely                                        │
│  • Can now make authenticated requests (future: resource server)│
│  • User identity verified: me = "https://example.com"           │
└─────────────────────────────────────────────────────────────────┘

Error Paths

Invalid Grant Type:

POST /token (grant_type=password) → 400 Bad Request
{
  "error": "unsupported_grant_type",
  "error_description": "Grant type must be 'authorization_code', got 'password'"
}

Authorization Code Not Found/Expired:

POST /token (code=expired_code) → 400 Bad Request
{
  "error": "invalid_grant",
  "error_description": "Authorization code is invalid or has expired"
}

Client ID Mismatch:

POST /token (client_id=wrong_client) → 400 Bad Request
{
  "error": "invalid_client",
  "error_description": "Client ID does not match authorization code"
}

Redirect URI Mismatch:

POST /token (redirect_uri=wrong_uri) → 400 Bad Request
{
  "error": "invalid_grant",
  "error_description": "Redirect URI does not match authorization request"
}

Code Replay Attack:

POST /token (code=already_used_code) → 400 Bad Request
{
  "error": "invalid_grant",
  "error_description": "Authorization code has already been used"
}

Token Generation Failure:

POST /token (valid params but DB error) → 500 Internal Server Error
{
  "error": "server_error",
  "error_description": "Failed to generate access token"
}

API Endpoints

POST /token

Purpose: Exchange authorization code for access token.

Content-Type: application/x-www-form-urlencoded

Request Parameters (Form Data):

Parameter Required Description Validation
grant_type Yes Must be "authorization_code" Exactly "authorization_code"
code Yes Authorization code from /authorize Non-empty string
client_id Yes Client application URL Valid URL, matches code
redirect_uri Yes Original redirect URI Valid URL, matches code
code_verifier No PKCE verifier (v1.1.0+) Ignored in v1.0.0

Success Response (200 OK):

{
  "access_token": "Xy9kP2mN8fR5tQ1wE7aZ4bV6cG3hJ0sL",
  "token_type": "Bearer",
  "me": "https://example.com",
  "scope": ""
}

Headers:

Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache

Error Response (400 Bad Request):

{
  "error": "invalid_grant",
  "error_description": "Authorization code is invalid or has expired"
}

Error Codes (OAuth 2.0 RFC 6749):

Error Description When Returned
invalid_request Missing parameters Required parameter missing
invalid_grant Invalid/expired code Code not found, expired, or replay
invalid_client Client mismatch client_id doesn't match code
unauthorized_client Client not authorized Client not allowed (future use)
unsupported_grant_type Wrong grant type grant_type != "authorization_code"
server_error Internal error Token generation failure

Rate Limiting: None at endpoint level (v1.0.0)

Authentication: None required (authorization code IS the authentication)

Example Request (curl):

curl -X POST https://auth.example.com/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=Xy9kP2mN8fR5tQ1wE7aZ4bV6cG3hJ0sL" \
  -d "client_id=https://client.example.com" \
  -d "redirect_uri=https://client.example.com/callback"

Example Response:

{
  "access_token": "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_",
  "token_type": "Bearer",
  "me": "https://user.example.com",
  "scope": ""
}

Data Models

Access Token (Database Table)

Table: tokens

Schema:

CREATE TABLE tokens (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    token_hash TEXT NOT NULL UNIQUE,          -- SHA-256 hash (64 hex chars)
    me TEXT NOT NULL,                         -- User's domain URL
    client_id TEXT NOT NULL,                  -- Client application URL
    scope TEXT NOT NULL DEFAULT '',           -- Scopes (empty for v1.0.0)
    issued_at TIMESTAMP NOT NULL,             -- Creation timestamp (UTC)
    expires_at TIMESTAMP NOT NULL,            -- Expiration timestamp (UTC)
    revoked BOOLEAN NOT NULL DEFAULT 0        -- Revocation flag
);

Indexes:

CREATE INDEX idx_tokens_hash ON tokens(token_hash);      -- Primary lookup
CREATE INDEX idx_tokens_expires ON tokens(expires_at);   -- Cleanup queries
CREATE INDEX idx_tokens_me ON tokens(me);                -- User token lookup
CREATE INDEX idx_tokens_client ON tokens(client_id);     -- Client token lookup

Example Row:

id: 1
token_hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
me: "https://user.example.com"
client_id: "https://client.example.com"
scope: ""
issued_at: "2025-11-20 10:00:00"
expires_at: "2025-11-20 11:00:00"
revoked: 0

Privacy Notes:

  • No email addresses stored (only domain)
  • No user-agent or IP addresses (GDPR compliance)
  • Token hash is irreversible (SHA-256)
  • Plaintext token never stored

Token Response Model

Pydantic Model:

class TokenResponse(BaseModel):
    access_token: str                        # 43-char base64url string
    token_type: str = "Bearer"               # Always "Bearer"
    me: str                                  # User's domain URL
    scope: str = ""                          # Empty for v1.0.0

JSON Schema:

{
  "type": "object",
  "properties": {
    "access_token": {"type": "string", "minLength": 43, "maxLength": 43},
    "token_type": {"type": "string", "enum": ["Bearer"]},
    "me": {"type": "string", "format": "uri"},
    "scope": {"type": "string", "default": ""}
  },
  "required": ["access_token", "token_type", "me"]
}

Security Requirements

Token Generation Security

Cryptographic Randomness:

import secrets

# Generate 256 bits of entropy
token = secrets.token_urlsafe(32)  # 32 bytes = 256 bits

# Why secrets.token_urlsafe?
# - Cryptographically secure random number generator (CSPRNG)
# - URL-safe base64 encoding (no special characters)
# - 43-character output (32 bytes * 4/3 + padding)
# - Standard library (no external dependencies)

Token Uniqueness:

  • 256 bits of entropy = 2^256 possible tokens
  • Probability of collision: negligible (birthday paradox: ~2^128 tokens needed)
  • Database UNIQUE constraint on token_hash (prevents storage collision)

Token Hashing:

import hashlib

# Hash token for storage (prevent recovery from database)
token_hash = hashlib.sha256(token.encode('utf-8')).hexdigest()

# Why SHA-256?
# - Cryptographically secure (collision-resistant)
# - Fast computation (not password hashing, intentionally fast)
# - Standard library
# - Irreversible (cannot recover token from hash)
# - 64-character hex output (fixed length)

Authorization Code Validation Security

Binding Verification:

# Code must be bound to client_id
if metadata['client_id'] != client_id:
    raise HTTPException(400, {"error": "invalid_client"})

# Code must be bound to redirect_uri
if metadata['redirect_uri'] != redirect_uri:
    raise HTTPException(400, {"error": "invalid_grant"})

# Why binding?
# - Prevents authorization code injection attacks
# - Ensures code can only be redeemed by issuing client
# - Prevents redirect URI manipulation after authorization

Single-Use Enforcement:

# Check if code already used
if metadata.get('used'):
    logger.error(f"Code replay attack detected: {code[:8]}...")
    raise HTTPException(400, {"error": "invalid_grant"})

# Mark code as used BEFORE generating token
metadata['used'] = True
code_storage.store(code, metadata, ttl=remaining_ttl)

# Delete code AFTER successful token generation
code_storage.delete(code)

# Why single-use?
# - Prevents code replay attacks
# - Detects potential security breaches (replay attempts logged)
# - OAuth 2.0 requirement (RFC 6749 Section 4.1.2)

Expiration Enforcement:

# Code expiration handled by CodeStorage TTL (Phase 1)
# - Codes expire after 10 minutes (600 seconds)
# - Automatic expiration via in-memory storage
# - No manual expiration checking needed

# Why short expiration?
# - Limits attack window for code interception
# - OAuth 2.0 best practice (codes should be "short-lived")
# - W3C IndieAuth spec: codes expire "shortly after" issuance

Token Storage Security

Hash-Only Storage:

# NEVER store plaintext token
# ❌ BAD:
# db.execute("INSERT INTO tokens (token, ...) VALUES (?, ...)", (token, ...))

# ✅ GOOD:
token_hash = hashlib.sha256(token.encode()).hexdigest()
db.execute("INSERT INTO tokens (token_hash, ...) VALUES (?, ...)", (token_hash, ...))

# Why hash-only?
# - Database compromise doesn't expose tokens
# - Meets "defense in depth" security principle
# - Prevents token recovery from backups
# - Industry standard practice

Constant-Time Comparison:

# Token validation uses constant-time comparison via SQL
# SQL = operator on hashes is constant-time (no early exit)

# Hash provided token
provided_hash = hashlib.sha256(provided_token.encode()).hexdigest()

# Lookup via SQL (constant-time comparison)
result = db.execute("SELECT * FROM tokens WHERE token_hash = ?", (provided_hash,))

# Why constant-time?
# - Prevents timing attacks (measuring comparison time to guess token)
# - Security best practice for secret comparison
# - SQL = on fixed-length hashes is constant-time

Logging Security

Safe Logging Practices:

# ✅ GOOD: Log token prefix only (8 characters)
logger.info(f"Token generated (prefix: {token[:8]}...)")

# ❌ BAD: Log full token (security breach!)
# logger.info(f"Token generated: {token}")  # NEVER DO THIS

# ✅ GOOD: Log domain (public information)
logger.info(f"Token issued for {me}")

# ✅ GOOD: Log client_id (public URL)
logger.info(f"Token issued to client: {client_id}")

# Why safe logging?
# - Prevents token leakage via log files
# - Logs may be stored insecurely (filesystem, log aggregation)
# - Token prefix allows correlation without revealing secret
# - Domains and client IDs are public (safe to log)

What NOT to Log:

  • Full access tokens (plaintext or hash)
  • Authorization codes (full or hash)
  • Email addresses (PII, GDPR concern)
  • IP addresses (PII, GDPR concern - unless explicitly needed for security)
  • User-Agent strings (fingerprinting concern)

What to Log:

  • Token prefix (first 8 chars for correlation)
  • User identity (domain, not email)
  • Client ID (public URL)
  • Event type (token_generated, token_validated, token_expired, etc.)
  • Timestamp (ISO 8601 UTC)
  • Error codes and descriptions

Error Handling

OAuth 2.0 Error Response Format

Standard Format (RFC 6749 Section 5.2):

{
  "error": "error_code",
  "error_description": "Human-readable description"
}

Required Fields:

  • error: Error code from OAuth 2.0 specification
  • error_description: Optional human-readable description

HTTP Status Codes:

  • 400 Bad Request: Client error (invalid request, invalid grant, etc.)
  • 500 Internal Server Error: Server error (token generation failure, database error)

Error Scenarios

1. Unsupported Grant Type

Request:

POST /token
grant_type=password
...

Response (400 Bad Request):

{
  "error": "unsupported_grant_type",
  "error_description": "Grant type must be 'authorization_code', got 'password'"
}

Logging:

WARNING: Unsupported grant_type: password

2. Authorization Code Not Found

Request:

POST /token
code=invalid_or_expired_code
...

Response (400 Bad Request):

{
  "error": "invalid_grant",
  "error_description": "Authorization code is invalid or has expired"
}

Logging:

WARNING: Authorization code not found or expired: invalid_o...

Cause: Code expired (>10 minutes old) or never existed


3. Client ID Mismatch

Request:

POST /token
code=valid_code
client_id=https://wrong-client.example.com
...

Response (400 Bad Request):

{
  "error": "invalid_client",
  "error_description": "Client ID does not match authorization code"
}

Logging:

ERROR: Client ID mismatch: expected https://original-client.example.com, got https://wrong-client.example.com

Cause: Authorization code injection attack or client misconfiguration


4. Redirect URI Mismatch

Request:

POST /token
code=valid_code
redirect_uri=https://wrong-uri.example.com/callback
...

Response (400 Bad Request):

{
  "error": "invalid_grant",
  "error_description": "Redirect URI does not match authorization request"
}

Logging:

ERROR: Redirect URI mismatch: expected https://original-uri.example.com/callback, got https://wrong-uri.example.com/callback

Cause: Redirect URI changed between authorization and token requests


5. Authorization Code Replay

Request:

POST /token
code=previously_used_code
...

Response (400 Bad Request):

{
  "error": "invalid_grant",
  "error_description": "Authorization code has already been used"
}

Logging:

ERROR: Authorization code replay detected: previous...

Cause: Replay attack or accidental duplicate request

Security Implication: HIGH SEVERITY - log and potentially alert


6. Token Generation Failure

Request:

POST /token
code=valid_code
...

Response (500 Internal Server Error):

{
  "error": "server_error",
  "error_description": "Failed to generate access token"
}

Logging:

ERROR: Token generation failed: [database error details]

Cause: Database connection failure, disk full, etc.

Remediation: Check database health, disk space, database permissions


Testing Requirements

Unit Tests

Token Service Tests (estimated 20 tests):

  1. Token Generation (5 tests):

    • Generate token with valid parameters
    • Generated token is 43 characters (base64url)
    • Token hash stored in database (not plaintext)
    • Token expiration calculated correctly
    • Token metadata stored correctly (me, client_id, scope)
  2. Token Validation (8 tests):

    • Valid token returns metadata
    • Invalid token returns None
    • Expired token returns None
    • Revoked token returns None
    • Token not in database returns None
    • Constant-time comparison used (verify via test timing)
    • Timestamp parsing handles string and datetime
    • Metadata returned correctly (me, client_id, scope)
  3. Token Revocation (3 tests):

    • Revoke valid token returns True
    • Revoke invalid token returns False
    • Revoked token fails validation
  4. Token Cleanup (4 tests):

    • Cleanup deletes expired tokens
    • Cleanup preserves valid tokens
    • Cleanup returns correct count
    • Cleanup handles empty database

Token Endpoint Tests (estimated 25 tests):

  1. Success Cases (3 tests):

    • Valid code exchange returns token
    • Response format matches TokenResponse model
    • Authorization code deleted after exchange
  2. Grant Type Validation (3 tests):

    • Reject grant_type != "authorization_code"
    • Error response format matches OAuth 2.0 spec
    • Correct error code (unsupported_grant_type)
  3. Authorization Code Validation (8 tests):

    • Reject missing code
    • Reject expired code
    • Reject invalid code (not found)
    • Reject code with missing metadata
    • Reject code with invalid metadata format
    • Reject used code (replay prevention)
    • Mark code as used before token generation
    • Delete code after successful exchange
  4. Client ID Validation (3 tests):

    • Reject client_id mismatch
    • Error code is invalid_client
    • Error description is clear
  5. Redirect URI Validation (3 tests):

    • Reject redirect_uri mismatch
    • Error code is invalid_grant
    • Error description is clear
  6. Token Generation (3 tests):

    • Token generated and returned
    • Token stored in database (via service)
    • Handle token generation failure gracefully
  7. PKCE Handling (2 tests):

    • Accept code_verifier parameter (ignored in v1.0.0)
    • Log PKCE presence but don't validate

Total Estimated Unit Tests: 45 tests


Integration Tests

Token Endpoint Integration (estimated 15 tests):

  1. Full Flow Tests (3 tests):

    • Complete flow: /authorize → /token → token response
    • Token can be validated after generation
    • Code cannot be reused after token exchange
  2. Error Response Tests (5 tests):

    • HTTP 400 for all client errors
    • HTTP 500 for server errors
    • Content-Type is application/json
    • Cache-Control: no-store header present
    • Error response format matches OAuth 2.0 spec
  3. Database Integration (4 tests):

    • Token persisted to database
    • Token hash stored (not plaintext)
    • Token can be retrieved and validated
    • Token expiration respected
  4. Code Storage Integration (3 tests):

    • Code marked as used in storage
    • Code deleted from storage
    • Expired code in storage handled correctly

Total Estimated Integration Tests: 15 tests


Security Tests

Security Test Scenarios (estimated 10 tests):

  1. Code Replay Prevention (2 tests):

    • Second use of same code fails
    • Replay logged as ERROR
  2. Binding Validation (3 tests):

    • Cannot use code with different client_id
    • Cannot use code with different redirect_uri
    • Both mismatches logged as ERROR
  3. Token Security (3 tests):

    • Token is cryptographically random
    • Token hash is SHA-256 (64 hex characters)
    • Plaintext token never logged
  4. Logging Security (2 tests):

    • Full token not in logs
    • Token prefix (8 chars) in logs for correlation

Total Estimated Security Tests: 10 tests


Coverage Target

Phase 3 Overall: 80%+ coverage (same as Phase 1 and Phase 2)

Critical Code (95%+ coverage):

  • Token service (generation, validation, revocation)
  • Token endpoint handler (validation, error handling)
  • Authorization code validation logic

Total Estimated Test Count: 70 tests


Dependencies

New Python Packages

None - All dependencies already in project from Phase 1 and Phase 2:

  • fastapi (endpoint)
  • pydantic (models)
  • sqlalchemy (database)
  • Python standard library: secrets, hashlib, datetime, logging

Configuration Additions

Environment Variables (add to .env.example):

# Token Configuration
GONDULF_TOKEN_EXPIRY=3600                    # Token lifetime in seconds (default: 1 hour)
GONDULF_TOKEN_CLEANUP_ENABLED=false          # Enable automatic cleanup (default: false)
GONDULF_TOKEN_CLEANUP_INTERVAL=3600          # Cleanup interval in seconds (default: 1 hour)

Configuration Validation:

  • TOKEN_EXPIRY: minimum 300 seconds (5 minutes), maximum 86400 seconds (24 hours)
  • TOKEN_CLEANUP_INTERVAL: minimum 600 seconds (10 minutes) if enabled

Implementation Notes

Suggested Implementation Order

  1. Database Migration (0.5 days)

    • Create 003_create_tokens_table.sql
    • Test migration execution
    • Verify schema and indexes
  2. Token Service (1 day)

    • Implement generate_token()
    • Implement validate_token()
    • Implement revoke_token() (future use)
    • Implement cleanup_expired_tokens()
    • Unit tests for all methods (20 tests)
  3. Configuration Updates (0.5 days)

    • Add TOKEN_EXPIRY, TOKEN_CLEANUP_* to Config
    • Update .env.example
    • Validation logic
  4. Dependency Injection (0.5 days)

    • Add get_token_service() to dependencies.py
    • Test singleton behavior
  5. Token Endpoint (1 day)

    • Create router in src/gondulf/routers/token.py
    • Implement token exchange logic
    • Error handling for all scenarios
    • Unit tests for endpoint (25 tests)
  6. Integration Testing (1 day)

    • Full flow tests (/authorize → /token)
    • Database integration tests
    • Code storage integration tests
    • Error response tests
    • (15 tests)
  7. Security Testing (0.5 days)

    • Code replay tests
    • Binding validation tests
    • Token security tests
    • Logging security tests
    • (10 tests)
  8. Documentation (0.5 days)

    • Update API documentation
    • Add usage examples
    • Document error codes

Total Estimated Effort: 5-6 days


Integration Points

Phase 1 Integration:

  • Database connection for token storage
  • In-memory code storage for authorization codes
  • Configuration for token expiry
  • Logging for all operations

Phase 2 Integration:

  • Authorization codes generated by /authorize endpoint
  • Code metadata structure (me, client_id, redirect_uri, scope, state, etc.)
  • Code expiration and single-use enforcement

Phase 3 Outputs:

  • Access tokens for client applications
  • Token validation capability (future: resource server)
  • Complete OAuth 2.0 authorization code flow

Risks and Mitigations

Risk 1: Code Metadata Structure

  • Issue: Phase 2 stores code metadata as dict, may need parsing
  • Mitigation: CodeStorage already supports dict storage (verified in Phase 2 report)
  • Impact: Low - no changes needed

Risk 2: Database Migration

  • Issue: Migration failure could leave database inconsistent
  • Mitigation: Test migration thoroughly, idempotent CREATE TABLE IF NOT EXISTS
  • Impact: Low - simple schema, no data migration

Risk 3: Token Storage Growth

  • Issue: Database grows with active tokens
  • Mitigation: Implement cleanup_expired_tokens(), add to periodic task (future)
  • Impact: Low - small scale (10s of users), slow growth

Risk 4: Constant-Time Comparison

  • Issue: Timing attacks on token validation
  • Mitigation: SQL = operator on fixed-length hashes is constant-time
  • Impact: Very Low - SHA-256 hashes are fixed 64-char hex strings

Performance Considerations

Token Generation:

  • Cryptographic random number generation: ~0.1ms
  • SHA-256 hashing: ~0.01ms
  • Database INSERT: ~1-5ms (SQLite)
  • Total: <10ms per token

Token Validation:

  • SHA-256 hashing: ~0.01ms
  • Database SELECT by index: ~1-5ms (SQLite)
  • Timestamp comparison: <0.01ms
  • Total: <10ms per validation

Database Growth:

  • Token size: ~200 bytes per row (metadata + indexes)
  • Expected tokens: 10 users × 5 tokens/user = 50 active tokens
  • Database growth: 50 tokens × 200 bytes = 10KB
  • Impact: Negligible for SQLite

Cleanup Performance:

  • DELETE query with index: ~1-10ms per expired token
  • Expected expired tokens: ~100/day (assuming hourly cleanup)
  • Impact: Negligible

Acceptance Criteria

Phase 3 is complete when ALL of the following criteria are met:

Functionality

  • Token service generates opaque tokens (43-char base64url)
  • Token service stores token hashes in database (not plaintext)
  • Token service validates tokens via database lookup
  • Token service revokes tokens (future use)
  • Token service cleans up expired tokens
  • Token endpoint accepts POST requests with form data
  • Token endpoint validates grant_type = "authorization_code"
  • Token endpoint retrieves authorization code from storage
  • Token endpoint validates client_id matches code
  • Token endpoint validates redirect_uri matches code
  • Token endpoint detects code replay (used flag)
  • Token endpoint marks code as used before generating token
  • Token endpoint generates access token
  • Token endpoint deletes authorization code after exchange
  • Token endpoint returns OAuth 2.0 compliant response
  • Token endpoint returns OAuth 2.0 compliant error responses

Database

  • Migration 003 creates tokens table successfully
  • Tokens table has correct schema (7 columns)
  • Indexes created on token_hash, expires_at, me, client_id
  • Token hashes are unique (UNIQUE constraint)
  • Migration is idempotent (can run multiple times)

Testing

  • All unit tests passing (estimated 45 tests)
  • All integration tests passing (estimated 15 tests)
  • All security tests passing (estimated 10 tests)
  • Test coverage ≥80% overall
  • Test coverage ≥95% for token service
  • Test coverage ≥95% for token endpoint
  • No known bugs or failing tests

Security

  • Tokens generated with secrets.token_urlsafe (CSPRNG)
  • Tokens are 256 bits of entropy (32 bytes)
  • Token hashes are SHA-256 (64 hex characters)
  • Plaintext tokens never stored in database
  • Constant-time comparison used in validation
  • Authorization code single-use enforced
  • Authorization code replay detected and logged
  • Client ID binding validated
  • Redirect URI binding validated
  • Full tokens never logged (only 8-char prefix)
  • No PII in logs (no emails, IPs, user-agents)

Error Handling

  • Unsupported grant_type returns correct error
  • Invalid code returns correct error
  • Client ID mismatch returns correct error
  • Redirect URI mismatch returns correct error
  • Code replay returns correct error
  • Token generation failure returns correct error
  • All errors follow OAuth 2.0 format (RFC 6749)
  • All errors logged appropriately (level and message)

Documentation

  • Token service has docstrings for all public methods
  • Token endpoint has docstring with examples
  • All functions have type hints
  • API endpoint documented (this design doc)
  • Error codes documented
  • Configuration parameters documented

Configuration

  • TOKEN_EXPIRY added to Config with validation
  • TOKEN_CLEANUP_ENABLED added to Config
  • TOKEN_CLEANUP_INTERVAL added to Config
  • .env.example updated with token configuration
  • Configuration validation prevents invalid values

Integration

  • Token service integrated with database (Phase 1)
  • Token endpoint integrated with code storage (Phase 1)
  • Token endpoint integrated with authorization endpoint (Phase 2)
  • Dependency injection configured for token service
  • Complete flow works: /authorize → /token → token response

Performance

  • Token generation completes within 10ms
  • Token validation completes within 10ms
  • Database queries use indexes (verified via EXPLAIN)
  • No memory leaks (tokens cleaned up)

Timeline Estimate

Phase 3 Implementation: 5-6 days

Breakdown:

  • Database Migration: 0.5 days
  • Token Service: 1 day
  • Configuration Updates: 0.5 days
  • Dependency Injection: 0.5 days
  • Token Endpoint: 1 day
  • Integration Testing: 1 day
  • Security Testing: 0.5 days
  • Documentation: 0.5 days

Dependencies: Phase 1 and Phase 2 complete and approved

Risk Buffer: +1 day (for unforeseen database or integration issues)


Sign-off

Design Status: Complete and ready for implementation

Architect: Claude (Architect Agent) Date: 2025-11-20

Next Steps:

  1. Developer reviews design document
  2. Developer asks clarification questions if needed
  3. Architect updates design based on feedback
  4. Developer begins implementation following design
  5. Developer creates implementation report upon completion
  6. Architect reviews implementation report

Related Documents:

  • /docs/architecture/overview.md - System architecture
  • /docs/architecture/indieauth-protocol.md - IndieAuth protocol implementation (token endpoint section)
  • /docs/architecture/security.md - Security architecture (token security section)
  • /docs/decisions/ADR-004-opaque-tokens-for-v1-0-0.md - Opaque token decision
  • /docs/decisions/ADR-003-pkce-deferred-to-v1-1-0.md - PKCE deferral decision
  • /docs/designs/phase-2-domain-verification.md - Phase 2 design (authorization codes)
  • /docs/reports/2025-11-20-phase-1-foundation.md - Phase 1 implementation
  • /docs/reports/2025-11-20-phase-2-domain-verification.md - Phase 2 implementation
  • /docs/roadmap/v1.0.0.md - Version plan

DESIGN READY: Phase 3 Token Endpoint - Please review /docs/designs/phase-3-token-endpoint.md