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>
17 KiB
Design Fix: Authorization Endpoint Domain Verification
Date: 2025-11-22 Architect: Claude (Architect Agent) Status: CRITICAL - Ready for Immediate Implementation Priority: P0 - Security Fix
Problem Statement
The authorization endpoint (GET /authorize) is bypassing domain verification entirely. This allows anyone to authenticate as any domain without proving ownership, which is a critical security vulnerability.
Current Behavior (BROKEN)
1. GET /authorize?me=https://example.com/&... -> 200 OK (consent page shown)
2. POST /authorize/consent -> 302 redirect with code
Expected Behavior (Per Design)
1. GET /authorize?me=https://example.com/&...
2. Check if domain is verified in database
3a. If NOT verified:
- Verify DNS TXT record for _gondulf.{domain}
- Fetch user's homepage
- Discover email from rel="me" link
- Send 6-digit verification code to email
- Show code entry form
4. POST /authorize/verify-code with code
5. Validate code -> Store verified domain in database
6. Show consent page
7. POST /authorize/consent -> 302 redirect with authorization code
Root Cause
In /src/gondulf/routers/authorization.py, lines 191-193:
# Check if domain is verified
# For Phase 2, we'll show consent form immediately (domain verification happens separately)
# In Phase 3, we'll check database for verified domains
The implementation shows the consent form directly without any verification checks. The DomainVerificationService exists and has the required methods, but they are never called in the authorization flow.
Design Fix
Overview
The fix requires modifying the GET /authorize endpoint to:
- Extract domain from
meparameter - Check if domain is already verified (in database)
- If not verified, initiate verification and show code entry form
- After verification, show consent page
Additionally, a new endpoint POST /authorize/verify-code must be implemented to handle code submission during the authorization flow.
Modified Authorization Flow
Step 1: Modify GET /authorize (authorization.py)
Location: /src/gondulf/routers/authorization.py, authorize_get function
After line 189 (after me URL validation), insert domain verification logic:
# Extract domain from me URL
domain = extract_domain_from_url(me)
# Check if domain is already verified
verification_service = Depends(get_verification_service)
# NOTE: Need to add verification_service to function parameters
# Query database for verified domain
is_verified = await check_domain_verified(database, domain)
if not is_verified:
# Start two-factor verification
result = verification_service.start_verification(domain, me)
if not result["success"]:
# Verification cannot start (DNS failed, no rel=me, etc)
return templates.TemplateResponse(
"verification_error.html",
{
"request": request,
"error": result["error"],
"domain": domain,
# Pass through auth params for retry
"client_id": normalized_client_id,
"redirect_uri": redirect_uri,
"response_type": effective_response_type,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"scope": scope,
"me": me
},
status_code=200
)
# Verification started - show code entry form
return templates.TemplateResponse(
"verify_code.html",
{
"request": request,
"masked_email": result["email"],
"domain": domain,
# Pass through auth params
"client_id": normalized_client_id,
"redirect_uri": redirect_uri,
"response_type": effective_response_type,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"scope": scope,
"me": me,
"client_metadata": client_metadata
}
)
# Domain is verified - show consent form (existing code from line 205)
Step 2: Add Database Check Function
Location: Add to /src/gondulf/routers/authorization.py or /src/gondulf/utils/validation.py
async def check_domain_verified(database: Database, domain: str) -> bool:
"""
Check if domain is verified in the database.
Args:
database: Database service
domain: Domain to check (e.g., "example.com")
Returns:
True if domain is verified, False otherwise
"""
async with database.get_session() as session:
result = await session.execute(
"SELECT verified FROM domains WHERE domain = ? AND verified = 1",
(domain,)
)
row = result.fetchone()
return row is not None
Step 3: Add New Endpoint POST /authorize/verify-code
Location: /src/gondulf/routers/authorization.py
@router.post("/authorize/verify-code")
async def authorize_verify_code(
request: Request,
domain: 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),
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:
domain: Domain being verified
code: 6-digit verification code from email
client_id, redirect_uri, etc: Authorization parameters (passed through)
Returns:
HTML response: consent page on success, code form with error on failure
"""
logger.info(f"Verification code submission for domain={domain}")
# Verify the code
result = verification_service.verify_email_code(domain, code)
if not result["success"]:
# Code invalid - show form again with error
# Need to get masked email again
email = verification_service.code_storage.get(f"email_addr:{domain}")
masked_email = mask_email(email) if email else "unknown"
return templates.TemplateResponse(
"verify_code.html",
{
"request": request,
"error": result["error"],
"masked_email": masked_email,
"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
)
# Code valid - store verified domain in database
await store_verified_domain(database, domain, result.get("email", ""))
logger.info(f"Domain verified successfully: {domain}")
# 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}")
# 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
}
)
Step 4: Add Store Verified Domain Function
async def store_verified_domain(database: Database, domain: str, email: str) -> None:
"""
Store verified domain in database.
Args:
database: Database service
domain: Verified domain
email: Email used for verification (for audit)
"""
from datetime import datetime
async with database.get_session() as session:
await session.execute(
"""
INSERT OR REPLACE INTO domains
(domain, verification_method, verified, verified_at, last_dns_check)
VALUES (?, 'two_factor', 1, ?, ?)
""",
(domain, datetime.utcnow(), datetime.utcnow())
)
await session.commit()
logger.info(f"Stored verified domain: {domain}")
Step 5: Create New Template verify_code.html
Location: /src/gondulf/templates/verify_code.html
{% extends "base.html" %}
{% block title %}Verify Your Identity - Gondulf{% endblock %}
{% block content %}
<h1>Verify Your Identity</h1>
<p>To sign in as <strong>{{ domain }}</strong>, please enter the verification code sent to <strong>{{ masked_email }}</strong>.</p>
{% if error %}
<div class="error">
<p>{{ error }}</p>
</div>
{% endif %}
<form method="POST" action="/authorize/verify-code">
<!-- Pass through authorization parameters -->
<input type="hidden" name="domain" value="{{ domain }}">
<input type="hidden" name="client_id" value="{{ client_id }}">
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
<input type="hidden" name="response_type" value="{{ response_type }}">
<input type="hidden" name="state" value="{{ state }}">
<input type="hidden" name="code_challenge" value="{{ code_challenge }}">
<input type="hidden" name="code_challenge_method" value="{{ code_challenge_method }}">
<input type="hidden" name="scope" value="{{ scope }}">
<input type="hidden" name="me" value="{{ me }}">
<div class="form-group">
<label for="code">Verification Code:</label>
<input type="text"
id="code"
name="code"
placeholder="000000"
maxlength="6"
pattern="[0-9]{6}"
inputmode="numeric"
autocomplete="one-time-code"
required
autofocus>
</div>
<button type="submit">Verify</button>
</form>
<p class="help-text">
Did not receive a code? Check your spam folder.
<a href="/authorize?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 }}">
Request a new code
</a>
</p>
{% endblock %}
Step 6: Create Error Template verification_error.html
Location: /src/gondulf/templates/verification_error.html
{% extends "base.html" %}
{% block title %}Verification Failed - Gondulf{% endblock %}
{% block content %}
<h1>Verification Failed</h1>
<div class="error">
<p>{{ error }}</p>
</div>
{% if "DNS" in error %}
<div class="instructions">
<h2>How to Fix</h2>
<p>Add the following DNS TXT record to your domain:</p>
<code>
Type: TXT<br>
Name: _gondulf.{{ domain }}<br>
Value: gondulf-verify-domain
</code>
<p>DNS changes may take up to 24 hours to propagate.</p>
</div>
{% endif %}
{% if "email" in error.lower() or "rel" in error.lower() %}
<div class="instructions">
<h2>How to Fix</h2>
<p>Add a rel="me" link to your homepage pointing to your email:</p>
<code><link rel="me" href="mailto:you@example.com"></code>
<p>Or as an anchor tag:</p>
<code><a rel="me" href="mailto:you@example.com">Email me</a></code>
</div>
{% endif %}
<p>
<a href="/authorize?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 }}">
Try Again
</a>
</p>
{% endblock %}
Changes to Existing Files
/src/gondulf/routers/authorization.py
-
Add import for
get_verification_serviceat line 17:from gondulf.dependencies import get_code_storage, get_database, get_happ_parser, get_verification_service -
Add
verification_serviceparameter toauthorize_getfunction signature (around line 57):verification_service: DomainVerificationService = Depends(get_verification_service) -
Replace lines 191-219 (the comment and consent form display) with the verification logic from Step 1 above.
-
Add the new
authorize_verify_codeendpoint after theauthorize_consentfunction. -
Add helper functions
check_domain_verifiedandstore_verified_domain.
/src/gondulf/utils/validation.py
Add mask_email function if not already present:
def mask_email(email: str) -> str:
"""Mask email for display: user@example.com -> u***@example.com"""
if not email or '@' not in email:
return email or "unknown"
local, domain = email.split('@', 1)
if len(local) <= 1:
return f"{local}***@{domain}"
return f"{local[0]}***@{domain}"
Data Flow After Fix
User/Client Gondulf DNS/Email
| | |
|-- GET /authorize --------->| |
| |-- Check DB for verified domain |
| | (not found) |
| |-- Query DNS TXT record ---------->|
| |<-- TXT: gondulf-verify-domain ---|
| |-- Fetch homepage --------------->|
| |<-- HTML with rel=me mailto ------|
| |-- Send verification email ------>|
|<-- Show verify_code.html --| |
| | |
|-- POST /verify-code ------>| |
| (code: 123456) |-- Verify code (storage check) |
| |-- Store verified domain (DB) |
|<-- Show authorize.html ----| |
| | |
|-- POST /authorize/consent->| |
|<-- 302 redirect with code -| |
Testing Requirements
The fix must include the following tests:
Unit Tests
test_authorize_unverified_domain_starts_verificationtest_authorize_verified_domain_shows_consenttest_verify_code_valid_code_shows_consenttest_verify_code_invalid_code_shows_errortest_verify_code_expired_code_shows_errortest_verify_code_stores_domain_on_successtest_verification_dns_failure_shows_instructionstest_verification_no_relme_shows_instructions
Integration Tests
test_full_verification_flow_new_domaintest_full_authorization_flow_verified_domaintest_verification_code_retry_with_correct_code
Acceptance Criteria
The fix is complete when:
-
Security
- Unverified domains NEVER see the consent page directly
- DNS TXT record verification is performed for new domains
- Email verification via rel="me" is required for new domains
- Verified domains are stored in the database
- Subsequent authentications skip verification for stored domains
-
Functionality
- Code entry form displays with masked email
- Invalid codes show error with retry option
- Verification errors show clear instructions
- All authorization parameters preserved through verification flow
- State parameter passed through correctly
-
Testing
- All unit tests pass
- All integration tests pass
- Manual testing confirms the flow works end-to-end
Implementation Order
- Add
mask_emailto validation utils (if missing) - Create
verify_code.htmltemplate - Create
verification_error.htmltemplate - Add
check_domain_verifiedfunction - Add
store_verified_domainfunction - Modify
authorize_getto include verification check - Add
authorize_verify_codeendpoint - Write and run tests
- Manual end-to-end testing
Estimated Effort
Time: 1-2 days
- Template creation: 0.25 days
- Authorization endpoint modification: 0.5 days
- New verify-code endpoint: 0.25 days
- Testing: 0.5 days
- Integration testing: 0.25 days
Sign-off
Design Status: Ready for immediate implementation
Architect: Claude (Architect Agent) Date: 2025-11-22
DESIGN READY: Authorization Verification Fix - Please implement immediately
This is a P0 security fix. Do not deploy to production until this is resolved.