Files
Gondulf/docs/designs/phase-2-domain-verification.md
Phil Skentelbery 6f06aebf40 docs: add Phase 2 domain verification design and clarifications
Add comprehensive Phase 2 documentation:
- Complete design document for two-factor domain verification
- Implementation guide with code examples
- ADR for implementation decisions (ADR-0004)
- ADR for rel="me" email discovery (ADR-008)
- Phase 1 impact assessment
- All 23 clarification questions answered
- Updated architecture docs (indieauth-protocol, security)
- Updated ADR-005 with rel="me" approach
- Updated backlog with technical debt items

Design ready for Phase 2 implementation.

Generated with Claude Code https://claude.com/claude-code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 13:05:09 -07:00

91 KiB
Raw Permalink Blame History

Phase 2 Design: Domain Verification & Authorization Endpoint

Date: 2025-11-20 Architect: Claude (Architect Agent) Status: Ready for Implementation Design Version: 1.0

Overview

What Phase 2 Builds

Phase 2 implements the complete two-factor domain verification flow and the IndieAuth authorization endpoint, building on Phase 1's foundational services.

Core Functionality:

  1. HTML fetching service to retrieve user's homepage
  2. rel="me" email discovery service to parse HTML for email links
  3. Domain verification service to orchestrate two-factor verification (DNS TXT + Email)
  4. HTTP endpoints for verification flow
  5. Authorization endpoint to start IndieAuth authentication flow

Connection to IndieAuth Protocol: Phase 2 implements steps 1-7 of the IndieAuth authorization flow (see /docs/architecture/indieauth-protocol.md lines 165-174), completing the domain verification and authorization code generation.

Connection to Phase 1: Phase 2 uses all Phase 1 services:

  • Configuration (SMTP, DNS, database settings)
  • Database (to store verified domains)
  • In-memory storage (for authorization codes)
  • Email service (to send verification codes)
  • DNS service (to verify TXT records)
  • Logging (structured logging throughout)

Authentication Security Model

Per ADR-005 and ADR-008, Phase 2 implements two-factor domain verification:

Factor 1: DNS TXT Record (proves DNS control)

  • Required: _gondulf.{domain} TXT record = verified
  • Verified via Phase 1 DNS service
  • Consensus from multiple resolvers

Factor 2: Email Verification via rel="me" (proves email control)

  • Discover email from <link rel="me" href="mailto:..."> on user's site
  • Send 6-digit code to discovered email
  • User enters code to complete verification

Combined Security: Attacker must compromise BOTH DNS and email to authenticate fraudulently.

Components

1. HTML Fetching Service

File: src/gondulf/html_fetcher.py

Purpose: Fetch user's homepage over HTTPS to discover rel="me" links.

Public Interface:

from typing import Optional
import requests

class HTMLFetcherService:
    """
    Fetch user's homepage over HTTPS with security safeguards.
    """

    def __init__(
        self,
        timeout: int = 10,
        max_redirects: int = 5,
        max_size: int = 5 * 1024 * 1024  # 5MB
    ):
        """
        Initialize HTML fetcher service.

        Args:
            timeout: HTTP request timeout in seconds (default: 10)
            max_redirects: Maximum redirects to follow (default: 5)
            max_size: Maximum response size in bytes (default: 5MB)
        """
        self.timeout = timeout
        self.max_redirects = max_redirects
        self.max_size = max_size

    def fetch_site(self, domain: str) -> Optional[str]:
        """
        Fetch site HTML content over HTTPS.

        Args:
            domain: Domain to fetch (e.g., "example.com")

        Returns:
            HTML content as string, or None if fetch fails

        Raises:
            No exceptions raised - all errors logged and None returned
        """

Implementation Details:

def fetch_site(self, domain: str) -> Optional[str]:
    """Fetch site HTML content over HTTPS."""
    url = f"https://{domain}"

    try:
        # Fetch with security limits
        response = requests.get(
            url,
            timeout=self.timeout,
            allow_redirects=True,
            max_redirects=self.max_redirects,
            verify=True,  # SECURITY: Enforce SSL certificate verification
            headers={
                'User-Agent': 'Gondulf/1.0.0 IndieAuth (+https://github.com/yourusername/gondulf)'
            }
        )
        response.raise_for_status()

        # SECURITY: Check response size to prevent memory exhaustion
        content_length = int(response.headers.get('Content-Length', 0))
        if content_length > self.max_size:
            logger.warning(f"Response too large for {domain}: {content_length} bytes")
            return None

        # Check actual content size (Content-Length may be absent)
        if len(response.content) > self.max_size:
            logger.warning(f"Response content too large for {domain}: {len(response.content)} bytes")
            return None

        logger.info(f"Successfully fetched {domain}: {len(response.content)} bytes")
        return response.text

    except requests.exceptions.SSLError as e:
        logger.error(f"SSL verification failed for {domain}: {e}")
        return None
    except requests.exceptions.Timeout:
        logger.error(f"Timeout fetching {domain} after {self.timeout}s")
        return None
    except requests.exceptions.TooManyRedirects:
        logger.error(f"Too many redirects for {domain}")
        return None
    except requests.exceptions.HTTPError as e:
        logger.error(f"HTTP error fetching {domain}: {e}")
        return None
    except Exception as e:
        logger.error(f"Unexpected error fetching {domain}: {e}")
        return None

Dependencies:

  • requests library (already in pyproject.toml)
  • Python standard library: typing
  • Phase 1 logging configuration

Error Handling:

  • SSL verification failure: Log error, return None (security: reject invalid certificates)
  • Timeout: Log error, return None (configurable timeout via init)
  • HTTP errors (404, 500, etc.): Log error with status code, return None
  • Size limit exceeded: Log warning, return None (prevent DoS)
  • Too many redirects: Log error, return None (prevent redirect loops)
  • Generic exceptions: Log error, return None (fail-safe)

Security Considerations:

  • HTTPS only (hardcoded in URL)
  • SSL certificate verification enforced (verify=True, cannot be disabled)
  • Response size limit (5MB default, configurable)
  • Timeout to prevent hanging (10s default, configurable)
  • Redirect limit (5 max, configurable)
  • User-Agent header identifies Gondulf for server logs

Testing Requirements:

  • Successful HTTPS fetch returns HTML content
  • SSL verification failure returns None
  • Timeout returns None
  • HTTP error codes (404, 500) return None
  • Redirects followed (up to max_redirects)
  • Too many redirects returns None
  • Content-Length exceeds max_size returns None
  • Actual content exceeds max_size returns None
  • Custom User-Agent sent in request

2. rel="me" Email Discovery Service

File: src/gondulf/relme.py

Purpose: Parse HTML to discover email addresses from rel="me" links following IndieWeb standards.

Public Interface:

from typing import Optional
from bs4 import BeautifulSoup
import re

class RelMeDiscoveryService:
    """
    Discover email addresses from rel="me" links in HTML.

    Follows IndieWeb rel="me" standard: https://indieweb.org/rel-me
    """

    def discover_email(self, html_content: str) -> Optional[str]:
        """
        Parse HTML and discover email from rel="me" link.

        Args:
            html_content: HTML content as string

        Returns:
            Email address or None if not found

        Raises:
            No exceptions raised - all errors logged and None returned
        """

    def validate_email_format(self, email: str) -> bool:
        """
        Validate email address format (RFC 5322 simplified).

        Args:
            email: Email address to validate

        Returns:
            True if valid format, False otherwise
        """

Implementation Details:

def discover_email(self, html_content: str) -> Optional[str]:
    """Parse HTML and discover email from rel='me' link."""
    try:
        # Parse HTML (BeautifulSoup handles malformed HTML gracefully)
        soup = BeautifulSoup(html_content, 'html.parser')

        # Find all rel="me" links - both <link> and <a> tags
        # Case-insensitive matching via BeautifulSoup
        me_links = soup.find_all('link', rel='me') + soup.find_all('a', rel='me')

        # Look for mailto: links
        for link in me_links:
            href = link.get('href', '')
            if href.startswith('mailto:'):
                # Extract email from mailto: URL
                email = href.replace('mailto:', '').strip()

                # Remove query parameters if present (e.g., mailto:user@example.com?subject=Hello)
                if '?' in email:
                    email = email.split('?')[0]

                # Validate email format
                if self.validate_email_format(email):
                    logger.info(f"Discovered email via rel='me': {email[:3]}***@{email.split('@')[1]}")
                    return email
                else:
                    logger.warning(f"Found rel='me' mailto link with invalid email format: {email}")

        logger.warning("No rel='me' mailto: link found in HTML")
        return None

    except Exception as e:
        logger.error(f"Failed to parse HTML for rel='me' links: {e}")
        return None

def validate_email_format(self, email: str) -> bool:
    """Validate email address format (RFC 5322 simplified)."""
    # Basic format validation
    email_regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'

    if not re.match(email_regex, email):
        return False

    # Length check (RFC 5321 maximum)
    if len(email) > 254:
        return False

    # Must have exactly one @
    if email.count('@') != 1:
        return False

    # Domain must have at least one dot
    local, domain = email.split('@')
    if '.' not in domain:
        return False

    return True

Dependencies:

  • beautifulsoup4>=4.12.0 (NEW - add to pyproject.toml)
  • html.parser (Python standard library, used by BeautifulSoup)
  • re (Python standard library)
  • Phase 1 logging configuration

