Files
Gondulf/docs/designs/bugfix-pkce-optional-v1.0.0.md
Phil Skentelbery 404d723ef8 fix(auth): make PKCE optional per ADR-003
PKCE was incorrectly required in the /authorize endpoint,
contradicting ADR-003 which defers PKCE to v1.1.0.

Changes:
- PKCE parameters are now optional in /authorize
- If code_challenge provided, validates method is S256
- Defaults to S256 if method not specified
- Logs when clients don't use PKCE for monitoring
- Updated tests for optional PKCE behavior

This fixes authentication for clients that don't implement PKCE.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 15:23:44 -07:00

7.3 KiB

Design: Make PKCE Optional in v1.0.0 (Bug Fix)

Date: 2025-12-17 Status: Ready for Implementation Priority: P0 (Blocking)

Problem Statement

The /authorize endpoint currently requires PKCE parameters (code_challenge and code_challenge_method), which contradicts ADR-003 that explicitly states PKCE is deferred to v1.1.0.

Current behavior (lines 325-343 in authorization.py):

# Validate code_challenge (PKCE required)
if not code_challenge:
    return {"error": "invalid_request", "error_description": "code_challenge is required (PKCE)"}

if code_challenge_method != "S256":
    return {"error": "invalid_request", "error_description": "code_challenge_method must be S256"}

Expected v1.0.0 behavior per ADR-003:

  • PKCE parameters should be optional
  • Clients without PKCE should be able to authenticate
  • PKCE validation is deferred to v1.1.0

This bug is blocking real-world IndieAuth clients that do not use PKCE.

Design Overview

The fix is straightforward: remove the mandatory PKCE checks from the authorization endpoint while preserving the ability to accept and store PKCE parameters for forward compatibility.

Principle: Minimal Change

This is a bug fix, not a feature. The change should be minimal and surgical:

  1. Remove the two error-returning conditionals
  2. Add validation only when PKCE parameters ARE provided
  3. Preserve all existing storage behavior

Detailed Changes

Change 1: Remove Mandatory PKCE Check

Location: /src/gondulf/routers/authorization.py, lines 325-343

Current Code (to be removed):

# Validate code_challenge (PKCE required)
if not code_challenge:
    error_params = {
        "error": "invalid_request",
        "error_description": "code_challenge is required (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)

New Code (replacement):

# PKCE validation (optional in v1.0.0, per ADR-003)
# If code_challenge is provided, validate the method
if code_challenge:
    if code_challenge_method and 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)
    # If code_challenge provided without method, default to S256
    if not code_challenge_method:
        code_challenge_method = "S256"
else:
    # Log for future monitoring (per ADR-003 recommendation)
    logger.info(f"Client {client_id} not using PKCE")

Change 2: Handle None Values in Session Storage

The AuthSessionService.create_session() already accepts these parameters, and the database schema likely allows NULL values. No changes needed to the service layer.

Verification: The auth_session.py already uses these parameters directly:

"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,

If code_challenge is None, this will store NULL in the database, which is the desired behavior.

Change 3: Update Template Context (Optional Cleanup)

The templates already receive code_challenge and code_challenge_method - they will now sometimes be None or empty. This should not cause issues as Jinja2 handles None values gracefully in form hidden fields.

Behavior Matrix

code_challenge code_challenge_method Result
None None Proceed without PKCE
None "S256" Proceed without PKCE (method ignored)
"abc123..." None Proceed with PKCE, default to S256
"abc123..." "S256" Proceed with PKCE
"abc123..." "plain" ERROR: method must be S256

What NOT to Change

  1. Token endpoint - Already handles PKCE correctly (optional, logged but not validated per ADR-003 lines 200-203)
  2. POST /authorize - Already handles PKCE correctly (optional, logged but not validated per lines 856-858)
  3. Auth session service - Already accepts optional code_challenge parameters
  4. Database schema - Likely already allows NULL for these fields

Security Considerations

No security regression:

  • ADR-003 explicitly accepted this risk for v1.0.0
  • HTTPS enforcement mitigates code interception
  • 10-minute code lifetime limits attack window
  • Single-use codes prevent replay

Forward compatibility:

  • PKCE parameters are still stored when provided
  • v1.1.0 can enable validation without schema changes
  • Clients using PKCE today will work in v1.1.0

Testing Strategy

Unit Tests

  1. Test authorization without PKCE:

    • Call /authorize without code_challenge - should succeed
    • Verify session is created with NULL code_challenge
  2. Test authorization with PKCE:

    • Call /authorize with valid code_challenge and code_challenge_method=S256 - should succeed
    • Verify session stores the code_challenge
  3. Test PKCE with default method:

    • Call /authorize with code_challenge but no code_challenge_method
    • Should succeed, default to S256
  4. Test invalid PKCE method:

    • Call /authorize with code_challenge and code_challenge_method=plain
    • Should return error (only S256 supported)
  5. End-to-end flow without PKCE:

    • Complete full authorization flow without PKCE parameters
    • Verify token can be obtained

Manual Testing

  1. Use a real IndieAuth client that does NOT send PKCE
  2. Verify authentication completes successfully

Acceptance Criteria

  1. Clients without PKCE can complete authorization flow
  2. Clients with PKCE continue to work unchanged
  3. Invalid PKCE method (not S256) is rejected
  4. PKCE parameters are stored in auth session when provided
  5. All existing tests continue to pass
  6. New tests cover optional PKCE behavior

Implementation Notes

For the Developer

The fix is contained to a single location in authorization.py. The key insight is:

  1. DELETE the two blocks that return errors for missing PKCE
  2. ADD a simpler block that only validates method IF code_challenge is provided
  3. ADD a log statement for clients not using PKCE (monitoring per ADR-003)

The rest of the codebase already handles optional PKCE correctly. This was an error in the GET /authorize validation logic only.

Estimated Effort

S (Small) - 1-2 hours including tests

Files to Modify

  1. /src/gondulf/routers/authorization.py - Remove mandatory PKCE checks (~20 lines changed)

Files to Add

  1. Tests for optional PKCE behavior (or add to existing authorization tests)

References