|
|
|
|
@@ -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"}
|
|
|
|
|
)
|
|
|
|
|
|