Files
Gondulf/docs/decisions/ADR-005-email-based-authentication-v1-0-0.md
Phil Skentelbery bebd47955f feat(core): implement Phase 1 foundation infrastructure
Implements Phase 1 Foundation with all core services:

Core Components:
- Configuration management with GONDULF_ environment variables
- Database layer with SQLAlchemy and migration system
- In-memory code storage with TTL support
- Email service with SMTP and TLS support (STARTTLS + implicit TLS)
- DNS service with TXT record verification
- Structured logging with Python standard logging
- FastAPI application with health check endpoint

Database Schema:
- authorization_codes table for OAuth 2.0 authorization codes
- domains table for domain verification
- migrations table for tracking schema versions
- Simple sequential migration system (001_initial_schema.sql)

Configuration:
- Environment-based configuration with validation
- .env.example template with all GONDULF_ variables
- Fail-fast validation on startup
- Sensible defaults for optional settings

Testing:
- 96 comprehensive tests (77 unit, 5 integration)
- 94.16% code coverage (exceeds 80% requirement)
- All tests passing
- Test coverage includes:
  - Configuration loading and validation
  - Database migrations and health checks
  - In-memory storage with expiration
  - Email service (STARTTLS, implicit TLS, authentication)
  - DNS service (TXT records, domain verification)
  - Health check endpoint integration

Documentation:
- Implementation report with test results
- Phase 1 clarifications document
- ADRs for key decisions (config, database, email, logging)

Technical Details:
- Python 3.10+ with type hints
- SQLite with configurable database URL
- System DNS with public DNS fallback
- Port-based TLS detection (465=SSL, 587=STARTTLS)
- Lazy configuration loading for testability

Exit Criteria Met:
✓ All foundation services implemented
✓ Application starts without errors
✓ Health check endpoint operational
✓ Database migrations working
✓ Test coverage exceeds 80%
✓ All tests passing

Ready for Architect review and Phase 2 development.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 12:21:42 -07:00

16 KiB

ADR-005: Email-Based Authentication for v1.0.0

Date: 2025-11-20

Status

Accepted

Context

Gondulf requires users to prove domain ownership to authenticate. Multiple authentication methods exist for proving domain control.

Authentication Methods Evaluated

1. Email Verification

  • User provides email at their domain
  • Server sends verification code to email
  • User enters code to prove email access
  • Assumes: Email access = domain control

2. DNS TXT Record

  • Admin adds TXT record to DNS: _gondulf.example.com = verified
  • Server queries DNS to verify record
  • Assumes: DNS control = domain control

3. External Identity Providers (GitHub, GitLab, etc.)

  • User links domain to GitHub/GitLab profile
  • Server verifies profile contains domain
  • User authenticates via OAuth to provider
  • Assumes: Provider verification = domain control

4. WebAuthn / FIDO2

  • User registers hardware security key
  • Authentication via cryptographic challenge
  • Assumes: Physical key possession = domain control (after initial registration)

5. IndieAuth Delegation

  • User's domain delegates to another IndieAuth server
  • Server follows delegation chain
  • Assumes: Delegated server = domain control

User Requirements

From project brief:

  • v1.0.0: Email-based ONLY (no other identity providers)
  • Simplicity: Keep MVP simple and focused
  • Scale: 10s of users initially
  • No client registration: Simplify client onboarding

Technical Constraints

SMTP Dependency:

  • Requires email server configuration
  • Potential delivery failures (spam filters, configuration errors)
  • Dependency on external service (email provider)

Security Considerations:

  • Email interception risk (transit security)
  • Email account compromise risk (user responsibility)
  • Code brute-force risk (limited entropy)

User Experience:

  • Familiar pattern (like password reset)
  • Requires email access during authentication
  • Additional step vs. provider OAuth (GitHub, etc.)

Decision

Gondulf v1.0.0 will use email-based verification as the PRIMARY authentication method, with DNS TXT record verification as an OPTIONAL fast-path.

Implementation Approach

Two-Tier Verification:

  1. DNS TXT Record (Preferred, Optional):

    • Check for _gondulf.{domain} TXT record = verified
    • If found: Skip email verification, use cached result
    • If not found: Fall back to email verification
    • Result cached in database for future use
  2. Email Verification (Required Fallback):

    • User provides email address at their domain
    • Server generates 6-digit verification code
    • Server sends code via SMTP
    • User enters code (15-minute expiration)
    • Domain marked as verified in database

