fix(auth): require email authentication every login
CRITICAL SECURITY FIX: - Email code required EVERY login (authentication, not verification) - DNS TXT check cached separately (domain verification) - New auth_sessions table for per-login state - Codes hashed with SHA-256, constant-time comparison - Max 3 attempts, 10-minute session expiry - OAuth params stored server-side (security improvement) New files: - services/auth_session.py - migrations 004, 005 - ADR-010: domain verification vs user authentication 312 tests passing, 86.21% coverage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
246
docs/designs/authentication-flow-fix.md
Normal file
246
docs/designs/authentication-flow-fix.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# Authentication Flow Fix Design
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The current implementation conflates domain verification (one-time DNS check) with user authentication (per-login email verification). This creates a security vulnerability where only the first user needs to authenticate via email code, while subsequent users bypass authentication entirely.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Domain Verification
|
||||
- **Purpose**: Establish that a domain is configured to use this IndieAuth server
|
||||
- **Method**: DNS TXT record containing server-specific verification string
|
||||
- **Frequency**: Once per domain, results cached in database
|
||||
- **Storage**: `domains` table with verification status and timestamp
|
||||
|
||||
### User Authentication
|
||||
- **Purpose**: Prove the current user owns the claimed identity
|
||||
- **Method**: Time-limited 6-digit code sent to rel="me" email
|
||||
- **Frequency**: EVERY authorization attempt
|
||||
- **Storage**: Temporary session storage, expires after 5-10 minutes
|
||||
|
||||
## Corrected Authorization Flow
|
||||
|
||||
### Step 1: Authorization Request
|
||||
Client initiates OAuth flow:
|
||||
```
|
||||
GET /authorize?
|
||||
response_type=code&
|
||||
client_id=https://app.example.com&
|
||||
redirect_uri=https://app.example.com/callback&
|
||||
state=xyz&
|
||||
code_challenge=abc&
|
||||
code_challenge_method=S256&
|
||||
me=https://user.example.com
|
||||
```
|
||||
|
||||
### Step 2: Domain Verification Check
|
||||
1. Extract domain from `me` parameter
|
||||
2. Check `domains` table for existing verification:
|
||||
```sql
|
||||
SELECT verified, last_checked
|
||||
FROM domains
|
||||
WHERE domain = 'user.example.com'
|
||||
```
|
||||
3. If not verified or stale (>24 hours):
|
||||
- Check DNS TXT record at `_indieauth.user.example.com`
|
||||
- Update database with verification status
|
||||
4. If domain not verified, reject with error
|
||||
|
||||
### Step 3: Profile Discovery
|
||||
1. Fetch the user's homepage at `me` URL
|
||||
2. Parse for IndieAuth metadata:
|
||||
- Authorization endpoint (must be this server)
|
||||
- Token endpoint (if present)
|
||||
- rel="me" links for authentication options
|
||||
3. Extract email from rel="me" links
|
||||
|
||||
### Step 4: User Authentication (ALWAYS REQUIRED)
|
||||
1. Generate 6-digit code
|
||||
2. Store in session with expiration:
|
||||
```json
|
||||
{
|
||||
"session_id": "uuid",
|
||||
"me": "https://user.example.com",
|
||||
"email": "user@example.com",
|
||||
"code": "123456",
|
||||
"client_id": "https://app.example.com",
|
||||
"redirect_uri": "https://app.example.com/callback",
|
||||
"state": "xyz",
|
||||
"code_challenge": "abc",
|
||||
"expires_at": "2024-01-01T12:05:00Z"
|
||||
}
|
||||
```
|
||||
3. Send code via email
|
||||
4. Show code entry form
|
||||
|
||||
### Step 5: Code Verification
|
||||
1. User submits code
|
||||
2. Validate against session storage
|
||||
3. If valid, mark session as authenticated
|
||||
4. If invalid, allow retry (max 3 attempts)
|
||||
|
||||
### Step 6: Consent
|
||||
1. Show consent page with client details
|
||||
2. User approves/denies
|
||||
3. If approved, generate authorization code
|
||||
|
||||
### Step 7: Authorization Code
|
||||
1. Generate authorization code
|
||||
2. Store with session binding:
|
||||
```json
|
||||
{
|
||||
"code": "auth_code_xyz",
|
||||
"session_id": "uuid",
|
||||
"me": "https://user.example.com",
|
||||
"client_id": "https://app.example.com",
|
||||
"redirect_uri": "https://app.example.com/callback",
|
||||
"code_challenge": "abc",
|
||||
"expires_at": "2024-01-01T12:10:00Z"
|
||||
}
|
||||
```
|
||||
3. Redirect to client with code
|
||||
|
||||
## Data Models
|
||||
|
||||
### domains table (persistent)
|
||||
```sql
|
||||
CREATE TABLE domains (
|
||||
domain VARCHAR(255) PRIMARY KEY,
|
||||
verified BOOLEAN DEFAULT FALSE,
|
||||
verification_string VARCHAR(255),
|
||||
last_checked TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### auth_sessions table (temporary, cleaned periodically)
|
||||
```sql
|
||||
CREATE TABLE auth_sessions (
|
||||
session_id VARCHAR(255) PRIMARY KEY,
|
||||
me VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255),
|
||||
verification_code VARCHAR(6),
|
||||
code_verified BOOLEAN DEFAULT FALSE,
|
||||
client_id VARCHAR(255) NOT NULL,
|
||||
redirect_uri VARCHAR(255) NOT NULL,
|
||||
state VARCHAR(255),
|
||||
code_challenge VARCHAR(255),
|
||||
code_challenge_method VARCHAR(10),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
INDEX idx_expires (expires_at)
|
||||
);
|
||||
```
|
||||
|
||||
### authorization_codes table (temporary)
|
||||
```sql
|
||||
CREATE TABLE authorization_codes (
|
||||
code VARCHAR(255) PRIMARY KEY,
|
||||
session_id VARCHAR(255) NOT NULL,
|
||||
me VARCHAR(255) NOT NULL,
|
||||
client_id VARCHAR(255) NOT NULL,
|
||||
redirect_uri VARCHAR(255) NOT NULL,
|
||||
code_challenge VARCHAR(255),
|
||||
used BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES auth_sessions(session_id),
|
||||
INDEX idx_expires (expires_at)
|
||||
);
|
||||
```
|
||||
|
||||
## Session Management
|
||||
|
||||
### Session Creation
|
||||
- Generate UUID for session_id
|
||||
- Set expiration to 10 minutes for email verification
|
||||
- Store all OAuth parameters in session
|
||||
|
||||
### Session Validation
|
||||
- Check expiration on every access
|
||||
- Verify session_id matches throughout flow
|
||||
- Clear expired sessions periodically (cron job)
|
||||
|
||||
### Security Considerations
|
||||
- Session IDs must be cryptographically random
|
||||
- Email codes must be 6 random digits
|
||||
- Authorization codes must be unguessable
|
||||
- All temporary data expires and is cleaned up
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Domain Not Verified
|
||||
```json
|
||||
{
|
||||
"error": "unauthorized_client",
|
||||
"error_description": "Domain not configured for this IndieAuth server"
|
||||
}
|
||||
```
|
||||
|
||||
### Invalid Email Code
|
||||
```json
|
||||
{
|
||||
"error": "access_denied",
|
||||
"error_description": "Invalid verification code"
|
||||
}
|
||||
```
|
||||
|
||||
### Session Expired
|
||||
```json
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Session expired, please start over"
|
||||
}
|
||||
```
|
||||
|
||||
## Migration from Current Implementation
|
||||
|
||||
1. **Immediate**: Disable caching of email verification
|
||||
2. **Add auth_sessions table**: Track per-login authentication state
|
||||
3. **Modify verification flow**: Always require email code
|
||||
4. **Update domain verification**: Separate from user authentication
|
||||
5. **Clean up old code**: Remove improper caching logic
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Unit Tests
|
||||
- Domain verification logic (DNS lookup, caching)
|
||||
- Session management (creation, expiration, cleanup)
|
||||
- Email code generation and validation
|
||||
- Authorization code generation and exchange
|
||||
|
||||
### Integration Tests
|
||||
- Full authorization flow with email verification
|
||||
- Multiple concurrent users for same domain
|
||||
- Session expiration during flow
|
||||
- Domain verification caching behavior
|
||||
|
||||
### Security Tests
|
||||
- Ensure email verification required every login
|
||||
- Verify sessions properly isolated between users
|
||||
- Test rate limiting on code attempts
|
||||
- Verify all codes are single-use
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. ✓ Domain verification via DNS TXT is cached appropriately
|
||||
2. ✓ Email verification code is required for EVERY login attempt
|
||||
3. ✓ Multiple users can authenticate for the same domain independently
|
||||
4. ✓ Sessions expire and are cleaned up properly
|
||||
5. ✓ Authorization codes are single-use
|
||||
6. ✓ Clear separation between domain verification and user authentication
|
||||
7. ✓ No security regression from current (broken) implementation
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
**CRITICAL**: This is a security vulnerability that must be fixed immediately. The current implementation allows unauthenticated access after the first user logs in for a domain.
|
||||
|
||||
## Notes
|
||||
|
||||
The confusion between domain verification and user authentication is a fundamental architectural error. This fix properly separates these concerns:
|
||||
|
||||
- **Domain verification** establishes trust in the domain configuration (one-time)
|
||||
- **User authentication** establishes trust in the current user (every time)
|
||||
|
||||
This aligns with the IndieAuth specification where the authorization endpoint MUST authenticate the user, not just verify the domain.
|
||||
509
docs/designs/authorization-verification-fix.md
Normal file
509
docs/designs/authorization-verification-fix.md
Normal file
@@ -0,0 +1,509 @@
|
||||
# Design Fix: Authorization Endpoint Domain Verification
|
||||
|
||||
**Date**: 2025-11-22
|
||||
**Architect**: Claude (Architect Agent)
|
||||
**Status**: CRITICAL - Ready for Immediate Implementation
|
||||
**Priority**: P0 - Security Fix
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The authorization endpoint (`GET /authorize`) is bypassing domain verification entirely. This allows anyone to authenticate as any domain without proving ownership, which is a critical security vulnerability.
|
||||
|
||||
### Current Behavior (BROKEN)
|
||||
```
|
||||
1. GET /authorize?me=https://example.com/&... -> 200 OK (consent page shown)
|
||||
2. POST /authorize/consent -> 302 redirect with code
|
||||
```
|
||||
|
||||
### Expected Behavior (Per Design)
|
||||
```
|
||||
1. GET /authorize?me=https://example.com/&...
|
||||
2. Check if domain is verified in database
|
||||
3a. If NOT verified:
|
||||
- Verify DNS TXT record for _gondulf.{domain}
|
||||
- Fetch user's homepage
|
||||
- Discover email from rel="me" link
|
||||
- Send 6-digit verification code to email
|
||||
- Show code entry form
|
||||
4. POST /authorize/verify-code with code
|
||||
5. Validate code -> Store verified domain in database
|
||||
6. Show consent page
|
||||
7. POST /authorize/consent -> 302 redirect with authorization code
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
In `/src/gondulf/routers/authorization.py`, lines 191-193:
|
||||
```python
|
||||
# Check if domain is verified
|
||||
# For Phase 2, we'll show consent form immediately (domain verification happens separately)
|
||||
# In Phase 3, we'll check database for verified domains
|
||||
```
|
||||
|
||||
The implementation shows the consent form directly without any verification checks. The `DomainVerificationService` exists and has the required methods, but they are never called in the authorization flow.
|
||||
|
||||
## Design Fix
|
||||
|
||||
### Overview
|
||||
|
||||
The fix requires modifying the `GET /authorize` endpoint to:
|
||||
1. Extract domain from `me` parameter
|
||||
2. Check if domain is already verified (in database)
|
||||
3. If not verified, initiate verification and show code entry form
|
||||
4. After verification, show consent page
|
||||
|
||||
Additionally, a new endpoint `POST /authorize/verify-code` must be implemented to handle code submission during the authorization flow.
|
||||
|
||||
### Modified Authorization Flow
|
||||
|
||||
#### Step 1: Modify `GET /authorize` (authorization.py)
|
||||
|
||||
**Location**: `/src/gondulf/routers/authorization.py`, `authorize_get` function
|
||||
|
||||
**After line 189** (after me URL validation), insert domain verification logic:
|
||||
|
||||
```python
|
||||
# Extract domain from me URL
|
||||
domain = extract_domain_from_url(me)
|
||||
|
||||
# Check if domain is already verified
|
||||
verification_service = Depends(get_verification_service)
|
||||
# NOTE: Need to add verification_service to function parameters
|
||||
|
||||
# Query database for verified domain
|
||||
is_verified = await check_domain_verified(database, domain)
|
||||
|
||||
if not is_verified:
|
||||
# Start two-factor verification
|
||||
result = verification_service.start_verification(domain, me)
|
||||
|
||||
if not result["success"]:
|
||||
# Verification cannot start (DNS failed, no rel=me, etc)
|
||||
return templates.TemplateResponse(
|
||||
"verification_error.html",
|
||||
{
|
||||
"request": request,
|
||||
"error": result["error"],
|
||||
"domain": domain,
|
||||
# Pass through auth params for retry
|
||||
"client_id": normalized_client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": effective_response_type,
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": code_challenge_method,
|
||||
"scope": scope,
|
||||
"me": me
|
||||
},
|
||||
status_code=200
|
||||
)
|
||||
|
||||
# Verification started - show code entry form
|
||||
return templates.TemplateResponse(
|
||||
"verify_code.html",
|
||||
{
|
||||
"request": request,
|
||||
"masked_email": result["email"],
|
||||
"domain": domain,
|
||||
# Pass through auth params
|
||||
"client_id": normalized_client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": effective_response_type,
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": code_challenge_method,
|
||||
"scope": scope,
|
||||
"me": me,
|
||||
"client_metadata": client_metadata
|
||||
}
|
||||
)
|
||||
|
||||
# Domain is verified - show consent form (existing code from line 205)
|
||||
```
|
||||
|
||||
#### Step 2: Add Database Check Function
|
||||
|
||||
**Location**: Add to `/src/gondulf/routers/authorization.py` or `/src/gondulf/utils/validation.py`
|
||||
|
||||
```python
|
||||
async def check_domain_verified(database: Database, domain: str) -> bool:
|
||||
"""
|
||||
Check if domain is verified in the database.
|
||||
|
||||
Args:
|
||||
database: Database service
|
||||
domain: Domain to check (e.g., "example.com")
|
||||
|
||||
Returns:
|
||||
True if domain is verified, False otherwise
|
||||
"""
|
||||
async with database.get_session() as session:
|
||||
result = await session.execute(
|
||||
"SELECT verified FROM domains WHERE domain = ? AND verified = 1",
|
||||
(domain,)
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row is not None
|
||||
```
|
||||
|
||||
#### Step 3: Add New Endpoint `POST /authorize/verify-code`
|
||||
|
||||
**Location**: `/src/gondulf/routers/authorization.py`
|
||||
|
||||
```python
|
||||
@router.post("/authorize/verify-code")
|
||||
async def authorize_verify_code(
|
||||
request: Request,
|
||||
domain: str = Form(...),
|
||||
code: str = Form(...),
|
||||
client_id: str = Form(...),
|
||||
redirect_uri: str = Form(...),
|
||||
response_type: str = Form("id"),
|
||||
state: str = Form(...),
|
||||
code_challenge: str = Form(...),
|
||||
code_challenge_method: str = Form(...),
|
||||
scope: str = Form(""),
|
||||
me: str = Form(...),
|
||||
database: Database = Depends(get_database),
|
||||
verification_service: DomainVerificationService = Depends(get_verification_service),
|
||||
happ_parser: HAppParser = Depends(get_happ_parser)
|
||||
) -> HTMLResponse:
|
||||
"""
|
||||
Handle verification code submission during authorization flow.
|
||||
|
||||
This endpoint is called when user submits the 6-digit email verification code.
|
||||
On success, shows consent page. On failure, shows code entry form with error.
|
||||
|
||||
Args:
|
||||
domain: Domain being verified
|
||||
code: 6-digit verification code from email
|
||||
client_id, redirect_uri, etc: Authorization parameters (passed through)
|
||||
|
||||
Returns:
|
||||
HTML response: consent page on success, code form with error on failure
|
||||
"""
|
||||
logger.info(f"Verification code submission for domain={domain}")
|
||||
|
||||
# Verify the code
|
||||
result = verification_service.verify_email_code(domain, code)
|
||||
|
||||
if not result["success"]:
|
||||
# Code invalid - show form again with error
|
||||
# Need to get masked email again
|
||||
email = verification_service.code_storage.get(f"email_addr:{domain}")
|
||||
masked_email = mask_email(email) if email else "unknown"
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"verify_code.html",
|
||||
{
|
||||
"request": request,
|
||||
"error": result["error"],
|
||||
"masked_email": masked_email,
|
||||
"domain": domain,
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": response_type,
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": code_challenge_method,
|
||||
"scope": scope,
|
||||
"me": me
|
||||
},
|
||||
status_code=200
|
||||
)
|
||||
|
||||
# Code valid - store verified domain in database
|
||||
await store_verified_domain(database, domain, result.get("email", ""))
|
||||
|
||||
logger.info(f"Domain verified successfully: {domain}")
|
||||
|
||||
# Fetch client metadata for consent page
|
||||
client_metadata = None
|
||||
try:
|
||||
client_metadata = await happ_parser.fetch_and_parse(client_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch client metadata: {e}")
|
||||
|
||||
# Show consent form
|
||||
return templates.TemplateResponse(
|
||||
"authorize.html",
|
||||
{
|
||||
"request": request,
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": response_type,
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": code_challenge_method,
|
||||
"scope": scope,
|
||||
"me": me,
|
||||
"client_metadata": client_metadata
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
#### Step 4: Add Store Verified Domain Function
|
||||
|
||||
```python
|
||||
async def store_verified_domain(database: Database, domain: str, email: str) -> None:
|
||||
"""
|
||||
Store verified domain in database.
|
||||
|
||||
Args:
|
||||
database: Database service
|
||||
domain: Verified domain
|
||||
email: Email used for verification (for audit)
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
async with database.get_session() as session:
|
||||
await session.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO domains
|
||||
(domain, verification_method, verified, verified_at, last_dns_check)
|
||||
VALUES (?, 'two_factor', 1, ?, ?)
|
||||
""",
|
||||
(domain, datetime.utcnow(), datetime.utcnow())
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Stored verified domain: {domain}")
|
||||
```
|
||||
|
||||
#### Step 5: Create New Template `verify_code.html`
|
||||
|
||||
**Location**: `/src/gondulf/templates/verify_code.html`
|
||||
|
||||
```html
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Verify Your Identity - Gondulf{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Verify Your Identity</h1>
|
||||
|
||||
<p>To sign in as <strong>{{ domain }}</strong>, please enter the verification code sent to <strong>{{ masked_email }}</strong>.</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/authorize/verify-code">
|
||||
<!-- Pass through authorization parameters -->
|
||||
<input type="hidden" name="domain" value="{{ domain }}">
|
||||
<input type="hidden" name="client_id" value="{{ client_id }}">
|
||||
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
|
||||
<input type="hidden" name="response_type" value="{{ response_type }}">
|
||||
<input type="hidden" name="state" value="{{ state }}">
|
||||
<input type="hidden" name="code_challenge" value="{{ code_challenge }}">
|
||||
<input type="hidden" name="code_challenge_method" value="{{ code_challenge_method }}">
|
||||
<input type="hidden" name="scope" value="{{ scope }}">
|
||||
<input type="hidden" name="me" value="{{ me }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="code">Verification Code:</label>
|
||||
<input type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
required
|
||||
autofocus>
|
||||
</div>
|
||||
|
||||
<button type="submit">Verify</button>
|
||||
</form>
|
||||
|
||||
<p class="help-text">
|
||||
Did not receive a code? Check your spam folder.
|
||||
<a href="/authorize?client_id={{ client_id }}&redirect_uri={{ redirect_uri }}&response_type={{ response_type }}&state={{ state }}&code_challenge={{ code_challenge }}&code_challenge_method={{ code_challenge_method }}&scope={{ scope }}&me={{ me }}">
|
||||
Request a new code
|
||||
</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
#### Step 6: Create Error Template `verification_error.html`
|
||||
|
||||
**Location**: `/src/gondulf/templates/verification_error.html`
|
||||
|
||||
```html
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Verification Failed - Gondulf{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Verification Failed</h1>
|
||||
|
||||
<div class="error">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
{% if "DNS" in error %}
|
||||
<div class="instructions">
|
||||
<h2>How to Fix</h2>
|
||||
<p>Add the following DNS TXT record to your domain:</p>
|
||||
<code>
|
||||
Type: TXT<br>
|
||||
Name: _gondulf.{{ domain }}<br>
|
||||
Value: gondulf-verify-domain
|
||||
</code>
|
||||
<p>DNS changes may take up to 24 hours to propagate.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if "email" in error.lower() or "rel" in error.lower() %}
|
||||
<div class="instructions">
|
||||
<h2>How to Fix</h2>
|
||||
<p>Add a rel="me" link to your homepage pointing to your email:</p>
|
||||
<code><link rel="me" href="mailto:you@example.com"></code>
|
||||
<p>Or as an anchor tag:</p>
|
||||
<code><a rel="me" href="mailto:you@example.com">Email me</a></code>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
<a href="/authorize?client_id={{ client_id }}&redirect_uri={{ redirect_uri }}&response_type={{ response_type }}&state={{ state }}&code_challenge={{ code_challenge }}&code_challenge_method={{ code_challenge_method }}&scope={{ scope }}&me={{ me }}">
|
||||
Try Again
|
||||
</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
### Changes to Existing Files
|
||||
|
||||
#### `/src/gondulf/routers/authorization.py`
|
||||
|
||||
1. **Add import for `get_verification_service`** at line 17:
|
||||
```python
|
||||
from gondulf.dependencies import get_code_storage, get_database, get_happ_parser, get_verification_service
|
||||
```
|
||||
|
||||
2. **Add `verification_service` parameter to `authorize_get`** function signature (around line 57):
|
||||
```python
|
||||
verification_service: DomainVerificationService = Depends(get_verification_service)
|
||||
```
|
||||
|
||||
3. **Replace lines 191-219** (the comment and consent form display) with the verification logic from Step 1 above.
|
||||
|
||||
4. **Add the new `authorize_verify_code` endpoint** after the `authorize_consent` function.
|
||||
|
||||
5. **Add helper functions** `check_domain_verified` and `store_verified_domain`.
|
||||
|
||||
#### `/src/gondulf/utils/validation.py`
|
||||
|
||||
Add `mask_email` function if not already present:
|
||||
```python
|
||||
def mask_email(email: str) -> str:
|
||||
"""Mask email for display: user@example.com -> u***@example.com"""
|
||||
if not email or '@' not in email:
|
||||
return email or "unknown"
|
||||
local, domain = email.split('@', 1)
|
||||
if len(local) <= 1:
|
||||
return f"{local}***@{domain}"
|
||||
return f"{local[0]}***@{domain}"
|
||||
```
|
||||
|
||||
### Data Flow After Fix
|
||||
|
||||
```
|
||||
User/Client Gondulf DNS/Email
|
||||
| | |
|
||||
|-- GET /authorize --------->| |
|
||||
| |-- Check DB for verified domain |
|
||||
| | (not found) |
|
||||
| |-- Query DNS TXT record ---------->|
|
||||
| |<-- TXT: gondulf-verify-domain ---|
|
||||
| |-- Fetch homepage --------------->|
|
||||
| |<-- HTML with rel=me mailto ------|
|
||||
| |-- Send verification email ------>|
|
||||
|<-- Show verify_code.html --| |
|
||||
| | |
|
||||
|-- POST /verify-code ------>| |
|
||||
| (code: 123456) |-- Verify code (storage check) |
|
||||
| |-- Store verified domain (DB) |
|
||||
|<-- Show authorize.html ----| |
|
||||
| | |
|
||||
|-- POST /authorize/consent->| |
|
||||
|<-- 302 redirect with code -| |
|
||||
```
|
||||
|
||||
### Testing Requirements
|
||||
|
||||
The fix must include the following tests:
|
||||
|
||||
#### Unit Tests
|
||||
- [ ] `test_authorize_unverified_domain_starts_verification`
|
||||
- [ ] `test_authorize_verified_domain_shows_consent`
|
||||
- [ ] `test_verify_code_valid_code_shows_consent`
|
||||
- [ ] `test_verify_code_invalid_code_shows_error`
|
||||
- [ ] `test_verify_code_expired_code_shows_error`
|
||||
- [ ] `test_verify_code_stores_domain_on_success`
|
||||
- [ ] `test_verification_dns_failure_shows_instructions`
|
||||
- [ ] `test_verification_no_relme_shows_instructions`
|
||||
|
||||
#### Integration Tests
|
||||
- [ ] `test_full_verification_flow_new_domain`
|
||||
- [ ] `test_full_authorization_flow_verified_domain`
|
||||
- [ ] `test_verification_code_retry_with_correct_code`
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
The fix is complete when:
|
||||
|
||||
1. **Security**
|
||||
- [ ] Unverified domains NEVER see the consent page directly
|
||||
- [ ] DNS TXT record verification is performed for new domains
|
||||
- [ ] Email verification via rel="me" is required for new domains
|
||||
- [ ] Verified domains are stored in the database
|
||||
- [ ] Subsequent authentications skip verification for stored domains
|
||||
|
||||
2. **Functionality**
|
||||
- [ ] Code entry form displays with masked email
|
||||
- [ ] Invalid codes show error with retry option
|
||||
- [ ] Verification errors show clear instructions
|
||||
- [ ] All authorization parameters preserved through verification flow
|
||||
- [ ] State parameter passed through correctly
|
||||
|
||||
3. **Testing**
|
||||
- [ ] All unit tests pass
|
||||
- [ ] All integration tests pass
|
||||
- [ ] Manual testing confirms the flow works end-to-end
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add `mask_email` to validation utils (if missing)
|
||||
2. Create `verify_code.html` template
|
||||
3. Create `verification_error.html` template
|
||||
4. Add `check_domain_verified` function
|
||||
5. Add `store_verified_domain` function
|
||||
6. Modify `authorize_get` to include verification check
|
||||
7. Add `authorize_verify_code` endpoint
|
||||
8. Write and run tests
|
||||
9. Manual end-to-end testing
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
**Time**: 1-2 days
|
||||
|
||||
- Template creation: 0.25 days
|
||||
- Authorization endpoint modification: 0.5 days
|
||||
- New verify-code endpoint: 0.25 days
|
||||
- Testing: 0.5 days
|
||||
- Integration testing: 0.25 days
|
||||
|
||||
## Sign-off
|
||||
|
||||
**Design Status**: Ready for immediate implementation
|
||||
|
||||
**Architect**: Claude (Architect Agent)
|
||||
**Date**: 2025-11-22
|
||||
|
||||
**DESIGN READY: Authorization Verification Fix - Please implement immediately**
|
||||
|
||||
This is a P0 security fix. Do not deploy to production until this is resolved.
|
||||
183
docs/designs/response-type-fix.md
Normal file
183
docs/designs/response-type-fix.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Fix: Response Type Parameter Default Handling
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The current authorization endpoint incorrectly requires the `response_type` parameter for all requests. According to the W3C IndieAuth specification:
|
||||
|
||||
- **Section 5.2**: When `response_type` is omitted in an authentication request, the authorization endpoint MUST default to `id`
|
||||
- **Section 6.2.1**: The `response_type=code` is required for authorization (access token) requests
|
||||
|
||||
Currently, the endpoint returns an error when `response_type` is missing, instead of defaulting to `id`.
|
||||
|
||||
## Design Overview
|
||||
|
||||
Modify the authorization endpoint to:
|
||||
1. Accept `response_type` as optional
|
||||
2. Default to `id` when omitted
|
||||
3. Support both `id` (authentication) and `code` (authorization) flows
|
||||
4. Return appropriate errors for invalid values
|
||||
|
||||
## Implementation Changes
|
||||
|
||||
### 1. Response Type Validation Logic
|
||||
|
||||
**Location**: `/src/gondulf/routers/authorization.py` lines 111-119
|
||||
|
||||
**Current implementation**:
|
||||
```python
|
||||
# Validate response_type
|
||||
if response_type != "code":
|
||||
error_params = {
|
||||
"error": "unsupported_response_type",
|
||||
"error_description": "Only response_type=code is supported",
|
||||
"state": state or ""
|
||||
}
|
||||
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
```
|
||||
|
||||
**New implementation**:
|
||||
```python
|
||||
# Validate response_type (defaults to 'id' per IndieAuth spec section 5.2)
|
||||
if response_type is None:
|
||||
response_type = "id" # Default per W3C spec
|
||||
|
||||
if response_type not in ["id", "code"]:
|
||||
error_params = {
|
||||
"error": "unsupported_response_type",
|
||||
"error_description": f"response_type '{response_type}' not supported. Must be 'id' or 'code'",
|
||||
"state": state or ""
|
||||
}
|
||||
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
```
|
||||
|
||||
### 2. Flow-Specific Validation
|
||||
|
||||
The authentication flow (`id`) and authorization flow (`code`) have different requirements:
|
||||
|
||||
#### Authentication Flow (`response_type=id`)
|
||||
- PKCE is optional (not required)
|
||||
- Scope is not applicable
|
||||
- Returns only user profile URL
|
||||
|
||||
#### Authorization Flow (`response_type=code`)
|
||||
- PKCE is required (current behavior)
|
||||
- Scope is applicable
|
||||
- Returns authorization code for token exchange
|
||||
|
||||
**Modified PKCE validation** (lines 121-139):
|
||||
```python
|
||||
# Validate PKCE (required only for authorization flow)
|
||||
if response_type == "code":
|
||||
if not code_challenge:
|
||||
error_params = {
|
||||
"error": "invalid_request",
|
||||
"error_description": "code_challenge is required for authorization requests (PKCE)",
|
||||
"state": state or ""
|
||||
}
|
||||
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
# Validate code_challenge_method
|
||||
if code_challenge_method != "S256":
|
||||
error_params = {
|
||||
"error": "invalid_request",
|
||||
"error_description": "code_challenge_method must be S256",
|
||||
"state": state or ""
|
||||
}
|
||||
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
```
|
||||
|
||||
### 3. Template Context Update
|
||||
|
||||
Pass the resolved `response_type` to the consent template (line 177-189):
|
||||
|
||||
```python
|
||||
return templates.TemplateResponse(
|
||||
"authorize.html",
|
||||
{
|
||||
"request": request,
|
||||
"client_id": normalized_client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": response_type, # Add this - resolved value
|
||||
"state": state or "",
|
||||
"code_challenge": code_challenge or "", # Make optional
|
||||
"code_challenge_method": code_challenge_method or "", # Make optional
|
||||
"scope": scope or "",
|
||||
"me": me,
|
||||
"client_metadata": client_metadata
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Consent Form Processing
|
||||
|
||||
The consent handler needs to differentiate between authentication and authorization flows:
|
||||
|
||||
**Location**: `/src/gondulf/routers/authorization.py` lines 193-245
|
||||
|
||||
Add `response_type` parameter to the form submission and handle accordingly:
|
||||
|
||||
1. Add `response_type` as a form field (line ~196)
|
||||
2. Process differently based on flow type
|
||||
3. For `id` flow: Return simpler response without creating full authorization code
|
||||
4. For `code` flow: Current behavior (create authorization code)
|
||||
|
||||
## Test Requirements
|
||||
|
||||
### New Test Cases
|
||||
|
||||
1. **Test missing response_type defaults to 'id'**
|
||||
- Request without `response_type` parameter
|
||||
- Should NOT return error
|
||||
- Should render consent page
|
||||
- Form should have `response_type=id`
|
||||
|
||||
2. **Test explicit response_type=id accepted**
|
||||
- Request with `response_type=id`
|
||||
- Should render consent page
|
||||
- PKCE parameters not required
|
||||
|
||||
3. **Test response_type=id without PKCE**
|
||||
- Request with `response_type=id` and no PKCE
|
||||
- Should succeed (PKCE optional for authentication)
|
||||
|
||||
4. **Test response_type=code requires PKCE**
|
||||
- Request with `response_type=code` without PKCE
|
||||
- Should redirect with error (current behavior)
|
||||
|
||||
5. **Test invalid response_type values**
|
||||
- Request with `response_type=token` or other invalid values
|
||||
- Should redirect with error
|
||||
|
||||
### Modified Test Cases
|
||||
|
||||
Update existing test in `test_authorization_flow.py`:
|
||||
- Line 115-126: `test_invalid_response_type_redirects_with_error`
|
||||
- Keep testing invalid values like "token"
|
||||
- Add new test for missing parameter (should NOT error)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. ✅ Missing `response_type` defaults to `id` (no error)
|
||||
2. ✅ `response_type=id` is accepted and processed
|
||||
3. ✅ `response_type=code` continues to work as before
|
||||
4. ✅ Invalid response_type values return appropriate error
|
||||
5. ✅ PKCE is optional for `id` flow
|
||||
6. ✅ PKCE remains required for `code` flow
|
||||
7. ✅ Error messages clearly indicate supported values
|
||||
8. ✅ All existing tests pass with modifications
|
||||
9. ✅ New tests cover all response_type scenarios
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- No security degradation: Authentication flow (`id`) has fewer requirements by design
|
||||
- PKCE remains mandatory for authorization flow (`code`)
|
||||
- Invalid values still produce errors
|
||||
- State parameter continues to be preserved in all flows
|
||||
|
||||
## Notes
|
||||
|
||||
This is a bug fix to bring the implementation into compliance with the W3C IndieAuth specification. The specification is explicit that `response_type` defaults to `id` when omitted, which enables simpler authentication-only flows.
|
||||
Reference in New Issue
Block a user