Implements Phase 1 Foundation with all core services: Core Components: - Configuration management with GONDULF_ environment variables - Database layer with SQLAlchemy and migration system - In-memory code storage with TTL support - Email service with SMTP and TLS support (STARTTLS + implicit TLS) - DNS service with TXT record verification - Structured logging with Python standard logging - FastAPI application with health check endpoint Database Schema: - authorization_codes table for OAuth 2.0 authorization codes - domains table for domain verification - migrations table for tracking schema versions - Simple sequential migration system (001_initial_schema.sql) Configuration: - Environment-based configuration with validation - .env.example template with all GONDULF_ variables - Fail-fast validation on startup - Sensible defaults for optional settings Testing: - 96 comprehensive tests (77 unit, 5 integration) - 94.16% code coverage (exceeds 80% requirement) - All tests passing - Test coverage includes: - Configuration loading and validation - Database migrations and health checks - In-memory storage with expiration - Email service (STARTTLS, implicit TLS, authentication) - DNS service (TXT records, domain verification) - Health check endpoint integration Documentation: - Implementation report with test results - Phase 1 clarifications document - ADRs for key decisions (config, database, email, logging) Technical Details: - Python 3.10+ with type hints - SQLite with configurable database URL - System DNS with public DNS fallback - Port-based TLS detection (465=SSL, 587=STARTTLS) - Lazy configuration loading for testability Exit Criteria Met: ✓ All foundation services implemented ✓ Application starts without errors ✓ Health check endpoint operational ✓ Database migrations working ✓ Test coverage exceeds 80% ✓ All tests passing Ready for Architect review and Phase 2 development. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
13 KiB
ADR-004: Opaque Tokens for v1.0.0 (Not JWT)
Date: 2025-11-20
Status
Accepted
Context
Access tokens in OAuth 2.0 can be implemented in two primary formats:
1. Opaque Tokens
Random strings with no inherent meaning. Token validation requires database lookup.
Characteristics:
- Random, unpredictable string (e.g.,
secrets.token_urlsafe(32)) - Server stores token metadata in database
- Validation requires database query
- Easily revocable (delete from database)
- No information leakage (token contains no data)
Example:
Token: Xy9kP2mN8fR5tQ1wE7aZ4bV6cG3hJ0sL
Database: {
token_hash: sha256(token),
me: "https://example.com",
client_id: "https://client.example.com",
scope: "",
issued_at: 2025-11-20T10:00:00Z,
expires_at: 2025-11-20T11:00:00Z
}
2. JWT (JSON Web Tokens)
Self-contained tokens encoding claims, signed by server.
Characteristics:
- Base64-encoded JSON with signature
- Contains all metadata (me, client_id, scope, expiration)
- Validation via signature verification (no database lookup)
- Stateless (server doesn't store tokens)
- Revocation complex (requires blocklist or short TTL)
Example:
Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZSI6Imh0dHBzOi8vZXhhbXBsZS5jb20iLCJjbGllbnRfaWQiOiJodHRwczovL2NsaWVudC5leGFtcGxlLmNvbSIsInNjb3BlIjoiIiwiaWF0IjoxNzAwNDgwNDAwLCJleHAiOjE3MDA0ODQwMDB9.signature
Decoded Payload: {
"me": "https://example.com",
"client_id": "https://client.example.com",
"scope": "",
"iat": 1700480400,
"exp": 1700484000
}
v1.0.0 Requirements
Use Case: Authentication only (no authorization)
- Users prove domain ownership
- Tokens confirm user identity to clients
- No resource server in v1.0.0 (no /api endpoints to protect)
- No token introspection endpoint in v1.0.0
Scale: 10s of users
- Dozens of active tokens maximum
- Database lookups negligible performance impact
- No distributed system requirements
Security Priorities:
- Simple, auditable security model
- Easy token revocation (future requirement)
- No information leakage
- No key management complexity
Simplicity Principle:
- Favor straightforward implementations
- Avoid unnecessary complexity
- Minimize dependencies
Decision
Gondulf v1.0.0 will use opaque tokens (NOT JWT).
Tokens will be:
- Generated using
secrets.token_urlsafe(32)(256 bits of entropy) - Stored in SQLite database as SHA-256 hashes
- Validated via database lookup and constant-time comparison
- 1-hour expiration (configurable)
Rationale
Simplicity:
- No signing algorithm selection (HS256 vs RS256 vs ES256)
- No key generation or rotation
- No JWT library dependency
- No clock skew handling
- Simple database lookup, not cryptographic verification
Security:
- No information leakage (token reveals nothing)
- Easy revocation (delete from database)
- No risk of algorithm confusion attacks
- No risk of "none" algorithm vulnerability
- Hashed storage prevents token recovery from database
v1.0.0 Scope Alignment:
- No resource server = no benefit from stateless validation
- Authentication only = simple token validation sufficient
- Small scale = database lookup performance acceptable
- No token introspection endpoint = no external validation needed
Future Flexibility:
- Can migrate to JWT in v2.0.0 if needed
- Database abstraction allows storage changes
- Token format is implementation detail (not exposed to clients)
Consequences
Positive Consequences
-
Simpler Implementation:
- No JWT library dependency
- No signing key management
- No algorithm selection complexity
- Straightforward database operations
-
Better Security (for this use case):
- No information in token (empty payload = no leakage)
- Trivial revocation (DELETE FROM tokens WHERE ...)
- No cryptographic algorithm vulnerabilities
- No key compromise risk
-
Easier Auditing:
- All tokens visible in database
- Clear token lifecycle (creation, usage, expiration)
- Simple query to list all active tokens
- Easy to track token usage
-
Operational Simplicity:
- No key rotation required
- No clock synchronization concerns
- No JWT debugging complexity
- Standard database operations
-
Privacy:
- Token reveals nothing about user
- No accidental PII in token claims
- Bearer token is just a random string
Negative Consequences
-
Database Dependency:
- Token validation requires database access
- Database outage = token validation fails
- Performance limited by database (acceptable for small scale)
-
Not Stateless:
- Cannot validate tokens without database
- Horizontal scaling requires shared database
- Not suitable for distributed resource servers (not needed in v1.0.0)
-
Larger Storage:
- Must store all active tokens in database
- Database grows with token count (cleaned up on expiration)
-
Token Introspection:
- Resource servers cannot validate tokens independently (not needed in v1.0.0)
- Would require introspection endpoint in future
Mitigation Strategies
Database Dependency:
- Acceptable for v1.0.0 (single-process deployment)
- SQLite is reliable (no network dependency)
- Future: Add Redis caching if performance becomes issue
- Future: Migrate to JWT if distributed validation needed
Storage Growth:
- Periodic cleanup of expired tokens
- Configurable expiration (default 1 hour)
- Database indexes on token_hash and expires_at
- Monitor database size, alerts if grows unexpectedly
Future Scaling:
- SQLAlchemy abstraction allows migration to PostgreSQL
- Can add Redis for caching if needed
- Can migrate to JWT in v2.0.0 if requirements change
Implementation
Token Generation
import secrets
import hashlib
from datetime import datetime, timedelta
def generate_token(me: str, client_id: str, scope: str = "") -> str:
"""
Generate opaque access token.
Returns: 43-character base64url string (256 bits of entropy)
"""
# Generate token (returned to client, never stored)
token = secrets.token_urlsafe(32) # 32 bytes = 256 bits
# Hash for storage (SHA-256)
token_hash = hashlib.sha256(token.encode('utf-8')).hexdigest()
# Store in database
expires_at = datetime.utcnow() + timedelta(hours=1)
db.execute('''
INSERT INTO tokens (token_hash, me, client_id, scope, issued_at, expires_at, revoked)
VALUES (?, ?, ?, ?, ?, ?, 0)
''', (token_hash, me, client_id, scope, datetime.utcnow(), expires_at))
logger.info(f"Token generated for {me} (client: {client_id})")
return token # Return plaintext to client (only time it exists in plaintext)
Token Validation
import secrets
import hashlib
from datetime import datetime
def verify_token(provided_token: str) -> Optional[dict]:
"""
Verify access token and return metadata.
Returns: Token metadata dict or None if invalid
"""
# Hash provided token
token_hash = hashlib.sha256(provided_token.encode('utf-8')).hexdigest()
# Lookup in database (constant-time comparison in SQL)
result = db.query_one('''
SELECT me, client_id, scope, expires_at, revoked
FROM tokens
WHERE token_hash = ?
''', (token_hash,))
if not result:
logger.warning("Token not found")
return None
# Check expiration
if datetime.utcnow() > result['expires_at']:
logger.info(f"Token expired for {result['me']}")
return None
# Check revocation
if result['revoked']:
logger.warning(f"Revoked token presented for {result['me']}")
return None
# Valid token
return {
'me': result['me'],
'client_id': result['client_id'],
'scope': result['scope']
}
Token Revocation (Future)
def revoke_token(provided_token: str) -> bool:
"""
Revoke access token.
Returns: True if revoked, False if not found
"""
token_hash = hashlib.sha256(provided_token.encode('utf-8')).hexdigest()
rows_updated = db.execute('''
UPDATE tokens
SET revoked = 1
WHERE token_hash = ?
''', (token_hash,))
if rows_updated > 0:
logger.info(f"Token revoked: {provided_token[:8]}...")
return True
else:
logger.warning(f"Revoke failed: token not found")
return False
Database Schema
CREATE TABLE tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE, -- SHA-256 hash
me TEXT NOT NULL, -- User's domain
client_id TEXT NOT NULL, -- Client application URL
scope TEXT NOT NULL DEFAULT '', -- Empty for v1.0.0
issued_at TIMESTAMP NOT NULL, -- When token created
expires_at TIMESTAMP NOT NULL, -- When token expires
revoked BOOLEAN NOT NULL DEFAULT 0 -- Revocation flag
-- Indexes for performance
CREATE INDEX idx_tokens_hash ON tokens(token_hash);
CREATE INDEX idx_tokens_expires ON tokens(expires_at);
CREATE INDEX idx_tokens_me ON tokens(me);
);
Periodic Cleanup
def cleanup_expired_tokens():
"""
Delete expired tokens from database.
Run periodically (e.g., hourly cron job).
"""
deleted = db.execute('''
DELETE FROM tokens
WHERE expires_at < ?
''', (datetime.utcnow(),))
logger.info(f"Cleaned up {deleted} expired tokens")
Comparison: Opaque vs JWT
| Aspect | Opaque Tokens | JWT |
|---|---|---|
| Complexity | Low (simple random string) | Medium (encoding, signing, claims) |
| Dependencies | None (standard library) | JWT library (python-jose, PyJWT) |
| Validation | Database lookup | Signature verification |
| Performance | Requires DB query (~1-5ms) | No DB query (~0.1ms) |
| Revocation | Trivial (DELETE) | Complex (blocklist required) |
| Stateless | No (requires DB) | Yes (self-contained) |
| Information Leakage | None (opaque) | Possible (claims readable) |
| Token Size | 43 bytes | 150-300 bytes |
| Key Management | Not required | Required (signing key) |
| Clock Skew | Not relevant | Can cause issues (exp claim) |
| Debugging | Simple (query database) | Complex (decode, verify signature) |
| Scale | Limited by DB | Unlimited (stateless) |
Verdict for v1.0.0: Opaque tokens win on simplicity, security, and alignment with MVP scope.
Migration Path to JWT (if needed)
If future requirements demand JWT (e.g., distributed resource servers, token introspection), migration is straightforward:
Step 1: Implement JWT generation alongside opaque tokens
if config.USE_JWT:
return generate_jwt_token(me, client_id, scope)
else:
return generate_opaque_token(me, client_id, scope)
Step 2: Support both token types in validation
if token.startswith('ey'): # JWT starts with 'ey' (base64 of {"alg":...)
return verify_jwt_token(token)
else:
return verify_opaque_token(token)
Step 3: Gradual migration (both types valid)
Step 4: Deprecate opaque tokens (future major version)
Alternatives Considered
Alternative 1: Use JWT from v1.0.0
Pros:
- Industry standard
- Stateless validation
- Self-contained (no DB for validation)
- Better for distributed systems
Cons:
- Adds complexity (signing, key management)
- Requires JWT library dependency
- Harder to revoke
- Not needed for v1.0.0 scope (no resource server)
- Risk of implementation mistakes (algorithm confusion, etc.)
Rejected: Violates simplicity principle, no benefit for v1.0.0 scope.
Alternative 2: Use JWT but store in database anyway
Pros:
- JWT benefits (self-contained)
- Easy revocation (DB lookup)
Cons:
- Worst of both worlds (complexity + database dependency)
- No performance benefit (still requires DB)
- Redundant storage (token + database)
Rejected: Adds complexity without benefits.
Alternative 3: Use Macaroons (fancy tokens)
Pros:
- Advanced capabilities (caveats, delegation)
- Cryptographically interesting
Cons:
- Extreme overkill for authentication
- No standard library support
- Complex implementation
- Not OAuth 2.0 standard
Rejected: Massive complexity for no benefit.
References
- OAuth 2.0 Bearer Token Usage (RFC 6750): https://datatracker.ietf.org/doc/html/rfc6750
- JWT (RFC 7519): https://datatracker.ietf.org/doc/html/rfc7519
- Token Introspection (RFC 7662): https://datatracker.ietf.org/doc/html/rfc7662
- OAuth 2.0 Security Best Practices: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics
Decision History
- 2025-11-20: Proposed (Architect)
- 2025-11-20: Accepted (Architect)
- TBD: Review for v2.0.0 (if JWT needed)