From b4d6269fac65af51c8f57271ae55a4b3edda00d3 Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Fri, 2 Jan 2026 11:27:27 -0300 Subject: [PATCH 1/5] feat: add authentication support --- env.sample | 5 + fastpubsub/api/app.py | 29 ++- fastpubsub/api/routers/clients.py | 88 +++++++ fastpubsub/api/routers/monitoring.py | 2 +- fastpubsub/api/routers/subscriptions.py | 87 +++++-- fastpubsub/api/routers/topics.py | 37 ++- fastpubsub/config.py | 6 + fastpubsub/database.py | 16 ++ fastpubsub/exceptions.py | 8 + fastpubsub/main.py | 24 +- fastpubsub/models.py | 84 ++++++- fastpubsub/services/__init__.py | 21 ++ fastpubsub/services/auth.py | 44 ++++ fastpubsub/services/clients.py | 144 +++++++++++ fastpubsub/services/helpers.py | 9 +- fastpubsub/services/topics.py | 2 +- .../002_3818df3592a5_new_migration.py | 43 ++++ pyproject.toml | 2 + tests/api/routers/test_clients.py | 122 ++++++++++ tests/api/routers/test_subscriptions.py | 86 ++++--- tests/api/routers/test_topics.py | 10 +- tests/conftest.py | 11 +- tests/helpers.py | 4 +- tests/services/test_auth.py | 95 ++++++++ tests/services/test_clients.py | 226 ++++++++++++++++++ tests/test_models.py | 52 ++++ uv.lock | 226 +++++++++++++++++- 27 files changed, 1377 insertions(+), 106 deletions(-) create mode 100644 fastpubsub/api/routers/clients.py create mode 100644 fastpubsub/services/auth.py create mode 100644 fastpubsub/services/clients.py create mode 100644 migrations/versions/002_3818df3592a5_new_migration.py create mode 100644 tests/api/routers/test_clients.py create mode 100644 tests/services/test_auth.py create mode 100644 tests/services/test_clients.py create mode 100644 tests/test_models.py diff --git a/env.sample b/env.sample index c7ccda4..2224007 100644 --- a/env.sample +++ b/env.sample @@ -18,3 +18,8 @@ fastpubsub_api_debug='true' fastpubsub_api_host='127.0.0.1' fastpubsub_api_port='8000' fastpubsub_api_num_workers='1' + +fastpubsub_auth_enabled='false' +fastpubsub_auth_secret_key='my-super-secret-key' +fastpubsub_auth_algorithm='HS256' +fastpubsub_auth_access_token_expire_minutes='30' diff --git a/fastpubsub/api/app.py b/fastpubsub/api/app.py index 2689c61..4770983 100644 --- a/fastpubsub/api/app.py +++ b/fastpubsub/api/app.py @@ -5,9 +5,15 @@ from fastpubsub import models from fastpubsub.api.helpers import _create_error_response from fastpubsub.api.middlewares import log_requests -from fastpubsub.api.routers import monitoring, subscriptions, topics +from fastpubsub.api.routers import clients, monitoring, subscriptions, topics from fastpubsub.config import settings -from fastpubsub.exceptions import AlreadyExistsError, NotFoundError, ServiceUnavailable +from fastpubsub.exceptions import ( + AlreadyExistsError, + InvalidClient, + InvalidClientToken, + NotFoundError, + ServiceUnavailable, +) tags_metadata = [ { @@ -22,6 +28,10 @@ "name": "monitoring", "description": "Operations with monitoring.", }, + { + "name": "clients", + "description": "Operations with clients.", + }, ] @@ -39,20 +49,29 @@ def create_app() -> FastAPI: # Add exception handlers @app.exception_handler(AlreadyExistsError) def already_exists_exception_handler(request: Request, exc: AlreadyExistsError): - return _create_error_response(models.AlreadyExists, status.HTTP_409_CONFLICT, exc) + return _create_error_response(models.GenericError, status.HTTP_409_CONFLICT, exc) @app.exception_handler(NotFoundError) def not_found_exception_handler(request: Request, exc: NotFoundError): - return _create_error_response(models.NotFound, status.HTTP_404_NOT_FOUND, exc) + return _create_error_response(models.GenericError, status.HTTP_404_NOT_FOUND, exc) @app.exception_handler(ServiceUnavailable) def service_unavailable_exception_handler(request: Request, exc: ServiceUnavailable): - return _create_error_response(models.ServiceUnavailable, status.HTTP_503_SERVICE_UNAVAILABLE, exc) + return _create_error_response(models.GenericError, status.HTTP_503_SERVICE_UNAVAILABLE, exc) + + @app.exception_handler(InvalidClient) + def invalid_client_exception_handler(request: Request, exc: InvalidClient): + return _create_error_response(models.GenericError, status.HTTP_401_UNAUTHORIZED, exc) + + @app.exception_handler(InvalidClientToken) + def invalid_client_token_exception_handler(request: Request, exc: InvalidClientToken): + return _create_error_response(models.GenericError, status.HTTP_403_FORBIDDEN, exc) # Add routers app.include_router(topics.router) app.include_router(subscriptions.router) app.include_router(monitoring.router) + app.include_router(clients.router) # Add Prometheus instrumentation Instrumentator().instrument(app).expose(app) diff --git a/fastpubsub/api/routers/clients.py b/fastpubsub/api/routers/clients.py new file mode 100644 index 0000000..21303b0 --- /dev/null +++ b/fastpubsub/api/routers/clients.py @@ -0,0 +1,88 @@ +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, Query, status + +from fastpubsub import models, services + +router = APIRouter(tags=["clients"]) + + +@router.post( + "/clients", + response_model=models.CreateClientResult, + status_code=status.HTTP_201_CREATED, + summary="Create a new client", +) +async def create_client( + data: models.CreateClient, + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("clients", "create"))], +): + return await services.create_client(data) + + +@router.get( + "/clients/{id}", + response_model=models.Client, + status_code=status.HTTP_200_OK, + responses={404: {"model": models.GenericError}}, + summary="Get a client", +) +async def get_client( + id: uuid.UUID, + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("clients", "read"))], +): + return await services.get_client(id) + + +@router.put( + "/clients/{id}", + response_model=models.Client, + status_code=status.HTTP_200_OK, + responses={404: {"model": models.GenericError}}, + summary="Update a client", +) +async def update_client( + id: uuid.UUID, + data: models.UpdateClient, + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("clients", "update"))], +): + return await services.update_client(id, data) + + +@router.get( + "/clients", + response_model=models.ListClientAPI, + status_code=status.HTTP_200_OK, + summary="List clients", +) +async def list_client( + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("clients", "read"))], + offset: int = Query(default=0, ge=0), + limit: int = Query(default=10, ge=1, le=100), +): + clients = await services.list_client(offset, limit) + return models.ListClientAPI(data=clients) + + +@router.delete( + "/clients/{id}", + status_code=status.HTTP_204_NO_CONTENT, + responses={404: {"model": models.GenericError}}, + summary="Delete a client", +) +async def delete_client( + id: uuid.UUID, + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("clients", "delete"))], +): + await services.delete_client(id) + + +@router.post( + "/oauth/token", + response_model=models.ClientToken, + status_code=status.HTTP_201_CREATED, + summary="Issue a new client token", +) +async def issue_client_token(data: models.IssueClientToken): + return await services.issue_jwt_client_token(client_id=data.client_id, client_secret=data.client_secret) diff --git a/fastpubsub/api/routers/monitoring.py b/fastpubsub/api/routers/monitoring.py index 5faeebc..0828cf2 100644 --- a/fastpubsub/api/routers/monitoring.py +++ b/fastpubsub/api/routers/monitoring.py @@ -20,7 +20,7 @@ async def liveness_probe(): "/readiness", response_model=models.HealthCheck, status_code=status.HTTP_200_OK, - responses={503: {"model": models.ServiceUnavailable}}, + responses={503: {"model": models.GenericError}}, summary="Readiness probe", ) async def readiness_probe(): diff --git a/fastpubsub/api/routers/subscriptions.py b/fastpubsub/api/routers/subscriptions.py index 4a38775..708c43d 100644 --- a/fastpubsub/api/routers/subscriptions.py +++ b/fastpubsub/api/routers/subscriptions.py @@ -1,6 +1,7 @@ +from typing import Annotated from uuid import UUID -from fastapi import APIRouter, Query, status +from fastapi import APIRouter, Depends, Query, status from fastpubsub import models, services @@ -11,10 +12,13 @@ "", response_model=models.Subscription, status_code=status.HTTP_201_CREATED, - responses={409: {"model": models.AlreadyExists}}, + responses={409: {"model": models.GenericError}}, summary="Create a subscription", ) -async def create_subscription(data: models.CreateSubscription): +async def create_subscription( + data: models.CreateSubscription, + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("subscriptions", "create"))], +): return await services.create_subscription(data) @@ -22,10 +26,13 @@ async def create_subscription(data: models.CreateSubscription): "/{id}", response_model=models.Subscription, status_code=status.HTTP_200_OK, - responses={404: {"model": models.NotFound}}, + responses={404: {"model": models.GenericError}}, summary="Get a subscription", ) -async def get_subscription(id: str): +async def get_subscription( + id: str, + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("subscriptions", "read"))], +): return await services.get_subscription(id) @@ -36,7 +43,9 @@ async def get_subscription(id: str): summary="List subscriptions", ) async def list_subscription( - offset: int = Query(default=0, ge=0), limit: int = Query(default=10, ge=1, le=100) + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("subscriptions", "read"))], + offset: int = Query(default=0, ge=0), + limit: int = Query(default=10, ge=1, le=100), ): subscriptions = await services.list_subscription(offset, limit) return models.ListSubscriptionAPI(data=subscriptions) @@ -45,10 +54,13 @@ async def list_subscription( @router.delete( "/{id}", status_code=status.HTTP_204_NO_CONTENT, - responses={404: {"model": models.NotFound}}, + responses={404: {"model": models.GenericError}}, summary="Delete subscription", ) -async def delete_subscription(id: str): +async def delete_subscription( + id: str, + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("subscriptions", "delete"))], +): await services.delete_subscription(id) @@ -56,11 +68,16 @@ async def delete_subscription(id: str): "/{id}/messages", response_model=models.ListMessageAPI, status_code=status.HTTP_200_OK, - responses={404: {"model": models.NotFound}}, + responses={404: {"model": models.GenericError}}, summary="Get messages", ) -async def consume_messages(id: str, consumer_id: str, batch_size: int = Query(default=10, ge=1, le=100)): - subscription = await get_subscription(id) +async def consume_messages( + id: str, + consumer_id: str, + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("subscriptions", "consume"))], + batch_size: int = Query(default=10, ge=1, le=100), +): + subscription = await get_subscription(id, token) messages = await services.consume_messages( subscription_id=subscription.id, consumer_id=consumer_id, batch_size=batch_size ) @@ -70,22 +87,30 @@ async def consume_messages(id: str, consumer_id: str, batch_size: int = Query(de @router.post( "/{id}/acks", status_code=status.HTTP_204_NO_CONTENT, - responses={404: {"model": models.NotFound}}, + responses={404: {"model": models.GenericError}}, summary="Ack messages", ) -async def ack_messages(id: str, data: list[UUID]): - subscription = await get_subscription(id) +async def ack_messages( + id: str, + data: list[UUID], + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("subscriptions", "consume"))], +): + subscription = await get_subscription(id, token) await services.ack_messages(subscription_id=subscription.id, message_ids=data) @router.post( "/{id}/nacks", status_code=status.HTTP_204_NO_CONTENT, - responses={404: {"model": models.NotFound}}, + responses={404: {"model": models.GenericError}}, summary="Nack messages", ) -async def nack_messages(id: str, data: list[UUID]): - subscription = await get_subscription(id) +async def nack_messages( + id: str, + data: list[UUID], + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("subscriptions", "consume"))], +): + subscription = await get_subscription(id, token) await services.nack_messages(subscription_id=subscription.id, message_ids=data) @@ -93,13 +118,16 @@ async def nack_messages(id: str, data: list[UUID]): "/{id}/dlq", response_model=models.ListMessageAPI, status_code=status.HTTP_200_OK, - responses={404: {"model": models.NotFound}}, + responses={404: {"model": models.GenericError}}, summary="List dlq messages", ) async def list_dlq( - id: str, offset: int = Query(default=0, ge=0), limit: int = Query(default=10, ge=1, le=100) + id: str, + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("subscriptions", "consume"))], + offset: int = Query(default=0, ge=0), + limit: int = Query(default=10, ge=1, le=100), ): - subscription = await get_subscription(id) + subscription = await get_subscription(id, token) messages = await services.list_dlq_messages(subscription_id=subscription.id, offset=offset, limit=limit) return models.ListMessageAPI(data=messages) @@ -107,11 +135,15 @@ async def list_dlq( @router.post( "/{id}/dlq/reprocess", status_code=status.HTTP_204_NO_CONTENT, - responses={404: {"model": models.NotFound}}, + responses={404: {"model": models.GenericError}}, summary="Reprocess dlq messages", ) -async def reprocess_dlq(id: str, data: list[UUID]): - subscription = await get_subscription(id) +async def reprocess_dlq( + id: str, + data: list[UUID], + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("subscriptions", "consume"))], +): + subscription = await get_subscription(id, token) await services.reprocess_dlq_messages(subscription_id=subscription.id, message_ids=data) @@ -119,9 +151,12 @@ async def reprocess_dlq(id: str, data: list[UUID]): "/{id}/metrics", response_model=models.SubscriptionMetrics, status_code=status.HTTP_200_OK, - responses={404: {"model": models.NotFound}}, + responses={404: {"model": models.GenericError}}, summary="Get subscription metrics", ) -async def subscription_metrics(id: str): - subscription = await get_subscription(id) +async def subscription_metrics( + id: str, + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("subscriptions", "read"))], +): + subscription = await get_subscription(id, token) return await services.subscription_metrics(subscription_id=subscription.id) diff --git a/fastpubsub/api/routers/topics.py b/fastpubsub/api/routers/topics.py index f787a08..39262a4 100644 --- a/fastpubsub/api/routers/topics.py +++ b/fastpubsub/api/routers/topics.py @@ -1,6 +1,6 @@ -from typing import Any +from typing import Annotated, Any -from fastapi import APIRouter, Query, status +from fastapi import APIRouter, Depends, Query, status from fastpubsub import models, services @@ -11,10 +11,13 @@ "", response_model=models.Topic, status_code=status.HTTP_201_CREATED, - responses={409: {"model": models.AlreadyExists}}, + responses={409: {"model": models.GenericError}}, summary="Create a new topic", ) -async def create_topic(data: models.CreateTopic): +async def create_topic( + data: models.CreateTopic, + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("topics", "create"))], +): return await services.create_topic(data) @@ -22,10 +25,12 @@ async def create_topic(data: models.CreateTopic): "/{id}", response_model=models.Topic, status_code=status.HTTP_200_OK, - responses={404: {"model": models.NotFound}}, + responses={404: {"model": models.GenericError}}, summary="Get a topic", ) -async def get_topic(id: str): +async def get_topic( + id: str, token: Annotated[models.DecodedClientToken, Depends(services.require_scope("topics", "read"))] +): return await services.get_topic(id) @@ -35,7 +40,11 @@ async def get_topic(id: str): status_code=status.HTTP_200_OK, summary="List topics", ) -async def list_topic(offset: int = Query(default=0, ge=0), limit: int = Query(default=10, ge=1, le=100)): +async def list_topic( + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("topics", "read"))], + offset: int = Query(default=0, ge=0), + limit: int = Query(default=10, ge=1, le=100), +): topics = await services.list_topic(offset, limit) return models.ListTopicAPI(data=topics) @@ -43,19 +52,25 @@ async def list_topic(offset: int = Query(default=0, ge=0), limit: int = Query(de @router.delete( "/{id}", status_code=status.HTTP_204_NO_CONTENT, - responses={404: {"model": models.NotFound}}, + responses={404: {"model": models.GenericError}}, summary="Delete a topic", ) -async def delete_topic(id: str): +async def delete_topic( + id: str, token: Annotated[models.DecodedClientToken, Depends(services.require_scope("topics", "delete"))] +): await services.delete_topic(id) @router.post( "/{id}/messages", status_code=status.HTTP_204_NO_CONTENT, - responses={404: {"model": models.NotFound}}, + responses={404: {"model": models.GenericError}}, summary="Post messages", ) -async def publish_messages(id: str, data: list[dict[str, Any]]): +async def publish_messages( + id: str, + data: list[dict[str, Any]], + token: Annotated[models.DecodedClientToken, Depends(services.require_scope("topics", "publish"))], +): topic = await services.get_topic(id) return await services.publish_messages(topic_id=topic.id, messages=data) diff --git a/fastpubsub/config.py b/fastpubsub/config.py index f40b229..5fccfa1 100644 --- a/fastpubsub/config.py +++ b/fastpubsub/config.py @@ -42,6 +42,12 @@ class Settings(BaseSettings): cleanup_acked_messages_older_than_seconds: int = Field(default=3600, ge=1) cleanup_stuck_messages_lock_timeout_seconds: int = Field(default=60, ge=1) + # auth + auth_enabled: bool = False + auth_secret_key: str | None = None + auth_algorithm: str = "HS256" + auth_access_token_expire_minutes: int = Field(default=30, ge=1) + # load .env model_config = SettingsConfigDict(env_file=".env", env_prefix="fastpubsub_") diff --git a/fastpubsub/database.py b/fastpubsub/database.py index 0a4dcdb..784bb20 100644 --- a/fastpubsub/database.py +++ b/fastpubsub/database.py @@ -70,6 +70,22 @@ def __repr__(self): return f"SubscriptionMessage(id={self.id}, subscription_id={self.subscription_id})" +class Client(Base): + id = sa.Column(postgresql.UUID, primary_key=True) + name = sa.Column(sa.Text, nullable=False) + scopes = sa.Column(sa.Text, nullable=False) + is_active = sa.Column(sa.Boolean, nullable=False) + secret_hash = sa.Column(sa.Text, nullable=False) + token_version = sa.Column(sa.Integer, nullable=False) + created_at = sa.Column(sa.DateTime(timezone=True), nullable=False) + updated_at = sa.Column(sa.DateTime(timezone=True), nullable=False) + + __tablename__ = "clients" + + def __repr__(self): + return f"Client(id={self.id}, name={self.name})" + + async def run_migrations(command_type: str = "upgrade", revision: str = "head") -> None: parent_path = Path(__file__).parents[1] script_location = parent_path.joinpath(Path("migrations")) diff --git a/fastpubsub/exceptions.py b/fastpubsub/exceptions.py index 60d2ffd..3ef4d37 100644 --- a/fastpubsub/exceptions.py +++ b/fastpubsub/exceptions.py @@ -8,3 +8,11 @@ class AlreadyExistsError(Exception): class ServiceUnavailable(Exception): pass + + +class InvalidClient(Exception): + pass + + +class InvalidClientToken(Exception): + pass diff --git a/fastpubsub/main.py b/fastpubsub/main.py index 7484d36..248605e 100644 --- a/fastpubsub/main.py +++ b/fastpubsub/main.py @@ -1,4 +1,5 @@ import asyncio +from typing import Annotated import typer @@ -6,7 +7,9 @@ from fastpubsub.config import settings from fastpubsub.database import run_migrations from fastpubsub.logger import get_logger -from fastpubsub.services import cleanup_acked_messages, cleanup_stuck_messages +from fastpubsub.models import CreateClient +from fastpubsub.services import cleanup_acked_messages, cleanup_stuck_messages, create_client +from fastpubsub.services.clients import generate_secret logger = get_logger(__name__) cli = typer.Typer() @@ -56,5 +59,24 @@ def run_cleanup_stuck_messages() -> None: ) +@cli.command("generate_secret_key") +def run_generate_secret_key() -> None: + secret = generate_secret() + typer.echo(f"new_secret={secret}") + + +@cli.command("create_client") +def run_create_client( + name: Annotated[str, typer.Argument(help="The client name.")], + scopes: Annotated[str, typer.Argument(help="The client scopes.")] = "*", + is_active: Annotated[bool, typer.Argument(help="The flag to enable or disable client.")] = True, +) -> None: + client_result = asyncio.run( + create_client(data=CreateClient(name=name, scopes=scopes, is_active=is_active)) + ) + typer.echo(f"client_id={client_result.id}") + typer.echo(f"client_secret={client_result.secret}") + + if __name__ == "__main__": cli() diff --git a/fastpubsub/models.py b/fastpubsub/models.py index acbce80..f08bdf1 100644 --- a/fastpubsub/models.py +++ b/fastpubsub/models.py @@ -1,7 +1,8 @@ import uuid from datetime import datetime +from typing import Annotated -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, StringConstraints from fastpubsub.config import settings from fastpubsub.sanitizer import sanitize_filter @@ -9,15 +10,7 @@ regex_for_id = "^[a-zA-Z0-9-._]+$" -class NotFound(BaseModel): - detail: str - - -class AlreadyExists(BaseModel): - detail: str - - -class ServiceUnavailable(BaseModel): +class GenericError(BaseModel): detail: str @@ -85,3 +78,74 @@ class SubscriptionMetrics(BaseModel): class HealthCheck(BaseModel): status: str + + +class CreateClient(BaseModel): + name: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + scopes: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] + is_active: bool = True + + @field_validator("scopes") + def validate_scopes(cls, v: str): + valid_scopes = ( + "*", + "topics:create", + "topics:read", + "topics:delete", + "topics:publish", + "subscriptions:create", + "subscriptions:read", + "subscriptions:delete", + "subscriptions:consume", + "clients:create", + "clients:update", + "clients:read", + "clients:delete", + ) + for scope in v.split(): + base_scope = scope + if len(scope.split(":")) == 3: + base_scope = scope.rsplit(":", 1)[0] + if base_scope not in valid_scopes: + raise ValueError(f"Invalid scope {scope}") + return v + + +class CreateClientResult(BaseModel): + id: uuid.UUID + secret: str + + +class Client(BaseModel): + id: uuid.UUID + name: str + scopes: str + is_active: bool + token_version: int + created_at: datetime + updated_at: datetime + + +class UpdateClient(CreateClient): + pass + + +class ClientToken(BaseModel): + access_token: str + token_type: str = "Bearer" + expires_in: int + scope: str + + +class DecodedClientToken(BaseModel): + client_id: uuid.UUID + scopes: set[str] + + +class ListClientAPI(BaseModel): + data: list[Client] + + +class IssueClientToken(BaseModel): + client_id: uuid.UUID + client_secret: str diff --git a/fastpubsub/services/__init__.py b/fastpubsub/services/__init__.py index 6e5d082..b9bd49d 100644 --- a/fastpubsub/services/__init__.py +++ b/fastpubsub/services/__init__.py @@ -1,3 +1,13 @@ +from fastpubsub.services.auth import has_scope, require_scope +from fastpubsub.services.clients import ( + create_client, + decode_jwt_client_token, + delete_client, + get_client, + issue_jwt_client_token, + list_client, + update_client, +) from fastpubsub.services.messages import ( ack_messages, cleanup_acked_messages, @@ -40,4 +50,15 @@ "cleanup_acked_messages", "subscription_metrics", "database_ping", + # Clients + "create_client", + "get_client", + "list_client", + "update_client", + "delete_client", + "issue_jwt_client_token", + "decode_jwt_client_token", + # Auth + "has_scope", + "require_scope", ] diff --git a/fastpubsub/services/auth.py b/fastpubsub/services/auth.py new file mode 100644 index 0000000..b97ff21 --- /dev/null +++ b/fastpubsub/services/auth.py @@ -0,0 +1,44 @@ +from typing import Annotated + +from fastapi import Depends, Request +from fastapi.security import OAuth2PasswordBearer + +from fastpubsub import services +from fastpubsub.config import settings +from fastpubsub.exceptions import InvalidClientToken +from fastpubsub.models import DecodedClientToken + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/oauth/token", auto_error=False) + + +def has_scope(token_scopes: set[str], resource: str, action: str, resource_id: str | None = None) -> bool: + if "*" in token_scopes: + return True + + base = f"{resource}:{action}" + + if base in token_scopes: + return True + + if resource_id and f"{base}:{resource_id}" in token_scopes: + return True + + return False + + +async def get_current_token(token: str | None = Depends(oauth2_scheme)) -> DecodedClientToken: + if token is None: + token = "" + return await services.decode_jwt_client_token(token, auth_enabled=settings.auth_enabled) + + +def require_scope(resource: str, action: str): + async def dependency(request: Request, token: Annotated[DecodedClientToken, Depends(get_current_token)]): + resource_id = str(request.path_params.get("id")) + + if not has_scope(token.scopes, resource, action, resource_id): + raise InvalidClientToken("Insufficient scope") from None + + return token + + return dependency diff --git a/fastpubsub/services/clients.py b/fastpubsub/services/clients.py new file mode 100644 index 0000000..7affeff --- /dev/null +++ b/fastpubsub/services/clients.py @@ -0,0 +1,144 @@ +import datetime +import secrets +import uuid + +from jose import jwt +from jose.exceptions import JWTError +from pwdlib import PasswordHash +from sqlalchemy import select + +from fastpubsub.config import settings +from fastpubsub.database import Client as DBClient +from fastpubsub.database import SessionLocal +from fastpubsub.exceptions import InvalidClient +from fastpubsub.models import ( + Client, + ClientToken, + CreateClient, + CreateClientResult, + DecodedClientToken, + UpdateClient, +) +from fastpubsub.services.helpers import _delete_entity, _get_entity, utc_now + +password_hash = PasswordHash.recommended() + + +def generate_secret() -> str: + return secrets.token_hex(16) + + +async def create_client(data: CreateClient) -> CreateClientResult: + async with SessionLocal() as session: + now = utc_now() + secret = generate_secret() + secret_hash = password_hash.hash(secret) + db_client = DBClient( + id=uuid.uuid7(), + name=data.name, + scopes=data.scopes, + is_active=data.is_active, + secret_hash=secret_hash, + token_version=1, + created_at=now, + updated_at=now, + ) + session.add(db_client) + + await session.commit() + + return CreateClientResult(id=db_client.id, secret=secret) + + +async def get_client(client_id: uuid.UUID) -> Client: + async with SessionLocal() as session: + db_client = await _get_entity(session, DBClient, client_id, "Client not found") + + return Client(**db_client.to_dict()) + + +async def list_client(offset: int, limit: int) -> list[Client]: + async with SessionLocal() as session: + stmt = select(DBClient).order_by(DBClient.id.asc()).offset(offset).limit(limit) + result = await session.execute(stmt) + db_clients = result.scalars().all() + + return [Client(**db_client.to_dict()) for db_client in db_clients] + + +async def update_client(client_id: uuid.UUID, data: UpdateClient) -> Client: + async with SessionLocal() as session: + db_client = await _get_entity(session, DBClient, client_id, "Client not found") + db_client.name = data.name + db_client.scopes = data.scopes + db_client.is_active = data.is_active + db_client.token_version += 1 + db_client.updated_at = utc_now() + + await session.commit() + + return Client(**db_client.to_dict()) + + +async def delete_client(client_id: uuid.UUID) -> None: + async with SessionLocal() as session: + await _delete_entity(session, DBClient, client_id, "Client not found") + + +async def issue_jwt_client_token(client_id: uuid.UUID, client_secret: str) -> ClientToken: + async with SessionLocal() as session: + db_client = await _get_entity(session, DBClient, client_id, "Client not found", raise_exception=False) + if not db_client: + raise InvalidClient("Client not found") from None + if not db_client.is_active: + raise InvalidClient("Client disabled") from None + if password_hash.verify(client_secret, db_client.secret_hash) is False: + raise InvalidClient("Client secret is invalid") from None + + now = utc_now() + expires_in = now + datetime.timedelta(minutes=settings.auth_access_token_expire_minutes) + payload = { + "sub": str(client_id), + "exp": expires_in, + "iat": now, + "scope": db_client.scopes, + "ver": db_client.token_version, + } + access_token = jwt.encode(payload, key=settings.auth_secret_key, algorithm=settings.auth_algorithm) + + return ClientToken( + access_token=access_token, + expires_in=int((expires_in - now).total_seconds()), + scope=db_client.scopes, + ) + + +async def decode_jwt_client_token(access_token: str, auth_enabled: bool = True) -> DecodedClientToken: + if not auth_enabled: + return DecodedClientToken(client_id=uuid.uuid7(), scopes={"*"}) + + try: + payload = jwt.decode( + access_token, + key=settings.auth_secret_key, + algorithms=[settings.auth_algorithm], + ) + except JWTError: + raise InvalidClient("Invalid jwt token") from None + + client_id = payload["sub"] + payload["scope"] + token_version = payload["ver"] + + async with SessionLocal() as session: + db_client = await _get_entity(session, DBClient, client_id, "Client not found", raise_exception=False) + if not db_client: + raise InvalidClient("Client not found") from None + if not db_client.is_active: + raise InvalidClient("Client disabled") from None + if token_version != db_client.token_version: + raise InvalidClient("Token revoked") from None + + return DecodedClientToken( + client_id=uuid.UUID(client_id), scopes={scope for scope in db_client.scopes.split()} + ) diff --git a/fastpubsub/services/helpers.py b/fastpubsub/services/helpers.py index 5e2d042..f5f45cf 100644 --- a/fastpubsub/services/helpers.py +++ b/fastpubsub/services/helpers.py @@ -1,4 +1,5 @@ import datetime +import uuid from sqlalchemy import select, text @@ -10,17 +11,19 @@ def utc_now(): return datetime.datetime.now(datetime.UTC) -async def _get_entity(session, model, entity_id: str, error_message: str): +async def _get_entity( + session, model, entity_id: str | uuid.UUID, error_message: str, raise_exception: bool = True +): """Generic helper to get an entity by ID or raise NotFoundError.""" stmt = select(model).filter_by(id=entity_id) result = await session.execute(stmt) entity = result.scalar_one_or_none() - if entity is None: + if entity is None and raise_exception: raise NotFoundError(error_message) from None return entity -async def _delete_entity(session, model, entity_id: str, error_message: str) -> None: +async def _delete_entity(session, model, entity_id: str | uuid.UUID, error_message: str) -> None: """Generic helper to delete an entity by ID or raise NotFoundError.""" entity = await _get_entity(session, model, entity_id, error_message) await session.delete(entity) diff --git a/fastpubsub/services/topics.py b/fastpubsub/services/topics.py index d8b7a6b..a645fd5 100644 --- a/fastpubsub/services/topics.py +++ b/fastpubsub/services/topics.py @@ -37,6 +37,6 @@ async def list_topic(offset: int, limit: int) -> list[Topic]: return [Topic(**db_topic.to_dict()) for db_topic in db_topics] -async def delete_topic(topic_id) -> None: +async def delete_topic(topic_id: str) -> None: async with SessionLocal() as session: await _delete_entity(session, DBTopic, topic_id, "Topic not found") diff --git a/migrations/versions/002_3818df3592a5_new_migration.py b/migrations/versions/002_3818df3592a5_new_migration.py new file mode 100644 index 0000000..a29bb43 --- /dev/null +++ b/migrations/versions/002_3818df3592a5_new_migration.py @@ -0,0 +1,43 @@ +"""New migration + +Revision ID: 3818df3592a5 +Revises: 3aacd9174d1f +Create Date: 2025-12-31 14:16:22.091376 + +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "3818df3592a5" +down_revision: str | Sequence[str] | None = "3aacd9174d1f" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + """ + ---------- Tables ---------- + CREATE TABLE clients ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + scopes TEXT NOT NULL, + is_active BOOLEAN NOT NULL, + secret_hash TEXT NOT NULL, + token_version INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + ); + """ + ) + + +def downgrade() -> None: + op.execute( + """ + DROP TABLE IF EXISTS clients; + """ + ) diff --git a/pyproject.toml b/pyproject.toml index ecffcf2..95fbaa7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ dependencies = [ "orjson>=3.11.5,<4", "prometheus-fastapi-instrumentator>=7.1.0,<8", "psycopg[binary]>=3.3.2,<4", + "pwdlib[argon2]>=0.3.0,<1", + "python-jose[cryptography]>=3.5.0,<4", "python-json-logger>=4.0.0,<5", "sqlalchemy[asyncio]>=2.0.45,<3", "typer>=0.21.0,<1", diff --git a/tests/api/routers/test_clients.py b/tests/api/routers/test_clients.py new file mode 100644 index 0000000..6de2d15 --- /dev/null +++ b/tests/api/routers/test_clients.py @@ -0,0 +1,122 @@ +import uuid + +from fastapi import status + +from fastpubsub.models import CreateClient +from fastpubsub.services import create_client +from tests.helpers import sync_call_function + + +def test_create_client(session, client): + data = {"name": "my-client", "scopes": "*", "is_active": True} + + response = client.post("/clients", json=data) + response_data = response.json() + + assert response.status_code == status.HTTP_201_CREATED + assert len(response_data["id"]) == 36 + assert len(response_data["secret"]) == 32 + + +def test_get_client(session, client): + client_result = sync_call_function( + create_client, data=CreateClient(name="my-client", scopes="*", is_active=True) + ) + + response = client.get(f"/clients/{client_result.id}") + response_data = response.json() + + assert response.status_code == status.HTTP_200_OK + assert response_data["id"] == str(client_result.id) + assert response_data["name"] == "my-client" + assert response_data["scopes"] == "*" + assert response_data["is_active"] is True + assert response_data["token_version"] == 1 + assert response_data["created_at"] + assert response_data["updated_at"] + + response = client.get(f"/clients/{uuid.uuid7()}") + response_data = response.json() + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response_data == {"detail": "Client not found"} + + +def test_update_client(session, client): + client_result = sync_call_function( + create_client, data=CreateClient(name="my-client", scopes="*", is_active=True) + ) + data = {"name": "my-updated-client", "scopes": "clients:update", "is_active": False} + + response = client.put(f"/clients/{client_result.id}", json=data) + response_data = response.json() + + assert response.status_code == status.HTTP_200_OK + assert response_data["id"] == str(client_result.id) + assert response_data["name"] == "my-updated-client" + assert response_data["scopes"] == "clients:update" + assert response_data["is_active"] is False + assert response_data["token_version"] == 2 + assert response_data["created_at"] + assert response_data["updated_at"] + + response = client.put(f"/clients/{uuid.uuid7()}", json=data) + response_data = response.json() + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response_data == {"detail": "Client not found"} + + +def test_list_client(session, client): + client_result_1 = sync_call_function(create_client, data=CreateClient(name="my-client-1", scopes="*")) + client_result_2 = sync_call_function(create_client, data=CreateClient(name="my-client-2", scopes="*")) + + response = client.get("/clients") + response_data = response.json() + + assert response.status_code == status.HTTP_200_OK + assert len(response_data["data"]) == 2 + assert response_data["data"][0]["id"] == str(client_result_1.id) + assert response_data["data"][1]["id"] == str(client_result_2.id) + + response = client.get("/clients", params={"offset": 0, "limit": 1}) + response_data = response.json() + + assert response.status_code == status.HTTP_200_OK + assert len(response_data["data"]) == 1 + assert response_data["data"][0]["id"] == str(client_result_1.id) + + response = client.get("/clients", params={"offset": 1, "limit": 1}) + response_data = response.json() + + assert response.status_code == status.HTTP_200_OK + assert len(response_data["data"]) == 1 + assert response_data["data"][0]["id"] == str(client_result_2.id) + + +def test_delete_client(session, client): + client_result = sync_call_function(create_client, data=CreateClient(name="my-client", scopes="*")) + + response = client.delete(f"/clients/{client_result.id}") + + assert response.status_code == status.HTTP_204_NO_CONTENT + + response = client.delete(f"/clients/{client_result.id}") + response_data = response.json() + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response_data == {"detail": "Client not found"} + + +def test_issue_client_token(session, client): + client_result = sync_call_function(create_client, data=CreateClient(name="my-client", scopes="*")) + data = {"client_id": str(client_result.id), "client_secret": client_result.secret} + + response = client.post("/oauth/token", json=data) + response_data = response.json() + + assert response.status_code == status.HTTP_201_CREATED + assert response_data["access_token"] + assert response_data["token_type"] == "Bearer" + assert response_data["expires_in"] == 1800 + assert response_data["scope"] == "*" diff --git a/tests/api/routers/test_subscriptions.py b/tests/api/routers/test_subscriptions.py index e356d73..85cdbb3 100644 --- a/tests/api/routers/test_subscriptions.py +++ b/tests/api/routers/test_subscriptions.py @@ -8,11 +8,11 @@ nack_messages, publish_messages, ) -from tests.helpers import sync_call_service +from tests.helpers import sync_call_function def test_create_subscription(session, client): - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) data = {"id": "my-subscription", "topic_id": "my-topic"} response = client.post("/subscriptions", json=data) @@ -30,8 +30,10 @@ def test_create_subscription(session, client): def test_get_subscription(session, client): - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) - sync_call_service(create_subscription, data=CreateSubscription(id="my-subscription", topic_id="my-topic")) + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function( + create_subscription, data=CreateSubscription(id="my-subscription", topic_id="my-topic") + ) response = client.get("/subscriptions/my-subscription") response_data = response.json() @@ -49,10 +51,10 @@ def test_get_subscription(session, client): def test_list_subscription(session, client): - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) data = [{"id": "my-subscription-1"}, {"id": "my-subscription-2"}] for subscription_data in data: - sync_call_service( + sync_call_function( create_subscription, data=CreateSubscription(id=subscription_data["id"], topic_id="my-topic") ) @@ -80,8 +82,10 @@ def test_list_subscription(session, client): def test_delete_subscription(session, client): - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) - sync_call_service(create_subscription, data=CreateSubscription(id="my-subscription", topic_id="my-topic")) + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function( + create_subscription, data=CreateSubscription(id="my-subscription", topic_id="my-topic") + ) response = client.delete("/subscriptions/my-subscription") @@ -95,9 +99,11 @@ def test_delete_subscription(session, client): def test_consume_messages(session, client): - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) - sync_call_service(create_subscription, data=CreateSubscription(id="my-subscription", topic_id="my-topic")) - sync_call_service(publish_messages, topic_id="my-topic", messages=[{"id": 1}]) + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function( + create_subscription, data=CreateSubscription(id="my-subscription", topic_id="my-topic") + ) + sync_call_function(publish_messages, topic_id="my-topic", messages=[{"id": 1}]) response = client.get( "/subscriptions/my-subscription/messages", params={"consumer_id": "id", "batch_size": 1} @@ -118,10 +124,12 @@ def test_consume_messages(session, client): def test_ack_messages(session, client): - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) - sync_call_service(create_subscription, data=CreateSubscription(id="my-subscription", topic_id="my-topic")) - sync_call_service(publish_messages, topic_id="my-topic", messages=[{"id": 1}]) - messages = sync_call_service( + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function( + create_subscription, data=CreateSubscription(id="my-subscription", topic_id="my-topic") + ) + sync_call_function(publish_messages, topic_id="my-topic", messages=[{"id": 1}]) + messages = sync_call_function( consume_messages, subscription_id="my-subscription", consumer_id="id", batch_size=1 ) data = [str(message.id) for message in messages] @@ -138,10 +146,12 @@ def test_ack_messages(session, client): def test_nack_messages(session, client): - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) - sync_call_service(create_subscription, data=CreateSubscription(id="my-subscription", topic_id="my-topic")) - sync_call_service(publish_messages, topic_id="my-topic", messages=[{"id": 1}]) - messages = sync_call_service( + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function( + create_subscription, data=CreateSubscription(id="my-subscription", topic_id="my-topic") + ) + sync_call_function(publish_messages, topic_id="my-topic", messages=[{"id": 1}]) + messages = sync_call_function( consume_messages, subscription_id="my-subscription", consumer_id="id", batch_size=1 ) data = [str(message.id) for message in messages] @@ -158,16 +168,16 @@ def test_nack_messages(session, client): def test_list_dlq(session, client): - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) - sync_call_service( + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function( create_subscription, data=CreateSubscription(id="my-subscription", topic_id="my-topic", max_delivery_attempts=1), ) - sync_call_service(publish_messages, topic_id="my-topic", messages=[{"id": 1}]) - messages = sync_call_service( + sync_call_function(publish_messages, topic_id="my-topic", messages=[{"id": 1}]) + messages = sync_call_function( consume_messages, subscription_id="my-subscription", consumer_id="id", batch_size=1 ) - sync_call_service( + sync_call_function( nack_messages, subscription_id="my-subscription", message_ids=[str(message.id) for message in messages], @@ -188,21 +198,21 @@ def test_list_dlq(session, client): def test_reprocess_dlq(session, client): - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) - sync_call_service( + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function( create_subscription, data=CreateSubscription(id="my-subscription", topic_id="my-topic", max_delivery_attempts=1), ) - sync_call_service(publish_messages, topic_id="my-topic", messages=[{"id": 1}]) - messages = sync_call_service( + sync_call_function(publish_messages, topic_id="my-topic", messages=[{"id": 1}]) + messages = sync_call_function( consume_messages, subscription_id="my-subscription", consumer_id="id", batch_size=1 ) message = messages[0] - sync_call_service(nack_messages, subscription_id="my-subscription", message_ids=[str(message.id)]) + sync_call_function(nack_messages, subscription_id="my-subscription", message_ids=[str(message.id)]) data = [str(message.id)] response = client.post("/subscriptions/my-subscription/dlq/reprocess", json=data) - messages = sync_call_service( + messages = sync_call_function( consume_messages, subscription_id="my-subscription", consumer_id="id", batch_size=1 ) @@ -218,8 +228,10 @@ def test_reprocess_dlq(session, client): def test_subscription_metrics(session, client): - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) - sync_call_service(create_subscription, data=CreateSubscription(id="my-subscription", topic_id="my-topic")) + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function( + create_subscription, data=CreateSubscription(id="my-subscription", topic_id="my-topic") + ) response = client.get("/subscriptions/my-subscription/metrics") response_data = response.json() @@ -242,7 +254,7 @@ def test_subscription_metrics(session, client): def test_subscription_filter_xss_sanitization(session, client): """Test that XSS attacks in subscription filters are sanitized.""" - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) # Test XSS in filter value data = { @@ -264,7 +276,7 @@ def test_subscription_filter_xss_sanitization(session, client): def test_subscription_filter_sql_injection_patterns(session, client): """Test that SQL injection patterns in filters are sanitized.""" - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) # Test SQL-like patterns in filter value data = { @@ -284,7 +296,7 @@ def test_subscription_filter_sql_injection_patterns(session, client): def test_subscription_filter_control_characters(session, client): """Test that control characters in filters are removed.""" - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) # Test control characters in filter value data = { @@ -304,7 +316,7 @@ def test_subscription_filter_control_characters(session, client): def test_subscription_filter_invalid_structure(session, client): """Test that invalid filter structure is rejected.""" - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) # Test invalid structure (value not an array) data = { @@ -322,7 +334,7 @@ def test_subscription_filter_invalid_structure(session, client): def test_subscription_filter_with_special_characters(session, client): """Test that special characters are properly encoded.""" - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) data = { "id": "special-chars-subscription", diff --git a/tests/api/routers/test_topics.py b/tests/api/routers/test_topics.py index c22523b..c5c3ed6 100644 --- a/tests/api/routers/test_topics.py +++ b/tests/api/routers/test_topics.py @@ -2,7 +2,7 @@ from fastpubsub.models import CreateTopic from fastpubsub.services import create_topic -from tests.helpers import sync_call_service +from tests.helpers import sync_call_function def test_create_topic(session, client): @@ -22,7 +22,7 @@ def test_create_topic(session, client): def test_get_topic(session, client): - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) response = client.get("/topics/my-topic") response_data = response.json() @@ -41,7 +41,7 @@ def test_get_topic(session, client): def test_list_topic(session, client): data = [{"id": "my-topic-1"}, {"id": "my-topic-2"}] for topic_data in data: - sync_call_service(create_topic, data=CreateTopic(id=topic_data["id"])) + sync_call_function(create_topic, data=CreateTopic(id=topic_data["id"])) response = client.get("/topics") response_data = response.json() @@ -67,7 +67,7 @@ def test_list_topic(session, client): def test_delete_topic(session, client): - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) response = client.delete("/topics/my-topic") @@ -81,7 +81,7 @@ def test_delete_topic(session, client): def test_publish_messages(session, client): - sync_call_service(create_topic, data=CreateTopic(id="my-topic")) + sync_call_function(create_topic, data=CreateTopic(id="my-topic")) data = [{"id": 1}, {"id": 2}] diff --git a/tests/conftest.py b/tests/conftest.py index 783b15e..7bee1dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,15 @@ from sqlalchemy import delete from fastpubsub.api import app -from fastpubsub.database import engine, run_migrations, SessionLocal, Subscription, SubscriptionMessage, Topic +from fastpubsub.database import ( + Client, + engine, + run_migrations, + SessionLocal, + Subscription, + SubscriptionMessage, + Topic, +) @pytest_asyncio.fixture(scope="session") @@ -24,6 +32,7 @@ async def session(async_engine): await sess.execute(delete(SubscriptionMessage)) await sess.execute(delete(Subscription)) await sess.execute(delete(Topic)) + await sess.execute(delete(Client)) await sess.commit() diff --git a/tests/helpers.py b/tests/helpers.py index 02da06e..a29985c 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,6 +1,6 @@ import asyncio -def sync_call_service(service, *args, **kwargs): - """Helper function to run async services in sync tests.""" +def sync_call_function(service, *args, **kwargs): + """Helper function to run async functions in sync tests.""" return asyncio.run(service(*args, **kwargs)) diff --git a/tests/services/test_auth.py b/tests/services/test_auth.py new file mode 100644 index 0000000..555299b --- /dev/null +++ b/tests/services/test_auth.py @@ -0,0 +1,95 @@ +from typing import Annotated + +import pytest +import pytest_asyncio +from fastapi import Depends, FastAPI, Request, status +from fastapi.testclient import TestClient + +from fastpubsub import models, services +from fastpubsub.api.helpers import _create_error_response +from fastpubsub.config import settings +from fastpubsub.exceptions import InvalidClient, InvalidClientToken +from fastpubsub.models import DecodedClientToken +from tests.helpers import sync_call_function + + +@pytest_asyncio.fixture +async def make_client_token(): + async def _make_token(scopes: list[str], is_active: bool = True) -> models.ClientToken: + client_result = await services.create_client( + data=models.CreateClient(name="client", scopes=" ".join(scopes), is_active=is_active) + ) + return await services.issue_jwt_client_token(client_result.id, client_result.secret) + + return _make_token + + +@pytest.fixture +def app(): + app = FastAPI() + + @app.get("/topics/{id}") + def read_topic( + id: str, + token: Annotated[DecodedClientToken, Depends(services.require_scope("topics", "read"))], + ): + return {"topic": id} + + @app.exception_handler(InvalidClient) + def invalid_client_exception_handler(request: Request, exc: InvalidClient): + return _create_error_response(models.GenericError, status.HTTP_401_UNAUTHORIZED, exc) + + @app.exception_handler(InvalidClientToken) + def invalid_client_token_exception_handler(request: Request, exc: InvalidClientToken): + return _create_error_response(models.GenericError, status.HTTP_403_FORBIDDEN, exc) + + return app + + +@pytest.mark.parametrize( + "scopes,resource,action,resource_id,expected_result", + [ + ({"topics:publish"}, "topics", "publish", None, True), + ({"topics:publish"}, "topics", "publish", "topic", True), + ({"topics:publish:my-topic"}, "topics", "publish", "my-topic", True), + ({"topics:publish:my-topic"}, "topics", "publish", "other-topic", False), + ({"topics:publish:my-topic"}, "topics", "publish", None, False), + ({"topics:read"}, "topics", "publish", "topic", False), + ({"topics:read"}, "topics", "publish", None, False), + ({"*"}, "topics", "publish", None, True), + ({"*"}, "topics", "publish", "topic", True), + ({"*"}, "topics", "publish", "my-topic", True), + ({"*"}, "topics", "publish", "other-topic", True), + ({"*"}, "topics", "publish", None, True), + ({"*"}, "topics", "publish", "topic", True), + ({"*"}, "topics", "publish", None, True), + ], +) +def test_has_scope(scopes, resource, action, resource_id, expected_result): + assert services.has_scope(scopes, resource, action, resource_id) == expected_result + + +def test_require_scope(app, make_client_token, monkeypatch): + monkeypatch.setattr(settings, "auth_enabled", True) + client = TestClient(app) + client_token = sync_call_function(make_client_token, scopes=["topics:read:my-topic"]) + headers = {"Authorization": f"Bearer {client_token.access_token}"} + sync_call_function(services.create_topic, data=models.CreateTopic(id="my-topic")) + + response = client.get("/topics/my-topic") + response_data = response.json() + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response_data == {"detail": "Invalid jwt token"} + + response = client.get("/topics/my-topic", headers=headers) + response_data = response.json() + + assert response.status_code == status.HTTP_200_OK + assert response_data == {"topic": "my-topic"} + + response = client.get("/topics/my-topic-x", headers=headers) + response_data = response.json() + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response_data == {"detail": "Insufficient scope"} diff --git a/tests/services/test_clients.py b/tests/services/test_clients.py new file mode 100644 index 0000000..2f6fd7c --- /dev/null +++ b/tests/services/test_clients.py @@ -0,0 +1,226 @@ +import uuid + +import pytest + +from fastpubsub import services +from fastpubsub.exceptions import InvalidClient, NotFoundError +from fastpubsub.models import CreateClient, UpdateClient + + +@pytest.mark.asyncio +async def test_create_and_get_client(session): + client_result = await services.create_client( + data=CreateClient(name="my client", scopes="*", is_active=True) + ) + + assert isinstance(client_result.id, uuid.UUID) + assert len(client_result.secret) == 32 + + client = await services.get_client(client_result.id) + + assert client.id == client_result.id + assert client.name == "my client" + assert client.scopes == "*" + assert client.is_active is True + assert client.token_version == 1 + assert client.created_at is not None + assert client.updated_at is not None + + with pytest.raises(NotFoundError) as excinfo: + await services.get_client(uuid.uuid7()) + assert "Client not found" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_list_client(session): + client_result_1 = await services.create_client( + data=CreateClient(name="my client 1", scopes="*", is_active=True) + ) + client_result_2 = await services.create_client( + data=CreateClient(name="my client 2", scopes="*", is_active=True) + ) + + clients = await services.list_client(offset=0, limit=1) + assert len(clients) == 1 + assert clients[0].id == client_result_1.id + + clients = await services.list_client(offset=1, limit=1) + assert len(clients) == 1 + assert clients[0].id == client_result_2.id + + clients = await services.list_client(offset=2, limit=1) + assert len(clients) == 0 + + +@pytest.mark.asyncio +async def test_delete_client(session): + client_result = await services.create_client( + data=CreateClient(name="my client", scopes="*", is_active=True) + ) + + await services.delete_client(client_result.id) + + with pytest.raises(NotFoundError) as excinfo: + await services.get_client(client_result.id) + assert "Client not found" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_update_client(session): + client_result = await services.create_client( + data=CreateClient(name="my client", scopes="*", is_active=True) + ) + client = await services.get_client(client_result.id) + + assert client.name == "my client" + assert client.scopes == "*" + assert client.is_active is True + assert client.token_version == 1 + + updated_client = await services.update_client( + client.id, data=UpdateClient(name="my updated client", scopes="topics:create", is_active=False) + ) + + assert updated_client.name == "my updated client" + assert updated_client.scopes == "topics:create" + assert updated_client.is_active is False + assert updated_client.token_version == 2 + assert updated_client.created_at == client.created_at + assert updated_client.updated_at > client.updated_at + + updated_client = await services.update_client( + client.id, + data=UpdateClient( + name="my new updated client", scopes="topics:create subscriptions:create", is_active=True + ), + ) + + assert updated_client.name == "my new updated client" + assert updated_client.scopes == "topics:create subscriptions:create" + assert updated_client.is_active is True + assert updated_client.token_version == 3 + assert updated_client.created_at == client.created_at + assert updated_client.updated_at > client.updated_at + + +@pytest.mark.asyncio +async def test_issue_jwt_client_token_with_invalid_client_id(session): + with pytest.raises(InvalidClient) as excinfo: + await services.issue_jwt_client_token(client_id=uuid.uuid7(), client_secret="my-secret") + assert "Client not found" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_issue_jwt_client_token_with_not_active_client(session): + client_result = await services.create_client( + data=CreateClient(name="my client", scopes="*", is_active=False) + ) + + with pytest.raises(InvalidClient) as excinfo: + await services.issue_jwt_client_token(client_id=client_result.id, client_secret=client_result.secret) + assert "Client disabled" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_issue_jwt_client_token_with_invalid_secret(session): + client_result = await services.create_client( + data=CreateClient(name="my client", scopes="*", is_active=True) + ) + + with pytest.raises(InvalidClient) as excinfo: + await services.issue_jwt_client_token(client_id=client_result.id, client_secret="invalid-secret") + assert "Client secret is invalid" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_issue_jwt_client_token(session): + client_result = await services.create_client( + data=CreateClient(name="my client", scopes="*", is_active=True) + ) + + client_token = await services.issue_jwt_client_token( + client_id=client_result.id, client_secret=client_result.secret + ) + + assert client_token.access_token + assert client_token.expires_in > 0 + assert client_token.scope == "*" + + +@pytest.mark.asyncio +async def test_decode_jwt_client_token_with_invalid_jwt_token(session): + with pytest.raises(InvalidClient) as excinfo: + await services.decode_jwt_client_token("invalid-jwt-token") + assert "Invalid jwt token" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_decode_jwt_client_token_with_client_not_found(session): + client_result = await services.create_client( + data=CreateClient(name="my client", scopes="*", is_active=True) + ) + client_token = await services.issue_jwt_client_token( + client_id=client_result.id, client_secret=client_result.secret + ) + await services.delete_client(client_result.id) + + with pytest.raises(InvalidClient) as excinfo: + await services.decode_jwt_client_token(client_token.access_token) + assert "Client not found" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_decode_jwt_client_token_with_not_active_client(session): + client_result = await services.create_client( + data=CreateClient(name="my client", scopes="*", is_active=True) + ) + client_token = await services.issue_jwt_client_token( + client_id=client_result.id, client_secret=client_result.secret + ) + await services.update_client( + client_result.id, data=UpdateClient(name="my client", scopes="*", is_active=False) + ) + + with pytest.raises(InvalidClient) as excinfo: + await services.decode_jwt_client_token(client_token.access_token) + assert "Client disabled" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_decode_jwt_client_token_with_invalid_token_version(session): + client_result = await services.create_client( + data=CreateClient(name="my client", scopes="*", is_active=True) + ) + client_token = await services.issue_jwt_client_token( + client_id=client_result.id, client_secret=client_result.secret + ) + await services.update_client( + client_result.id, data=UpdateClient(name="my client", scopes="*", is_active=True) + ) + + with pytest.raises(InvalidClient) as excinfo: + await services.decode_jwt_client_token(client_token.access_token) + assert "Token revoked" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_decode_jwt_client_token(session): + client_result = await services.create_client( + data=CreateClient(name="my client", scopes="topics:create subscriptions:create", is_active=True) + ) + client_token = await services.issue_jwt_client_token( + client_id=client_result.id, client_secret=client_result.secret + ) + + decoded_client = await services.decode_jwt_client_token(client_token.access_token) + + assert decoded_client.client_id == client_result.id + assert decoded_client.scopes == set(["topics:create", "subscriptions:create"]) + + +@pytest.mark.asyncio +async def test_decode_jwt_client_token_with_auth_disabled(): + decoded_client = await services.decode_jwt_client_token("", auth_enabled=False) + + assert isinstance(decoded_client.client_id, uuid.UUID) + assert decoded_client.scopes == set("*") diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..5f524af --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,52 @@ +import pytest +from pydantic import ValidationError + +from fastpubsub.models import CreateClient + + +@pytest.mark.parametrize( + "scopes", + [ + "*", + "topics:create", + "topics:read", + "topics:delete", + "topics:publish", + "subscriptions:create", + "subscriptions:read", + "subscriptions:delete", + "subscriptions:consume", + "clients:create", + "clients:update", + "clients:read", + "clients:delete", + ], +) +def test_create_client_with_valid_scopes(scopes): + client = CreateClient(name="my client", scopes=scopes) + + assert client.scopes == scopes + + +@pytest.mark.parametrize( + "scopes", + [ + "", + "topic:create", + "topic:read", + "topic:delete", + "topic:publish", + "subscription:create", + "subscription:read", + "subscription:delete", + "subscription:consume", + "client:create", + "client:update", + "client:read", + "client:delete", + ], +) +def test_create_client_with_invalid_scopes(scopes): + print(f"scopes={scopes}") + with pytest.raises(ValidationError): + CreateClient(name="my client", scopes=scopes) diff --git a/uv.lock b/uv.lock index 4a8756b..425aad9 100644 --- a/uv.lock +++ b/uv.lock @@ -46,6 +46,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -55,6 +98,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -120,6 +196,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, ] +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -138,6 +270,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] +[[package]] +name = "ecdsa" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, +] + [[package]] name = "email-validator" version = "2.3.0" @@ -266,6 +410,8 @@ dependencies = [ { name = "orjson" }, { name = "prometheus-fastapi-instrumentator" }, { name = "psycopg", extra = ["binary"] }, + { name = "pwdlib", extra = ["argon2"] }, + { name = "python-jose", extra = ["cryptography"] }, { name = "python-json-logger" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "typer" }, @@ -287,6 +433,8 @@ requires-dist = [ { name = "orjson", specifier = ">=3.11.5,<4" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=7.1.0,<8" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.2,<4" }, + { name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.0,<1" }, + { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0,<4" }, { name = "python-json-logger", specifier = ">=4.0.0,<5" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.45,<3" }, { name = "typer", specifier = ">=0.21.0,<1" }, @@ -630,6 +778,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/f7/212343c1c9cfac35fd943c527af85e9091d633176e2a407a0797856ff7b9/psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1", size = 3642122, upload-time = "2025-12-06T17:34:52.506Z" }, ] +[[package]] +name = "pwdlib" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/41/a7c0d8a003c36ce3828ae3ed0391fe6a15aad65f082dbd6bec817ea95c0b/pwdlib-0.3.0.tar.gz", hash = "sha256:6ca30f9642a1467d4f5d0a4d18619de1c77f17dfccb42dd200b144127d3c83fc", size = 215810, upload-time = "2025-10-25T12:44:24.395Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/0c/9086a357d02a050fbb3270bf5043ac284dbfb845670e16c9389a41defc9e/pwdlib-0.3.0-py3-none-any.whl", hash = "sha256:f86c15c138858c09f3bba0a10984d4f9178158c55deaa72eac0210849b1a140d", size = 8633, upload-time = "2025-10-25T12:44:23.406Z" }, +] + +[package.optional-dependencies] +argon2 = [ + { name = "argon2-cffi" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -691,15 +871,15 @@ wheels = [ [[package]] name = "pydantic-extra-types" -version = "2.10.6" +version = "2.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/10/fb64987804cde41bcc39d9cd757cd5f2bb5d97b389d81aa70238b14b8a7e/pydantic_extra_types-2.10.6.tar.gz", hash = "sha256:c63d70bf684366e6bbe1f4ee3957952ebe6973d41e7802aea0b770d06b116aeb", size = 141858, upload-time = "2025-10-08T13:47:49.483Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/35/2fee58b1316a73e025728583d3b1447218a97e621933fc776fb8c0f2ebdd/pydantic_extra_types-2.11.0.tar.gz", hash = "sha256:4e9991959d045b75feb775683437a97991d02c138e00b59176571db9ce634f0e", size = 157226, upload-time = "2025-12-31T16:18:27.944Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/04/5c918669096da8d1c9ec7bb716bd72e755526103a61bc5e76a3e4fb23b53/pydantic_extra_types-2.10.6-py3-none-any.whl", hash = "sha256:6106c448316d30abf721b5b9fecc65e983ef2614399a24142d689c7546cc246a", size = 40949, upload-time = "2025-10-08T13:47:48.268Z" }, + { url = "https://files.pythonhosted.org/packages/fe/17/fabd56da47096d240dd45ba627bead0333b0cf0ee8ada9bec579287dadf3/pydantic_extra_types-2.11.0-py3-none-any.whl", hash = "sha256:84b864d250a0fc62535b7ec591e36f2c5b4d1325fa0017eb8cda9aeb63b374a6", size = 74296, upload-time = "2025-12-31T16:18:26.38Z" }, ] [[package]] @@ -776,6 +956,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, +] + [[package]] name = "python-json-logger" version = "4.0.0" @@ -885,6 +1084,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "sentry-sdk" version = "2.48.0" @@ -907,6 +1118,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.45" From bf54585085f7e0444d24f7ba8af81490a62d5aba Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Fri, 2 Jan 2026 11:31:04 -0300 Subject: [PATCH 2/5] Update tests/test_models.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_models.py b/tests/test_models.py index 5f524af..83c16e7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -47,6 +47,5 @@ def test_create_client_with_valid_scopes(scopes): ], ) def test_create_client_with_invalid_scopes(scopes): - print(f"scopes={scopes}") with pytest.raises(ValidationError): CreateClient(name="my client", scopes=scopes) From 1f268326c85efa11fb9c8a661f29c118c89f2857 Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Fri, 2 Jan 2026 11:32:35 -0300 Subject: [PATCH 3/5] fix: fix decode_jwt_client_token --- fastpubsub/services/clients.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastpubsub/services/clients.py b/fastpubsub/services/clients.py index 7affeff..4203869 100644 --- a/fastpubsub/services/clients.py +++ b/fastpubsub/services/clients.py @@ -127,7 +127,7 @@ async def decode_jwt_client_token(access_token: str, auth_enabled: bool = True) raise InvalidClient("Invalid jwt token") from None client_id = payload["sub"] - payload["scope"] + scopes = payload["scope"] token_version = payload["ver"] async with SessionLocal() as session: @@ -140,5 +140,5 @@ async def decode_jwt_client_token(access_token: str, auth_enabled: bool = True) raise InvalidClient("Token revoked") from None return DecodedClientToken( - client_id=uuid.UUID(client_id), scopes={scope for scope in db_client.scopes.split()} + client_id=uuid.UUID(client_id), scopes={scope for scope in scopes.split()} ) From 9c89458ce096d0a8b7f4ca582708b1c007e88548 Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Fri, 2 Jan 2026 11:35:11 -0300 Subject: [PATCH 4/5] fix: fix require_scope --- fastpubsub/services/auth.py | 4 +++- fastpubsub/services/clients.py | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/fastpubsub/services/auth.py b/fastpubsub/services/auth.py index b97ff21..3da5d6e 100644 --- a/fastpubsub/services/auth.py +++ b/fastpubsub/services/auth.py @@ -34,7 +34,9 @@ async def get_current_token(token: str | None = Depends(oauth2_scheme)) -> Decod def require_scope(resource: str, action: str): async def dependency(request: Request, token: Annotated[DecodedClientToken, Depends(get_current_token)]): - resource_id = str(request.path_params.get("id")) + resource_id = request.path_params.get("id") + if resource_id is not None: + resource_id = str(resource_id) if not has_scope(token.scopes, resource, action, resource_id): raise InvalidClientToken("Insufficient scope") from None diff --git a/fastpubsub/services/clients.py b/fastpubsub/services/clients.py index 4203869..55cd9b2 100644 --- a/fastpubsub/services/clients.py +++ b/fastpubsub/services/clients.py @@ -139,6 +139,4 @@ async def decode_jwt_client_token(access_token: str, auth_enabled: bool = True) if token_version != db_client.token_version: raise InvalidClient("Token revoked") from None - return DecodedClientToken( - client_id=uuid.UUID(client_id), scopes={scope for scope in scopes.split()} - ) + return DecodedClientToken(client_id=uuid.UUID(client_id), scopes={scope for scope in scopes.split()}) From e6c3ef61b09702e5edc42ba22d7444aa3d53c83d Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Fri, 2 Jan 2026 11:36:52 -0300 Subject: [PATCH 5/5] Update tests/services/test_clients.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/services/test_clients.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/services/test_clients.py b/tests/services/test_clients.py index 2f6fd7c..9ecb08a 100644 --- a/tests/services/test_clients.py +++ b/tests/services/test_clients.py @@ -223,4 +223,4 @@ async def test_decode_jwt_client_token_with_auth_disabled(): decoded_client = await services.decode_jwt_client_token("", auth_enabled=False) assert isinstance(decoded_client.client_id, uuid.UUID) - assert decoded_client.scopes == set("*") + assert decoded_client.scopes == {"*"}