Files
Gondulf/src/gondulf/utils/validation.py
Phil Skentelbery 074f74002c feat(phase-2): implement domain verification system
Implements complete domain verification flow with:
- rel=me link verification service
- HTML fetching with security controls
- Rate limiting to prevent abuse
- Email validation utilities
- Authorization and verification API endpoints
- User-facing templates for authorization and verification flows

This completes Phase 2: Domain Verification as designed.

Tests:
- All Phase 2 unit tests passing
- Coverage: 85% overall
- Migration tests updated

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

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

149 lines
3.5 KiB
Python

"""Client validation and utility functions."""
import re
from urllib.parse import urlparse
def mask_email(email: str) -> str:
"""
Mask email for display: user@example.com -> u***@example.com
Args:
email: Email address to mask
Returns:
Masked email string
"""
if '@' not in email:
return email
local, domain = email.split('@', 1)
if len(local) <= 1:
return email
masked_local = local[0] + '***'
return f"{masked_local}@{domain}"
def normalize_client_id(client_id: str) -> str:
"""
Normalize client_id URL to canonical form.
Rules:
- Ensure https:// scheme
- Remove default port (443)
- Preserve path
Args:
client_id: Client ID URL
Returns:
Normalized client_id
Raises:
ValueError: If client_id does not use https scheme
"""
parsed = urlparse(client_id)
# Ensure https
if parsed.scheme != 'https':
raise ValueError("client_id must use https scheme")
# Remove default HTTPS port
netloc = parsed.netloc
if netloc.endswith(':443'):
netloc = netloc[:-4]
# Reconstruct
normalized = f"https://{netloc}{parsed.path}"
if parsed.query:
normalized += f"?{parsed.query}"
if parsed.fragment:
normalized += f"#{parsed.fragment}"
return normalized
def validate_redirect_uri(redirect_uri: str, client_id: str) -> bool:
"""
Validate redirect_uri against client_id per IndieAuth spec.
Rules:
- Must use https scheme (except localhost)
- Must share same origin as client_id OR
- Must be subdomain of client_id domain OR
- Can be localhost/127.0.0.1 for development
Args:
redirect_uri: Redirect URI to validate
client_id: Client ID for comparison
Returns:
True if valid, False otherwise
"""
try:
redirect_parsed = urlparse(redirect_uri)
client_parsed = urlparse(client_id)
# Allow localhost/127.0.0.1 for development (can use HTTP)
if redirect_parsed.hostname in ('localhost', '127.0.0.1'):
return True
# Check scheme (must be https for non-localhost)
if redirect_parsed.scheme != 'https':
return False
# Same origin check
if (redirect_parsed.scheme == client_parsed.scheme and
redirect_parsed.netloc == client_parsed.netloc):
return True
# Subdomain check
redirect_host = redirect_parsed.hostname or ''
client_host = client_parsed.hostname or ''
# Must end with .{client_host}
if redirect_host.endswith(f".{client_host}"):
return True
return False
except Exception:
return False
def extract_domain_from_url(url: str) -> str:
"""
Extract domain from URL.
Args:
url: URL to extract domain from
Returns:
Domain name
Raises:
ValueError: If URL is invalid or has no hostname
"""
try:
parsed = urlparse(url)
if not parsed.hostname:
raise ValueError("URL has no hostname")
return parsed.hostname
except Exception as e:
raise ValueError(f"Invalid URL: {e}") from e
def validate_email(email: str) -> bool:
"""
Validate email address format.
Args:
email: Email address to validate
Returns:
True if valid email format, False otherwise
"""
# Simple email validation pattern
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return bool(re.match(pattern, email))