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>
149 lines
3.5 KiB
Python
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))
|