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>
1904 lines
66 KiB
Markdown
1904 lines
66 KiB
Markdown
# 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**
|