feat(auth): implement response_type=id authentication flow

Implements both IndieAuth flows per W3C specification:
- Authentication flow (response_type=id): Code redeemed at authorization endpoint, returns only user identity
- Authorization flow (response_type=code): Code redeemed at token endpoint, returns access token

Changes:
- Authorization endpoint GET: Accept response_type=id (default) and code
- Authorization endpoint POST: Handle code verification for authentication flow
- Token endpoint: Validate response_type=code for authorization flow
- Store response_type in authorization code metadata
- Update metadata endpoint: response_types_supported=[code, id], code_challenge_methods_supported=[S256]

The default behavior now correctly defaults to response_type=id when omitted, per IndieAuth spec section 5.2.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-22 12:23:20 -07:00
parent 9dfa77633a
commit 052d3ad3e1
11 changed files with 684 additions and 28 deletions

View File

@@ -1,15 +1,23 @@
"""Authorization endpoint for OAuth 2.0 / IndieAuth authorization code flow."""
"""Authorization endpoint for OAuth 2.0 / IndieAuth authorization code flow.
Supports both IndieAuth flows per W3C specification:
- Authentication (response_type=id): Returns user identity only, code redeemed at authorization endpoint
- Authorization (response_type=code): Returns access token, code redeemed at token endpoint
"""
import logging
from typing import Optional
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi import APIRouter, Depends, Form, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from gondulf.database.connection import Database
from gondulf.dependencies import get_database, get_happ_parser, get_verification_service
from gondulf.dependencies import get_code_storage, get_database, get_happ_parser, get_verification_service
from gondulf.services.domain_verification import DomainVerificationService
from gondulf.services.happ_parser import HAppParser
from gondulf.storage import CodeStore
from gondulf.utils.validation import (
extract_domain_from_url,
normalize_client_id,
@@ -21,6 +29,19 @@ logger = logging.getLogger("gondulf.authorization")
router = APIRouter()
templates = Jinja2Templates(directory="src/gondulf/templates")
# Valid response types per IndieAuth spec
VALID_RESPONSE_TYPES = {"id", "code"}
class AuthenticationResponse(BaseModel):
"""
IndieAuth authentication response (response_type=id flow).
Per W3C IndieAuth specification Section 5.3.3:
https://www.w3.org/TR/indieauth/#authentication-response
"""
me: str
@router.get("/authorize")
async def authorize_get(
@@ -42,17 +63,22 @@ async def authorize_get(
Validates client_id, redirect_uri, and required parameters.
Shows consent form if domain is verified, or verification form if not.
Supports two IndieAuth flows per W3C specification:
- response_type=id (default): Authentication only, returns user identity
- response_type=code: Authorization, returns access token
Args:
request: FastAPI request object
client_id: Client application identifier
redirect_uri: Callback URI for client
response_type: Must be "code"
response_type: "id" (default) for authentication, "code" for authorization
state: Client state parameter
code_challenge: PKCE code challenge
code_challenge_method: PKCE method (S256)
scope: Requested scope
scope: Requested scope (only meaningful for response_type=code)
me: User identity URL
database: Database service
happ_parser: H-app parser for client metadata
Returns:
HTML response with consent form or error page
@@ -108,11 +134,13 @@ async def authorize_get(
# From here on, redirect errors to client via OAuth error redirect
# Validate response_type
if response_type != "code":
# Validate response_type - default to "id" if not provided (per IndieAuth spec)
effective_response_type = response_type or "id"
if effective_response_type not in VALID_RESPONSE_TYPES:
error_params = {
"error": "unsupported_response_type",
"error_description": "Only response_type=code is supported",
"error_description": f"response_type must be 'id' or 'code', got '{response_type}'",
"state": state or ""
}
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
@@ -180,6 +208,7 @@ async def authorize_get(
"request": request,
"client_id": normalized_client_id,
"redirect_uri": redirect_uri,
"response_type": effective_response_type,
"state": state or "",
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
@@ -195,6 +224,7 @@ async def authorize_consent(
request: Request,
client_id: str = Form(...),
redirect_uri: str = Form(...),
response_type: str = Form("id"), # Default to "id" for authentication flow
state: str = Form(...),
code_challenge: str = Form(...),
code_challenge_method: str = Form(...),
@@ -211,6 +241,7 @@ async def authorize_consent(
request: FastAPI request object
client_id: Client application identifier
redirect_uri: Callback URI
response_type: "id" for authentication, "code" for authorization
state: Client state
code_challenge: PKCE challenge
code_challenge_method: PKCE method
@@ -221,9 +252,9 @@ async def authorize_consent(
Returns:
Redirect to client callback with authorization code
"""
logger.info(f"Authorization consent granted for client_id={client_id}")
logger.info(f"Authorization consent granted for client_id={client_id} response_type={response_type}")
# Create authorization code
# Create authorization code with response_type metadata
authorization_code = verification_service.create_authorization_code(
client_id=client_id,
redirect_uri=redirect_uri,
@@ -231,7 +262,8 @@ async def authorize_consent(
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
scope=scope,
me=me
me=me,
response_type=response_type
)
# Build redirect URL with authorization code
@@ -243,3 +275,161 @@ async def authorize_consent(
logger.info(f"Redirecting to {redirect_uri} with authorization code")
return RedirectResponse(url=redirect_url, status_code=302)
@router.post("/authorize")
async def authorize_post(
response: Response,
code: str = Form(...),
client_id: str = Form(...),
redirect_uri: Optional[str] = Form(None),
code_verifier: Optional[str] = Form(None),
code_storage: CodeStore = Depends(get_code_storage)
) -> JSONResponse:
"""
Handle authorization code verification for authentication flow (response_type=id).
Per W3C IndieAuth specification Section 5.3.3:
https://www.w3.org/TR/indieauth/#redeeming-the-authorization-code-id
This endpoint is used ONLY for the authentication flow (response_type=id).
For the authorization flow (response_type=code), clients must use the token endpoint.
Request (application/x-www-form-urlencoded):
code: Authorization code from /authorize redirect
client_id: Client application URL (must match original request)
redirect_uri: Original redirect URI (optional but recommended)
code_verifier: PKCE verifier (optional, for PKCE validation)
Response (200 OK):
{
"me": "https://user.example.com/"
}
Error Response (400 Bad Request):
{
"error": "invalid_grant",
"error_description": "..."
}
Returns:
JSONResponse with user identity or error
"""
# Set cache headers (OAuth 2.0 best practice)
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
logger.info(f"Authorization code verification request from client: {client_id}")
# STEP 1: Retrieve authorization code from storage
storage_key = f"authz:{code}"
code_data = code_storage.get(storage_key)
if code_data is None:
logger.warning(f"Authorization code not found or expired: {code[:8]}...")
return JSONResponse(
status_code=400,
content={
"error": "invalid_grant",
"error_description": "Authorization code is invalid or has expired"
},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"}
)
# Validate code_data is a dict
if not isinstance(code_data, dict):
logger.error(f"Authorization code metadata is not a dict: {type(code_data)}")
return JSONResponse(
status_code=400,
content={
"error": "invalid_grant",
"error_description": "Authorization code is malformed"
},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"}
)
# STEP 2: Validate this code was issued for response_type=id
stored_response_type = code_data.get('response_type', 'id')
if stored_response_type != 'id':
logger.warning(
f"Code redemption at authorization endpoint for response_type={stored_response_type}"
)
return JSONResponse(
status_code=400,
content={
"error": "invalid_grant",
"error_description": "Authorization code must be redeemed at the token endpoint"
},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"}
)
# STEP 3: Validate client_id matches
if code_data.get('client_id') != client_id:
logger.warning(
f"Client ID mismatch: expected {code_data.get('client_id')}, got {client_id}"
)
return JSONResponse(
status_code=400,
content={
"error": "invalid_client",
"error_description": "Client ID does not match authorization code"
},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"}
)
# STEP 4: Validate redirect_uri if provided
if redirect_uri and code_data.get('redirect_uri') != redirect_uri:
logger.warning(
f"Redirect URI mismatch: expected {code_data.get('redirect_uri')}, got {redirect_uri}"
)
return JSONResponse(
status_code=400,
content={
"error": "invalid_grant",
"error_description": "Redirect URI does not match authorization request"
},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"}
)
# STEP 5: Check if code already used (prevent replay)
if code_data.get('used'):
logger.warning(f"Authorization code replay detected: {code[:8]}...")
return JSONResponse(
status_code=400,
content={
"error": "invalid_grant",
"error_description": "Authorization code has already been used"
},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"}
)
# STEP 6: Extract user identity
me = code_data.get('me')
if not me:
logger.error("Authorization code missing 'me' parameter")
return JSONResponse(
status_code=400,
content={
"error": "invalid_grant",
"error_description": "Authorization code is malformed"
},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"}
)
# STEP 7: PKCE validation (optional for authentication flow)
if code_verifier:
logger.debug(f"PKCE code_verifier provided but not validated (v1.0.0)")
# v1.1.0 will validate: SHA256(code_verifier) == code_challenge
# STEP 8: Delete authorization code (single-use enforcement)
code_storage.delete(storage_key)
logger.info(f"Authorization code verified and deleted: {code[:8]}...")
# STEP 9: Return authentication response with user identity
logger.info(f"Authentication successful for {me} (client: {client_id})")
return JSONResponse(
status_code=200,
content={"me": me},
headers={"Cache-Control": "no-store", "Pragma": "no-cache"}
)