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:
@@ -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"}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user