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

1904 lines
66 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**:
```python
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**:
```python
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**:
```python
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**:
```python
@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**:
```sql
-- 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**:
```python
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**:
```python
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`):
```bash
# 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):
```json
{
"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):
```json
{
"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):
```bash
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**:
```json
{
"access_token": "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_",
"token_type": "Bearer",
"me": "https://user.example.com",
"scope": ""
}
```
---
## Data Models
### Access Token (Database Table)
**Table**: `tokens`
**Schema**:
```sql
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**:
```sql
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**:
```python
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**:
```json
{
"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**:
```python
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**:
```python
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**:
```python
# 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**:
```python
# 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**:
```python
# 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**:
```python
# 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**:
```python
# 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**:
```python
# ✅ 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):
```json
{
"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):
```json
{
"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):
```json
{
"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):
```json
{
"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):
```json
{
"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):
```json
{
"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):
```json
{
"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`):
```bash
# 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**