Why Both?:

  • DNS provides fast path for tech-savvy users
  • Email provides accessible path for all users
  • DNS requires upfront setup but smoother repeat authentication
  • Email requires no setup but requires email access each time

Rationale

Meets User Requirements:

  • Email-based authentication as specified
  • No external identity providers (GitHub, GitLab) in v1.0.0
  • Simple to understand and implement
  • Familiar UX pattern

Simplicity:

  • Email verification is well-understood
  • Standard library SMTP support (smtplib)
  • No OAuth 2.0 client implementation needed
  • No external API dependencies

Security Sufficient for MVP:

  • Email access typically indicates domain control
  • 6-digit codes provide 1,000,000 combinations
  • 15-minute expiration limits brute-force window
  • Rate limiting prevents abuse
  • TLS for email delivery (STARTTLS)

Operational Simplicity:

  • Requires only SMTP configuration (widely available)
  • No API keys or provider accounts needed
  • No rate limits from external providers
  • Full control over verification flow

DNS TXT as Enhancement:

  • Provides better UX for repeat authentication
  • Demonstrates domain control more directly
  • Optional (users not forced to configure DNS)
  • Cached result eliminates email requirement

Consequences

Positive Consequences

  1. User Simplicity:

    • Familiar email verification pattern
    • No need to create accounts on external services
    • Works with any email provider
  2. Implementation Simplicity:

    • Standard library support (smtplib, email)
    • No external API integration
    • Straightforward testing (mock SMTP)
  3. Operational Simplicity:

    • Single external dependency (SMTP server)
    • No API rate limits to manage
    • No provider outages to worry about
    • Admin controls email templates
  4. Privacy:

    • Email addresses NOT stored (deleted after verification)
    • No data shared with third parties
    • No tracking by external providers
  5. Flexibility:

    • DNS TXT provides fast-path for power users
    • Email fallback ensures accessibility
    • No user locked out if DNS unavailable

Negative Consequences

  1. Email Dependency:

    • Requires functioning SMTP configuration
    • Email delivery not guaranteed (spam filters)
    • Users must have email access during authentication
    • Email account compromise = domain compromise
  2. User Experience:

    • Extra step vs. provider OAuth (more clicks)
    • Requires checking email inbox
    • Potential delay (email delivery time)
    • Code expiration can frustrate users
  3. Security Limitations:

    • Email interception risk (mitigated by TLS)
    • Email account compromise risk (user responsibility)
    • Weaker than hardware-based auth (WebAuthn)
  4. Scalability Concerns:

    • Email delivery at scale (future concern)
    • SMTP rate limits (future concern)
    • Email provider blocking (spam prevention)

Mitigation Strategies

Email Delivery Reliability:

# Robust SMTP configuration
SMTP_CONFIG = {
    'host': os.environ['SMTP_HOST'],
    'port': int(os.environ.get('SMTP_PORT', '587')),
    'use_tls': True,  # STARTTLS required
    'username': os.environ['SMTP_USERNAME'],
    'password': os.environ['SMTP_PASSWORD'],
    'from_email': os.environ['SMTP_FROM'],
    'timeout': 10,  # Fail fast
}

# Comprehensive error handling
try:
    send_email(to=email, code=code)
except SMTPException as e:
    logger.error(f"Email send failed: {e}")
    # Display user-friendly error
    raise HTTPException(500, "Email delivery failed. Try again or contact admin.")

Code Security:

# Sufficient entropy
code = ''.join(secrets.choice('0123456789') for _ in range(6))
# 1,000,000 possible codes

# Rate limiting
MAX_ATTEMPTS = 3  # Per email
MAX_CODES = 3     # Per hour per email

# Expiration
CODE_LIFETIME = timedelta(minutes=15)

# Attempt tracking
attempts = code_storage.get_attempts(email)
if attempts >= MAX_ATTEMPTS:
    raise HTTPException(429, "Too many attempts. Try again in 15 minutes.")

Email Interception:

# Require TLS for email delivery
smtp.starttls()

# Clear warning to users
"""
We've sent a verification code to your email.
Only enter this code if you initiated this login.
The code expires in 15 minutes.
"""

