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>
66 KiB
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:
- Token endpoint handler to exchange authorization codes for access tokens
- Authorization code validation (expiration, single-use, binding verification)
- Opaque access token generation (cryptographically secure)
- Token storage in SQLite database (hashed)
- 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
/authorizeendpoint - 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 specificationerror_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):
-
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)
-
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)
-
Token Revocation (3 tests):
- ✅ Revoke valid token returns True
- ✅ Revoke invalid token returns False
- ✅ Revoked token fails validation
-
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):
-
Success Cases (3 tests):
- ✅ Valid code exchange returns token
- ✅ Response format matches TokenResponse model
- ✅ Authorization code deleted after exchange
-
Grant Type Validation (3 tests):
- ✅ Reject grant_type != "authorization_code"
- ✅ Error response format matches OAuth 2.0 spec
- ✅ Correct error code (unsupported_grant_type)
-
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
-
Client ID Validation (3 tests):
- ✅ Reject client_id mismatch
- ✅ Error code is invalid_client
- ✅ Error description is clear
-
Redirect URI Validation (3 tests):
- ✅ Reject redirect_uri mismatch
- ✅ Error code is invalid_grant
- ✅ Error description is clear
-
Token Generation (3 tests):
- ✅ Token generated and returned
- ✅ Token stored in database (via service)
- ✅ Handle token generation failure gracefully
-
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):
-
Full Flow Tests (3 tests):
- ✅ Complete flow: /authorize → /token → token response
- ✅ Token can be validated after generation
- ✅ Code cannot be reused after token exchange
-
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
-
Database Integration (4 tests):
- ✅ Token persisted to database
- ✅ Token hash stored (not plaintext)
- ✅ Token can be retrieved and validated
- ✅ Token expiration respected
-
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):
-
Code Replay Prevention (2 tests):
- ✅ Second use of same code fails
- ✅ Replay logged as ERROR
-
Binding Validation (3 tests):
- ✅ Cannot use code with different client_id
- ✅ Cannot use code with different redirect_uri
- ✅ Both mismatches logged as ERROR
-
Token Security (3 tests):
- ✅ Token is cryptographically random
- ✅ Token hash is SHA-256 (64 hex characters)
- ✅ Plaintext token never logged
-
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
-
Database Migration (0.5 days)
- Create
003_create_tokens_table.sql - Test migration execution
- Verify schema and indexes
- Create
-
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)
- Implement
-
Configuration Updates (0.5 days)
- Add TOKEN_EXPIRY, TOKEN_CLEANUP_* to Config
- Update .env.example
- Validation logic
-
Dependency Injection (0.5 days)
- Add
get_token_service()to dependencies.py - Test singleton behavior
- Add
-
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)
- Create router in
-
Integration Testing (1 day)
- Full flow tests (/authorize → /token)
- Database integration tests
- Code storage integration tests
- Error response tests
- (15 tests)
-
Security Testing (0.5 days)
- Code replay tests
- Binding validation tests
- Token security tests
- Logging security tests
- (10 tests)
-
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
/authorizeendpoint - 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:
- Developer reviews design document
- Developer asks clarification questions if needed
- Architect updates design based on feedback
- Developer begins implementation following design
- Developer creates implementation report upon completion
- 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