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>
This commit is contained in:
@@ -3,6 +3,12 @@
|
||||
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
|
||||
@@ -16,14 +22,34 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
from gondulf.database.connection import Database
|
||||
from gondulf.dependencies import get_code_storage, get_database, get_happ_parser, get_verification_service
|
||||
from gondulf.services.domain_verification import DomainVerificationService
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -35,6 +61,9 @@ 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):
|
||||
"""
|
||||
@@ -46,41 +75,81 @@ class AuthenticationResponse(BaseModel):
|
||||
me: str
|
||||
|
||||
|
||||
async def check_domain_verified(database: Database, domain: str) -> bool:
|
||||
async def check_domain_dns_verified(database: Database, domain: str) -> bool:
|
||||
"""
|
||||
Check if domain is verified in the database.
|
||||
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 is verified, False otherwise
|
||||
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 FROM domains WHERE domain = :domain AND verified = 1"),
|
||||
text("""
|
||||
SELECT verified, last_checked
|
||||
FROM domains
|
||||
WHERE domain = :domain AND verified = 1
|
||||
"""),
|
||||
{"domain": domain}
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row is not None
|
||||
|
||||
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 verification: {e}")
|
||||
logger.error(f"Failed to check domain DNS verification: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def store_verified_domain(database: Database, domain: str, email: str) -> None:
|
||||
async def verify_domain_dns(
|
||||
database: Database,
|
||||
dns_service: DNSService,
|
||||
domain: str
|
||||
) -> bool:
|
||||
"""
|
||||
Store verified domain in database.
|
||||
Verify domain DNS TXT record and update cache.
|
||||
|
||||
This performs the actual DNS lookup and caches the result.
|
||||
|
||||
Args:
|
||||
database: Database service
|
||||
domain: Verified domain
|
||||
email: Email used for verification (for audit)
|
||||
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:
|
||||
@@ -88,19 +157,56 @@ async def store_verified_domain(database: Database, domain: str, email: str) ->
|
||||
conn.execute(
|
||||
text("""
|
||||
INSERT OR REPLACE INTO domains
|
||||
(domain, email, verification_code, verified, verified_at, two_factor)
|
||||
VALUES (:domain, :email, '', 1, :verified_at, 1)
|
||||
(domain, email, verification_code, verified, verified_at, last_checked, two_factor)
|
||||
VALUES (:domain, '', '', 1, :now, :now, 0)
|
||||
"""),
|
||||
{
|
||||
"domain": domain,
|
||||
"email": email,
|
||||
"verified_at": now
|
||||
}
|
||||
{"domain": domain, "now": now}
|
||||
)
|
||||
logger.info(f"Stored verified domain: {domain}")
|
||||
|
||||
logger.info(f"Domain DNS verification successful and cached: {domain}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store verified domain: {e}")
|
||||
raise
|
||||
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")
|
||||
@@ -115,18 +221,22 @@ async def authorize_get(
|
||||
scope: str | None = None,
|
||||
me: str | None = None,
|
||||
database: Database = Depends(get_database),
|
||||
happ_parser: HAppParser = Depends(get_happ_parser),
|
||||
verification_service: DomainVerificationService = Depends(get_verification_service)
|
||||
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).
|
||||
|
||||
Validates client_id, redirect_uri, and required parameters.
|
||||
Shows consent form if domain is verified, or verification form if not.
|
||||
|
||||
Supports two IndieAuth flows per W3C specification:
|
||||
- response_type=id (default): Authentication only, returns user identity
|
||||
- response_type=code: Authorization, returns access token
|
||||
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
|
||||
@@ -139,11 +249,15 @@ async def authorize_get(
|
||||
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
|
||||
verification_service: Domain verification service
|
||||
|
||||
Returns:
|
||||
HTML response with consent form, verification form, or error page
|
||||
HTML response with code entry form or error page
|
||||
"""
|
||||
# Validate required parameters (pre-client validation)
|
||||
if not client_id:
|
||||
@@ -250,35 +364,20 @@ async def authorize_get(
|
||||
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
# SECURITY FIX: Check if domain is verified before showing consent
|
||||
is_verified = await check_domain_verified(database, domain)
|
||||
# STEP 1: Domain DNS Verification (can be cached)
|
||||
dns_verified = await check_domain_dns_verified(database, domain)
|
||||
|
||||
if not is_verified:
|
||||
logger.info(f"Domain {domain} not verified, starting verification")
|
||||
|
||||
# Start two-factor verification
|
||||
result = verification_service.start_verification(domain, me)
|
||||
|
||||
if not result["success"]:
|
||||
# Verification cannot start (DNS failed, no rel=me, etc)
|
||||
error_message = result.get("error", "verification_failed")
|
||||
|
||||
# Map error codes to user-friendly messages
|
||||
error_messages = {
|
||||
"dns_verification_failed": "DNS verification failed. Please add the required TXT record.",
|
||||
"email_discovery_failed": "Could not find an email address on your homepage. Please add a rel='me' link to your email.",
|
||||
"invalid_email_format": "The email address discovered on your homepage is invalid.",
|
||||
"email_send_failed": "Failed to send verification email. Please try again."
|
||||
}
|
||||
friendly_error = error_messages.get(error_message, error_message)
|
||||
|
||||
logger.warning(f"Verification start failed for domain={domain}: {error_message}")
|
||||
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": friendly_error,
|
||||
"error": "DNS verification failed. Please add the required TXT record.",
|
||||
"domain": domain,
|
||||
"client_id": normalized_client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
@@ -292,14 +391,18 @@ async def authorize_get(
|
||||
status_code=200
|
||||
)
|
||||
|
||||
# Verification started - show code entry form
|
||||
logger.info(f"Verification code sent for domain={domain}")
|
||||
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(
|
||||
"verify_code.html",
|
||||
"verification_error.html",
|
||||
{
|
||||
"request": request,
|
||||
"masked_email": result["email"],
|
||||
"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,
|
||||
@@ -309,25 +412,59 @@ async def authorize_get(
|
||||
"code_challenge_method": code_challenge_method,
|
||||
"scope": scope or "",
|
||||
"me": me
|
||||
}
|
||||
},
|
||||
status_code=200
|
||||
)
|
||||
|
||||
# Domain is verified - fetch client metadata and show consent form
|
||||
logger.info(f"Domain {domain} is verified, showing consent page")
|
||||
|
||||
client_metadata = None
|
||||
# STEP 3: Create auth session and send verification code
|
||||
# THIS IS ALWAYS REQUIRED - email code is authentication, not domain verification
|
||||
try:
|
||||
client_metadata = await happ_parser.fetch_and_parse(normalized_client_id)
|
||||
logger.info(f"Fetched client metadata for {normalized_client_id}: {client_metadata.name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch client metadata for {normalized_client_id}: {e}")
|
||||
# Continue without metadata - will show client_id instead
|
||||
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
|
||||
)
|
||||
|
||||
# Show consent form
|
||||
# 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(
|
||||
"authorize.html",
|
||||
"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,
|
||||
@@ -335,8 +472,7 @@ async def authorize_get(
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": code_challenge_method,
|
||||
"scope": scope or "",
|
||||
"me": me,
|
||||
"client_metadata": client_metadata
|
||||
"me": me
|
||||
}
|
||||
)
|
||||
|
||||
@@ -344,18 +480,9 @@ async def authorize_get(
|
||||
@router.post("/authorize/verify-code")
|
||||
async def authorize_verify_code(
|
||||
request: Request,
|
||||
domain: str = Form(...),
|
||||
session_id: str = Form(...),
|
||||
code: str = Form(...),
|
||||
client_id: str = Form(...),
|
||||
redirect_uri: str = Form(...),
|
||||
response_type: str = Form("id"),
|
||||
state: str = Form(...),
|
||||
code_challenge: str = Form(...),
|
||||
code_challenge_method: str = Form(...),
|
||||
scope: str = Form(""),
|
||||
me: str = Form(...),
|
||||
database: Database = Depends(get_database),
|
||||
verification_service: DomainVerificationService = Depends(get_verification_service),
|
||||
auth_session_service: AuthSessionService = Depends(get_auth_session_service),
|
||||
happ_parser: HAppParser = Depends(get_happ_parser)
|
||||
) -> HTMLResponse:
|
||||
"""
|
||||
@@ -366,149 +493,224 @@ async def authorize_verify_code(
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
domain: Domain being verified
|
||||
session_id: Auth session identifier
|
||||
code: 6-digit verification code from email
|
||||
client_id: Client application identifier
|
||||
redirect_uri: Callback URI
|
||||
response_type: "id" for authentication, "code" for authorization
|
||||
state: Client state parameter
|
||||
code_challenge: PKCE code challenge
|
||||
code_challenge_method: PKCE method
|
||||
scope: Requested scope
|
||||
me: User identity URL
|
||||
database: Database service
|
||||
verification_service: Domain verification service
|
||||
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 domain={domain}")
|
||||
logger.info(f"Verification code submission for session={session_id[:8]}...")
|
||||
|
||||
# Verify the code
|
||||
result = verification_service.verify_email_code(domain, code)
|
||||
try:
|
||||
# Verify the code - this is the authentication step
|
||||
session = auth_session_service.verify_code(session_id, code)
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(f"Verification code invalid for domain={domain}: {result.get('error')}")
|
||||
logger.info(f"Code verified successfully for session={session_id[:8]}...")
|
||||
|
||||
# Get masked email for display
|
||||
email = verification_service.code_storage.get(f"email_addr:{domain}")
|
||||
masked = mask_email(email) if email else "unknown"
|
||||
|
||||
# Map error codes to user-friendly messages
|
||||
error_messages = {
|
||||
"invalid_code": "Invalid verification code. Please check and try again.",
|
||||
"email_not_found": "Verification session expired. Please start over."
|
||||
}
|
||||
error_message = result.get("error", "invalid_code")
|
||||
friendly_error = error_messages.get(error_message, error_message)
|
||||
# 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(
|
||||
"verify_code.html",
|
||||
"authorize.html",
|
||||
{
|
||||
"request": request,
|
||||
"error": friendly_error,
|
||||
"masked_email": masked,
|
||||
"domain": domain,
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": response_type,
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": code_challenge_method,
|
||||
"scope": scope,
|
||||
"me": me
|
||||
},
|
||||
status_code=200
|
||||
"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
|
||||
}
|
||||
)
|
||||
|
||||
# Code valid - store verified domain in database
|
||||
email = result.get("email", "")
|
||||
await store_verified_domain(database, domain, email)
|
||||
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
|
||||
)
|
||||
|
||||
logger.info(f"Domain verified successfully: {domain}")
|
||||
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
|
||||
)
|
||||
|
||||
# Fetch client metadata for consent page
|
||||
client_metadata = None
|
||||
try:
|
||||
client_metadata = await happ_parser.fetch_and_parse(client_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch client metadata: {e}")
|
||||
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
|
||||
)
|
||||
|
||||
# Show consent form
|
||||
return templates.TemplateResponse(
|
||||
"authorize.html",
|
||||
{
|
||||
"request": request,
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": response_type,
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": code_challenge_method,
|
||||
"scope": scope,
|
||||
"me": me,
|
||||
"client_metadata": client_metadata
|
||||
}
|
||||
)
|
||||
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,
|
||||
client_id: str = Form(...),
|
||||
redirect_uri: str = Form(...),
|
||||
response_type: str = Form("id"), # Default to "id" for authentication flow
|
||||
state: str = Form(...),
|
||||
code_challenge: str = Form(...),
|
||||
code_challenge_method: str = Form(...),
|
||||
scope: str = Form(...),
|
||||
me: str = Form(...),
|
||||
verification_service: DomainVerificationService = Depends(get_verification_service)
|
||||
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).
|
||||
|
||||
Creates authorization code and redirects to client callback.
|
||||
Validates that the session is authenticated, then creates authorization
|
||||
code and redirects to client callback.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
client_id: Client application identifier
|
||||
redirect_uri: Callback URI
|
||||
response_type: "id" for authentication, "code" for authorization
|
||||
state: Client state
|
||||
code_challenge: PKCE challenge
|
||||
code_challenge_method: PKCE method
|
||||
scope: Requested scope
|
||||
me: User identity
|
||||
verification_service: Domain verification service
|
||||
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 granted for client_id={client_id} response_type={response_type}")
|
||||
logger.info(f"Authorization consent for session={session_id[:8]}...")
|
||||
|
||||
# Create authorization code with response_type metadata
|
||||
authorization_code = verification_service.create_authorization_code(
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
state=state,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method=code_challenge_method,
|
||||
scope=scope,
|
||||
me=me,
|
||||
response_type=response_type
|
||||
)
|
||||
try:
|
||||
# Get and validate session
|
||||
session = auth_session_service.get_session(session_id)
|
||||
|
||||
# Build redirect URL with authorization code
|
||||
redirect_params = {
|
||||
"code": authorization_code,
|
||||
"state": state
|
||||
}
|
||||
redirect_url = f"{redirect_uri}?{urlencode(redirect_params)}"
|
||||
# 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
|
||||
)
|
||||
|
||||
logger.info(f"Redirecting to {redirect_uri} with authorization code")
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
# 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")
|
||||
|
||||
Reference in New Issue
Block a user