Files
fast-next-template/backend/app/api/routes/oauth_provider.py
Felipe Cardoso b3f0dd4005 Add full OAuth provider functionality and enhance flows
- Implemented OAuth 2.0 Authorization Server endpoints per RFCs, including token, introspection, revocation, and metadata discovery.
- Added user consent submission, listing, and revocation APIs alongside frontend integration for improved UX.
- Enforced stricter OAuth security measures (PKCE, state validation, scopes).
- Refactored schemas and services for consistency and expanded coverage of OAuth workflows.
- Updated documentation and type definitions for new API behaviors.
2025-11-26 13:23:44 +01:00

847 lines
30 KiB
Python

# app/api/routes/oauth_provider.py
"""
OAuth Provider routes (Authorization Server mode) for MCP integration.
Implements OAuth 2.0 Authorization Server endpoints:
- GET /.well-known/oauth-authorization-server - Server metadata (RFC 8414)
- GET /oauth/provider/authorize - Authorization endpoint
- POST /oauth/provider/token - Token endpoint
- POST /oauth/provider/revoke - Token revocation (RFC 7009)
- POST /oauth/provider/introspect - Token introspection (RFC 7662)
- Client management endpoints
Security features:
- PKCE required for public clients (S256)
- CSRF protection via state parameter
- Secure token handling
- Rate limiting on sensitive endpoints
"""
import logging
from typing import Any
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, status
from fastapi.responses import RedirectResponse
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies.auth import (
get_current_active_user,
get_current_superuser,
get_optional_current_user,
)
from app.core.config import settings
from app.core.database import get_db
from app.crud import oauth_client as oauth_client_crud
from app.models.user import User
from app.schemas.oauth import (
OAuthClientCreate,
OAuthClientResponse,
OAuthServerMetadata,
OAuthTokenIntrospectionResponse,
OAuthTokenResponse,
)
from app.services import oauth_provider_service as provider_service
router = APIRouter()
# Separate router for RFC 8414 well-known endpoint (registered at root level)
wellknown_router = APIRouter()
logger = logging.getLogger(__name__)
limiter = Limiter(key_func=get_remote_address)
def require_provider_enabled():
"""Dependency to check if OAuth provider mode is enabled."""
if not settings.OAUTH_PROVIDER_ENABLED:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="OAuth provider mode is not enabled. Set OAUTH_PROVIDER_ENABLED=true",
)
# ============================================================================
# Server Metadata (RFC 8414)
# ============================================================================
@wellknown_router.get(
"/.well-known/oauth-authorization-server",
response_model=OAuthServerMetadata,
summary="OAuth Server Metadata",
description="""
OAuth 2.0 Authorization Server Metadata (RFC 8414).
Returns server metadata including supported endpoints, scopes,
and capabilities. MCP clients use this to discover the server.
Note: This endpoint is at the root level per RFC 8414.
""",
operation_id="get_oauth_server_metadata",
tags=["OAuth Provider"],
)
async def get_server_metadata(
_: None = Depends(require_provider_enabled),
) -> OAuthServerMetadata:
"""Get OAuth 2.0 server metadata."""
base_url = settings.OAUTH_ISSUER.rstrip("/")
return OAuthServerMetadata(
issuer=base_url,
authorization_endpoint=f"{base_url}/api/v1/oauth/provider/authorize",
token_endpoint=f"{base_url}/api/v1/oauth/provider/token",
revocation_endpoint=f"{base_url}/api/v1/oauth/provider/revoke",
introspection_endpoint=f"{base_url}/api/v1/oauth/provider/introspect",
registration_endpoint=None, # Dynamic registration not supported
scopes_supported=[
"openid",
"profile",
"email",
"read:users",
"write:users",
"read:organizations",
"write:organizations",
"admin",
],
response_types_supported=["code"],
grant_types_supported=["authorization_code", "refresh_token"],
code_challenge_methods_supported=["S256"],
token_endpoint_auth_methods_supported=[
"client_secret_basic",
"client_secret_post",
"none", # For public clients with PKCE
],
)
# ============================================================================
# Authorization Endpoint
# ============================================================================
@router.get(
"/provider/authorize",
summary="Authorization Endpoint",
description="""
OAuth 2.0 Authorization Endpoint.
Initiates the authorization code flow:
1. Validates client and parameters
2. Checks if user is authenticated (redirects to login if not)
3. Checks existing consent
4. Redirects to consent page if needed
5. Issues authorization code and redirects back to client
Required parameters:
- response_type: Must be "code"
- client_id: Registered client ID
- redirect_uri: Must match registered URI
Recommended parameters:
- state: CSRF protection
- code_challenge + code_challenge_method: PKCE (required for public clients)
- scope: Requested permissions
""",
operation_id="oauth_provider_authorize",
tags=["OAuth Provider"],
)
@limiter.limit("30/minute")
async def authorize(
request: Request,
response_type: str = Query(..., description="Must be 'code'"),
client_id: str = Query(..., description="OAuth client ID"),
redirect_uri: str = Query(..., description="Redirect URI"),
scope: str = Query(default="", description="Requested scopes (space-separated)"),
state: str = Query(default="", description="CSRF state parameter"),
code_challenge: str | None = Query(default=None, description="PKCE code challenge"),
code_challenge_method: str | None = Query(
default=None, description="PKCE method (S256)"
),
nonce: str | None = Query(default=None, description="OpenID Connect nonce"),
db: AsyncSession = Depends(get_db),
_: None = Depends(require_provider_enabled),
current_user: User | None = Depends(get_optional_current_user),
) -> Any:
"""
Authorization endpoint - initiates OAuth flow.
If user is not authenticated, redirects to login with return URL.
If user has not consented, redirects to consent page.
If all checks pass, generates code and redirects to client.
"""
# Validate response_type
if response_type != "code":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid_request: response_type must be 'code'",
)
# Validate PKCE method if provided - ONLY S256 is allowed (RFC 7636 Section 4.3)
# "plain" method provides no security benefit and MUST NOT be used
if code_challenge_method and code_challenge_method != "S256":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid_request: code_challenge_method must be 'S256' (plain is not supported)",
)
# Validate client
try:
client = await provider_service.get_client(db, client_id)
if not client:
raise provider_service.InvalidClientError("Unknown client_id")
provider_service.validate_redirect_uri(client, redirect_uri)
except provider_service.OAuthProviderError as e:
# For client/redirect errors, we can't safely redirect - show error
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{e.error}: {e.error_description}",
)
# Validate and filter scopes
try:
requested_scopes = provider_service.parse_scope(scope)
valid_scopes = provider_service.validate_scopes(client, requested_scopes)
except provider_service.InvalidScopeError as e:
# Redirect with error
scope_error_params: dict[str, str] = {"error": e.error}
if e.error_description:
scope_error_params["error_description"] = e.error_description
if state:
scope_error_params["state"] = state
return RedirectResponse(
url=f"{redirect_uri}?{urlencode(scope_error_params)}",
status_code=status.HTTP_302_FOUND,
)
# Public clients MUST use PKCE
if client.client_type == "public":
if not code_challenge or code_challenge_method != "S256":
pkce_error_params: dict[str, str] = {
"error": "invalid_request",
"error_description": "PKCE with S256 is required for public clients",
}
if state:
pkce_error_params["state"] = state
return RedirectResponse(
url=f"{redirect_uri}?{urlencode(pkce_error_params)}",
status_code=status.HTTP_302_FOUND,
)
# If user is not authenticated, redirect to login
if not current_user:
# Store authorization request in session and redirect to login
# The frontend will handle the return URL
login_url = f"{settings.FRONTEND_URL}/login"
return_params = urlencode(
{
"oauth_authorize": "true",
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": " ".join(valid_scopes),
"state": state,
"code_challenge": code_challenge or "",
"code_challenge_method": code_challenge_method or "",
"nonce": nonce or "",
}
)
return RedirectResponse(
url=f"{login_url}?return_to=/auth/consent?{return_params}",
status_code=status.HTTP_302_FOUND,
)
# Check if user has already consented
has_consent = await provider_service.check_consent(
db, current_user.id, client_id, valid_scopes
)
if not has_consent:
# Redirect to consent page
consent_params = urlencode(
{
"client_id": client_id,
"client_name": client.client_name,
"redirect_uri": redirect_uri,
"scope": " ".join(valid_scopes),
"state": state,
"code_challenge": code_challenge or "",
"code_challenge_method": code_challenge_method or "",
"nonce": nonce or "",
}
)
return RedirectResponse(
url=f"{settings.FRONTEND_URL}/auth/consent?{consent_params}",
status_code=status.HTTP_302_FOUND,
)
# User is authenticated and has consented - issue authorization code
try:
code = await provider_service.create_authorization_code(
db=db,
client=client,
user=current_user,
redirect_uri=redirect_uri,
scope=" ".join(valid_scopes),
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
state=state,
nonce=nonce,
)
except provider_service.OAuthProviderError as e:
error_params: dict[str, str] = {"error": e.error}
if e.error_description:
error_params["error_description"] = e.error_description
if state:
error_params["state"] = state
return RedirectResponse(
url=f"{redirect_uri}?{urlencode(error_params)}",
status_code=status.HTTP_302_FOUND,
)
# Success - redirect with code
success_params = {"code": code}
if state:
success_params["state"] = state
return RedirectResponse(
url=f"{redirect_uri}?{urlencode(success_params)}",
status_code=status.HTTP_302_FOUND,
)
@router.post(
"/provider/authorize/consent",
summary="Submit Authorization Consent",
description="""
Submit user consent for OAuth authorization.
Called by the consent page after user approves or denies.
""",
operation_id="oauth_provider_consent",
tags=["OAuth Provider"],
)
@limiter.limit("30/minute")
async def submit_consent(
request: Request,
approved: bool = Form(..., description="Whether user approved"),
client_id: str = Form(..., description="OAuth client ID"),
redirect_uri: str = Form(..., description="Redirect URI"),
scope: str = Form(default="", description="Granted scopes"),
state: str = Form(default="", description="CSRF state parameter"),
code_challenge: str | None = Form(default=None),
code_challenge_method: str | None = Form(default=None),
nonce: str | None = Form(default=None),
db: AsyncSession = Depends(get_db),
_: None = Depends(require_provider_enabled),
current_user: User = Depends(get_current_active_user),
) -> Any:
"""Process consent form submission."""
# Validate client
try:
client = await provider_service.get_client(db, client_id)
if not client:
raise provider_service.InvalidClientError("Unknown client_id")
provider_service.validate_redirect_uri(client, redirect_uri)
except provider_service.OAuthProviderError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{e.error}: {e.error_description}",
)
# If user denied, redirect with error
if not approved:
denied_params: dict[str, str] = {
"error": "access_denied",
"error_description": "User denied authorization",
}
if state:
denied_params["state"] = state
return RedirectResponse(
url=f"{redirect_uri}?{urlencode(denied_params)}",
status_code=status.HTTP_302_FOUND,
)
# Parse and validate scopes
granted_scopes = provider_service.parse_scope(scope)
valid_scopes = provider_service.validate_scopes(client, granted_scopes)
# Record consent
await provider_service.grant_consent(db, current_user.id, client_id, valid_scopes)
# Generate authorization code
try:
code = await provider_service.create_authorization_code(
db=db,
client=client,
user=current_user,
redirect_uri=redirect_uri,
scope=" ".join(valid_scopes),
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
state=state,
nonce=nonce,
)
except provider_service.OAuthProviderError as e:
error_params: dict[str, str] = {"error": e.error}
if e.error_description:
error_params["error_description"] = e.error_description
if state:
error_params["state"] = state
return RedirectResponse(
url=f"{redirect_uri}?{urlencode(error_params)}",
status_code=status.HTTP_302_FOUND,
)
# Success
success_params = {"code": code}
if state:
success_params["state"] = state
return RedirectResponse(
url=f"{redirect_uri}?{urlencode(success_params)}",
status_code=status.HTTP_302_FOUND,
)
# ============================================================================
# Token Endpoint
# ============================================================================
@router.post(
"/provider/token",
response_model=OAuthTokenResponse,
summary="Token Endpoint",
description="""
OAuth 2.0 Token Endpoint.
Supports:
- authorization_code: Exchange code for tokens
- refresh_token: Refresh access token
Client authentication:
- Confidential clients: client_secret (Basic auth or POST body)
- Public clients: No secret, but PKCE code_verifier required
""",
operation_id="oauth_provider_token",
tags=["OAuth Provider"],
)
@limiter.limit("60/minute")
async def token(
request: Request,
grant_type: str = Form(..., description="Grant type"),
code: str | None = Form(default=None, description="Authorization code"),
redirect_uri: str | None = Form(default=None, description="Redirect URI"),
client_id: str | None = Form(default=None, description="Client ID"),
client_secret: str | None = Form(default=None, description="Client secret"),
code_verifier: str | None = Form(default=None, description="PKCE code verifier"),
refresh_token: str | None = Form(default=None, description="Refresh token"),
scope: str | None = Form(default=None, description="Scope (for refresh)"),
db: AsyncSession = Depends(get_db),
_: None = Depends(require_provider_enabled),
) -> OAuthTokenResponse:
"""Token endpoint - exchange code for tokens or refresh."""
# Extract client credentials from Basic auth if not in body
if not client_id:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Basic "):
import base64
try:
decoded = base64.b64decode(auth_header[6:]).decode()
client_id, client_secret = decoded.split(":", 1)
except Exception as e:
# Log malformed Basic auth for security monitoring
logger.warning(
f"Malformed Basic auth header in token request: {type(e).__name__}"
)
# Fall back to form body
if not client_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid_client: client_id required",
headers={"WWW-Authenticate": "Basic"},
)
# Get device info
device_info = request.headers.get("User-Agent", "")[:500]
ip_address = get_remote_address(request)
try:
if grant_type == "authorization_code":
if not code:
raise provider_service.InvalidRequestError("code required")
if not redirect_uri:
raise provider_service.InvalidRequestError("redirect_uri required")
result = await provider_service.exchange_authorization_code(
db=db,
code=code,
client_id=client_id,
redirect_uri=redirect_uri,
code_verifier=code_verifier,
client_secret=client_secret,
device_info=device_info,
ip_address=ip_address,
)
elif grant_type == "refresh_token":
if not refresh_token:
raise provider_service.InvalidRequestError("refresh_token required")
result = await provider_service.refresh_tokens(
db=db,
refresh_token=refresh_token,
client_id=client_id,
client_secret=client_secret,
scope=scope,
device_info=device_info,
ip_address=ip_address,
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="unsupported_grant_type: Must be authorization_code or refresh_token",
)
return OAuthTokenResponse(**result)
except provider_service.InvalidClientError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"{e.error}: {e.error_description}",
headers={"WWW-Authenticate": "Basic"},
)
except provider_service.OAuthProviderError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{e.error}: {e.error_description}",
)
# ============================================================================
# Token Revocation (RFC 7009)
# ============================================================================
@router.post(
"/provider/revoke",
status_code=status.HTTP_200_OK,
summary="Token Revocation Endpoint",
description="""
OAuth 2.0 Token Revocation Endpoint (RFC 7009).
Revokes an access token or refresh token.
Always returns 200 OK (even if token is invalid) per spec.
""",
operation_id="oauth_provider_revoke",
tags=["OAuth Provider"],
)
@limiter.limit("30/minute")
async def revoke(
request: Request,
token: str = Form(..., description="Token to revoke"),
token_type_hint: str | None = Form(
default=None, description="Token type hint (access_token, refresh_token)"
),
client_id: str | None = Form(default=None, description="Client ID"),
client_secret: str | None = Form(default=None, description="Client secret"),
db: AsyncSession = Depends(get_db),
_: None = Depends(require_provider_enabled),
) -> dict[str, str]:
"""Revoke a token."""
# Extract client credentials from Basic auth if not in body
if not client_id:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Basic "):
import base64
try:
decoded = base64.b64decode(auth_header[6:]).decode()
client_id, client_secret = decoded.split(":", 1)
except Exception as e:
# Log malformed Basic auth for security monitoring
logger.warning(
f"Malformed Basic auth header in revoke request: {type(e).__name__}"
)
# Fall back to form body
try:
await provider_service.revoke_token(
db=db,
token=token,
token_type_hint=token_type_hint,
client_id=client_id,
client_secret=client_secret,
)
except provider_service.InvalidClientError:
# Per RFC 7009, we should return 200 OK even for errors
# But client authentication errors can return 401
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid_client",
headers={"WWW-Authenticate": "Basic"},
)
except Exception as e:
# Log but don't expose errors per RFC 7009
logger.warning(f"Token revocation error: {e}")
# Always return 200 OK per RFC 7009
return {"status": "ok"}
# ============================================================================
# Token Introspection (RFC 7662)
# ============================================================================
@router.post(
"/provider/introspect",
response_model=OAuthTokenIntrospectionResponse,
summary="Token Introspection Endpoint",
description="""
OAuth 2.0 Token Introspection Endpoint (RFC 7662).
Allows resource servers to query the authorization server
to determine the active state and metadata of a token.
""",
operation_id="oauth_provider_introspect",
tags=["OAuth Provider"],
)
@limiter.limit("120/minute")
async def introspect(
request: Request,
token: str = Form(..., description="Token to introspect"),
token_type_hint: str | None = Form(
default=None, description="Token type hint (access_token, refresh_token)"
),
client_id: str | None = Form(default=None, description="Client ID"),
client_secret: str | None = Form(default=None, description="Client secret"),
db: AsyncSession = Depends(get_db),
_: None = Depends(require_provider_enabled),
) -> OAuthTokenIntrospectionResponse:
"""Introspect a token."""
# Extract client credentials from Basic auth if not in body
if not client_id:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Basic "):
import base64
try:
decoded = base64.b64decode(auth_header[6:]).decode()
client_id, client_secret = decoded.split(":", 1)
except Exception as e:
# Log malformed Basic auth for security monitoring
logger.warning(
f"Malformed Basic auth header in introspect request: {type(e).__name__}"
)
# Fall back to form body
try:
result = await provider_service.introspect_token(
db=db,
token=token,
token_type_hint=token_type_hint,
client_id=client_id,
client_secret=client_secret,
)
return OAuthTokenIntrospectionResponse(**result)
except provider_service.InvalidClientError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid_client",
headers={"WWW-Authenticate": "Basic"},
)
except Exception as e:
logger.warning(f"Token introspection error: {e}")
return OAuthTokenIntrospectionResponse(active=False)
# ============================================================================
# Client Management (Admin)
# ============================================================================
@router.post(
"/provider/clients",
response_model=dict,
summary="Register OAuth Client",
description="""
Register a new OAuth client (admin only).
Creates an MCP client that can authenticate against this API.
Returns client_id and client_secret (for confidential clients).
**Important:** Store the client_secret securely - it won't be shown again!
""",
operation_id="register_oauth_client",
tags=["OAuth Provider Admin"],
)
async def register_client(
client_name: str = Form(..., description="Client application name"),
redirect_uris: str = Form(..., description="Comma-separated redirect URIs"),
client_type: str = Form(default="public", description="public or confidential"),
scopes: str = Form(
default="openid profile email",
description="Allowed scopes (space-separated)",
),
mcp_server_url: str | None = Form(default=None, description="MCP server URL"),
db: AsyncSession = Depends(get_db),
_: None = Depends(require_provider_enabled),
current_user: User = Depends(get_current_superuser),
) -> dict:
"""Register a new OAuth client."""
# Parse redirect URIs
uris = [uri.strip() for uri in redirect_uris.split(",") if uri.strip()]
if not uris:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one redirect_uri is required",
)
# Parse scopes
allowed_scopes = [s.strip() for s in scopes.split() if s.strip()]
client_data = OAuthClientCreate(
client_name=client_name,
client_description=None,
redirect_uris=uris,
allowed_scopes=allowed_scopes,
client_type=client_type,
)
client, secret = await oauth_client_crud.create_client(db, obj_in=client_data)
# Update MCP server URL if provided
if mcp_server_url:
client.mcp_server_url = mcp_server_url
await db.commit()
result = {
"client_id": client.client_id,
"client_name": client.client_name,
"client_type": client.client_type,
"redirect_uris": client.redirect_uris,
"allowed_scopes": client.allowed_scopes,
}
if secret:
result["client_secret"] = secret
result["warning"] = (
"Store the client_secret securely! It will not be shown again."
)
return result
@router.get(
"/provider/clients",
response_model=list[OAuthClientResponse],
summary="List OAuth Clients",
description="List all registered OAuth clients (admin only).",
operation_id="list_oauth_clients",
tags=["OAuth Provider Admin"],
)
async def list_clients(
db: AsyncSession = Depends(get_db),
_: None = Depends(require_provider_enabled),
current_user: User = Depends(get_current_superuser),
) -> list[OAuthClientResponse]:
"""List all OAuth clients."""
clients = await oauth_client_crud.get_all_clients(db)
return [OAuthClientResponse.model_validate(c) for c in clients]
@router.delete(
"/provider/clients/{client_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete OAuth Client",
description="Delete an OAuth client (admin only). Revokes all tokens.",
operation_id="delete_oauth_client",
tags=["OAuth Provider Admin"],
)
async def delete_client(
client_id: str,
db: AsyncSession = Depends(get_db),
_: None = Depends(require_provider_enabled),
current_user: User = Depends(get_current_superuser),
) -> None:
"""Delete an OAuth client."""
client = await provider_service.get_client(db, client_id)
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Client not found",
)
await oauth_client_crud.delete_client(db, client_id=client_id)
# ============================================================================
# User Consent Management
# ============================================================================
@router.get(
"/provider/consents",
summary="List My Consents",
description="List OAuth applications the current user has authorized.",
operation_id="list_my_oauth_consents",
tags=["OAuth Provider"],
)
async def list_my_consents(
db: AsyncSession = Depends(get_db),
_: None = Depends(require_provider_enabled),
current_user: User = Depends(get_current_active_user),
) -> list[dict]:
"""List applications the user has authorized."""
from sqlalchemy import select
from app.models.oauth_client import OAuthClient
from app.models.oauth_provider_token import OAuthConsent
result = await db.execute(
select(OAuthConsent, OAuthClient)
.join(OAuthClient, OAuthConsent.client_id == OAuthClient.client_id)
.where(OAuthConsent.user_id == current_user.id)
)
rows = result.all()
return [
{
"client_id": consent.client_id,
"client_name": client.client_name,
"client_description": client.client_description,
"granted_scopes": consent.granted_scopes.split()
if consent.granted_scopes
else [],
"granted_at": consent.created_at.isoformat(),
}
for consent, client in rows
]
@router.delete(
"/provider/consents/{client_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Revoke My Consent",
description="Revoke authorization for an OAuth application. Also revokes all tokens.",
operation_id="revoke_my_oauth_consent",
tags=["OAuth Provider"],
)
async def revoke_my_consent(
client_id: str,
db: AsyncSession = Depends(get_db),
_: None = Depends(require_provider_enabled),
current_user: User = Depends(get_current_active_user),
) -> None:
"""Revoke consent for an application."""
revoked = await provider_service.revoke_consent(db, current_user.id, client_id)
if not revoked:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No consent found for this client",
)