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>
872 lines
30 KiB
Python
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"}
|
|
)
|