# Log suspicious activity
if time_between_send_and_verify < 1_second:
    logger.warning(f"Suspiciously fast verification: {domain}")

DNS TXT Fast-Path:

# Check DNS first, skip email if verified
txt_record = dns.query(f'_gondulf.{domain}', 'TXT')
if txt_record == 'verified':
    logger.info(f"DNS verification successful: {domain}")
    # Use cached verification, skip email
    return verified_domain(domain)

# Fall back to email
logger.info(f"DNS verification not found, using email: {domain}")
return email_verification_flow(domain)

User Education:

## Domain Verification

Gondulf offers two ways to verify domain ownership:

### Option 1: DNS TXT Record (Recommended)
Add this DNS record to skip email verification:
- Type: TXT
- Name: _gondulf.example.com
- Value: verified

Benefits:
- Faster authentication (no email required)
- Verify once, use forever
- More secure (DNS control = domain control)

### Option 2: Email Verification
- Enter an email address at your domain
- We'll send a 6-digit code
- Enter the code to verify

Benefits:
- No DNS configuration needed
- Works immediately
- Familiar process

Implementation

Email Verification Flow

from datetime import datetime, timedelta
import secrets
import smtplib
from email.message import EmailMessage

class EmailVerificationService:
    def __init__(self, smtp_config: dict):
        self.smtp = smtp_config
        self.codes = {}  # In-memory storage (short-lived)

    def request_code(self, email: str, domain: str) -> None:
        """
        Generate and send verification code.

        Raises:
            ValueError: If email domain doesn't match requested domain
            HTTPException: If rate limit exceeded or email send fails
        """
        # Validate email matches domain
        email_domain = email.split('@')[1].lower()
        if email_domain != domain.lower():
            raise ValueError(f"Email must be at {domain}")

        # Check rate limit
        if self._is_rate_limited(email):
            raise HTTPException(429, "Too many requests. Try again in 1 hour.")

        # Generate 6-digit code
        code = ''.join(secrets.choice('0123456789') for _ in range(6))

        # Store code with expiration
        self.codes[email] = {
            'code': code,
            'domain': domain,
            'created_at': datetime.utcnow(),
            'expires_at': datetime.utcnow() + timedelta(minutes=15),
            'attempts': 0,
        }

        # Send email
        try:
            self._send_code_email(email, code)
            logger.info(f"Verification code sent to {email[:3]}***@{email_domain}")
        except Exception as e:
            logger.error(f"Failed to send email to {email_domain}: {e}")
            raise HTTPException(500, "Email delivery failed")

    def verify_code(self, email: str, submitted_code: str) -> str:
        """
        Verify submitted code.

        Returns: domain if valid
        Raises: HTTPException if invalid/expired
        """
        code_data = self.codes.get(email)

        if not code_data:
            raise HTTPException(400, "No verification code found")

        # Check expiration
        if datetime.utcnow() > code_data['expires_at']:
            del self.codes[email]
            raise HTTPException(400, "Code expired. Request a new one.")

        # Check attempts
        code_data['attempts'] += 1
        if code_data['attempts'] > 3:
            del self.codes[email]
            raise HTTPException(429, "Too many attempts")

        # Verify code (constant-time comparison)
        if not secrets.compare_digest(submitted_code, code_data['code']):
            raise HTTPException(400, "Invalid code")

        # Success: Clean up and return domain
        domain = code_data['domain']
        del self.codes[email]  # Single-use code

        logger.info(f"Domain verified via email: {domain}")
        return domain

    def _send_code_email(self, to: str, code: str) -> None:
        """Send verification code via SMTP."""
        msg = EmailMessage()
        msg['From'] = self.smtp['from_email']
        msg['To'] = to
        msg['Subject'] = 'Gondulf Verification Code'

        msg.set_content(f"""
Your Gondulf verification code is:

{code}

This code expires in 15 minutes.

Only enter this code if you initiated this login.
If you did not request this code, ignore this email.
        """)

        with smtplib.SMTP(self.smtp['host'], self.smtp['port'], timeout=10) as smtp:
            smtp.starttls()
            smtp.login(self.smtp['username'], self.smtp['password'])
            smtp.send_message(msg)

    def _is_rate_limited(self, email: str) -> bool:
        """Check if email is rate limited."""
        # Simple in-memory tracking (for v1.0.0)
        # Future: Redis-based rate limiting
        recent_codes = [
            code for code in self.codes.values()
            if code.get('email') == email
            and datetime.utcnow() - code['created_at'] < timedelta(hours=1)
        ]
        return len(recent_codes) >= 3

