diff --git a/src/gondulf/routers/authorization.py b/src/gondulf/routers/authorization.py index ffd6dcf..da3915e 100644 --- a/src/gondulf/routers/authorization.py +++ b/src/gondulf/routers/authorization.py @@ -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"} + ) diff --git a/src/gondulf/routers/metadata.py b/src/gondulf/routers/metadata.py index d126970..12b07c6 100644 --- a/src/gondulf/routers/metadata.py +++ b/src/gondulf/routers/metadata.py @@ -29,9 +29,9 @@ async def get_metadata(config: Config = Depends(get_config)) -> Response: "issuer": config.BASE_URL, "authorization_endpoint": f"{config.BASE_URL}/authorize", "token_endpoint": f"{config.BASE_URL}/token", - "response_types_supported": ["code"], + "response_types_supported": ["code", "id"], "grant_types_supported": ["authorization_code"], - "code_challenge_methods_supported": [], + "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none"], "revocation_endpoint_auth_methods_supported": ["none"], "scopes_supported": [] diff --git a/src/gondulf/routers/token.py b/src/gondulf/routers/token.py index e56f7a1..35b3a02 100644 --- a/src/gondulf/routers/token.py +++ b/src/gondulf/routers/token.py @@ -156,6 +156,21 @@ async def token_exchange( } ) + # STEP 4.5: Validate this code was issued for response_type=code + # Codes with response_type=id must be redeemed at the authorization endpoint + stored_response_type = code_data.get('response_type', 'id') + if stored_response_type != 'code': + logger.warning( + f"Code redemption at token endpoint for response_type={stored_response_type}" + ) + raise HTTPException( + status_code=400, + detail={ + "error": "invalid_grant", + "error_description": "Authorization code must be redeemed at the authorization endpoint" + } + ) + # STEP 5: Check if code already used (prevent replay) if code_data.get('used'): logger.error(f"Authorization code replay detected: {code[:8]}...") diff --git a/src/gondulf/services/domain_verification.py b/src/gondulf/services/domain_verification.py index 5965efc..f3ce95b 100644 --- a/src/gondulf/services/domain_verification.py +++ b/src/gondulf/services/domain_verification.py @@ -212,7 +212,8 @@ class DomainVerificationService: code_challenge: str, code_challenge_method: str, scope: str, - me: str + me: str, + response_type: str = "id" ) -> str: """ Create authorization code with metadata. @@ -225,6 +226,7 @@ class DomainVerificationService: code_challenge_method: PKCE method (S256) scope: Requested scope me: Verified user identity + response_type: "id" for authentication, "code" for authorization Returns: Authorization code @@ -232,7 +234,7 @@ class DomainVerificationService: # Generate authorization code authorization_code = self._generate_authorization_code() - # Create metadata + # Create metadata including response_type for flow determination during redemption metadata = { "client_id": client_id, "redirect_uri": redirect_uri, @@ -241,6 +243,7 @@ class DomainVerificationService: "code_challenge_method": code_challenge_method, "scope": scope, "me": me, + "response_type": response_type, "created_at": int(time.time()), "expires_at": int(time.time()) + 600, "used": False diff --git a/src/gondulf/templates/authorize.html b/src/gondulf/templates/authorize.html index 4df8abc..b96b7f8 100644 --- a/src/gondulf/templates/authorize.html +++ b/src/gondulf/templates/authorize.html @@ -36,6 +36,7 @@