feat(phase-3): implement token endpoint and OAuth 2.0 flow

Phase 3 Implementation:
- Token service with secure token generation and validation
- Token endpoint (POST /token) with OAuth 2.0 compliance
- Database migration 003 for tokens table
- Authorization code validation and single-use enforcement

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-20 14:24:06 -07:00
parent 074f74002c
commit 05b4ff7a6b
18 changed files with 4049 additions and 26 deletions

View File

@@ -0,0 +1,219 @@
"""Token endpoint for OAuth 2.0 / IndieAuth token exchange."""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, Form, HTTPException, Response
from pydantic import BaseModel
from gondulf.dependencies import get_code_storage, get_token_service
from gondulf.services.token_service import TokenService
from gondulf.storage import CodeStore
logger = logging.getLogger("gondulf.token")
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(
response: Response,
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: TokenService = Depends(get_token_service),
code_storage: CodeStore = 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
invalid_client: Client authentication failed
unsupported_grant_type: Grant type not "authorization_code"
Raises:
HTTPException: 400 for validation errors, 500 for server errors
"""
# Set OAuth 2.0 cache headers (RFC 6749 Section 5.1)
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
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
storage_key = f"authz:{code}"
code_data = code_storage.get(storage_key)
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"
}
)
# code_data should be a dict from Phase 2
if not isinstance(code_data, dict):
logger.error(f"Authorization code metadata is not a dict: {type(code_data)}")
raise HTTPException(
status_code=400,
detail={
"error": "invalid_grant",
"error_description": "Authorization code is malformed"
}
)
# STEP 3: Validate client_id matches
if code_data.get('client_id') != client_id:
logger.error(
f"Client ID mismatch: expected {code_data.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 code_data.get('redirect_uri') != redirect_uri:
logger.error(
f"Redirect URI mismatch: expected {code_data.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 code_data.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: Extract user identity from code
me = code_data.get('me')
scope = code_data.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 7: 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 8: 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 9: Delete authorization code (single-use enforcement)
code_storage.delete(storage_key)
logger.info(f"Authorization code exchanged and deleted: {code[:8]}...")
# STEP 10: 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
)