Files
Gondulf/src/gondulf/routers/authorization.py
Phil Skentelbery 9135edfe84 fix(auth): require email authentication every login
CRITICAL SECURITY FIX:
- Email code required EVERY login (authentication, not verification)
- DNS TXT check cached separately (domain verification)
- New auth_sessions table for per-login state
- Codes hashed with SHA-256, constant-time comparison
- Max 3 attempts, 10-minute session expiry
- OAuth params stored server-side (security improvement)

New files:
- services/auth_session.py
- migrations 004, 005
- ADR-010: domain verification vs user authentication

312 tests passing, 86.21% coverage

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 15:16:26 -07:00

872 lines
30 KiB
Python

"""Authorization endpoint for OAuth 2.0 / IndieAuth authorization code flow.
Supports both IndieAuth flows per W3C specification:
- Authentication (response_type=id): Returns user identity only, code redeemed at authorization endpoint
- Authorization (response_type=code): Returns access token, code redeemed at token endpoint
IMPORTANT: This implementation correctly separates:
- Domain verification (DNS TXT check) - one-time, can be cached
- User authentication (email code) - EVERY login, NEVER cached
See ADR-010 for the architectural decision behind this separation.
"""
import logging
from datetime import datetime
from typing import Optional
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Form, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy import text
from gondulf.database.connection import Database
from gondulf.dependencies import (
get_auth_session_service,
get_code_storage,
get_database,
get_dns_service,
get_email_service,
get_happ_parser,
get_html_fetcher,
get_relme_parser,
)
from gondulf.dns import DNSService
from gondulf.email import EmailService
from gondulf.services.auth_session import (
AuthSessionService,
CodeVerificationError,
MaxAttemptsExceededError,
SessionExpiredError,
SessionNotFoundError,
)
from gondulf.services.happ_parser import HAppParser
from gondulf.services.html_fetcher import HTMLFetcherService
from gondulf.services.relme_parser import RelMeParser
from gondulf.storage import CodeStore
from gondulf.utils.validation import (
extract_domain_from_url,
mask_email,
normalize_client_id,
validate_email,
validate_redirect_uri,
)
logger = logging.getLogger("gondulf.authorization")
router = APIRouter()
templates = Jinja2Templates(directory="src/gondulf/templates")
# Valid response types per IndieAuth spec
VALID_RESPONSE_TYPES = {"id", "code"}
# Domain verification cache duration (24 hours)
DOMAIN_VERIFICATION_CACHE_HOURS = 24
class AuthenticationResponse(BaseModel):
"""
IndieAuth authentication response (response_type=id flow).
Per W3C IndieAuth specification Section 5.3.3:
https://www.w3.org/TR/indieauth/#authentication-response
"""
me: str
async def check_domain_dns_verified(database: Database, domain: str) -> bool:
"""
Check if domain has valid DNS TXT record verification (cached).
This checks ONLY the DNS verification status, NOT user authentication.
DNS verification can be cached as it's about domain configuration,
not user identity.
Args:
database: Database service
domain: Domain to check (e.g., "example.com")
Returns:
True if domain has valid cached DNS verification, False otherwise
"""
try:
engine = database.get_engine()
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT verified, last_checked
FROM domains
WHERE domain = :domain AND verified = 1
"""),
{"domain": domain}
)
row = result.fetchone()
if row is None:
return False
# Check if verification is still fresh (within cache window)
last_checked = row[1]
if isinstance(last_checked, str):
last_checked = datetime.fromisoformat(last_checked)
if last_checked:
hours_since_check = (datetime.utcnow() - last_checked).total_seconds() / 3600
if hours_since_check > DOMAIN_VERIFICATION_CACHE_HOURS:
logger.info(f"Domain {domain} DNS verification cache expired")
return False
return True
except Exception as e:
logger.error(f"Failed to check domain DNS verification: {e}")
return False
async def verify_domain_dns(
database: Database,
dns_service: DNSService,
domain: str
) -> bool:
"""
Verify domain DNS TXT record and update cache.
This performs the actual DNS lookup and caches the result.
Args:
database: Database service
dns_service: DNS service for TXT lookup
domain: Domain to verify
Returns:
True if DNS verification successful, False otherwise
"""
try:
# Check DNS TXT record
dns_verified = dns_service.verify_txt_record(domain, "gondulf-verify-domain")
if not dns_verified:
logger.warning(f"DNS verification failed for domain={domain}")
return False
# Update cache in database
engine = database.get_engine()
now = datetime.utcnow()
with engine.begin() as conn:
# Use INSERT OR REPLACE for SQLite
conn.execute(
text("""
INSERT OR REPLACE INTO domains
(domain, email, verification_code, verified, verified_at, last_checked, two_factor)
VALUES (:domain, '', '', 1, :now, :now, 0)
"""),
{"domain": domain, "now": now}
)
logger.info(f"Domain DNS verification successful and cached: {domain}")
return True
except Exception as e:
logger.error(f"DNS verification error for domain={domain}: {e}")
return False
async def discover_email_from_profile(
me_url: str,
html_fetcher: HTMLFetcherService,
relme_parser: RelMeParser
) -> str | None:
"""
Discover email address from user's profile page via rel=me links.
Args:
me_url: User's identity URL
html_fetcher: HTML fetcher service
relme_parser: rel=me parser
Returns:
Email address if found, None otherwise
"""
try:
html = html_fetcher.fetch(me_url)
if not html:
logger.warning(f"Failed to fetch HTML from {me_url}")
return None
email = relme_parser.find_email(html)
if not email:
logger.warning(f"No email found in rel=me links at {me_url}")
return None
if not validate_email(email):
logger.warning(f"Invalid email format discovered: {email}")
return None
return email
except Exception as e:
logger.error(f"Email discovery error for {me_url}: {e}")
return None
@router.get("/authorize")
async def authorize_get(
request: Request,
client_id: str | None = None,
redirect_uri: str | None = None,
response_type: str | None = None,
state: str | None = None,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
scope: str | None = None,
me: str | None = None,
database: Database = Depends(get_database),
dns_service: DNSService = Depends(get_dns_service),
html_fetcher: HTMLFetcherService = Depends(get_html_fetcher),
relme_parser: RelMeParser = Depends(get_relme_parser),
email_service: EmailService = Depends(get_email_service),
auth_session_service: AuthSessionService = Depends(get_auth_session_service),
happ_parser: HAppParser = Depends(get_happ_parser)
) -> HTMLResponse:
"""
Handle authorization request (GET).
Flow:
1. Validate OAuth parameters
2. Check domain DNS verification (cached OK)
3. Discover email from rel=me on user's homepage
4. Send verification code to email (ALWAYS - this is authentication)
5. Create auth session and show code entry form
Args:
request: FastAPI request object
client_id: Client application identifier
redirect_uri: Callback URI for client
response_type: "id" (default) for authentication, "code" for authorization
state: Client state parameter
code_challenge: PKCE code challenge
code_challenge_method: PKCE method (S256)
scope: Requested scope (only meaningful for response_type=code)
me: User identity URL
database: Database service
dns_service: DNS service for domain verification
html_fetcher: HTML fetcher for profile discovery
relme_parser: rel=me parser for email extraction
email_service: Email service for sending codes
auth_session_service: Auth session service for tracking login state
happ_parser: H-app parser for client metadata
Returns:
HTML response with code entry form or error page
"""
# Validate required parameters (pre-client validation)
if not client_id:
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "Missing required parameter: client_id",
"error_code": "invalid_request"
},
status_code=400
)
if not redirect_uri:
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "Missing required parameter: redirect_uri",
"error_code": "invalid_request"
},
status_code=400
)
# Normalize and validate client_id
try:
normalized_client_id = normalize_client_id(client_id)
except ValueError:
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "client_id must use HTTPS",
"error_code": "invalid_request"
},
status_code=400
)
# Validate redirect_uri against client_id
if not validate_redirect_uri(redirect_uri, normalized_client_id):
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "redirect_uri does not match client_id domain",
"error_code": "invalid_request"
},
status_code=400
)
# From here on, redirect errors to client via OAuth error redirect
# Validate response_type - default to "id" if not provided (per IndieAuth spec)
effective_response_type = response_type or "id"
if effective_response_type not in VALID_RESPONSE_TYPES:
error_params = {
"error": "unsupported_response_type",
"error_description": f"response_type must be 'id' or 'code', got '{response_type}'",
"state": state or ""
}
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
return RedirectResponse(url=redirect_url, status_code=302)
# Validate code_challenge (PKCE required)
if not code_challenge:
error_params = {
"error": "invalid_request",
"error_description": "code_challenge is required (PKCE)",
"state": state or ""
}
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
return RedirectResponse(url=redirect_url, status_code=302)
# Validate code_challenge_method
if code_challenge_method != "S256":
error_params = {
"error": "invalid_request",
"error_description": "code_challenge_method must be S256",
"state": state or ""
}
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
return RedirectResponse(url=redirect_url, status_code=302)
# Validate me parameter
if not me:
error_params = {
"error": "invalid_request",
"error_description": "me parameter is required",
"state": state or ""
}
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
return RedirectResponse(url=redirect_url, status_code=302)
# Validate me URL format and extract domain
try:
domain = extract_domain_from_url(me)
except ValueError:
error_params = {
"error": "invalid_request",
"error_description": "Invalid me URL",
"state": state or ""
}
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
return RedirectResponse(url=redirect_url, status_code=302)
# STEP 1: Domain DNS Verification (can be cached)
dns_verified = await check_domain_dns_verified(database, domain)
if not dns_verified:
# Try fresh DNS verification
dns_verified = await verify_domain_dns(database, dns_service, domain)
if not dns_verified:
logger.warning(f"Domain {domain} not DNS verified")
return templates.TemplateResponse(
"verification_error.html",
{
"request": request,
"error": "DNS verification failed. Please add the required TXT record.",
"domain": domain,
"client_id": normalized_client_id,
"redirect_uri": redirect_uri,
"response_type": effective_response_type,
"state": state or "",
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"scope": scope or "",
"me": me
},
status_code=200
)
logger.info(f"Domain {domain} DNS verified (cached or fresh)")
# STEP 2: Discover email from profile (rel=me)
email = await discover_email_from_profile(me, html_fetcher, relme_parser)
if not email:
logger.warning(f"Could not discover email for {me}")
return templates.TemplateResponse(
"verification_error.html",
{
"request": request,
"error": "Could not find an email address on your homepage. Please add a rel='me' link to your email.",
"domain": domain,
"client_id": normalized_client_id,
"redirect_uri": redirect_uri,
"response_type": effective_response_type,
"state": state or "",
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"scope": scope or "",
"me": me
},
status_code=200
)
# STEP 3: Create auth session and send verification code
# THIS IS ALWAYS REQUIRED - email code is authentication, not domain verification
try:
session_result = auth_session_service.create_session(
me=me,
email=email,
client_id=normalized_client_id,
redirect_uri=redirect_uri,
state=state or "",
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
scope=scope or "",
response_type=effective_response_type
)
# Send verification code via email
verification_code = session_result["verification_code"]
email_service.send_verification_code(email, verification_code, domain)
logger.info(f"Verification code sent for {me} (session: {session_result['session_id'][:8]}...)")
except Exception as e:
logger.error(f"Failed to start authentication: {e}")
return templates.TemplateResponse(
"verification_error.html",
{
"request": request,
"error": "Failed to send verification email. Please try again.",
"domain": domain,
"client_id": normalized_client_id,
"redirect_uri": redirect_uri,
"response_type": effective_response_type,
"state": state or "",
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"scope": scope or "",
"me": me
},
status_code=200
)
# STEP 4: Show code entry form
return templates.TemplateResponse(
"verify_code.html",
{
"request": request,
"masked_email": mask_email(email),
"session_id": session_result["session_id"],
"domain": domain,
"client_id": normalized_client_id,
"redirect_uri": redirect_uri,
"response_type": effective_response_type,
"state": state or "",
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"scope": scope or "",
"me": me
}
)
@router.post("/authorize/verify-code")
async def authorize_verify_code(
request: Request,
session_id: str = Form(...),
code: str = Form(...),
auth_session_service: AuthSessionService = Depends(get_auth_session_service),
happ_parser: HAppParser = Depends(get_happ_parser)
) -> HTMLResponse:
"""
Handle verification code submission during authorization flow.
This endpoint is called when user submits the 6-digit email verification code.
On success, shows consent page. On failure, shows code entry form with error.
Args:
request: FastAPI request object
session_id: Auth session identifier
code: 6-digit verification code from email
auth_session_service: Auth session service
happ_parser: H-app parser for client metadata
Returns:
HTML response: consent page on success, code form with error on failure
"""
logger.info(f"Verification code submission for session={session_id[:8]}...")
try:
# Verify the code - this is the authentication step
session = auth_session_service.verify_code(session_id, code)
logger.info(f"Code verified successfully for session={session_id[:8]}...")
# Fetch client metadata for consent page
client_metadata = None
try:
client_metadata = await happ_parser.fetch_and_parse(session["client_id"])
except Exception as e:
logger.warning(f"Failed to fetch client metadata: {e}")
# Show consent form
return templates.TemplateResponse(
"authorize.html",
{
"request": request,
"session_id": session_id,
"client_id": session["client_id"],
"redirect_uri": session["redirect_uri"],
"response_type": session["response_type"],
"state": session["state"],
"code_challenge": session["code_challenge"],
"code_challenge_method": session["code_challenge_method"],
"scope": session["scope"],
"me": session["me"],
"client_metadata": client_metadata
}
)
except SessionNotFoundError:
logger.warning(f"Session not found: {session_id[:8]}...")
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "Session not found or expired. Please start over.",
"error_code": "invalid_request"
},
status_code=400
)
except SessionExpiredError:
logger.warning(f"Session expired: {session_id[:8]}...")
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "Session expired. Please start over.",
"error_code": "invalid_request"
},
status_code=400
)
except MaxAttemptsExceededError:
logger.warning(f"Max attempts exceeded for session: {session_id[:8]}...")
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "Too many incorrect code attempts. Please start over.",
"error_code": "access_denied"
},
status_code=400
)
except CodeVerificationError:
logger.warning(f"Invalid code for session: {session_id[:8]}...")
# Get session to show code entry form again
try:
session = auth_session_service.get_session(session_id)
return templates.TemplateResponse(
"verify_code.html",
{
"request": request,
"error": "Invalid verification code. Please check and try again.",
"masked_email": mask_email(session["email"]),
"session_id": session_id,
"domain": extract_domain_from_url(session["me"]),
"client_id": session["client_id"],
"redirect_uri": session["redirect_uri"],
"response_type": session["response_type"],
"state": session["state"],
"code_challenge": session["code_challenge"],
"code_challenge_method": session["code_challenge_method"],
"scope": session["scope"],
"me": session["me"]
},
status_code=200
)
except Exception:
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "Session not found or expired. Please start over.",
"error_code": "invalid_request"
},
status_code=400
)
@router.post("/authorize/consent")
async def authorize_consent(
request: Request,
session_id: str = Form(...),
auth_session_service: AuthSessionService = Depends(get_auth_session_service),
code_storage: CodeStore = Depends(get_code_storage)
) -> RedirectResponse:
"""
Handle authorization consent (POST).
Validates that the session is authenticated, then creates authorization
code and redirects to client callback.
Args:
request: FastAPI request object
session_id: Auth session identifier
auth_session_service: Auth session service
code_storage: Code storage for authorization codes
Returns:
Redirect to client callback with authorization code
"""
logger.info(f"Authorization consent for session={session_id[:8]}...")
try:
# Get and validate session
session = auth_session_service.get_session(session_id)
# Verify session has been authenticated
if not session.get("code_verified"):
logger.warning(f"Session {session_id[:8]}... not authenticated")
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "Session not authenticated. Please start over.",
"error_code": "access_denied"
},
status_code=400
)
# Create authorization code
import secrets
import time
authorization_code = secrets.token_urlsafe(32)
# Store authorization code with metadata
metadata = {
"client_id": session["client_id"],
"redirect_uri": session["redirect_uri"],
"state": session["state"],
"code_challenge": session["code_challenge"],
"code_challenge_method": session["code_challenge_method"],
"scope": session["scope"],
"me": session["me"],
"response_type": session["response_type"],
"created_at": int(time.time()),
"expires_at": int(time.time()) + 600,
"used": False
}
storage_key = f"authz:{authorization_code}"
code_storage.store(storage_key, metadata)
# Clean up auth session
auth_session_service.delete_session(session_id)
# Build redirect URL with authorization code
redirect_params = {
"code": authorization_code,
"state": session["state"]
}
redirect_url = f"{session['redirect_uri']}?{urlencode(redirect_params)}"
logger.info(
f"Authorization code created for client_id={session['client_id']} "
f"response_type={session['response_type']}"
)
return RedirectResponse(url=redirect_url, status_code=302)
except SessionNotFoundError:
logger.warning(f"Session not found for consent: {session_id[:8]}...")
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "Session not found or expired. Please start over.",
"error_code": "invalid_request"
},
status_code=400
)
except SessionExpiredError:
logger.warning(f"Session expired for consent: {session_id[:8]}...")
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "Session expired. Please start over.",
"error_code": "invalid_request"
},
status_code=400
)
@router.post("/authorize")
async def authorize_post(
response: Response,
code: str = Form(...),
client_id: str = Form(...),
redirect_uri: Optional[str] = Form(None),
code_verifier: Optional[str] = Form(None),
code_storage: CodeStore = Depends(get_code_storage)
) -> JSONResponse:
"""
Handle authorization code verification for authentication flow (response_type=id).
Per W3C IndieAuth specification Section 5.3.3:
https://www.w3.org/TR/indieauth/#redeeming-the-authorization-code-id
This endpoint is used ONLY for the authentication flow (response_type=id).
For the authorization flow (response_type=code), clients must use the token endpoint.
Request (application/x-www-form-urlencoded):
code: Authorization code from /authorize redirect
client_id: Client application URL (must match original request)
redirect_uri: Original redirect URI (optional but recommended)
code_verifier: PKCE verifier (optional, for PKCE validation)
Response (200 OK):
{
"me": "https://user.example.com/"
}
Error Response (400 Bad Request):
{
"error": "invalid_grant",
"error_description": "..."
}
Returns:
JSONResponse with user identity or error
"""
# Set cache headers (OAuth 2.0 best practice)
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
logger.info(f"Authorization code verification request from client: {client_id}")
# STEP 1: 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]}...")
return JSONResponse(
status_code=400,
content={
"error": "invalid_grant",
"error_description": "Authorization code is invalid or has expired"
},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"}
)
# Validate code_data is a dict
if not isinstance(code_data, dict):
logger.error(f"Authorization code metadata is not a dict: {type(code_data)}")
return JSONResponse(
status_code=400,
content={
"error": "invalid_grant",
"error_description": "Authorization code is malformed"
},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"}
)
# STEP 2: Validate this code was issued for response_type=id
stored_response_type = code_data.get('response_type', 'id')
if stored_response_type != 'id':
logger.warning(
f"Code redemption at authorization endpoint for response_type={stored_response_type}"
)
return JSONResponse(
status_code=400,
content={
"error": "invalid_grant",
"error_description": "Authorization code must be redeemed at the token endpoint"
},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"}
)
# STEP 3: Validate client_id matches
if code_data.get('client_id') != client_id:
logger.warning(
f"Client ID mismatch: expected {code_data.get('client_id')}, got {client_id}"
)
return JSONResponse(
status_code=400,
content={
"error": "invalid_client",
"error_description": "Client ID does not match authorization code"
},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"}
)
# STEP 4: Validate redirect_uri if provided
if redirect_uri and code_data.get('redirect_uri') != redirect_uri:
logger.warning(
f"Redirect URI mismatch: expected {code_data.get('redirect_uri')}, got {redirect_uri}"
)
return JSONResponse(
status_code=400,
content={
"error": "invalid_grant",
"error_description": "Redirect URI does not match authorization request"
},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"}
)
# STEP 5: Check if code already used (prevent replay)
if code_data.get('used'):
logger.warning(f"Authorization code replay detected: {code[:8]}...")
return JSONResponse(
status_code=400,
content={
"error": "invalid_grant",
"error_description": "Authorization code has already been used"
},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"}
)
# STEP 6: Extract user identity
me = code_data.get('me')
if not me:
logger.error("Authorization code missing 'me' parameter")
return JSONResponse(
status_code=400,
content={
"error": "invalid_grant",
"error_description": "Authorization code is malformed"
},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"}
)
# STEP 7: PKCE validation (optional for authentication flow)
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: Delete authorization code (single-use enforcement)
code_storage.delete(storage_key)
logger.info(f"Authorization code verified and deleted: {code[:8]}...")
# STEP 9: Return authentication response with user identity
logger.info(f"Authentication successful for {me} (client: {client_id})")
return JSONResponse(
status_code=200,
content={"me": me},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"}
)