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>
2560 lines
91 KiB
Markdown
2560 lines
91 KiB
Markdown
# 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**:
|
||
|
||
```python
|
||
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**:
|
||
|
||
```python
|
||
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**:
|
||
|
||
```python
|
||
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**:
|
||
|
||
```python
|
||
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**:
|
||
|
||
```python
|
||
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**:
|
||
|
||
```python
|
||
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**:
|
||
|
||
```python
|
||
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**:
|
||
|
||
```python
|
||
@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**:
|
||
|
||
```python
|
||
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):
|
||
|
||
```python
|
||
@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**:
|
||
```json
|
||
{
|
||
"domain": "example.com"
|
||
}
|
||
```
|
||
|
||
**Success Response** (200 OK):
|
||
```json
|
||
{
|
||
"success": true,
|
||
"email_masked": "u***@example.com",
|
||
"error": null
|
||
}
|
||
```
|
||
|
||
**Error Response** (200 OK with success=false):
|
||
```json
|
||
{
|
||
"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):
|
||
```json
|
||
{
|
||
"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**:
|
||
```json
|
||
{
|
||
"email": "user@example.com",
|
||
"code": "123456"
|
||
}
|
||
```
|
||
|
||
**Success Response** (200 OK):
|
||
```json
|
||
{
|
||
"success": true,
|
||
"domain": "example.com",
|
||
"error": null
|
||
}
|
||
```
|
||
|
||
**Error Response** (200 OK with success=false):
|
||
```json
|
||
{
|
||
"success": false,
|
||
"domain": null,
|
||
"error": "Invalid code. 2 attempts remaining."
|
||
}
|
||
```
|
||
|
||
**Validation Errors** (422 Unprocessable Entity):
|
||
```json
|
||
{
|
||
"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):
|
||
```html
|
||
<!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):
|
||
```sql
|
||
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`:
|
||
```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):
|
||
```sql
|
||
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**:
|
||
```python
|
||
{
|
||
"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**:
|
||
```python
|
||
{
|
||
"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**:
|
||
```python
|
||
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**:
|
||
```python
|
||
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):
|
||
```python
|
||
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**:
|
||
```python
|
||
# 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**:
|
||
```python
|
||
# 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**:
|
||
```python
|
||
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**:
|
||
```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**:
|
||
```bash
|
||
# 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**:
|
||
```bash
|
||
# 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**
|