Error Handling:

  • Malformed HTML: BeautifulSoup handles gracefully, continues parsing
  • Missing rel="me" links: Log warning, return None
  • Invalid email format in link: Log warning, skip link, continue searching
  • Multiple rel="me" mailto links: Return first valid one
  • Empty href attribute: Skip link, continue searching
  • Exception during parsing: Log error, return None

Security Considerations:

  • No script execution: BeautifulSoup only extracts attributes, never executes JavaScript
  • Email validation: Strict format checking prevents injection
  • Link extraction only: No rendering or evaluation of HTML
  • Partial masking in logs: Only log first 3 chars of email (privacy)

Testing Requirements:

  • Discovery from <link rel="me" href="mailto:..."> tag
  • Discovery from <a rel="me" href="mailto:..."> tag
  • Multiple rel="me" links: select first mailto
  • Malformed HTML handled gracefully
  • Missing rel="me" links returns None
  • Invalid email format in link returns None (but logs warning)
  • Empty href returns None
  • Non-mailto rel="me" links ignored (e.g., https:// links)
  • mailto with query parameters (e.g., ?subject=Hi) strips params
  • Email validation: valid formats accepted
  • Email validation: invalid formats rejected (no @, no domain, too long, etc.)

3. Domain Verification Service

File: src/gondulf/domain_verification.py

Purpose: Orchestrate two-factor domain verification (DNS TXT + Email via rel="me").

Public Interface:

from typing import Tuple, Optional
from .dns import DNSService
from .html_fetcher import HTMLFetcherService
from .relme import RelMeDiscoveryService
from .email import EmailService
from .storage import CodeStorage
from .database.connection import DatabaseConnection
import secrets

class DomainVerificationService:
    """
    Two-factor domain verification service.

    Verifies domain ownership through:
    1. DNS TXT record verification (_gondulf.{domain} = verified)
    2. Email verification via rel="me" discovery
    """

    def __init__(
        self,
        dns_service: DNSService,
        html_fetcher: HTMLFetcherService,
        relme_discovery: RelMeDiscoveryService,
        email_service: EmailService,
        code_storage: CodeStorage,
        database: DatabaseConnection,
        code_ttl: int = 900  # 15 minutes
    ):
        """
        Initialize domain verification service.

        Args:
            dns_service: DNS service for TXT record verification
            html_fetcher: HTML fetcher service
            relme_discovery: rel="me" email discovery service
            email_service: Email service for sending codes
            code_storage: In-memory storage for verification codes
            database: Database connection for storing verified domains
            code_ttl: Verification code TTL in seconds (default: 900 = 15 min)
        """

    def start_verification(self, domain: str) -> Tuple[bool, Optional[str], Optional[str]]:
        """
        Start domain verification process.

        Steps:
        1. Verify DNS TXT record exists
        2. Fetch user's homepage
        3. Discover email from rel="me" link
        4. Generate and send verification code

        Args:
            domain: Domain to verify (e.g., "example.com")

        Returns:
            Tuple of (success, discovered_email_masked, error_message)
            - success: True if code sent, False if verification cannot start
            - discovered_email_masked: Email with partial masking (e.g., "u***@example.com")
            - error_message: Error description if success=False, None otherwise
        """

    def verify_code(self, email: str, submitted_code: str) -> Tuple[bool, Optional[str], Optional[str]]:
        """
        Verify submitted code.

        Args:
            email: Email address (discovered from rel="me")
            submitted_code: 6-digit code entered by user

        Returns:
            Tuple of (success, domain, error_message)
            - success: True if code valid, False otherwise
            - domain: User's verified domain if success=True
            - error_message: Error description if success=False
        """

    def is_domain_verified(self, domain: str) -> bool:
        """
        Check if domain is already verified (cached in database).

        Args:
            domain: Domain to check

        Returns:
            True if domain previously verified, False otherwise
        """

Implementation Details:

def start_verification(self, domain: str) -> Tuple[bool, Optional[str], Optional[str]]:
    """Start domain verification process."""
    logger.info(f"Starting domain verification: {domain}")

    # Step 1: Verify DNS TXT record (first factor)
    logger.debug(f"Verifying DNS TXT record for {domain}")
    dns_verified = self.dns_service.verify_txt_record(domain, "verified")

    if not dns_verified:
        error = (
            f"DNS verification failed. TXT record not found for _gondulf.{domain}. "
            f"Please add: Type=TXT, Name=_gondulf.{domain}, Value=verified"
        )
        logger.warning(f"DNS verification failed: {domain}")
        return False, None, error

    logger.info(f"DNS TXT record verified: {domain}")

    # Step 2: Fetch site homepage
    logger.debug(f"Fetching homepage for {domain}")
    html = self.html_fetcher.fetch_site(domain)

    if html is None:
        error = (
            f"Could not fetch site at https://{domain}. "
            f"Please ensure site is accessible via HTTPS with valid SSL certificate."
        )
        logger.warning(f"Site fetch failed: {domain}")
        return False, None, error

    logger.info(f"Successfully fetched homepage: {domain}")

    # Step 3: Discover email from rel="me" (second factor discovery)
    logger.debug(f"Discovering email via rel='me' for {domain}")
    email = self.relme_discovery.discover_email(html)

    if email is None:
        error = (
            'No rel="me" mailto: link found on homepage. '
            f'Please add to https://{domain}: '
            '<link rel="me" href="mailto:your-email@example.com">'
        )
        logger.warning(f"rel='me' discovery failed: {domain}")
        return False, None, error

    logger.info(f"Email discovered via rel='me' for {domain}: {email[:3]}***")

    # Step 4: Check rate limiting
    if self._is_rate_limited(domain):
        error = (
            f"Rate limit exceeded for {domain}. "
            f"Please wait before requesting another verification code."
        )
        logger.warning(f"Rate limit exceeded: {domain}")
        return False, email, error

    # Step 5: Generate verification code
    code = self._generate_code()

    # Step 6: Store code with metadata
    self.code_storage.store(email, code, ttl=self.code_ttl)

    # Store metadata for rate limiting and domain association
    self._store_code_metadata(email, domain)

    logger.debug(f"Verification code generated and stored for {email[:3]}***")

    # Step 7: Send verification email (second factor verification)
    logger.debug(f"Sending verification email to {email[:3]}***")
    email_sent = self.email_service.send_verification_email(email, code)

    if not email_sent:
        # Clean up stored code if email fails
        self.code_storage.delete(email)
        error = (
            f"Failed to send verification code to {email}. "
            f"Please check email address in rel='me' link and try again."
        )
        logger.error(f"Email send failed: {email[:3]}***")
        return False, email, error

    logger.info(f"Verification code sent successfully to {email[:3]}***")

    # Mask email for display: u***@example.com
    email_masked = self._mask_email(email)

    return True, email_masked, None

def verify_code(self, email: str, submitted_code: str) -> Tuple[bool, Optional[str], Optional[str]]:
    """Verify submitted code."""
    logger.info(f"Verifying code for {email[:3]}***")

    # Retrieve stored code
    stored_code = self.code_storage.get(email)

    if stored_code is None:
        logger.warning(f"No verification code found for {email[:3]}***")
        return False, None, "No verification code found. Please request a new code."

    # Get code metadata
    metadata = self._get_code_metadata(email)
    if metadata is None:
        logger.error(f"Code found but metadata missing for {email[:3]}***")
        return False, None, "Verification error. Please request a new code."

    domain = metadata['domain']
    attempts = metadata.get('attempts', 0)

    # Check attempt limit (prevent brute force)
    if attempts >= 3:
        logger.warning(f"Too many attempts for {email[:3]}***")
        self.code_storage.delete(email)
        self._delete_code_metadata(email)
        return False, None, "Too many attempts. Please request a new code."

    # Increment attempt counter
    self._increment_attempts(email)

    # Verify code using constant-time comparison (SECURITY: prevent timing attacks)
    if not secrets.compare_digest(submitted_code, stored_code):
        logger.warning(f"Invalid code submitted for {email[:3]}***")
        return False, None, f"Invalid code. {3 - attempts - 1} attempts remaining."

    # Code is valid - clean up and mark domain as verified
    logger.info(f"Code verified successfully for {domain}")

    self.code_storage.delete(email)
    self._delete_code_metadata(email)

    # Store verified domain in database
    self._store_verified_domain(domain)

    return True, domain, None

def is_domain_verified(self, domain: str) -> bool:
    """Check if domain already verified."""
    with self.database.get_connection() as conn:
        result = conn.execute(
            "SELECT verified FROM domains WHERE domain = ?",
            (domain,)
        ).fetchone()

        if result and result['verified']:
            logger.debug(f"Domain already verified: {domain}")
            return True

        return False

def _generate_code(self) -> str:
    """Generate 6-digit verification code."""
    return ''.join(secrets.choice('0123456789') for _ in range(6))

def _mask_email(self, email: str) -> str:
    """Mask email for display: u***@example.com"""
    local, domain = email.split('@')
    if len(local) <= 1:
        return f"{local[0]}***@{domain}"
    return f"{local[0]}***@{domain}"

def _is_rate_limited(self, domain: str) -> bool:
    """
    Check if domain is rate limited.

    Rate limit: Max 3 codes per domain per hour.
    """
    # TODO: Implement rate limiting using code metadata
    # For Phase 2, we'll implement simple in-memory tracking
    # Future: Use Redis for distributed rate limiting
    return False  # Placeholder - implement in actual code

def _store_code_metadata(self, email: str, domain: str) -> None:
    """Store code metadata for rate limiting and domain association."""
    # TODO: Implement metadata storage
    # Store: email -> {domain, created_at, attempts}
    pass

def _get_code_metadata(self, email: str) -> Optional[dict]:
    """Retrieve code metadata."""
    # TODO: Implement metadata retrieval
    # Return: {domain, created_at, attempts}
    return {'domain': 'example.com', 'attempts': 0}  # Placeholder

def _delete_code_metadata(self, email: str) -> None:
    """Delete code metadata."""
    # TODO: Implement metadata deletion
    pass

def _increment_attempts(self, email: str) -> None:
    """Increment attempt counter for email."""
    # TODO: Implement attempt increment
    pass

def _store_verified_domain(self, domain: str) -> None:
    """Store verified domain in database."""
    from datetime import datetime

    with self.database.get_connection() as conn:
        conn.execute(
            """
            INSERT OR REPLACE INTO domains (domain, verification_method, verified, verified_at, last_dns_check)
            VALUES (?, ?, ?, ?, ?)
            """,
            (domain, 'two_factor', True, datetime.utcnow(), datetime.utcnow())
        )
        conn.commit()

    logger.info(f"Domain verification stored in database: {domain}")

Dependencies:

  • All Phase 1 services (DNS, Email, Storage, Database)
  • HTML fetcher service (Phase 2)
  • rel="me" discovery service (Phase 2)
  • Python standard library: secrets, datetime

Error Handling:

  • DNS verification failure: Return error with setup instructions
  • Site fetch failure: Return error with troubleshooting steps
  • rel="me" discovery failure: Return error with HTML example
  • Email send failure: Return error, clean up stored code
  • Code not found: Return error, suggest requesting new code
  • Code expired: Handled by CodeStorage TTL
  • Too many attempts: Return error, invalidate code
  • Invalid code: Return error with remaining attempts
  • Rate limit exceeded: Return error, suggest waiting

Security Considerations:

  • Two-factor verification: Both DNS and email required
  • Constant-time code comparison: Prevent timing attacks (secrets.compare_digest)
  • Rate limiting: Max 3 codes per domain per hour (prevents abuse)
  • Attempt limiting: Max 3 code submission attempts (prevents brute force)
  • Single-use codes: Deleted after successful verification
  • Email masking in logs: Only log partial email (privacy)
  • No email storage: Email used only during verification, never persisted

Testing Requirements:

  • Full verification flow: DNS → rel="me" → email → code verification
  • DNS verification failure blocks flow
  • Site fetch failure blocks flow
  • rel="me" discovery failure blocks flow
  • Email send failure cleans up stored code
  • Code verification success stores domain in database
  • Code verification failure decrements remaining attempts
  • Too many attempts invalidates code
  • Invalid code returns error with attempts remaining
  • Code expiration handled by storage layer
  • Rate limiting prevents excessive code requests
  • Already verified domain check works
  • Email masking works correctly

4. Domain Verification Endpoints

File: src/gondulf/routers/verification.py

Purpose: HTTP API endpoints for user interaction during verification flow.

Public Interface:

from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel, Field
from typing import Optional

router = APIRouter(prefix="/api/verify", tags=["verification"])

# Request/Response Models
class VerificationStartRequest(BaseModel):
    """Request to start domain verification."""
    domain: str = Field(
        ...,
        min_length=3,
        max_length=253,
        description="Domain to verify (e.g., 'example.com')"
    )

class VerificationStartResponse(BaseModel):
    """Response from starting verification."""
    success: bool
    email_masked: Optional[str] = Field(None, description="Partially masked email (e.g., 'u***@example.com')")
    error: Optional[str] = Field(None, description="Error message if success=False")

class VerificationCodeRequest(BaseModel):
    """Request to verify code."""
    email: str = Field(..., description="Email address discovered from rel='me'")
    code: str = Field(..., min_length=6, max_length=6, pattern="^[0-9]{6}$", description="6-digit verification code")

class VerificationCodeResponse(BaseModel):
    """Response from code verification."""
    success: bool
    domain: Optional[str] = Field(None, description="Verified domain if success=True")
    error: Optional[str] = Field(None, description="Error message if success=False")

# Endpoints
@router.post("/start", response_model=VerificationStartResponse)
async def start_verification(
    request: VerificationStartRequest,
    domain_verification: DomainVerificationService = Depends(get_domain_verification_service)
) -> VerificationStartResponse:
    """
    Start domain verification process.

    Steps:
    1. Verify DNS TXT record exists
    2. Discover email from rel="me" link
    3. Send verification code to discovered email

    Returns masked email on success, error message on failure.
    """

@router.post("/code", response_model=VerificationCodeResponse)
async def verify_code(
    request: VerificationCodeRequest,
    domain_verification: DomainVerificationService = Depends(get_domain_verification_service)
) -> VerificationCodeResponse:
    """
    Verify submitted code.

    Returns verified domain on success, error message on failure.
    """

Implementation Details:

@router.post("/start", response_model=VerificationStartResponse)
async def start_verification(
    request: VerificationStartRequest,
    domain_verification: DomainVerificationService = Depends(get_domain_verification_service)
) -> VerificationStartResponse:
    """Start domain verification process."""
    logger.info(f"Verification start request: {request.domain}")

    # Normalize domain (lowercase, remove trailing slash)
    domain = request.domain.lower().rstrip('/')

    # Remove protocol if present
    if domain.startswith('http://') or domain.startswith('https://'):
        domain = domain.split('://', 1)[1]

    # Remove path if present
    if '/' in domain:
        domain = domain.split('/')[0]

    # Validate domain format (basic validation)
    if not domain or '.' not in domain:
        logger.warning(f"Invalid domain format: {request.domain}")
        return VerificationStartResponse(
            success=False,
            email_masked=None,
            error="Invalid domain format. Please provide a valid domain (e.g., 'example.com')."
        )

    # Start verification
    success, email_masked, error = domain_verification.start_verification(domain)

    if not success:
        logger.warning(f"Verification start failed for {domain}: {error}")
        return VerificationStartResponse(
            success=False,
            email_masked=email_masked,
            error=error
        )

    logger.info(f"Verification started successfully for {domain}")
    return VerificationStartResponse(
        success=True,
        email_masked=email_masked,
        error=None
    )

@router.post("/code", response_model=VerificationCodeResponse)
async def verify_code(
    request: VerificationCodeRequest,
    domain_verification: DomainVerificationService = Depends(get_domain_verification_service)
) -> VerificationCodeResponse:
    """Verify submitted code."""
    logger.info(f"Code verification request for email: {request.email[:3]}***")

    # Verify code
    success, domain, error = domain_verification.verify_code(request.email, request.code)

    if not success:
        logger.warning(f"Code verification failed for {request.email[:3]}***: {error}")
        return VerificationCodeResponse(
            success=False,
            domain=None,
            error=error
        )

    logger.info(f"Code verified successfully for domain: {domain}")
    return VerificationCodeResponse(
        success=True,
        domain=domain,
        error=None
    )

Dependencies:

  • FastAPI router and dependency injection
  • Pydantic models for request/response validation
  • Domain verification service (injected via Depends)
  • Phase 1 logging configuration

Error Handling:

  • Invalid domain format: Return 200 with success=False, descriptive error
  • Pydantic validation errors: Automatic 422 response with validation details
  • Service errors: Propagated via success=False in response
  • All errors logged at WARNING level
  • No 500 errors expected (all errors handled gracefully)

Security Considerations:

  • Input validation: Pydantic models enforce constraints
  • Domain normalization: Prevent URL injection
  • No authentication required: Public endpoints (verification is the authentication)
  • Rate limiting: Handled by DomainVerificationService (not endpoint level)
  • Email not validated at endpoint level: Service handles validation

Testing Requirements:

  • POST /api/verify/start with valid domain returns success
  • POST /api/verify/start with invalid domain format returns error
  • POST /api/verify/start with DNS failure returns error
  • POST /api/verify/start with rel="me" failure returns error
  • POST /api/verify/start with email send failure returns error
  • POST /api/verify/code with valid code returns domain
  • POST /api/verify/code with invalid code returns error
  • POST /api/verify/code with expired code returns error
  • POST /api/verify/code with missing code returns error
  • POST /api/verify/code with too many attempts returns error
  • Pydantic validation errors return 422

5. Authorization Endpoint

File: src/gondulf/routers/authorization.py

Purpose: Implement IndieAuth authorization endpoint (/authorize) per W3C spec.

Public Interface:

from fastapi import APIRouter, Request, HTTPException, Depends
from fastapi.responses import RedirectResponse, HTMLResponse
from pydantic import BaseModel, HttpUrl, Field
from typing import Optional, Literal

router = APIRouter(tags=["indieauth"])

# Request Models
class AuthorizeRequest(BaseModel):
    """
    IndieAuth authorization request parameters.

    Per W3C IndieAuth specification (Section 5.1):
    https://www.w3.org/TR/indieauth/#authorization-request
    """
    me: HttpUrl = Field(..., description="User's profile URL (domain identity)")
    client_id: HttpUrl = Field(..., description="Client application URL")
    redirect_uri: HttpUrl = Field(..., description="Where to redirect after authorization")
    state: str = Field(..., min_length=1, max_length=512, description="CSRF protection token")
    response_type: Literal["code"] = Field(..., description="Must be 'code' for authorization code flow")
    scope: Optional[str] = Field(None, description="Requested scopes (ignored in v1.0.0)")
    code_challenge: Optional[str] = Field(None, description="PKCE challenge (not supported in v1.0.0)")
    code_challenge_method: Optional[str] = Field(None, description="PKCE method (not supported in v1.0.0)")

# Endpoints
@router.get("/authorize")
async def authorize(
    request: Request,
    me: str,
    client_id: str,
    redirect_uri: str,
    state: str,
    response_type: str,
    scope: Optional[str] = None,
    code_challenge: Optional[str] = None,
    code_challenge_method: Optional[str] = None,
    domain_verification: DomainVerificationService = Depends(get_domain_verification_service)
) -> HTMLResponse:
    """
    IndieAuth authorization endpoint.

    Per W3C IndieAuth specification:
    https://www.w3.org/TR/indieauth/#authorization-request

    Flow:
    1. Validate all parameters
    2. Check if domain already verified (skip verification if cached)
    3. If not verified, initiate two-factor verification flow
    4. Display consent screen with client info
    5. On approval, generate authorization code
    6. Redirect to client with code + state
    """

Implementation Details (High-Level - Full implementation too long for this doc):

@router.get("/authorize")
async def authorize(
    request: Request,
    me: str,
    client_id: str,
    redirect_uri: str,
    state: str,
    response_type: str,
    # ... other parameters
) -> HTMLResponse:
    """IndieAuth authorization endpoint."""

    # STEP 1: Validate response_type
    if response_type != "code":
        # Return error (redirect if possible)
        return _error_response(
            redirect_uri=redirect_uri,
            state=state,
            error="unsupported_response_type",
            description="Only response_type=code is supported"
        )

    # STEP 2: Validate and normalize 'me' parameter
    me_normalized = _validate_and_normalize_me(me)
    if me_normalized is None:
        return _error_response(
            redirect_uri=redirect_uri,
            state=state,
            error="invalid_request",
            description="Invalid 'me' parameter format"
        )

    # STEP 3: Validate client_id
    client_valid = _validate_client_id(client_id)
    if not client_valid:
        return _error_response(
            redirect_uri=redirect_uri,
            state=state,
            error="invalid_client",
            description="Invalid client_id"
        )

    # STEP 4: Validate redirect_uri
    redirect_valid = _validate_redirect_uri(redirect_uri, client_id)
    if not redirect_valid:
        # SECURITY: Cannot redirect to invalid URI - display error page
        return _error_page("Invalid redirect_uri")

    # STEP 5: Check if domain already verified
    domain = _extract_domain_from_me(me_normalized)

    if domain_verification.is_domain_verified(domain):
        # Skip verification, go directly to consent
        logger.info(f"Domain already verified: {domain}")
        return await _show_consent_screen(
            me=me_normalized,
            client_id=client_id,
            redirect_uri=redirect_uri,
            state=state
        )

    # STEP 6: Domain not verified - start verification flow
    logger.info(f"Starting verification for new domain: {domain}")

    success, email_masked, error = domain_verification.start_verification(domain)

    if not success:
        # Verification failed - show error with instructions
        return _verification_error_page(domain, error)

    # STEP 7: Show code entry form
    return _code_entry_page(
        domain=domain,
        email_masked=email_masked,
        me=me_normalized,
        client_id=client_id,
        redirect_uri=redirect_uri,
        state=state
    )

# Additional endpoints for verification flow
@router.post("/authorize/verify-code")
async def verify_code_and_consent(
    request: Request,
    email: str,
    code: str,
    me: str,
    client_id: str,
    redirect_uri: str,
    state: str,
    domain_verification: DomainVerificationService = Depends(get_domain_verification_service)
) -> HTMLResponse:
    """
    Verify code and show consent screen.

    Called when user submits verification code during authorization flow.
    """
    # Verify code
    success, domain, error = domain_verification.verify_code(email, code)

    if not success:
        # Code invalid - show error, allow retry
        return _code_entry_page_with_error(
            domain=_extract_domain_from_me(me),
            email_masked=_mask_email(email),
            error=error,
            me=me,
            client_id=client_id,
            redirect_uri=redirect_uri,
            state=state
        )

    # Code valid - show consent screen
    return await _show_consent_screen(
        me=me,
        client_id=client_id,
        redirect_uri=redirect_uri,
        state=state
    )

@router.post("/authorize/consent")
async def handle_consent(
    request: Request,
    action: Literal["approve", "deny"],
    me: str,
    client_id: str,
    redirect_uri: str,
    state: str,
    code_storage: CodeStorage = Depends(get_code_storage)
) -> RedirectResponse:
    """
    Handle user consent decision.

    Called when user approves or denies authorization.
    """
    if action == "deny":
        # User denied - redirect with error
        return RedirectResponse(
            url=f"{redirect_uri}?error=access_denied&error_description=User denied authorization&state={state}",
            status_code=302
        )

    # User approved - generate authorization code
    auth_code = _generate_authorization_code()

    # Store code in memory with metadata
    code_storage.store(auth_code, {
        'me': me,
        'client_id': client_id,
        'redirect_uri': redirect_uri,
        'state': state,
        'created_at': datetime.utcnow()
    }, ttl=600)  # 10 minutes

    logger.info(f"Authorization code generated for {me} / {client_id}")

    # Redirect to client with code + state
    return RedirectResponse(
        url=f"{redirect_uri}?code={auth_code}&state={state}",
        status_code=302
    )

# Helper functions (implementations not shown for brevity)
def _validate_and_normalize_me(me: str) -> Optional[str]:
    """Validate and normalize 'me' parameter per IndieAuth spec."""
    pass

def _validate_client_id(client_id: str) -> bool:
    """Validate client_id is a valid URL."""
    pass

def _validate_redirect_uri(redirect_uri: str, client_id: str) -> bool:
    """Validate redirect_uri against client_id."""
    pass

def _extract_domain_from_me(me: str) -> str:
    """Extract domain from 'me' URL."""
    pass

async def _show_consent_screen(...) -> HTMLResponse:
    """Render consent screen HTML."""
    pass

def _code_entry_page(...) -> HTMLResponse:
    """Render code entry page HTML."""
    pass

def _error_response(...) -> RedirectResponse:
    """Generate OAuth 2.0 error redirect."""
    pass

def _generate_authorization_code() -> str:
    """Generate cryptographically secure authorization code."""
    return secrets.token_urlsafe(32)  # 256 bits

Dependencies:

  • FastAPI router, Request, Response types
  • Pydantic models for validation
  • Domain verification service (Phase 2)
  • Code storage (Phase 1)
  • HTML templates (new - Jinja2)
  • Python standard library: secrets, datetime

Error Handling:

  • Invalid response_type: Redirect with unsupported_response_type error
  • Invalid me parameter: Redirect with invalid_request error
  • Invalid client_id: Redirect with invalid_client error
  • Invalid redirect_uri: Display error page (cannot redirect)
  • DNS verification failure: Display error page with setup instructions
  • rel="me" discovery failure: Display error page with HTML example
  • Email send failure: Display error page with troubleshooting
  • Code verification failure: Display code entry page with error, allow retry
  • User denies consent: Redirect with access_denied error
  • All errors follow OAuth 2.0 error response format

Security Considerations:

  • HTTPS only: Enforced by middleware (production)
  • redirect_uri validation: Prevent open redirect attacks
  • State parameter: Passed through, client validates (CSRF protection)
  • Authorization code: Cryptographically secure (256 bits)
  • Code single-use: Enforced by token endpoint (Phase 3)
  • Code expiration: 10 minutes TTL
  • Domain verification: Two-factor required before code generation
  • No client secrets: All clients are public per IndieAuth spec

Testing Requirements:

  • GET /authorize with valid parameters shows verification or consent
  • GET /authorize with invalid response_type returns error
  • GET /authorize with invalid me parameter returns error
  • GET /authorize with invalid client_id returns error
  • GET /authorize with invalid redirect_uri shows error page
  • GET /authorize with already verified domain skips to consent
  • POST /authorize/verify-code with valid code shows consent
  • POST /authorize/verify-code with invalid code shows error
  • POST /authorize/consent with action=approve generates code and redirects
  • POST /authorize/consent with action=deny redirects with access_denied
  • Authorization code stored in memory with correct metadata
  • Authorization code expires after 10 minutes
  • State parameter passed through all steps

Data Flow

Complete Two-Factor Verification Flow

┌─────────────────────────────────────────────────────────────────┐
│                      User / Client Application                   │
└───────────────────────────────┬─────────────────────────────────┘
                                │
                                │ GET /authorize?me=example.com&...
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Authorization Endpoint                        │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ 1. Validate parameters (me, client_id, redirect_uri,    │   │
│  │    state, response_type)                                 │   │
│  └──────────────────────────┬───────────────────────────────┘   │
│                             │                                    │
│  ┌──────────────────────────▼───────────────────────────────┐   │
│  │ 2. Check if domain already verified in database          │   │
│  └──────────────────────────┬───────────────────────────────┘   │
│                             │                                    │
│                    ┌────────┴────────┐                           │
│                    │                 │                           │
│                    │ Verified?       │                           │
│                    │                 │                           │
│          ┌─────────┴─────No─────────┴─────────┐                 │
│          │                                     │                 │
│          │ YES                                 │ NO              │
│          │                                     │                 │
│          ▼                                     ▼                 │
│  ┌──────────────────┐              ┌──────────────────────────┐ │
│  │ Skip to Consent  │              │ Start Verification Flow  │ │
│  │ (Step 9)         │              │ (Step 3)                 │ │
│  └──────────────────┘              └─────────┬────────────────┘ │
│                                               │                  │
└───────────────────────────────────────────────┼──────────────────┘
                                                │
                                                ▼
┌─────────────────────────────────────────────────────────────────┐
│              Domain Verification Service (Two-Factor)            │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ 3. Verify DNS TXT Record (First Factor)                  │   │
│  │    Query: _gondulf.example.com TXT                       │   │
│  │    Expected: "verified"                                  │   │
│  └──────────────────────────┬───────────────────────────────┘   │
│                             │                                    │
│                    ┌────────┴────────┐                           │
│                    │ TXT found?      │                           │
│          ┌─────────┴─────No─────────┴─────────┐                 │
│          │ YES                                 │ NO              │
│          ▼                                     ▼                 │
│  ┌──────────────────┐              ┌──────────────────────────┐ │
│  │ Continue to      │              │ FAIL: Display error      │ │
│  │ Step 4           │              │ "Add DNS TXT record"     │ │
│  └─────────┬────────┘              └──────────────────────────┘ │
│            │                                                     │
│  ┌─────────▼────────────────────────────────────────────────┐   │
│  │ 4. Fetch User's Homepage via HTTPS                       │   │
│  │    URL: https://example.com                              │   │
│  │    Timeout: 10s, Max size: 5MB, Verify SSL              │   │
│  └──────────────────────────┬───────────────────────────────┘   │
│                             │                                    │
│                    ┌────────┴────────┐                           │
│                    │ Fetch success?  │                           │
│          ┌─────────┴─────No─────────┴─────────┐                 │
│          │ YES                                 │ NO              │
│          ▼                                     ▼                 │
│  ┌──────────────────┐              ┌──────────────────────────┐ │
│  │ Continue to      │              │ FAIL: Display error      │ │
│  │ Step 5           │              │ "Site unreachable"       │ │
│  └─────────┬────────┘              └──────────────────────────┘ │
│            │                                                     │
│  ┌─────────▼────────────────────────────────────────────────┐   │
│  │ 5. Discover Email via rel="me" (Second Factor Discovery)│   │
│  │    Parse HTML for: <link rel="me" href="mailto:...">    │   │
│  │    Extract and validate email format                     │   │
│  └──────────────────────────┬───────────────────────────────┘   │
│                             │                                    │
│                    ┌────────┴────────┐                           │
│                    │ Email found?    │                           │
│          ┌─────────┴─────No─────────┴─────────┐                 │
│          │ YES                                 │ NO              │
│          ▼                                     ▼                 │
│  ┌──────────────────┐              ┌──────────────────────────┐ │
│  │ Continue to      │              │ FAIL: Display error      │ │
│  │ Step 6           │              │ "Add rel='me' link"      │ │
│  └─────────┬────────┘              └──────────────────────────┘ │
│            │                                                     │
│  ┌─────────▼────────────────────────────────────────────────┐   │
│  │ 6. Generate and Send Verification Code                   │   │
│  │    (Second Factor Verification)                          │   │
│  │    - Generate 6-digit code (cryptographically secure)    │   │
│  │    - Store code in memory (TTL: 15 minutes)              │   │
│  │    - Send code to discovered email via SMTP              │   │
│  └──────────────────────────┬───────────────────────────────┘   │
│                             │                                    │
└─────────────────────────────┼────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Display Code Entry Form                       │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ "Verification code sent to u***@example.com"             │   │
│  │ [Enter 6-digit code: ______]                             │   │
│  │ [Submit]                                                 │   │
│  └──────────────────────────┬───────────────────────────────┘   │
└─────────────────────────────┼────────────────────────────────────┘
                              │
                              │ POST /authorize/verify-code
                              │ {email, code, me, client_id, ...}
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│              Domain Verification Service (Continued)             │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ 7. Verify Submitted Code                                 │   │
│  │    - Retrieve stored code from memory                    │   │
│  │    - Check expiration (15 min TTL)                       │   │
│  │    - Check attempts (max 3)                              │   │
│  │    - Constant-time compare submitted vs stored           │   │
│  └──────────────────────────┬───────────────────────────────┘   │
│                             │                                    │
│                    ┌────────┴────────┐                           │
│                    │ Code valid?     │                           │
│          ┌─────────┴─────No─────────┴─────────┐                 │
│          │ YES                                 │ NO              │
│          ▼                                     ▼                 │
│  ┌──────────────────┐              ┌──────────────────────────┐ │
│  │ Store verified   │              │ Show error, allow retry  │ │
│  │ domain in DB     │              │ (if attempts remaining)  │ │
│  └─────────┬────────┘              └──────────────────────────┘ │
│            │                                                     │
│  ┌─────────▼────────────────────────────────────────────────┐   │
│  │ 8. Domain Verified (Two-Factor Complete)                 │   │
│  │    - DNS TXT verified ✓                                  │   │
│  │    - Email verified ✓                                    │   │
│  │    - Store in database: verification_method='two_factor' │   │
│  └──────────────────────────┬───────────────────────────────┘   │
└─────────────────────────────┼────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                      Display Consent Screen                      │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ "Sign in to [App Name] as example.com"                   │   │
│  │                                                           │   │
│  │ Client: https://client.example.com                       │   │
│  │ Redirect: https://client.example.com/callback            │   │
│  │                                                           │   │
│  │ [Approve] [Deny]                                         │   │
│  └──────────────────────────┬───────────────────────────────┘   │
└─────────────────────────────┼────────────────────────────────────┘
                              │
                              │ POST /authorize/consent
                              │ {action: "approve", ...}
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Authorization Endpoint (Continued)            │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ 9. Generate Authorization Code                           │   │
│  │    - Generate cryptographically secure code (256 bits)   │   │
│  │    - Store in memory with metadata:                      │   │
│  │      • me (user's domain)                                │   │
│  │      • client_id                                         │   │
│  │      • redirect_uri                                      │   │
│  │      • state                                             │   │
│  │      • TTL: 10 minutes                                   │   │
│  └──────────────────────────┬───────────────────────────────┘   │
│                             │                                    │
│  ┌──────────────────────────▼───────────────────────────────┐   │
│  │ 10. Redirect to Client with Code                         │   │
│  │     {redirect_uri}?code={code}&state={state}             │   │
│  └──────────────────────────┬───────────────────────────────┘   │
└─────────────────────────────┼────────────────────────────────────┘
                              │
                              │ HTTP 302 Redirect
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                      Client Application                          │
│  • Receives authorization code                                  │
│  • Validates state parameter (CSRF protection)                  │
│  • Exchanges code for token (Phase 3: Token Endpoint)           │
└─────────────────────────────────────────────────────────────────┘

State Transitions

Domain Verification States:

  1. Unverified: Domain never seen before
  2. DNS Verified: TXT record confirmed
  3. Email Discovered: rel="me" link found
  4. Code Sent: Verification code sent to email
  5. Fully Verified: Code verified, stored in database
  6. Cached: Domain verification cached (skip steps 1-5 on future auth)

Authorization Flow States:

  1. Request Received: Parameters validated
  2. Domain Check: Checking if domain verified
  3. Verification In Progress: User entering code
  4. Consent Pending: User viewing consent screen
  5. Approved: User approved, code generated
  6. Denied: User denied, error redirect
  7. Complete: Redirected to client with code

Error Paths

DNS Verification Failure:

/authorize → Validate params → Check DNS TXT → [NOT FOUND]
    → Display error page with instructions
    → User adds TXT record, clicks "Retry"
    → Loop back to Check DNS TXT

rel="me" Discovery Failure:

/authorize → DNS verified → Fetch site → Discover email → [NOT FOUND]
    → Display error page with HTML example
    → User adds <link rel="me">, clicks "Retry"
    → Loop back to Fetch site

Email Send Failure:

/authorize → DNS + rel="me" OK → Send email → [SMTP ERROR]
    → Display error page with troubleshooting
    → User checks SMTP config, clicks "Retry"
    → Loop back to Send email

Invalid Code:

/authorize/verify-code → Verify code → [INVALID]
    → Display code entry form with error
    → "Invalid code. 2 attempts remaining."
    → User enters code again
    → Loop back to Verify code

Rate Limit Exceeded:

/authorize → Start verification → Check rate limit → [EXCEEDED]
    → Display error: "Too many attempts, wait 1 hour"
    → User waits, tries again later

API Endpoints

POST /api/verify/start

Purpose: Start domain verification process.

Request:

{
  "domain": "example.com"
}

Success Response (200 OK):

{
  "success": true,
  "email_masked": "u***@example.com",
  "error": null
}

Error Response (200 OK with success=false):

{
  "success": false,
  "email_masked": null,
  "error": "DNS TXT record not found for _gondulf.example.com. Please add: Type=TXT, Name=_gondulf.example.com, Value=verified"
}

Validation Errors (422 Unprocessable Entity):

{
  "detail": [
    {
      "loc": ["body", "domain"],
      "msg": "field required",
      "type": "value_error.missing"
    }
  ]
}

Rate Limiting:

  • Max 3 requests per domain per hour
  • Enforced by DomainVerificationService

Authentication: None required (public endpoint)


POST /api/verify/code

Purpose: Verify submitted 6-digit code.

Request:

{
  "email": "user@example.com",
  "code": "123456"
}

Success Response (200 OK):

{
  "success": true,
  "domain": "example.com",
  "error": null
}

Error Response (200 OK with success=false):

{
  "success": false,
  "domain": null,
  "error": "Invalid code. 2 attempts remaining."
}

Validation Errors (422 Unprocessable Entity):

{
  "detail": [
    {
      "loc": ["body", "code"],
      "msg": "string does not match regex \"^[0-9]{6}$\"",
      "type": "value_error.str.regex"
    }
  ]
}

Rate Limiting:

  • Max 3 attempts per email per code
  • Enforced by code verification logic

Authentication: None required (code is the authentication)


GET /authorize

Purpose: IndieAuth authorization endpoint.

Query Parameters:

  • me (required): User's profile URL (e.g., "https://example.com")
  • client_id (required): Client application URL
  • redirect_uri (required): Where to redirect after authorization
  • state (required): CSRF protection token
  • response_type (required): Must be "code"
  • scope (optional): Requested scopes (ignored in v1.0.0)
  • code_challenge (optional): PKCE challenge (not supported in v1.0.0)
  • code_challenge_method (optional): PKCE method (not supported in v1.0.0)

Success Response: HTML page (verification form or consent screen)

Error Redirect (302 Found):

{redirect_uri}?error=invalid_request&error_description=Invalid+me+parameter&state={state}

Error Codes (OAuth 2.0 standard):

  • invalid_request: Missing or invalid parameter
  • unauthorized_client: Client not authorized
  • access_denied: User denied authorization
  • unsupported_response_type: response_type not "code"
  • server_error: Internal server error

Error Page (when redirect not possible):

<!DOCTYPE html>
<html>
<head><title>Authorization Error</title></head>
<body>
  <h1>Authorization Error</h1>
  <p>Invalid redirect_uri. Cannot redirect safely.</p>
</body>
</html>

Rate Limiting: None at endpoint level (handled by verification service)

Authentication: None initially (domain verification IS the authentication)


POST /authorize/verify-code

Purpose: Verify code during authorization flow.

Form Data:

  • email (required): Email address from rel="me"
  • code (required): 6-digit verification code
  • me (required): User's profile URL
  • client_id (required): Client application URL
  • redirect_uri (required): Redirect URI
  • state (required): State parameter

Success Response: HTML page (consent screen)

Error Response: HTML page (code entry form with error message)


POST /authorize/consent

Purpose: Handle user consent decision.

Form Data:

  • action (required): "approve" or "deny"
  • me (required): User's profile URL
  • client_id (required): Client application URL
  • redirect_uri (required): Redirect URI
  • state (required): State parameter

Success Response (Approve) (302 Found):

{redirect_uri}?code={authorization_code}&state={state}

Success Response (Deny) (302 Found):

{redirect_uri}?error=access_denied&error_description=User+denied+authorization&state={state}

Data Models

Verified Domain (Database Table)

Table: domains

Schema (from Phase 1):

CREATE TABLE domains (
    domain TEXT PRIMARY KEY,
    verification_method TEXT NOT NULL,  -- 'two_factor' for v1.0.0
    verified BOOLEAN NOT NULL DEFAULT FALSE,
    verified_at TIMESTAMP,
    last_dns_check TIMESTAMP,
    last_email_check TIMESTAMP
);

Updated in Phase 2: Change verification_method values from 'email' / 'txt_record' to 'two_factor'.

Migration: 002_update_verification_method.sql:

-- Update verification_method values to reflect two-factor requirement
UPDATE domains
SET verification_method = 'two_factor'
WHERE verification_method IN ('email', 'txt_record');

Indexes (from Phase 1):

CREATE INDEX idx_domains_domain ON domains(domain);
CREATE INDEX idx_domains_verified ON domains(verified);

Authorization Code (In-Memory)

Storage: Phase 1 CodeStorage with metadata

Structure:

{
    "code": "abc123...",  # 43-char base64url (32 bytes)
    "me": "https://example.com",
    "client_id": "https://client.example.com",
    "redirect_uri": "https://client.example.com/callback",
    "state": "client-provided-state",
    "created_at": datetime,
    "expires_at": datetime,  # created_at + 10 minutes
    "used": False  # For Phase 3 token endpoint
}

TTL: 10 minutes (per W3C spec: "shortly after")

Storage Location: Phase 1 CodeStorage service


Verification Code Metadata (In-Memory)

Storage: Additional metadata alongside verification codes

Structure:

{
    "email": "user@example.com",
    "domain": "example.com",
    "attempts": 0,  # Increment on each failed attempt
    "created_at": datetime
}

Purpose: Track attempts and associate email with domain for rate limiting.

TTL: Same as verification code (15 minutes)

Security Requirements

Input Validation

Domain Parameter:

def validate_domain(domain: str) -> Tuple[bool, Optional[str], Optional[str]]:
    """
    Validate domain parameter.

    Returns: (is_valid, normalized_domain, error_message)
    """
    # Remove protocol if present
    if domain.startswith('http://') or domain.startswith('https://'):
        domain = domain.split('://', 1)[1]

    # Remove path if present
    if '/' in domain:
        domain = domain.split('/')[0]

    # Lowercase
    domain = domain.lower().strip()

    # Must contain at least one dot
    if '.' not in domain:
        return False, None, "Domain must contain at least one dot (e.g., example.com)"

    # Must not be empty
    if not domain:
        return False, None, "Domain cannot be empty"

    # Must not contain invalid characters
    if any(c in domain for c in [' ', '@', ':', '?', '#']):
        return False, None, "Domain contains invalid characters"

    # Length check
    if len(domain) > 253:
        return False, None, "Domain too long (max 253 characters)"

    return True, domain, None

Email Parameter:

def validate_email(email: str) -> bool:
    """
    Validate email format (RFC 5322 simplified).

    Used by rel="me" discovery service.
    """
    email_regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'

    if not re.match(email_regex, email):
        return False

    if len(email) > 254:  # RFC 5321 maximum
        return False

    if email.count('@') != 1:
        return False

    local, domain = email.split('@')
    if '.' not in domain:
        return False

    return True

URL Parameters (me, client_id, redirect_uri):

def validate_url(url: str, param_name: str) -> Tuple[bool, Optional[str]]:
    """
    Validate URL parameter.

    Returns: (is_valid, error_message)
    """
    from urllib.parse import urlparse

    try:
        parsed = urlparse(url)
    except Exception:
        return False, f"{param_name} must be a valid URL"

    # Must have scheme and netloc
    if not parsed.scheme or not parsed.netloc:
        return False, f"{param_name} must be a complete URL (e.g., https://example.com)"

    # Must be http or https
    if parsed.scheme not in ['http', 'https']:
        return False, f"{param_name} must use http or https"

    # No fragments for 'me' parameter
    if param_name == "me" and parsed.fragment:
        return False, "me parameter must not contain fragment"

    # No credentials
    if parsed.username or parsed.password:
        return False, f"{param_name} must not contain credentials"

    return True, None

HTTPS Enforcement

Configuration:

# In production config
if not DEBUG:
    # Enforce HTTPS
    app.add_middleware(HTTPSRedirectMiddleware)

    # Reject HTTP redirect_uri (except localhost)
    if redirect_uri.startswith('http://'):
        parsed = urlparse(redirect_uri)
        if parsed.hostname not in ['localhost', '127.0.0.1']:
            return error_response("redirect_uri must use HTTPS in production")

HTML Fetching:

  • HTTPS only (hardcoded https:// in URL)
  • SSL certificate verification enforced (verify=True, no option to disable)
  • Reject sites with invalid certificates

HTML Parsing Security

BeautifulSoup Configuration:

# Use html.parser (Python standard library, safe for untrusted HTML)
soup = BeautifulSoup(html_content, 'html.parser')

Why html.parser:

  • Part of Python standard library (no external C dependencies)
  • Designed for untrusted HTML
  • No script execution
  • No external resource loading
  • Handles malformed HTML gracefully

Size Limits:

  • Maximum response size: 5MB (configurable)
  • Checked both in Content-Length header and actual content

Timeout:

  • HTTP request timeout: 10 seconds (configurable)
  • Prevents hanging on slow sites

Protection Against Open Redirects

redirect_uri Validation:

def validate_redirect_uri(redirect_uri: str, client_id: str) -> Tuple[bool, Optional[str]]:
    """
    Validate redirect_uri against client_id.

    Returns: (is_valid, warning_message)
    """
    from urllib.parse import urlparse

    redirect_parsed = urlparse(redirect_uri)
    client_parsed = urlparse(client_id)

    # Must be HTTPS (except localhost)
    if redirect_parsed.scheme != 'https':
        if redirect_parsed.hostname not in ['localhost', '127.0.0.1']:
            return False, "redirect_uri must use HTTPS"

    # Must have valid hostname
    if not redirect_parsed.hostname:
        return False, "redirect_uri must have valid hostname"

    redirect_domain = redirect_parsed.hostname.lower()
    client_domain = client_parsed.hostname.lower()

    # Exact match: OK
    if redirect_domain == client_domain:
        return True, None

    # Subdomain of client: OK
    if redirect_domain.endswith('.' + client_domain):
        return True, None

    # Different domain: WARNING (display to user, but allow)
    warning = (
        f"Warning: Redirect to different domain ({redirect_domain}) "
        f"than client ({client_domain}). Ensure you trust this application."
    )
    return True, warning

Display Warning to User:

  • If redirect_uri domain differs from client_id domain, show warning on consent screen
  • User must explicitly approve redirect to different domain
  • Prevents phishing via redirect URI manipulation

CSRF Protection

State Parameter:

  • Required in authorization request
  • Stored with authorization code
  • Passed through verification and consent steps
  • Returned unchanged in redirect
  • Client validates state matches original (client responsibility per OAuth 2.0)

Gondulf does NOT validate state - This is intentional per OAuth 2.0:

  • State is opaque to authorization server
  • Client generates state, client validates state
  • Gondulf only passes it through unchanged

Code Replay Prevention

Authorization Code:

  • Single-use enforcement (Phase 3 token endpoint marks as used)
  • 10-minute expiration
  • Bound to client_id, redirect_uri, me
  • Stored in memory (Phase 1 CodeStorage)

Verification Code:

  • Single-use: Deleted after successful verification
  • 15-minute expiration
  • Max 3 attempts before invalidation
  • Constant-time comparison (prevent timing attacks)

Testing Requirements

Unit Tests

HTML Fetcher Service (9 tests):

  • Successful HTTPS fetch returns content
  • SSL verification failure returns None
  • Timeout returns None
  • HTTP error codes (404, 500) return None
  • Redirects followed (up to max)
  • Too many redirects returns None
  • Content-Length exceeds limit returns None
  • Actual content exceeds limit returns None
  • Custom User-Agent sent

rel="me" Discovery Service (12 tests):

  • Discovery from <link rel="me"> tag
  • Discovery from <a rel="me"> tag
  • Multiple rel="me" links: first mailto selected
  • Malformed HTML handled
  • Missing rel="me" returns None
  • Invalid email in link returns None
  • Empty href returns None
  • Non-mailto links ignored
  • mailto with query params strips params
  • Email validation: valid formats
  • Email validation: invalid formats
  • Exception during parsing returns None

Domain Verification Service (15 tests):

  • Full flow: DNS → rel="me" → email → code
  • DNS failure blocks flow
  • Site fetch failure blocks flow
  • rel="me" failure blocks flow
  • Email send failure cleans up code
  • Code verification success stores domain
  • Code verification failure decrements attempts
  • Too many attempts invalidates code
  • Invalid code returns error
  • Code expiration handled
  • Rate limiting works
  • Already verified domain check
  • Email masking correct
  • Constant-time comparison used
  • Metadata tracking works

Estimated Unit Test Count: ~36 tests


Integration Tests

Verification Endpoints (10 tests):

  • POST /api/verify/start success case
  • POST /api/verify/start with invalid domain
  • POST /api/verify/start with DNS failure
  • POST /api/verify/start with rel="me" failure
  • POST /api/verify/start with email send failure
  • POST /api/verify/code success case
  • POST /api/verify/code with invalid code
  • POST /api/verify/code with expired code
  • POST /api/verify/code with missing code
  • POST /api/verify/code with too many attempts

Authorization Endpoint (15 tests):

  • GET /authorize with valid params (already verified domain)
  • GET /authorize with valid params (new domain)
  • GET /authorize with invalid response_type
  • GET /authorize with invalid me parameter
  • GET /authorize with invalid client_id
  • GET /authorize with invalid redirect_uri
  • GET /authorize with missing state
  • POST /authorize/verify-code with valid code
  • POST /authorize/verify-code with invalid code
  • POST /authorize/consent with action=approve
  • POST /authorize/consent with action=deny
  • Authorization code stored with metadata
  • Authorization code expires after 10 min
  • State parameter passed through
  • redirect_uri domain mismatch shows warning

Estimated Integration Test Count: ~25 tests


End-to-End Tests

Complete Flows (5 tests):

  • Full auth flow: /authorize → verify → consent → redirect with code
  • Full auth flow with cached domain (skip verification)
  • User denies consent → redirect with access_denied
  • DNS verification failure → error page → retry → success
  • Invalid code × 3 → error "too many attempts"

Estimated E2E Test Count: ~5 tests


Security Tests

Input Validation (8 tests):

  • Malformed domain rejected
  • Malformed email rejected (during validation)
  • Malformed URL (me, client_id, redirect_uri) rejected
  • URL with credentials rejected
  • URL with fragment rejected (me parameter)
  • Oversized HTML (>5MB) rejected
  • Invalid email in rel="me" logged and skipped
  • SQL injection attempts in domain parameter (should be parameterized)

Authentication Security (5 tests):

  • Expired code rejected
  • Used code rejected (Phase 3)
  • Invalid code rejected
  • Brute force prevented (max 3 attempts)
  • Constant-time comparison used (verify via timing analysis - difficult to test)

TLS/HTTPS (4 tests):

  • HTTP redirect_uri rejected in production
  • Invalid SSL certificate rejected
  • Site fetch over HTTPS only
  • HTTP allowed for localhost only

Open Redirect (3 tests):

  • redirect_uri domain mismatch shows warning
  • Invalid redirect_uri shows error page (no redirect)
  • redirect_uri without hostname rejected

Estimated Security Test Count: ~20 tests


Coverage Target

Phase 2 Overall: 80%+ coverage (same as Phase 1)

Critical Code (95%+ coverage):

  • Domain verification service (orchestration logic)
  • rel="me" discovery (email extraction)
  • Authorization endpoint (parameter validation)
  • Security functions (validation, constant-time comparison)

Total Estimated Test Count: ~86 tests

Error Handling

DNS Verification Failure

Error Message:

DNS Verification Failed

The DNS TXT record was not found for your domain.

Please add the following TXT record to your DNS:
  Type: TXT
  Name: _gondulf.example.com
  Value: verified

DNS changes may take up to 24 hours to propagate.

[Retry]

HTTP Response: 200 OK (HTML error page)

Logging: WARNING level with domain


rel="me" Discovery Failure

Error Message:

Email Discovery Failed

No rel="me" email link was found on your homepage.

Please add the following to https://example.com:
  <link rel="me" href="mailto:your-email@example.com">

This allows us to discover your email address automatically.

Learn more: https://indieweb.org/rel-me

[Retry]

HTTP Response: 200 OK (HTML error page)

Logging: WARNING level with domain


Site Unreachable

Error Message:

Site Fetch Failed

Could not fetch your site at https://example.com

Please check:
• Site is accessible via HTTPS
• SSL certificate is valid
• No firewall blocking requests

[Retry]

HTTP Response: 200 OK (HTML error page)

Logging: ERROR level with domain and error details


Email Send Failure

Error Message:

Email Delivery Failed

Failed to send verification code to u***@example.com

Please check:
• Email address is correct in your rel="me" link
• Email server is accepting mail
• Check spam/junk folder

[Retry]

HTTP Response: 200 OK (HTML error page)

Logging: ERROR level with masked email


Invalid Code

Error Message:

Invalid code. 2 attempts remaining.

HTTP Response: 200 OK (code entry form with error)

Logging: WARNING level with masked email


Too Many Attempts

Error Message:

Too Many Attempts

You have exceeded the maximum number of attempts.

Please request a new verification code.

[Request New Code]

HTTP Response: 200 OK (error page with retry link)

Logging: WARNING level with masked email


Rate Limit Exceeded

Error Message:

Rate Limit Exceeded

Too many verification requests for this domain.

Please wait 1 hour before requesting another code.

HTTP Response: 200 OK (error page)

Logging: WARNING level with domain


OAuth 2.0 Errors (Authorization Endpoint)

Error Redirect Format:

{redirect_uri}?error={error_code}&error_description={description}&state={state}

Error Codes:

  • invalid_request: Missing or invalid parameter
  • unauthorized_client: Client not authorized
  • access_denied: User denied authorization
  • unsupported_response_type: response_type not "code"
  • server_error: Internal server error

Example:

https://client.example.com/callback?error=invalid_request&error_description=Missing+state+parameter&state=abc123

Logging: WARNING or ERROR level depending on error type


Error Logging Standards

Log Levels:

  • DEBUG: Normal operations, detailed flow
  • INFO: Successful operations (code sent, domain verified)
  • WARNING: Expected errors (invalid code, DNS not found)
  • ERROR: Unexpected errors (SMTP failure, site unreachable)
  • CRITICAL: System failures (should not occur in Phase 2)

What to Log:

  • Domain (public information)
  • Email (partial mask: first 3 chars)
  • Error details (for debugging)
  • Request IDs (for correlation)

What NOT to Log:

  • Full email addresses
  • Verification codes
  • Authorization codes
  • User-Agent (GDPR)
  • IP addresses (GDPR)

Dependencies

New Python Packages

Add to pyproject.toml:

[project]
dependencies = [
    # ... existing dependencies from Phase 1
    "beautifulsoup4>=4.12.0",  # HTML parsing for rel="me" discovery
]

Why beautifulsoup4:

  • Robust HTML parsing (handles malformed HTML)
  • Safe for untrusted content (no script execution)
  • Standard in Python ecosystem
  • Pure Python (no C dependencies with html.parser)

Phase 1 Dependencies Used

  • requests (HTTP fetching - already in pyproject.toml)
  • dnspython (DNS queries - Phase 1)
  • smtplib (Email sending - Python stdlib, used by Phase 1)
  • sqlalchemy (Database - Phase 1)
  • fastapi (Web framework - Phase 1)
  • pydantic (Data validation - Phase 1)

Configuration Additions

Optional new environment variables:

# HTML Fetching (optional - has defaults)
GONDULF_HTML_FETCH_TIMEOUT=10  # seconds
GONDULF_HTML_MAX_SIZE=5242880  # bytes (5MB)
GONDULF_HTML_MAX_REDIRECTS=5

# Rate Limiting (optional - has defaults)
GONDULF_VERIFICATION_RATE_LIMIT=3  # codes per domain per hour

Add to .env.example:

# HTML Fetching Configuration (optional)
GONDULF_HTML_FETCH_TIMEOUT=10
GONDULF_HTML_MAX_SIZE=5242880
GONDULF_HTML_MAX_REDIRECTS=5

# Rate Limiting (optional)
GONDULF_VERIFICATION_RATE_LIMIT=3

Implementation Notes

Suggested Implementation Order

  1. HTML Fetcher Service (0.5 days)

    • Straightforward HTTP fetching
    • Few dependencies
    • Easy to test in isolation
  2. rel="me" Discovery Service (0.5 days)

    • Pure parsing logic
    • No external dependencies (besides HTML input)
    • Easy to test with mock HTML
  3. Domain Verification Service (1 day)

    • Orchestrates all services
    • More complex logic
    • Needs all previous services complete
  4. Database Migration (0.5 days)

    • Simple UPDATE query
    • Apply before verification endpoints
  5. Verification Endpoints (0.5 days)

    • Thin API layer over service
    • FastAPI makes this straightforward
  6. Authorization Endpoint (3-4 days)

    • Most complex component
    • HTML templates needed
    • Multiple sub-endpoints
    • Needs comprehensive testing
  7. Integration Testing (1 day)

    • Test all components together
    • End-to-end flow verification

Total: ~7-8 days (matches estimate in phase-1-impact-assessment.md)


Risks and Mitigations

Risk 1: HTML Parsing Edge Cases

  • Mitigation: BeautifulSoup handles malformed HTML gracefully
  • Testing: Include malformed HTML in test cases
  • Fallback: Clear error messages guide users to fix HTML

Risk 2: Email Delivery Failures

  • Mitigation: Comprehensive SMTP error handling
  • Testing: Mock SMTP failures in tests
  • Fallback: Clear troubleshooting instructions in error messages

Risk 3: DNS TXT Record Setup Complexity

  • Mitigation: Clear setup instructions with examples
  • User Education: Document common DNS providers
  • Support: Provide example DNS configurations

Risk 4: Authorization Endpoint Complexity

  • Mitigation: Break into smaller sub-endpoints (verify-code, consent)
  • Testing: Comprehensive integration tests
  • Design: Keep state management simple (use forms, avoid complex sessions)

Risk 5: Rate Limiting Implementation

  • Mitigation: Start with simple in-memory tracking (Phase 2)
  • Future: Migrate to Redis for distributed rate limiting (Phase 3+)
  • Placeholder: Implement rate limit check, return False for now

Performance Considerations

HTML Fetching:

  • Timeout: 10 seconds (prevent hanging)
  • Size limit: 5MB (prevent memory exhaustion)
  • Concurrent requests: Not needed in Phase 2 (one request per auth flow)

Database Queries:

  • Index on domains.domain ensures fast lookups
  • Simple SELECT queries (no joins in Phase 2)
  • Consider adding index on domains.verified if needed

In-Memory Storage:

  • Verification codes: ~100 bytes each
  • Authorization codes: ~200 bytes each
  • Expected load: 10s of users, <100 concurrent verifications
  • Memory impact: Negligible (<10KB)

rel="me" Parsing:

  • BeautifulSoup is pure Python (not fastest, but sufficient)
  • HTML size limited to 5MB (parse time <1 second)
  • No performance issues expected for typical homepages

Future Extensibility

Redis Integration (Phase 3+):

  • Replace in-memory CodeStorage with Redis
  • Enables distributed deployment (multiple Gondulf instances)
  • No code changes needed (CodeStorage interface unchanged)

Client Metadata Caching (Phase 3):

  • Cache client_id fetch results
  • Reduces HTTP requests during authorization
  • Store in database or Redis

PKCE Support (v1.1.0):

  • Add code_challenge validation in authorization endpoint
  • Add code_verifier validation in token endpoint (Phase 3)
  • No breaking changes to v1.0.0 clients

Additional Authentication Methods (v1.2.0+):

  • GitHub/GitLab OAuth providers
  • WebAuthn support
  • All additive (user chooses method)

Acceptance Criteria

Phase 2 is complete when ALL of the following criteria are met:

Functionality

  • HTML fetcher service fetches user homepages successfully
  • rel="me" discovery service discovers email from HTML
  • Domain verification service orchestrates two-factor verification
  • DNS TXT verification required and working
  • Email verification via rel="me" required and working
  • Verification endpoints (/api/verify/start, /api/verify/code) working
  • Authorization endpoint (/authorize) validates all parameters
  • Authorization endpoint checks domain verification status
  • Authorization endpoint shows verification form for unverified domains
  • Authorization endpoint shows consent screen after verification
  • Authorization code generated and stored on approval
  • User can deny consent (redirects with access_denied)
  • State parameter passed through all steps

Testing

  • All unit tests passing (estimated ~36 tests)
  • All integration tests passing (estimated ~25 tests)
  • All end-to-end tests passing (estimated ~5 tests)
  • All security tests passing (estimated ~20 tests)
  • Test coverage ≥80% overall
  • Test coverage ≥95% for domain verification service
  • Test coverage ≥95% for authorization endpoint
  • No known bugs or failing tests

Security

  • HTTPS enforcement working (production)
  • SSL certificate validation enforced (HTML fetching)
  • HTML parsing secure (BeautifulSoup with html.parser)
  • Input validation comprehensive (domain, email, URLs)
  • Open redirect protection working (redirect_uri validation)
  • Constant-time code comparison used
  • Rate limiting implemented (basic in-memory)
  • Attempt limiting working (max 3 per code)
  • No PII in logs (email masked, no full addresses)
  • Authorization codes single-use (marked for Phase 3)

Error Handling

  • DNS verification failure shows clear instructions
  • rel="me" discovery failure shows HTML example
  • Site unreachable shows troubleshooting steps
  • Email send failure shows error with retry
  • Invalid code shows attempts remaining
  • Too many attempts invalidates code
  • Rate limit exceeded shows wait time
  • OAuth 2.0 errors formatted correctly
  • All errors logged appropriately

Documentation

  • All new services have docstrings
  • All public methods have type hints
  • API endpoints documented (this design doc)
  • Error messages user-friendly
  • Setup instructions clear (DNS + rel="me")
  • Database migration documented

Dependencies

  • beautifulsoup4 added to pyproject.toml
  • No new system dependencies (all Python)
  • Configuration updated (.env.example)

Database

  • Migration 002 applied successfully
  • domains.verification_method updated to 'two_factor'
  • No schema changes needed (existing schema works)

Integration

  • All Phase 1 services integrated successfully
  • DNS service used for TXT verification
  • Email service used for code sending
  • Database service used for storing verified domains
  • In-memory storage used for codes
  • Logging used throughout

Performance

  • HTML fetching completes within 10 seconds
  • rel="me" parsing completes within 1 second
  • Full verification flow completes within 30 seconds
  • Authorization endpoint responds within 2 seconds
  • No memory leaks (codes expire and clean up)

Timeline Estimate

Phase 2 Implementation: 7-9 days

Breakdown:

  • HTML Fetcher Service: 0.5 days
  • rel="me" Discovery Service: 0.5 days
  • Domain Verification Service: 1 day
  • Database Migration: 0.5 days
  • Verification Endpoints: 0.5 days
  • Authorization Endpoint: 3-4 days
  • Integration Testing: 1 day
  • Documentation: 0.5 days (included in parallel)

Dependencies: Phase 1 complete and approved

Risk Buffer: +2 days (for unforeseen issues with HTML parsing or authorization flow complexity)

Sign-off

Design Status: Complete and ready for implementation

Architect: Claude (Architect Agent) Date: 2025-11-20

Next Steps:

  1. Developer reviews design document
  2. Developer asks clarification questions if needed
  3. Architect updates design based on feedback
  4. Developer begins implementation following design
  5. Developer creates implementation report upon completion
  6. Architect reviews implementation report

Related Documents:

  • /docs/architecture/overview.md - System architecture
  • /docs/architecture/indieauth-protocol.md - IndieAuth protocol implementation
  • /docs/architecture/security.md - Security architecture
  • /docs/architecture/phase-1-impact-assessment.md - Phase 2 requirements
  • /docs/decisions/ADR-005-email-based-authentication-v1-0-0.md - Two-factor verification decision
  • /docs/decisions/ADR-008-rel-me-email-discovery.md - rel="me" pattern decision
  • /docs/reports/2025-11-20-phase-1-foundation.md - Phase 1 implementation
  • /docs/roadmap/v1.0.0.md - Version plan

DESIGN READY: Phase 2 Domain Verification - Please review /docs/designs/phase-2-domain-verification.md