DNS TXT Record Verification

import dns.resolver

class DNSVerificationService:
    def __init__(self, cache_storage):
        self.cache = cache_storage

    def verify_domain(self, domain: str) -> bool:
        """
        Check if domain has valid DNS TXT record.

        Returns: True if verified, False otherwise
        """
        # Check cache first
        cached = self.cache.get(domain)
        if cached and cached['verified']:
            logger.info(f"Using cached DNS verification: {domain}")
            return True

        # Query DNS
        try:
            verified = self._query_txt_record(domain)

            # Cache result
            self.cache.set(domain, {
                'verified': verified,
                'verified_at': datetime.utcnow(),
                'method': 'txt_record'
            })

            return verified

        except Exception as e:
            logger.warning(f"DNS verification failed for {domain}: {e}")
            return False

    def _query_txt_record(self, domain: str) -> bool:
        """
        Query _gondulf.{domain} TXT record.

        Returns: True if record exists with value 'verified'
        """
        record_name = f'_gondulf.{domain}'

        # Use multiple resolvers for redundancy
        resolvers = ['8.8.8.8', '1.1.1.1']

        for resolver_ip in resolvers:
            try:
                resolver = dns.resolver.Resolver()
                resolver.nameservers = [resolver_ip]
                resolver.timeout = 5
                resolver.lifetime = 5

                answers = resolver.resolve(record_name, 'TXT')

                for rdata in answers:
                    txt_value = rdata.to_text().strip('"')
                    if txt_value == 'verified':
                        logger.info(f"DNS TXT verified: {domain} (resolver: {resolver_ip})")
                        return True

            except Exception as e:
                logger.debug(f"DNS query failed (resolver {resolver_ip}): {e}")
                continue

        return False

Future Enhancements

v1.1.0+: Additional Authentication Methods

GitHub/GitLab Providers:

  • OAuth 2.0 flow with provider
  • Verify domain in profile URL
  • Link GitHub username to domain

WebAuthn / FIDO2:

  • Register hardware security key
  • Challenge/response authentication
  • Strongest security option

IndieAuth Delegation:

  • Follow rel="authorization_endpoint" link
  • Delegate to another IndieAuth server
  • Support federated authentication

These will be additive (user chooses method), not replacing email.

Alternatives Considered

Alternative 1: External Providers Only (GitHub, GitLab)

Pros:

  • No email infrastructure needed
  • Established OAuth 2.0 flows
  • Users already have accounts

Cons:

  • Contradicts user requirement (email-only in v1.0.0)
  • Requires external API integration
  • Users locked to specific providers
  • Privacy concerns (data sharing)

Rejected: Violates user requirements for v1.0.0.


Alternative 2: WebAuthn as Primary Method

Pros:

  • Strongest security (hardware keys)
  • Phishing-resistant
  • No password/email needed

Cons:

  • Requires hardware key (barrier to entry)
  • Complex implementation (WebAuthn API)
  • Browser compatibility issues
  • Not suitable for MVP

Rejected: Too complex for MVP, hardware requirement.


Alternative 3: SMS Verification

Pros:

  • Familiar pattern
  • Fast delivery

Cons:

  • Requires phone number (PII collection)
  • SMS delivery costs
  • Phone number != domain ownership
  • SIM swapping attacks

Rejected: Doesn't prove domain ownership, adds PII collection.


Alternative 4: DNS Only (No Email Fallback)

Pros:

  • Strongest proof of domain control
  • No email infrastructure
  • Simple implementation

Cons:

  • Requires DNS knowledge
  • Barrier to entry for non-technical users
  • DNS propagation delays
  • No fallback if DNS inaccessible

Rejected: Too restrictive, not accessible enough.

References

Decision History

  • 2025-11-20: Proposed (Architect)
  • 2025-11-20: Accepted (Architect)
  • TBD: Review after v1.0.0 deployment (gather user feedback)