Files
Gondulf/docs/designs/authorization-verification-fix.md
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

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:

  1. Extract domain from me parameter
  2. Check if domain is already verified (in database)
  3. If not verified, initiate verification and show code entry form
  4. 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>&lt;link rel="me" href="mailto:you@example.com"&gt;</code>
    <p>Or as an anchor tag:</p>
    <code>&lt;a rel="me" href="mailto:you@example.com"&gt;Email me&lt;/a&gt;</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

  1. Add import for get_verification_service at line 17:

    from gondulf.dependencies import get_code_storage, get_database, get_happ_parser, get_verification_service
    
  2. Add verification_service parameter to authorize_get function signature (around line 57):

    verification_service: DomainVerificationService = Depends(get_verification_service)
    
  3. Replace lines 191-219 (the comment and consent form display) with the verification logic from Step 1 above.

  4. Add the new authorize_verify_code endpoint after the authorize_consent function.

  5. Add helper functions check_domain_verified and store_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_verification
  • test_authorize_verified_domain_shows_consent
  • test_verify_code_valid_code_shows_consent
  • test_verify_code_invalid_code_shows_error
  • test_verify_code_expired_code_shows_error
  • test_verify_code_stores_domain_on_success
  • test_verification_dns_failure_shows_instructions
  • test_verification_no_relme_shows_instructions

Integration Tests

  • test_full_verification_flow_new_domain
  • test_full_authorization_flow_verified_domain
  • test_verification_code_retry_with_correct_code

Acceptance Criteria

The fix is complete when:

  1. 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
  2. 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
  3. Testing

    • All unit tests pass
    • All integration tests pass
    • Manual testing confirms the flow works end-to-end

Implementation Order

  1. Add mask_email to validation utils (if missing)
  2. Create verify_code.html template
  3. Create verification_error.html template
  4. Add check_domain_verified function
  5. Add store_verified_domain function
  6. Modify authorize_get to include verification check
  7. Add authorize_verify_code endpoint
  8. Write and run tests
  9. 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.