From 5a5336b222affdcd315de951176d8676e9e9b268 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 11 Feb 2026 18:57:37 +0530 Subject: [PATCH 01/11] feat: Add Multiple Custom Domains (MCD) support --- README.md | 32 +++ examples/MultipleCustomDomains.md | 254 +++++++++++++++++++++ src/auth0_fastapi/auth/auth_client.py | 19 +- src/auth0_fastapi/config.py | 4 +- src/auth0_fastapi/server/routes.py | 32 ++- src/auth0_fastapi/test/test_auth_client.py | 179 +++++++++++++++ src/auth0_fastapi/test/test_routes.py | 254 +++++++++++++++++++++ src/auth0_fastapi/util/__init__.py | 28 ++- 8 files changed, 786 insertions(+), 16 deletions(-) create mode 100644 examples/MultipleCustomDomains.md diff --git a/README.md b/README.md index 372a7b9..017d7b1 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ - **Fully Integrated Auth Flows**: Automatic routes for `/auth/login`, `/auth/logout`, `/auth/callback`, etc. - **Session-Based**: Uses secure cookies to store user sessions, either stateless (all data in cookie) or stateful (data in a database). +- **Multiple Custom Domains (MCD)**: Support for multi-tenant applications with different Auth0 domains per tenant. - **Account Linking**: Optional routes for linking multiple social or username/password accounts into a single Auth0 profile. - **Backchannel Logout**: Receive logout tokens from Auth0 to invalidate sessions server-side. - **Extensible**: Swap in your own store implementations or tune existing ones (cookie name, expiration, etc.) @@ -265,6 +266,37 @@ config = Auth0Config( The `AUTH0_AUDIENCE` is the identifier of the API you want to call. You can find this in the [APIs section of the Auth0 Dashboard](https://manage.auth0.com/#/apis/). +## Multiple Custom Domains (MCD) + +For multi-tenant applications where each tenant uses a different Auth0 domain, pass a callable instead of a static domain string: + +```python +from auth0_server_python.auth_types import DomainResolverContext + +async def domain_resolver(context: DomainResolverContext) -> str: + """Resolve Auth0 domain based on request host.""" + host = context.request_headers.get("host", "").split(":")[0] + return { + "tenant-a.myapp.com": "tenant-a.auth0.com", + "tenant-b.myapp.com": "tenant-b.auth0.com", + }.get(host, "default.auth0.com") + +config = Auth0Config( + domain=domain_resolver, # Callable instead of string + client_id="YOUR_CLIENT_ID", + client_secret="YOUR_CLIENT_SECRET", + app_base_url="https://myapp.com", + secret="YOUR_SESSION_SECRET", +) +``` + +When using MCD, the SDK automatically: +- Builds dynamic `redirect_uri` based on the incoming request host +- Stores the origin domain in the session for domain-isolated token refresh +- Validates tokens against the correct issuer + +For detailed usage patterns, see [examples/MultipleCustomDomains.md](./examples/MultipleCustomDomains.md). + ## Feedback ### Contributing diff --git a/examples/MultipleCustomDomains.md b/examples/MultipleCustomDomains.md new file mode 100644 index 0000000..c4520eb --- /dev/null +++ b/examples/MultipleCustomDomains.md @@ -0,0 +1,254 @@ +# Multiple Custom Domains (MCD) + +This guide covers usage patterns for implementing Multiple Custom Domains in your FastAPI application. + +## Overview + +MCD allows a single application to serve multiple tenants, each with their own Auth0 domain. This is useful for: + +## Basic Setup + +### 1. Define a Domain Resolver + +The domain resolver is an async function that receives request context and returns the appropriate Auth0 domain: + +```python +from auth0_server_python.auth_types import DomainResolverContext + +async def domain_resolver(context: DomainResolverContext) -> str: + """ + Resolve Auth0 domain based on incoming request. + + Args: + context.request_url: Full request URL (e.g., "https://tenant-a.myapp.com/auth/login") + context.request_headers: Dict of request headers + + Returns: + Auth0 domain string (e.g., "tenant-a.auth0.com") + """ + if context.request_headers: + host = context.request_headers.get("host", "").split(":")[0] + + # Map hostnames to Auth0 domains + domain_map = { + "tenant-a.myapp.com": "tenant-a.auth0.com", + "tenant-b.myapp.com": "tenant-b.auth0.com", + } + return domain_map.get(host, "default.auth0.com") + + return "default.auth0.com" +``` + +### 2. Configure Auth0Config + +Pass the resolver function instead of a static domain string: + +```python +from auth0_fastapi import Auth0Config, AuthClient + +config = Auth0Config( + domain=domain_resolver, # Callable triggers MCD mode + client_id="YOUR_CLIENT_ID", + client_secret="YOUR_CLIENT_SECRET", + app_base_url="https://myapp.com", + secret="your-32-character-secret-key!!", +) + +auth_client = AuthClient(config) +``` + +## Usage Patterns + +### Pattern 1: Host Header Mapping + +Map request hostnames directly to Auth0 domains: + +```python +DOMAIN_MAP = { + "acme.myapp.com": "acme-corp.auth0.com", + "globex.myapp.com": "globex-inc.auth0.com", + "initech.myapp.com": "initech.auth0.com", +} + +async def domain_resolver(context: DomainResolverContext) -> str: + host = context.request_headers.get("host", "").split(":")[0] + return DOMAIN_MAP.get(host, "default.auth0.com") +``` + +### Pattern 2: Subdomain Extraction + +Extract tenant from subdomain: + +```python +async def domain_resolver(context: DomainResolverContext) -> str: + host = context.request_headers.get("host", "").split(":")[0] + + # Extract subdomain: "acme.myapp.com" -> "acme" + parts = host.split(".") + if len(parts) >= 3: + tenant = parts[0] + return f"{tenant}.auth0.com" + + return "default.auth0.com" +``` + +### Pattern 3: Database Lookup + +Fetch domain from database based on tenant: + +```python +async def domain_resolver(context: DomainResolverContext) -> str: + host = context.request_headers.get("host", "").split(":")[0] + + # Extract tenant identifier + tenant = host.split(".")[0] + + # Lookup in database (use caching in production) + tenant_config = await get_tenant_config(tenant) + if tenant_config: + return tenant_config.auth0_domain + + return "default.auth0.com" +``` + +### Pattern 4: Environment-Based Configuration + +Use environment variables for tenant configuration: + +```python +import os +import json + +# Load from environment: TENANT_DOMAINS='{"acme": "acme.auth0.com", "globex": "globex.auth0.com"}' +TENANT_DOMAINS = json.loads(os.environ.get("TENANT_DOMAINS", "{}")) + +async def domain_resolver(context: DomainResolverContext) -> str: + host = context.request_headers.get("host", "").split(":")[0] + tenant = host.split(".")[0] + return TENANT_DOMAINS.get(tenant, os.environ.get("DEFAULT_AUTH0_DOMAIN")) +``` + +## Proxy Headers + +When running behind a reverse proxy (nginx, load balancer), use forwarded headers: + +```python +async def domain_resolver(context: DomainResolverContext) -> str: + headers = context.request_headers or {} + + # Prefer x-forwarded-host over host + host = headers.get("x-forwarded-host") or headers.get("host", "") + host = host.split(":")[0] # Remove port + + return DOMAIN_MAP.get(host, "default.auth0.com") +``` + +## Auth0 Dashboard Configuration + +For MCD to work, configure each Auth0 tenant: + +1. **Allowed Callback URLs**: Add all tenant callback URLs + ``` + https://tenant-a.myapp.com/auth/callback + https://tenant-b.myapp.com/auth/callback + ``` + +2. **Allowed Logout URLs**: Add all tenant base URLs + ``` + https://tenant-a.myapp.com + https://tenant-b.myapp.com + ``` + +3. **Allowed Web Origins** (if using SPA features): + ``` + https://tenant-a.myapp.com + https://tenant-b.myapp.com + ``` + +## Error Handling + +Handle domain resolver errors gracefully: + +```python +from auth0_server_python.errors import DomainResolverError + +async def domain_resolver(context: DomainResolverContext) -> str: + host = context.request_headers.get("host", "").split(":")[0] + + domain = DOMAIN_MAP.get(host) + if not domain: + # Option 1: Return default + return "default.auth0.com" + + # Option 2: Raise error (will return 500 to user) + # raise DomainResolverError(f"Unknown tenant: {host}") + + return domain +``` + +## How It Works + +When MCD is enabled, the SDK: + +1. **Login**: Resolves domain from request, builds dynamic `redirect_uri`, stores `origin_domain` in transaction +2. **Callback**: Retrieves `origin_domain` from transaction, exchanges code with correct token endpoint, validates issuer +3. **Session**: Stores `domain` field in session for future requests +4. **Token Refresh**: Uses session's stored domain (not current request domain) +5. **Logout**: Resolves current domain for logout URL + +## Security Considerations + +- **Session Isolation**: Sessions are bound to their origin domain. A session created on `tenant-a.myapp.com` cannot be used on `tenant-b.myapp.com` +- **Issuer Validation**: Token issuer is validated against the expected domain (with normalization for trailing slashes and case) +- **Token Refresh**: Refresh tokens are used with their original domain's token endpoint + +## Complete Example + +```python +# main.py +import os +from fastapi import FastAPI, Depends, Request, Response +from starlette.middleware.sessions import SessionMiddleware + +from auth0_fastapi import Auth0Config, AuthClient +from auth0_fastapi.server.routes import router, register_auth_routes +from auth0_server_python.auth_types import DomainResolverContext + +app = FastAPI(title="Multi-Tenant App") +app.add_middleware(SessionMiddleware, secret_key=os.environ["SESSION_SECRET"]) + +# Tenant configuration +TENANT_DOMAINS = { + "acme.myapp.com": "acme-corp.auth0.com", + "globex.myapp.com": "globex-inc.auth0.com", +} + +async def domain_resolver(context: DomainResolverContext) -> str: + host = context.request_headers.get("x-forwarded-host") or \ + context.request_headers.get("host", "") + host = host.split(":")[0] + return TENANT_DOMAINS.get(host, "default.auth0.com") + +config = Auth0Config( + domain=domain_resolver, + client_id=os.environ["AUTH0_CLIENT_ID"], + client_secret=os.environ["AUTH0_CLIENT_SECRET"], + app_base_url="https://myapp.com", + secret=os.environ["SESSION_SECRET"], +) + +auth_client = AuthClient(config) +app.state.config = config +app.state.auth_client = auth_client + +register_auth_routes(router, config) +app.include_router(router) + +@app.get("/") +async def home(): + return {"message": "Multi-tenant app"} + +@app.get("/profile") +async def profile(session=Depends(auth_client.require_session)): + return {"user": session.user} +``` diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index be42a8b..28318be 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -43,20 +43,25 @@ def __init__( transaction_store = CookieTransactionStore( config.secret, cookie_name="_a0_tx") + # When domain is callable (MCD), don't hardcode redirect_uri in authorization_params + # It will be set dynamically per-request based on the incoming host + auth_params = { + "audience": config.audience, + **(config.authorization_params or {}), + } + if not callable(config.domain): + auth_params["redirect_uri"] = redirect_uri + self.client = ServerClient( - domain=config.domain, + domain=config.domain, # Can be str or callable client_id=config.client_id, client_secret=config.client_secret, - redirect_uri=redirect_uri, + redirect_uri=redirect_uri, # Default fallback secret=config.secret, transaction_store=transaction_store, state_store=state_store, pushed_authorization_requests=config.pushed_authorization_requests, - authorization_params={ - "audience": config.audience, - "redirect_uri": redirect_uri, - **(config.authorization_params or {}), - }, + authorization_params=auth_params, ) async def start_login( diff --git a/src/auth0_fastapi/config.py b/src/auth0_fastapi/config.py index f68eb37..c8ce575 100644 --- a/src/auth0_fastapi/config.py +++ b/src/auth0_fastapi/config.py @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Any, Callable, Optional, Union from pydantic import AnyUrl, BaseModel, Field @@ -7,7 +7,7 @@ class Auth0Config(BaseModel): """ Configuration settings for the FastAPI SDK integrating auth0-server-python. """ - domain: str + domain: Union[str, Callable] = Field(..., description="Auth0 domain - either a static string or a callable for dynamic custom domain resolution") client_id: str = Field(..., alias="clientId") client_secret: str = Field(..., alias="clientSecret") app_base_url: AnyUrl = Field(..., alias="appBaseUrl", description="Base URL of your application (e.g., https://example.com)") diff --git a/src/auth0_fastapi/server/routes.py b/src/auth0_fastapi/server/routes.py index 3167ee4..bffc287 100644 --- a/src/auth0_fastapi/server/routes.py +++ b/src/auth0_fastapi/server/routes.py @@ -6,7 +6,7 @@ from ..auth.auth_client import AuthClient from ..config import Auth0Config from ..errors import ConfigurationError -from ..util import create_route_url, to_safe_redirect +from ..util import build_request_base_url, create_route_url, to_safe_redirect router = APIRouter() @@ -45,15 +45,24 @@ async def login( Endpoint to initiate the login process. Optionally accepts a 'return_to' query parameter and passes it as part of the app state. Redirects the user to the Auth0 authorization URL. + + When domain is a callable (MCD), the redirect_uri is built dynamically + from the request host to ensure proper domain handling. """ return_to: Optional[str] = request.query_params.get("returnTo") authorization_params = {k: v for k, v in request.query_params.items() if k not in [ "returnTo"]} + + # Build dynamic redirect_uri from request host when domain is callable + if callable(auth_client.config.domain): + base_url = build_request_base_url(request) + authorization_params["redirect_uri"] = f"{base_url}/auth/callback" + auth_url = await auth_client.start_login( app_state={"returnTo": return_to} if return_to else None, authorization_params=authorization_params, - store_options={"response": response}, + store_options={"request": request, "response": response}, ) return RedirectResponse(url=auth_url, headers=response.headers) @@ -90,8 +99,12 @@ async def callback( # Extract the returnTo URL from the appState if available. return_to = app_state.get("returnTo") - # Assuming config is stored on app.state - default_redirect = auth_client.config.app_base_url + # Build dynamic default_redirect from request host if domain is callable + if callable(auth_client.config.domain): + default_redirect = build_request_base_url(request) + else: + # Assuming config is stored on app.state + default_redirect = auth_client.config.app_base_url safe_redirect = to_safe_redirect(return_to, default_redirect) if return_to else str(default_redirect) return RedirectResponse(url=safe_redirect, headers=response.headers) @@ -106,13 +119,20 @@ async def logout( Endpoint to handle logout. Clears the session cookie (if applicable) and generates a logout URL, then redirects the user to Auth0's logout endpoint. + + For MCD, builds dynamic returnTo URL based on incoming request host. """ return_to: Optional[str] = request.query_params.get("returnTo") try: - default_redirect = str(auth_client.config.app_base_url) + # Build dynamic default_redirect from request host if domain is callable + if callable(auth_client.config.domain): + default_redirect = build_request_base_url(request) + else: + default_redirect = str(auth_client.config.app_base_url) + logout_url = await auth_client.logout( return_to=return_to or default_redirect, - store_options={"response": response}, + store_options={"request": request, "response": response}, # Pass request for MCD ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index e7b4d5b..3e394a7 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -440,3 +440,182 @@ async def test_complete_connect_account(self, auth_client): assert result == mock_result mock_complete.assert_called_once_with(mock_callback_url, store_options=None) + + +class TestAuthClientMultipleCustomDomains: + """Test AuthClient with Multiple Custom Domains support.""" + + def test_auth_client_with_callable_domain(self): + """Test that AuthClient accepts callable domain.""" + async def domain_resolver(context): + return "tenant.auth0.com" + + config = Auth0Config( + domain=domain_resolver, + client_id="test_client_id", + client_secret="test_client_secret", + app_base_url="https://example.com", + secret="test_secret_key_minimum_32_characters", + ) + + client = AuthClient(config) + assert client is not None + assert client.config.domain is domain_resolver + assert callable(client.config.domain) + assert client.client is not None # ServerClient was created + assert client.config is config # Config reference preserved + + def test_auth_client_with_static_domain_still_works(self): + """Test backward compatibility with static string domain.""" + config = Auth0Config( + domain="tenant.auth0.com", + client_id="test_client_id", + client_secret="test_client_secret", + app_base_url="https://example.com", + secret="test_secret_key_minimum_32_characters", + ) + + client = AuthClient(config) + assert client is not None + assert client.config.domain == "tenant.auth0.com" + assert isinstance(client.config.domain, str) + assert not callable(client.config.domain) + + def test_auth_client_passes_domain_resolver_to_server_client(self): + """Test that domain resolver is passed to underlying ServerClient.""" + async def domain_resolver(context): + return "tenant.auth0.com" + + config = Auth0Config( + domain=domain_resolver, + client_id="test_client_id", + client_secret="test_client_secret", + app_base_url="https://example.com", + secret="test_secret_key_minimum_32_characters", + authorization_params={ + "redirect_uri": "https://example.com/auth/callback" + }, + audience="https://api.example.com", + ) + + with patch('auth0_fastapi.auth.auth_client.ServerClient') as mock_server_client: + client = AuthClient(config) + + # Verify ServerClient was called exactly once + mock_server_client.assert_called_once() + _, kwargs = mock_server_client.call_args + + # Verify domain resolver is passed by reference + assert kwargs['domain'] is domain_resolver + assert callable(kwargs['domain']) + + # Verify other critical parameters are passed correctly + assert kwargs['client_id'] == "test_client_id" + assert kwargs['client_secret'] == "test_client_secret" + assert kwargs['secret'] == "test_secret_key_minimum_32_characters" + assert 'redirect_uri' in kwargs + assert kwargs['redirect_uri'] == "https://example.com/auth/callback" + + # Verify authorization_params includes redirect_uri and audience + assert 'authorization_params' in kwargs + assert kwargs['authorization_params']['redirect_uri'] == "https://example.com/auth/callback" + assert kwargs['authorization_params'].get('audience') == "https://api.example.com" + + # Verify client was created + assert client is not None + assert client.config is config + + def test_redirect_uri_set_when_domain_is_callable(self): + """Test that redirect_uri is set in ServerClient even when domain is callable.""" + async def domain_resolver(context): + return "tenant.auth0.com" + + config = Auth0Config( + domain=domain_resolver, + client_id="test_client_id", + client_secret="test_client_secret", + app_base_url="https://example.com", + secret="test_secret_key_minimum_32_characters", + authorization_params={ + "redirect_uri": "https://example.com/auth/callback" + } + ) + + with patch('auth0_fastapi.auth.auth_client.ServerClient') as mock_server_client: + client = AuthClient(config) + + mock_server_client.assert_called_once() + _, kwargs = mock_server_client.call_args + # AuthClient still passes redirect_uri - routes will override dynamically + assert 'redirect_uri' in kwargs + assert kwargs['redirect_uri'] == "https://example.com/auth/callback" + assert 'authorization_params' in kwargs + assert kwargs['authorization_params']['redirect_uri'] == "https://example.com/auth/callback" + + # Verify client was created successfully + assert client is not None + assert client.config.domain is domain_resolver + + @pytest.mark.asyncio + async def test_domain_resolver_receives_request_context_through_store_options(self, mock_request, mock_response): + """Test that domain resolver receives context when called through AuthClient.""" + received_context = None + + async def domain_resolver(context): + nonlocal received_context + received_context = context + return "tenant.auth0.com" + + config = Auth0Config( + domain=domain_resolver, + client_id="test_client_id", + client_secret="test_client_secret", + app_base_url="https://example.com", + secret="test_secret_key_minimum_32_characters", + ) + + client = AuthClient(config) + assert client is not None + assert callable(client.config.domain) + + # Mock the underlying ServerClient method + with patch.object(client.client, 'start_interactive_login', new_callable=AsyncMock) as mock_start: + mock_start.return_value = "https://auth0.com/authorize" + + result = await client.start_login( + store_options={"request": mock_request, "response": mock_response} + ) + + # Verify start_interactive_login was called with store_options + mock_start.assert_called_once() + call_kwargs = mock_start.call_args.kwargs + assert 'store_options' in call_kwargs + assert call_kwargs['store_options']['request'] is mock_request + assert call_kwargs['store_options']['response'] is mock_response + + # Verify the return value is passed through + assert result == "https://auth0.com/authorize" + + def test_auth_client_stores_config_with_domain_resolver(self): + """Test that AuthClient properly stores config with domain resolver.""" + async def domain_resolver(context): + host = context.request_headers.get('host', '') + return f"{host}.auth0.com" + + config = Auth0Config( + domain=domain_resolver, + client_id="test_client_id", + client_secret="test_client_secret", + app_base_url="https://example.com", + secret="test_secret_key_minimum_32_characters", + ) + + client = AuthClient(config) + assert client is not None + assert client.client is not None + assert client.config is config + assert client.config.domain is domain_resolver + assert callable(client.config.domain) + # Verify other config properties are accessible + assert client.config.client_id == "test_client_id" + assert str(client.config.app_base_url) == "https://example.com/" # Pydantic normalizes with trailing slash diff --git a/src/auth0_fastapi/test/test_routes.py b/src/auth0_fastapi/test/test_routes.py index 2d226c2..d82384b 100644 --- a/src/auth0_fastapi/test/test_routes.py +++ b/src/auth0_fastapi/test/test_routes.py @@ -540,3 +540,257 @@ def test_connect_routes_conditional_mounting(self, auth_config): else: # Connect routes should not be mounted pass + + +class TestMCDSupport: + """ + Test Multiple Custom Domains (MCD) support. + + MCD allows a single application to serve multiple tenants, each with their own + Auth0 domain. This requires dynamic redirect_uri construction based on the + incoming request host. + """ + + # ========================================================================= + # build_request_base_url() utility tests + # ========================================================================= + + def test_build_url_from_host_header(self): + """Test building URL from standard host header.""" + from auth0_fastapi.util import build_request_base_url + + request = Mock() + request.headers = {"host": "example.com"} + + result = build_request_base_url(request) + assert result == "http://example.com" + + def test_build_url_from_x_forwarded_host(self): + """Test building URL from x-forwarded-host (proxy scenario).""" + from auth0_fastapi.util import build_request_base_url + + request = Mock() + request.headers = { + "host": "localhost:8000", + "x-forwarded-host": "app.example.com", + "x-forwarded-proto": "https" + } + + result = build_request_base_url(request) + assert result == "https://app.example.com" + + def test_build_url_removes_standard_https_port(self): + """Test that standard HTTPS port (443) is removed.""" + from auth0_fastapi.util import build_request_base_url + + request = Mock() + request.headers = { + "host": "example.com:443", + "x-forwarded-proto": "https" + } + + result = build_request_base_url(request) + assert result == "https://example.com" + + def test_build_url_removes_standard_http_port(self): + """Test that standard HTTP port (80) is removed.""" + from auth0_fastapi.util import build_request_base_url + + request = Mock() + request.headers = { + "host": "example.com:80" + } + + result = build_request_base_url(request) + assert result == "http://example.com" + + def test_build_url_preserves_non_standard_port(self): + """Test that non-standard ports are preserved.""" + from auth0_fastapi.util import build_request_base_url + + request = Mock() + request.headers = { + "host": "example.com:8080", + "x-forwarded-proto": "https" + } + + result = build_request_base_url(request) + assert result == "https://example.com:8080" + + def test_build_url_defaults_to_localhost(self): + """Test fallback to localhost when no host header.""" + from auth0_fastapi.util import build_request_base_url + + request = Mock() + request.headers = {} + + result = build_request_base_url(request) + assert result == "http://localhost" + + def test_build_url_x_forwarded_proto_precedence(self): + """Test that x-forwarded-proto takes precedence.""" + from auth0_fastapi.util import build_request_base_url + + request = Mock() + request.headers = { + "host": "example.com", + "x-forwarded-proto": "https" + } + + result = build_request_base_url(request) + assert result == "https://example.com" + + def test_build_url_multiple_tenants(self): + """Test URL building for multiple tenant domains.""" + from auth0_fastapi.util import build_request_base_url + + # Tenant A + request_a = Mock() + request_a.headers = { + "x-forwarded-host": "tenant-a.myapp.com", + "x-forwarded-proto": "https" + } + result_a = build_request_base_url(request_a) + assert result_a == "https://tenant-a.myapp.com" + + # Tenant B + request_b = Mock() + request_b.headers = { + "x-forwarded-host": "tenant-b.myapp.com", + "x-forwarded-proto": "https" + } + result_b = build_request_base_url(request_b) + assert result_b == "https://tenant-b.myapp.com" + + # Verify they're different + assert result_a != result_b + + # ========================================================================= + # Login route MCD tests + # ========================================================================= + + @pytest.mark.asyncio + async def test_login_callable_domain_builds_dynamic_redirect_uri(self): + """Test that login route builds dynamic redirect_uri when domain is callable.""" + from auth0_fastapi.util import build_request_base_url + + mock_request = Mock() + mock_request.headers = { + "host": "tenant-a.myapp.com", + "x-forwarded-proto": "https" + } + mock_request.query_params = {} + + async def domain_resolver(context): + return "tenant-a.auth0.com" + + mock_auth_client = Mock(spec=AuthClient) + mock_auth_client.config = Mock() + mock_auth_client.config.domain = domain_resolver + mock_auth_client.start_login = AsyncMock(return_value="https://tenant-a.auth0.com/authorize") + mock_request.app.state.auth_client = mock_auth_client + + base_url = build_request_base_url(mock_request) + assert base_url == "https://tenant-a.myapp.com" + + expected_redirect_uri = f"{base_url}/auth/callback" + assert expected_redirect_uri == "https://tenant-a.myapp.com/auth/callback" + + @pytest.mark.asyncio + async def test_login_static_domain_uses_app_base_url(self): + """Test that login route uses app_base_url when domain is static string.""" + mock_auth_client = Mock(spec=AuthClient) + mock_auth_client.config = Mock() + mock_auth_client.config.domain = "tenant.auth0.com" + mock_auth_client.config.app_base_url = "https://example.com" + + expected_redirect_uri = f"{mock_auth_client.config.app_base_url}/auth/callback" + assert expected_redirect_uri == "https://example.com/auth/callback" + assert not callable(mock_auth_client.config.domain) + + @pytest.mark.asyncio + async def test_login_different_tenants_get_different_redirect_uris(self): + """Test that different tenant hosts get different redirect_uris.""" + from auth0_fastapi.util import build_request_base_url + + # Tenant A + request_a = Mock() + request_a.headers = {"host": "tenant-a.myapp.com", "x-forwarded-proto": "https"} + redirect_a = f"{build_request_base_url(request_a)}/auth/callback" + + # Tenant B + request_b = Mock() + request_b.headers = {"host": "tenant-b.myapp.com", "x-forwarded-proto": "https"} + redirect_b = f"{build_request_base_url(request_b)}/auth/callback" + + assert redirect_a == "https://tenant-a.myapp.com/auth/callback" + assert redirect_b == "https://tenant-b.myapp.com/auth/callback" + assert redirect_a != redirect_b + + # ========================================================================= + # Callback route MCD tests + # ========================================================================= + + @pytest.mark.asyncio + async def test_callback_works_with_callable_domain(self, mock_auth_client): + """Test that callback route works correctly with callable domain.""" + mock_request = Mock() + mock_request.url = "https://tenant-a.myapp.com/auth/callback?code=test_code&state=test_state" + mock_request.headers = {"host": "tenant-a.myapp.com", "x-forwarded-proto": "https"} + mock_request.app.state.auth_client = mock_auth_client + + async def domain_resolver(context): + return "tenant-a.auth0.com" + + mock_auth_client.config.domain = domain_resolver + mock_auth_client.complete_login = AsyncMock(return_value={ + "user": {"sub": "user123"}, + "app_state": None + }) + + assert callable(mock_auth_client.config.domain) + result = await mock_auth_client.complete_login(str(mock_request.url)) + assert result["user"]["sub"] == "user123" + + # ========================================================================= + # Logout route MCD tests + # ========================================================================= + + @pytest.mark.asyncio + async def test_logout_works_with_callable_domain(self, mock_auth_client): + """Test that logout route works correctly with callable domain.""" + mock_request = Mock() + mock_request.headers = {"host": "tenant-a.myapp.com", "x-forwarded-proto": "https"} + mock_request.app.state.auth_client = mock_auth_client + + async def domain_resolver(context): + return "tenant-a.auth0.com" + + mock_auth_client.config.domain = domain_resolver + mock_auth_client.logout = AsyncMock(return_value="https://tenant-a.auth0.com/v2/logout") + + assert callable(mock_auth_client.config.domain) + result = await mock_auth_client.logout(return_to="/") + assert "logout" in result + + # ========================================================================= + # Dependency injection MCD tests + # ========================================================================= + + def test_get_auth_client_works_with_callable_domain(self): + """Test that get_auth_client dependency works with callable domain.""" + from auth0_fastapi.server.routes import get_auth_client + + async def domain_resolver(context): + return "tenant.auth0.com" + + mock_request = Mock() + mock_auth_client = Mock(spec=AuthClient) + mock_auth_client.config = Mock() + mock_auth_client.config.domain = domain_resolver + mock_request.app.state.auth_client = mock_auth_client + + result = get_auth_client(mock_request) + + assert result is mock_auth_client + assert callable(result.config.domain) diff --git a/src/auth0_fastapi/util/__init__.py b/src/auth0_fastapi/util/__init__.py index b5b121a..874203e 100644 --- a/src/auth0_fastapi/util/__init__.py +++ b/src/auth0_fastapi/util/__init__.py @@ -1,6 +1,9 @@ -from typing import Optional +from typing import TYPE_CHECKING, Optional from urllib.parse import urljoin, urlparse +if TYPE_CHECKING: + from fastapi import Request + def ensure_no_leading_slash(url: str) -> str: """ @@ -63,3 +66,26 @@ def to_safe_redirect(dangerous_redirect: str, safe_base_url: str) -> Optional[st if route_origin == safe_origin: return route_url return None + + +def build_request_base_url(request: "Request") -> str: + """ + Build base URL from request headers. + Supports proxy headers (x-forwarded-host, x-forwarded-proto) for MCD scenarios. + + Args: + request: FastAPI Request object + + Returns: + Base URL string (e.g., "https://app.example.com") + """ + host = request.headers.get('x-forwarded-host') or request.headers.get('host', 'localhost') + proto = request.headers.get('x-forwarded-proto', 'http') + + # Remove port from host if it's standard (443 for https, 80 for http) + if ':443' in host and proto == 'https': + host = host.replace(':443', '') + elif ':80' in host and proto == 'http': + host = host.replace(':80', '') + + return f"{proto}://{host}" From 776d723feb99ecf733d2143d78f6dec7eaaed395 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 11 Feb 2026 19:21:57 +0530 Subject: [PATCH 02/11] refactor: fix linting issues --- src/auth0_fastapi/server/routes.py | 10 +++++----- src/auth0_fastapi/test/test_auth_client.py | 18 +++++++++--------- src/auth0_fastapi/util/__init__.py | 8 ++++---- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/auth0_fastapi/server/routes.py b/src/auth0_fastapi/server/routes.py index bffc287..731d423 100644 --- a/src/auth0_fastapi/server/routes.py +++ b/src/auth0_fastapi/server/routes.py @@ -45,7 +45,7 @@ async def login( Endpoint to initiate the login process. Optionally accepts a 'return_to' query parameter and passes it as part of the app state. Redirects the user to the Auth0 authorization URL. - + When domain is a callable (MCD), the redirect_uri is built dynamically from the request host to ensure proper domain handling. """ @@ -53,12 +53,12 @@ async def login( return_to: Optional[str] = request.query_params.get("returnTo") authorization_params = {k: v for k, v in request.query_params.items() if k not in [ "returnTo"]} - + # Build dynamic redirect_uri from request host when domain is callable if callable(auth_client.config.domain): base_url = build_request_base_url(request) authorization_params["redirect_uri"] = f"{base_url}/auth/callback" - + auth_url = await auth_client.start_login( app_state={"returnTo": return_to} if return_to else None, authorization_params=authorization_params, @@ -119,7 +119,7 @@ async def logout( Endpoint to handle logout. Clears the session cookie (if applicable) and generates a logout URL, then redirects the user to Auth0's logout endpoint. - + For MCD, builds dynamic returnTo URL based on incoming request host. """ return_to: Optional[str] = request.query_params.get("returnTo") @@ -129,7 +129,7 @@ async def logout( default_redirect = build_request_base_url(request) else: default_redirect = str(auth_client.config.app_base_url) - + logout_url = await auth_client.logout( return_to=return_to or default_redirect, store_options={"request": request, "response": response}, # Pass request for MCD diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index 3e394a7..a8f1400 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -500,27 +500,27 @@ async def domain_resolver(context): with patch('auth0_fastapi.auth.auth_client.ServerClient') as mock_server_client: client = AuthClient(config) - + # Verify ServerClient was called exactly once mock_server_client.assert_called_once() _, kwargs = mock_server_client.call_args - + # Verify domain resolver is passed by reference assert kwargs['domain'] is domain_resolver assert callable(kwargs['domain']) - + # Verify other critical parameters are passed correctly assert kwargs['client_id'] == "test_client_id" assert kwargs['client_secret'] == "test_client_secret" assert kwargs['secret'] == "test_secret_key_minimum_32_characters" assert 'redirect_uri' in kwargs assert kwargs['redirect_uri'] == "https://example.com/auth/callback" - + # Verify authorization_params includes redirect_uri and audience assert 'authorization_params' in kwargs assert kwargs['authorization_params']['redirect_uri'] == "https://example.com/auth/callback" assert kwargs['authorization_params'].get('audience') == "https://api.example.com" - + # Verify client was created assert client is not None assert client.config is config @@ -543,7 +543,7 @@ async def domain_resolver(context): with patch('auth0_fastapi.auth.auth_client.ServerClient') as mock_server_client: client = AuthClient(config) - + mock_server_client.assert_called_once() _, kwargs = mock_server_client.call_args # AuthClient still passes redirect_uri - routes will override dynamically @@ -551,7 +551,7 @@ async def domain_resolver(context): assert kwargs['redirect_uri'] == "https://example.com/auth/callback" assert 'authorization_params' in kwargs assert kwargs['authorization_params']['redirect_uri'] == "https://example.com/auth/callback" - + # Verify client was created successfully assert client is not None assert client.config.domain is domain_resolver @@ -592,10 +592,10 @@ async def domain_resolver(context): assert 'store_options' in call_kwargs assert call_kwargs['store_options']['request'] is mock_request assert call_kwargs['store_options']['response'] is mock_response - + # Verify the return value is passed through assert result == "https://auth0.com/authorize" - + def test_auth_client_stores_config_with_domain_resolver(self): """Test that AuthClient properly stores config with domain resolver.""" async def domain_resolver(context): diff --git a/src/auth0_fastapi/util/__init__.py b/src/auth0_fastapi/util/__init__.py index 874203e..388ec4c 100644 --- a/src/auth0_fastapi/util/__init__.py +++ b/src/auth0_fastapi/util/__init__.py @@ -72,20 +72,20 @@ def build_request_base_url(request: "Request") -> str: """ Build base URL from request headers. Supports proxy headers (x-forwarded-host, x-forwarded-proto) for MCD scenarios. - + Args: request: FastAPI Request object - + Returns: Base URL string (e.g., "https://app.example.com") """ host = request.headers.get('x-forwarded-host') or request.headers.get('host', 'localhost') proto = request.headers.get('x-forwarded-proto', 'http') - + # Remove port from host if it's standard (443 for https, 80 for http) if ':443' in host and proto == 'https': host = host.replace(':443', '') elif ':80' in host and proto == 'http': host = host.replace(':80', '') - + return f"{proto}://{host}" From 7730989fbee9fbf03caa80b2b91bb8374f1e3c66 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 11 Feb 2026 19:25:07 +0530 Subject: [PATCH 03/11] fix: Add acceptance for Unknown license in Snyk policy --- .snyk | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.snyk b/.snyk index 020fe3c..0ccb7ee 100644 --- a/.snyk +++ b/.snyk @@ -17,4 +17,8 @@ ignore: - '*': reason: "Accepting jwcrypto’s LGPL-3.0 license for now" expires: "2030-12-31T23:59:59Z" + "snyk:lic:pip:cryptography:Unknown": + - '*': + reason: "Accepting the Unknown license for now" + expires: "2030-12-31T23:59:59Z" patch: {} From f701635b6ddcb862ede5bb4afda6ced57cad6eff Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Sat, 14 Feb 2026 02:00:19 +0530 Subject: [PATCH 04/11] docs: Clarify Multiple Custom Domains (MCD) usage in README and examples --- README.md | 4 ++-- examples/MultipleCustomDomains.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 017d7b1..887e822 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ - **Fully Integrated Auth Flows**: Automatic routes for `/auth/login`, `/auth/logout`, `/auth/callback`, etc. - **Session-Based**: Uses secure cookies to store user sessions, either stateless (all data in cookie) or stateful (data in a database). -- **Multiple Custom Domains (MCD)**: Support for multi-tenant applications with different Auth0 domains per tenant. +- **Multiple Custom Domains (MCD)**: Support for applications using multiple custom domains on the same Auth0 tenant. - **Account Linking**: Optional routes for linking multiple social or username/password accounts into a single Auth0 profile. - **Backchannel Logout**: Receive logout tokens from Auth0 to invalidate sessions server-side. - **Extensible**: Swap in your own store implementations or tune existing ones (cookie name, expiration, etc.) @@ -268,7 +268,7 @@ The `AUTH0_AUDIENCE` is the identifier of the API you want to call. You can find ## Multiple Custom Domains (MCD) -For multi-tenant applications where each tenant uses a different Auth0 domain, pass a callable instead of a static domain string: +For applications using multiple custom domains on the same Auth0 tenant, pass a callable instead of a static domain string: ```python from auth0_server_python.auth_types import DomainResolverContext diff --git a/examples/MultipleCustomDomains.md b/examples/MultipleCustomDomains.md index c4520eb..bd4dca6 100644 --- a/examples/MultipleCustomDomains.md +++ b/examples/MultipleCustomDomains.md @@ -4,7 +4,7 @@ This guide covers usage patterns for implementing Multiple Custom Domains in you ## Overview -MCD allows a single application to serve multiple tenants, each with their own Auth0 domain. This is useful for: +MCD allows a single application to use multiple custom domains configured on the same Auth0 tenant. This is useful for: ## Basic Setup @@ -145,7 +145,7 @@ async def domain_resolver(context: DomainResolverContext) -> str: ## Auth0 Dashboard Configuration -For MCD to work, configure each Auth0 tenant: +For MCD to work, configure your Auth0 application: 1. **Allowed Callback URLs**: Add all tenant callback URLs ``` From 88a88ed53380fe3299d39dbbc928c9cbb77548c1 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 25 Feb 2026 22:37:53 +0530 Subject: [PATCH 05/11] feat: Enhance documentation and add tests for non-standard ports --- examples/MultipleCustomDomains.md | 58 +++++++++++++++++++++++++-- src/auth0_fastapi/test/test_routes.py | 25 ++++++++++++ src/auth0_fastapi/util/__init__.py | 8 ++-- 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/examples/MultipleCustomDomains.md b/examples/MultipleCustomDomains.md index bd4dca6..133d0b1 100644 --- a/examples/MultipleCustomDomains.md +++ b/examples/MultipleCustomDomains.md @@ -1,10 +1,12 @@ # Multiple Custom Domains (MCD) -This guide covers usage patterns for implementing Multiple Custom Domains in your FastAPI application. +MCD lets you resolve the Auth0 domain per request while keeping a single `AuthClient` instance. This is useful when your application uses multiple custom domains configured on the same Auth0 tenant. -## Overview +**Example:** +- `https://acme.yourapp.com` → Custom domain: `auth.acme.com` +- `https://globex.yourapp.com` → Custom domain: `auth.globex.com` -MCD allows a single application to use multiple custom domains configured on the same Auth0 tenant. This is useful for: +MCD is enabled by providing a **domain resolver function** instead of a static domain string. ## Basic Setup @@ -57,6 +59,8 @@ config = Auth0Config( auth_client = AuthClient(config) ``` +> **Note:** In resolver mode, the SDK builds the `redirect_uri` dynamically from the request host. You do not need to set it manually. If you override `redirect_uri` in `authorization_params`, the SDK uses your value as-is. + ## Usage Patterns ### Pattern 1: Host Header Mapping @@ -77,6 +81,8 @@ async def domain_resolver(context: DomainResolverContext) -> str: ### Pattern 2: Subdomain Extraction +> **Warning:** This pattern constructs the Auth0 domain from raw header input. An attacker who controls the `Host` header can influence the resolved domain. Use an allowlist (Pattern 1) for production deployments. See [Security Considerations](#use-an-allowlist-in-your-resolver). + Extract tenant from subdomain: ```python @@ -196,11 +202,55 @@ When MCD is enabled, the SDK: 4. **Token Refresh**: Uses session's stored domain (not current request domain) 5. **Logout**: Resolves current domain for logout URL +## Session Behavior in Resolver Mode + +In resolver mode, sessions are bound to the domain that created them. On each request, the SDK compares the session's stored domain against the current resolved domain: + +- `get_user()` and `get_session()` return `None` on domain mismatch. +- `get_access_token()` raises `AccessTokenError` on domain mismatch. +- Token refresh uses the session's stored domain, not the current request domain. + +> **Warning:** If you switch from a static domain string to a resolver function, existing sessions that do not include a stored domain continue to work — the SDK treats the absent domain field as valid. New sessions will store the resolved domain automatically. Once old sessions expire, all sessions will be domain-aware. + +## Discovery Cache + +The SDK caches OIDC metadata and JWKS per domain in memory (LRU eviction, 600-second TTL, up to 100 domains). This avoids repeated network calls when serving multiple domains. The cache is shared across all requests to the same `AuthClient` instance. + ## Security Considerations -- **Session Isolation**: Sessions are bound to their origin domain. A session created on `tenant-a.myapp.com` cannot be used on `tenant-b.myapp.com` +- **Session Isolation**: Sessions are bound to their origin domain. A session created on one custom domain cannot be used on another. - **Issuer Validation**: Token issuer is validated against the expected domain (with normalization for trailing slashes and case) - **Token Refresh**: Refresh tokens are used with their original domain's token endpoint +- **Redirect URI Protection**: Auth0 rejects authorization requests where `redirect_uri` is not in the application's Allowed Callback URLs, preventing redirect-based attacks even if host headers are spoofed. + +### Use an Allowlist in Your Resolver + +The SDK passes request headers to your domain resolver via `DomainResolverContext`. These headers come directly from the HTTP request and can be spoofed. Always use a mapping or allowlist — never construct domains from raw header values: + +```python +# Safe: unknown hosts fall back to default +async def domain_resolver(context: DomainResolverContext) -> str: + host = context.request_headers.get("host", "").split(":")[0] + return DOMAIN_MAP.get(host, DEFAULT_DOMAIN) + +# Risky: attacker sends Host: evil.myapp.com → evil.auth0.com +async def domain_resolver(context: DomainResolverContext) -> str: + tenant = context.request_headers.get("host", "").split(".")[0] + return f"{tenant}.auth0.com" +``` + +### Trust Forwarded Headers Only Behind a Proxy + +Only use `x-forwarded-host` when behind a trusted reverse proxy. Consider adding `TrustedHostMiddleware`: + +```python +from starlette.middleware.trustedhost import TrustedHostMiddleware + +app.add_middleware( + TrustedHostMiddleware, + allowed_hosts=["acme.myapp.com", "globex.myapp.com"] +) +``` ## Complete Example diff --git a/src/auth0_fastapi/test/test_routes.py b/src/auth0_fastapi/test/test_routes.py index d82384b..9abd978 100644 --- a/src/auth0_fastapi/test/test_routes.py +++ b/src/auth0_fastapi/test/test_routes.py @@ -665,6 +665,31 @@ def test_build_url_multiple_tenants(self): # Verify they're different assert result_a != result_b + def test_build_url_non_standard_port_not_corrupted_http(self): + """Test that non-standard ports starting with 80 are not corrupted.""" + from auth0_fastapi.util import build_request_base_url + + request = Mock() + request.headers = { + "host": "example.com:8080", + } + + result = build_request_base_url(request) + assert result == "http://example.com:8080" + + def test_build_url_non_standard_port_not_corrupted_https(self): + """Test that non-standard ports starting with 443 are not corrupted.""" + from auth0_fastapi.util import build_request_base_url + + request = Mock() + request.headers = { + "host": "example.com:4430", + "x-forwarded-proto": "https" + } + + result = build_request_base_url(request) + assert result == "https://example.com:4430" + # ========================================================================= # Login route MCD tests # ========================================================================= diff --git a/src/auth0_fastapi/util/__init__.py b/src/auth0_fastapi/util/__init__.py index 388ec4c..834fe95 100644 --- a/src/auth0_fastapi/util/__init__.py +++ b/src/auth0_fastapi/util/__init__.py @@ -83,9 +83,9 @@ def build_request_base_url(request: "Request") -> str: proto = request.headers.get('x-forwarded-proto', 'http') # Remove port from host if it's standard (443 for https, 80 for http) - if ':443' in host and proto == 'https': - host = host.replace(':443', '') - elif ':80' in host and proto == 'http': - host = host.replace(':80', '') + if host.endswith(':443') and proto == 'https': + host = host[:-4] + elif host.endswith(':80') and proto == 'http': + host = host[:-3] return f"{proto}://{host}" From 999059f730e7d4b50087bf8c7fd81975b4fccefe Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 11 Mar 2026 22:20:10 +0530 Subject: [PATCH 06/11] docs/chore: update MCD docs for backward compatibility changes and restructure README and fix lint issue --- README.md | 50 ++++++++++--------- examples/MultipleCustomDomains.md | 24 +++++++-- src/auth0_fastapi/auth/auth_client.py | 10 ++-- src/auth0_fastapi/errors/__init__.py | 3 ++ src/auth0_fastapi/server/routes.py | 5 +- src/auth0_fastapi/test/test_auth_client.py | 7 ++- src/auth0_fastapi/test/test_routes.py | 3 +- src/auth0_fastapi/test/test_stateful_store.py | 3 +- .../test/test_stateless_store.py | 5 +- 9 files changed, 66 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 887e822..1bcc3ca 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,18 @@ ## Documentation -- [Examples](https://github.com/auth0/auth0-server-python/blob/main/packages/auth0_server_python/examples) - examples for your different use cases. +- [Examples](./examples) - examples for your different use cases. - [Docs Site](https://auth0.com/docs) - explore our docs site and learn more about Auth0. ## Getting Started - [1. Features](#1-features) - [2. Installation](#2-installation) -- [3. Setup](#2-setup) - - [Minimal Setup](#minimal) +- [3. Setup](#3-setup) + - [Minimal](#minimal) + - [Auth0 Dashboard Configurations](#auth0-dashboard-configurations) - [Advanced](#advanced) - [4. Routes](#4-routes) - - [Protecting Routes](#protecting-routes) ### 1. Features @@ -40,13 +40,14 @@ pip install auth0-fastapi ``` -If you’re using Poetry: +If you're using Poetry: ```shell poetry install auth0-fastapi ``` ### 3. Setup + #### Minimal ```python @@ -85,7 +86,7 @@ app.state.auth_client = auth_client # 4) Conditionally register routes register_auth_routes(router, config) -# 5) Include the SDK’s default routes +# 5) Include the SDK's default routes app.include_router(router) @@ -109,7 +110,7 @@ openssl rand -hex 64 - The `APP_BASE_URL` is the URL that your application is running on. When developing locally, this is most commonly `http://localhost:3000`. -> [!IMPORTANT] +> [!IMPORTANT] > You will need to register the following URLs in your Auth0 Application via the [Auth0 Dashboard](https://manage.auth0.com): > > - Add `http://localhost:3000/auth/callback` to the list of **Allowed Callback URLs** @@ -117,7 +118,7 @@ openssl rand -hex 64 #### Advanced -If you need more control over session management, transaction cookies, or additional settings, here’s a more extensive setup. +If you need more control over session management, transaction cookies, or additional settings, here's a more extensive setup. ##### Customizing the Cookie Stores @@ -164,11 +165,11 @@ app.state.auth_client = auth_client # 4) Conditionally register routes register_auth_routes(router, config) -# 5) Include the SDK’s default routes +# 5) Include the SDK's default routes app.include_router(router) ``` -#### 4. Routes +### 4. Routes The SDK for Web Applications mounts 4 main routes: @@ -181,7 +182,7 @@ To disable this behavior, you can set the `mount_routes` option to `False` (it's ```python config = Auth0Config( - domain="YOUR_AUTH0_DOMAIN", + domain="YOUR_AUTH0_DOMAIN", client_id="YOUR_CLIENT_ID", client_secret="YOUR_CLIENT_SECRET", app_base_url="http://localhost:3000", @@ -193,7 +194,7 @@ config = Auth0Config( Additionally, by setting `mount_connected_account_routes` to `True` (it's `False` by default) the SDK also can also mount routes useful for using Token Vault with Connected Accounts: 1. `/auth/connect`: the route that the user will be redirected to to initiate account linking -2. `/auth/callback`: will also handle the callback behaviour from the Connected Accounts flow +2. `/auth/callback`: will also handle the callback behaviour from the Connected Accounts flow Alternatively, by setting `mount_connect_routes` to `True` (it's `False` by default) the SDK also can also mount 4 routes useful for account-linking: @@ -203,8 +204,10 @@ Alternatively, by setting `mount_connect_routes` to `True` (it's `False` by defa 4. `/auth/unconnect/callback`: the callback route for account linking that must be added to your Auth0 application's Allowed Callback URLs These two behaviours cannot be used simultaneously. This form of account-linking is now considered legacy, use of Connected Accounts is preferred. - -#### Protecting Routes + +## Usage + +### Protecting Routes In order to protect a FastAPI route, you can use the SDK's `get_session()` method and pass it through `Depends`: @@ -221,7 +224,7 @@ config = Auth0Config( app_base_url="http://localhost:3000", # or your production URL secret="YOUR_SESSION_SECRET", authorization_params={ - "scope": "openid profile", # required get the user information from Auth0 + "scope": "openid profile", # required to get user information from Auth0 } ) @@ -234,7 +237,7 @@ async def profile(request: Request, response: Response, session=Depends(auth_cli user = await auth_client.client.get_user(store_options=store_options) if not user: return {"error": "User not authenticated"} - + return { "message": "Your Profile", "user": user, @@ -242,12 +245,12 @@ async def profile(request: Request, response: Response, session=Depends(auth_cli } ``` -> [!IMPORTANT] +> [!IMPORTANT] > The above is to protect server-side rendering routes by the means of a session, and not API routes using a bearer token. -> The `authorization_params` passing the `scope` is used in to retrieve the user information from Auth0. Can be omitted if you don't need the user information. +> The `authorization_params` passing the `scope` is used to retrieve the user information from Auth0. Can be omitted if you don't need the user information. -#### Requesting an Access Token to call an API +### Requesting an Access Token to call an API If you need to call an API on behalf of the user, you want to specify the `audience` parameter when registering the plugin. This will make the SDK request an access token for the specified audience when the user logs in. @@ -266,7 +269,7 @@ config = Auth0Config( The `AUTH0_AUDIENCE` is the identifier of the API you want to call. You can find this in the [APIs section of the Auth0 Dashboard](https://manage.auth0.com/#/apis/). -## Multiple Custom Domains (MCD) +### Multiple Custom Domains (MCD) For applications using multiple custom domains on the same Auth0 tenant, pass a callable instead of a static domain string: @@ -292,8 +295,9 @@ config = Auth0Config( When using MCD, the SDK automatically: - Builds dynamic `redirect_uri` based on the incoming request host -- Stores the origin domain in the session for domain-isolated token refresh -- Validates tokens against the correct issuer +- Stores the resolved domain in the session for domain-isolated token refresh +- Validates tokens against the correct issuer (derived from OIDC metadata) +- Handles legacy sessions (created before MCD) via a fallback chain For detailed usage patterns, see [examples/MultipleCustomDomains.md](./examples/MultipleCustomDomains.md). @@ -329,4 +333,4 @@ Please do not report security vulnerabilities on the public GitHub issue tracker

This project is licensed under the MIT license. See the LICENSE file for more info. -

+

\ No newline at end of file diff --git a/examples/MultipleCustomDomains.md b/examples/MultipleCustomDomains.md index 133d0b1..5f46509 100644 --- a/examples/MultipleCustomDomains.md +++ b/examples/MultipleCustomDomains.md @@ -176,7 +176,7 @@ For MCD to work, configure your Auth0 application: Handle domain resolver errors gracefully: ```python -from auth0_server_python.errors import DomainResolverError +from auth0_server_python.error import DomainResolverError async def domain_resolver(context: DomainResolverContext) -> str: host = context.request_headers.get("host", "").split(":")[0] @@ -196,8 +196,8 @@ async def domain_resolver(context: DomainResolverContext) -> str: When MCD is enabled, the SDK: -1. **Login**: Resolves domain from request, builds dynamic `redirect_uri`, stores `origin_domain` in transaction -2. **Callback**: Retrieves `origin_domain` from transaction, exchanges code with correct token endpoint, validates issuer +1. **Login**: Resolves domain from request, builds dynamic `redirect_uri`, stores `domain` in transaction +2. **Callback**: Retrieves `domain` from transaction, derives issuer from OIDC metadata, exchanges code with correct token endpoint, validates issuer 3. **Session**: Stores `domain` field in session for future requests 4. **Token Refresh**: Uses session's stored domain (not current request domain) 5. **Logout**: Resolves current domain for logout URL @@ -210,7 +210,23 @@ In resolver mode, sessions are bound to the domain that created them. On each re - `get_access_token()` raises `AccessTokenError` on domain mismatch. - Token refresh uses the session's stored domain, not the current request domain. -> **Warning:** If you switch from a static domain string to a resolver function, existing sessions that do not include a stored domain continue to work — the SDK treats the absent domain field as valid. New sessions will store the resolved domain automatically. Once old sessions expire, all sessions will be domain-aware. +> **Note:** When moving from a static domain to a resolver function, existing sessions that lack a `domain` field continue to work. The SDK uses a three-tier fallback to determine the session's domain: (1) `session.domain`, (2) the static domain if configured, (3) the hostname extracted from the user's `iss` claim. New sessions store the resolved domain automatically. See [Legacy Sessions](#legacy-sessions) for details. + +## Legacy Sessions + +When moving from a static domain setup to resolver mode, existing sessions can continue +to work if the resolver returns the same Auth0 domain that was used for those legacy sessions. + +The SDK determines the session's domain using a fallback chain: + +1. **`session.domain`** — new sessions created after MCD was enabled store this field. +2. **Static domain** — if a static `domain` string was configured, it is used as a fallback. +3. **User's issuer claim** — the hostname is extracted from the `iss` claim in the user's + ID token (e.g., `https://tenant.auth0.com/` yields `tenant.auth0.com`). + +In most cases, the issuer claim already matches the Auth0 domain, so legacy sessions work +without re-authentication. If the resolver returns a different domain that does not match +any fallback tier, the user will need to sign in again. ## Discovery Cache diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index 28318be..8d132d8 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -2,6 +2,9 @@ # Imported from auth0-server-python from typing import Optional +from auth0_fastapi.config import Auth0Config +from auth0_fastapi.stores.cookie_transaction_store import CookieTransactionStore +from auth0_fastapi.stores.stateless_state_store import StatelessStateStore from auth0_server_python.auth_server.server_client import ServerClient from auth0_server_python.auth_types import ( CompleteConnectAccountResponse, @@ -11,10 +14,6 @@ ) from fastapi import HTTPException, Request, Response, status -from auth0_fastapi.config import Auth0Config -from auth0_fastapi.stores.cookie_transaction_store import CookieTransactionStore -from auth0_fastapi.stores.stateless_state_store import StatelessStateStore - class AuthClient: """ @@ -141,11 +140,12 @@ async def logout( async def handle_backchannel_logout( self, logout_token: str, + store_options: dict = None, ) -> None: """ Processes a backchannel logout using the provided logout token. """ - return await self.client.handle_backchannel_logout(logout_token) + return await self.client.handle_backchannel_logout(logout_token, store_options=store_options) async def start_link_user( self, diff --git a/src/auth0_fastapi/errors/__init__.py b/src/auth0_fastapi/errors/__init__.py index ddb6355..184bfeb 100644 --- a/src/auth0_fastapi/errors/__init__.py +++ b/src/auth0_fastapi/errors/__init__.py @@ -5,6 +5,7 @@ ApiError, Auth0Error, BackchannelLogoutError, + IssuerValidationError, MissingRequiredArgumentError, MissingTransactionError, ) @@ -34,6 +35,8 @@ def auth0_exception_handler(request: Request, exc: Auth0Error): status_code = 404 # Not Found elif isinstance(exc, MissingRequiredArgumentError): status_code = 422 # Unprocessable Entity + elif isinstance(exc, IssuerValidationError): + status_code = 401 # Unauthorized - token issuer mismatch elif isinstance(exc, ApiError): status_code = 502 # Bad Gateway, indicates an upstream error elif isinstance(exc, AccessTokenError): diff --git a/src/auth0_fastapi/server/routes.py b/src/auth0_fastapi/server/routes.py index 731d423..48a2e05 100644 --- a/src/auth0_fastapi/server/routes.py +++ b/src/auth0_fastapi/server/routes.py @@ -156,7 +156,10 @@ async def backchannel_logout( status_code=400, detail="Missing 'logout_token' in request body.") try: - await auth_client.handle_backchannel_logout(logout_token) + await auth_client.handle_backchannel_logout( + logout_token, + store_options={"request": request}, + ) except Exception as e: raise HTTPException(status_code=400, detail=str(e)) return Response(status_code=204) diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index a8f1400..50eec35 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -2,11 +2,10 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from auth0_server_python.auth_types import CompleteConnectAccountResponse, ConnectAccountOptions -from fastapi import HTTPException, Request, Response - from auth0_fastapi.auth.auth_client import AuthClient from auth0_fastapi.config import Auth0Config +from auth0_server_python.auth_types import CompleteConnectAccountResponse, ConnectAccountOptions +from fastapi import HTTPException, Request, Response @pytest.fixture @@ -242,7 +241,7 @@ async def test_backchannel_logout(self, auth_client): result = await auth_client.handle_backchannel_logout(logout_token) assert result is None - mock_backchannel.assert_called_once_with(logout_token) + mock_backchannel.assert_called_once_with(logout_token, store_options=None) @pytest.mark.asyncio async def test_backchannel_logout_with_invalid_token(self, auth_client): diff --git a/src/auth0_fastapi/test/test_routes.py b/src/auth0_fastapi/test/test_routes.py index 9abd978..0df3285 100644 --- a/src/auth0_fastapi/test/test_routes.py +++ b/src/auth0_fastapi/test/test_routes.py @@ -1,11 +1,10 @@ from unittest.mock import AsyncMock, Mock import pytest -from fastapi import HTTPException - from auth0_fastapi.auth.auth_client import AuthClient from auth0_fastapi.config import Auth0Config from auth0_fastapi.server.routes import get_auth_client, register_auth_routes +from fastapi import HTTPException @pytest.fixture diff --git a/src/auth0_fastapi/test/test_stateful_store.py b/src/auth0_fastapi/test/test_stateful_store.py index 52a3fe3..8f9d48b 100644 --- a/src/auth0_fastapi/test/test_stateful_store.py +++ b/src/auth0_fastapi/test/test_stateful_store.py @@ -1,11 +1,10 @@ from unittest.mock import AsyncMock, Mock import pytest +from auth0_fastapi.stores.stateful_state_store import StatefulStateStore from auth0_server_python.auth_types import StateData from fastapi import Request, Response -from auth0_fastapi.stores.stateful_state_store import StatefulStateStore - @pytest.fixture def mock_request(): diff --git a/src/auth0_fastapi/test/test_stateless_store.py b/src/auth0_fastapi/test/test_stateless_store.py index 95c8456..5b11812 100644 --- a/src/auth0_fastapi/test/test_stateless_store.py +++ b/src/auth0_fastapi/test/test_stateless_store.py @@ -1,11 +1,10 @@ from unittest.mock import Mock, patch import pytest -from auth0_server_python.auth_types import TransactionData -from fastapi import Request, Response - from auth0_fastapi.stores.cookie_transaction_store import CookieTransactionStore from auth0_fastapi.stores.stateless_state_store import StatelessStateStore +from auth0_server_python.auth_types import TransactionData +from fastapi import Request, Response @pytest.fixture From c2ac67dc9a1b51f878d23e86f22a28046e43d12c Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Mon, 6 Apr 2026 18:05:03 +0530 Subject: [PATCH 07/11] feat: Update Multiple Custom Domains (MCD) support and enhance session management for cross-domain logout --- README.md | 8 +- examples/MultipleCustomDomains.md | 216 ++++++++++++------ .../stores/stateful_state_store.py | 44 +++- src/auth0_fastapi/util/__init__.py | 38 +++ 4 files changed, 215 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 1bcc3ca..fd5400a 100644 --- a/README.md +++ b/README.md @@ -280,15 +280,15 @@ async def domain_resolver(context: DomainResolverContext) -> str: """Resolve Auth0 domain based on request host.""" host = context.request_headers.get("host", "").split(":")[0] return { - "tenant-a.myapp.com": "tenant-a.auth0.com", - "tenant-b.myapp.com": "tenant-b.auth0.com", - }.get(host, "default.auth0.com") + "brand-1.yourapp.com": "login.brand-1.com", + "brand-2.yourapp.com": "login.brand-2.com", + }.get(host, "login.yourapp.com") config = Auth0Config( domain=domain_resolver, # Callable instead of string client_id="YOUR_CLIENT_ID", client_secret="YOUR_CLIENT_SECRET", - app_base_url="https://myapp.com", + app_base_url="https://yourapp.com", secret="YOUR_SESSION_SECRET", ) ``` diff --git a/examples/MultipleCustomDomains.md b/examples/MultipleCustomDomains.md index 5f46509..e490139 100644 --- a/examples/MultipleCustomDomains.md +++ b/examples/MultipleCustomDomains.md @@ -2,12 +2,16 @@ MCD lets you resolve the Auth0 domain per request while keeping a single `AuthClient` instance. This is useful when your application uses multiple custom domains configured on the same Auth0 tenant. +> **Important:** MCD supports multiple custom domains on a **single Auth0 tenant**. It does not support connecting to multiple Auth0 tenants from a single application. Each custom domain must belong to the same Auth0 tenant. Using domains from different Auth0 tenants is not supported and will result in authentication failures. + **Example:** -- `https://acme.yourapp.com` → Custom domain: `auth.acme.com` -- `https://globex.yourapp.com` → Custom domain: `auth.globex.com` +- `https://brand-1.yourapp.com` → Custom domain: `login.brand-1.com` +- `https://brand-2.yourapp.com` → Custom domain: `login.brand-2.com` MCD is enabled by providing a **domain resolver function** instead of a static domain string. +See [Security Best Practices](#security-best-practices) for important guidance on configuring your resolver safely. + ## Basic Setup ### 1. Define a Domain Resolver @@ -17,28 +21,28 @@ The domain resolver is an async function that receives request context and retur ```python from auth0_server_python.auth_types import DomainResolverContext +DOMAIN_MAP = { + "brand-1.yourapp.com": "login.brand-1.com", + "brand-2.yourapp.com": "login.brand-2.com", +} +DEFAULT_DOMAIN = "login.yourapp.com" + async def domain_resolver(context: DomainResolverContext) -> str: """ Resolve Auth0 domain based on incoming request. Args: - context.request_url: Full request URL (e.g., "https://tenant-a.myapp.com/auth/login") + context.request_url: Full request URL (e.g., "https://brand-1.yourapp.com/auth/login") context.request_headers: Dict of request headers Returns: - Auth0 domain string (e.g., "tenant-a.auth0.com") + Auth0 domain string (e.g., "login.brand-1.com") """ if context.request_headers: host = context.request_headers.get("host", "").split(":")[0] + return DOMAIN_MAP.get(host, DEFAULT_DOMAIN) - # Map hostnames to Auth0 domains - domain_map = { - "tenant-a.myapp.com": "tenant-a.auth0.com", - "tenant-b.myapp.com": "tenant-b.auth0.com", - } - return domain_map.get(host, "default.auth0.com") - - return "default.auth0.com" + return DEFAULT_DOMAIN ``` ### 2. Configure Auth0Config @@ -52,86 +56,88 @@ config = Auth0Config( domain=domain_resolver, # Callable triggers MCD mode client_id="YOUR_CLIENT_ID", client_secret="YOUR_CLIENT_SECRET", - app_base_url="https://myapp.com", + app_base_url="https://yourapp.com", secret="your-32-character-secret-key!!", ) auth_client = AuthClient(config) ``` -> **Note:** In resolver mode, the SDK builds the `redirect_uri` dynamically from the request host. You do not need to set it manually. If you override `redirect_uri` in `authorization_params`, the SDK uses your value as-is. +## Redirect URI Requirements + +In resolver mode, the SDK builds the `redirect_uri` dynamically from the request host. You do not need to set it manually. If you override `redirect_uri` in `authorization_params`, the SDK uses your value as-is. + +> **Note:** In resolver mode, MCD needs an ID token in the callback so the SDK can validate +> the `iss` claim. The `openid` scope is required to receive an ID token. Ensure `openid` is +> included in your `authorization_params.scope`. ## Usage Patterns -### Pattern 1: Host Header Mapping +### Pattern 1: Host Header Mapping (Recommended) -Map request hostnames directly to Auth0 domains: +Map request hostnames directly to Auth0 domains using an allowlist: ```python DOMAIN_MAP = { - "acme.myapp.com": "acme-corp.auth0.com", - "globex.myapp.com": "globex-inc.auth0.com", - "initech.myapp.com": "initech.auth0.com", + "brand-1.yourapp.com": "login.brand-1.com", + "brand-2.yourapp.com": "login.brand-2.com", + "brand-3.yourapp.com": "login.brand-3.com", } async def domain_resolver(context: DomainResolverContext) -> str: host = context.request_headers.get("host", "").split(":")[0] - return DOMAIN_MAP.get(host, "default.auth0.com") + return DOMAIN_MAP.get(host, DEFAULT_DOMAIN) ``` ### Pattern 2: Subdomain Extraction -> **Warning:** This pattern constructs the Auth0 domain from raw header input. An attacker who controls the `Host` header can influence the resolved domain. Use an allowlist (Pattern 1) for production deployments. See [Security Considerations](#use-an-allowlist-in-your-resolver). - -Extract tenant from subdomain: +> **Warning:** This pattern constructs the Auth0 domain from raw header input. An attacker who controls the `Host` header can influence the resolved domain. Use an allowlist (Pattern 1) for production deployments. See [Security Best Practices](#security-best-practices). ```python async def domain_resolver(context: DomainResolverContext) -> str: host = context.request_headers.get("host", "").split(":")[0] - # Extract subdomain: "acme.myapp.com" -> "acme" + # Extract subdomain: "brand-1.yourapp.com" -> "brand-1" parts = host.split(".") if len(parts) >= 3: - tenant = parts[0] - return f"{tenant}.auth0.com" + subdomain = parts[0] + return f"login.{subdomain}.com" # attacker sends Host: evil.yourapp.com -> login.evil.com - return "default.auth0.com" + return DEFAULT_DOMAIN ``` ### Pattern 3: Database Lookup -Fetch domain from database based on tenant: +Fetch domain from database: ```python async def domain_resolver(context: DomainResolverContext) -> str: host = context.request_headers.get("host", "").split(":")[0] - - # Extract tenant identifier - tenant = host.split(".")[0] + subdomain = host.split(".")[0] # Lookup in database (use caching in production) - tenant_config = await get_tenant_config(tenant) - if tenant_config: - return tenant_config.auth0_domain + domain_config = await get_domain_config(subdomain) + if domain_config: + return domain_config.auth0_domain - return "default.auth0.com" + return DEFAULT_DOMAIN ``` ### Pattern 4: Environment-Based Configuration -Use environment variables for tenant configuration: +Use environment variables for domain configuration: ```python import os import json -# Load from environment: TENANT_DOMAINS='{"acme": "acme.auth0.com", "globex": "globex.auth0.com"}' -TENANT_DOMAINS = json.loads(os.environ.get("TENANT_DOMAINS", "{}")) +# Load from environment: DOMAIN_MAP='{"brand-1": "login.brand-1.com", "brand-2": "login.brand-2.com"}' +DOMAIN_MAP = json.loads(os.environ.get("DOMAIN_MAP", "{}")) async def domain_resolver(context: DomainResolverContext) -> str: host = context.request_headers.get("host", "").split(":")[0] - tenant = host.split(".")[0] - return TENANT_DOMAINS.get(tenant, os.environ.get("DEFAULT_AUTH0_DOMAIN")) + subdomain = host.split(".")[0] + return DOMAIN_MAP.get(subdomain, os.environ.get("DEFAULT_AUTH0_DOMAIN")) ``` ## Proxy Headers @@ -146,29 +152,29 @@ async def domain_resolver(context: DomainResolverContext) -> str: host = headers.get("x-forwarded-host") or headers.get("host", "") host = host.split(":")[0] # Remove port - return DOMAIN_MAP.get(host, "default.auth0.com") + return DOMAIN_MAP.get(host, DEFAULT_DOMAIN) ``` ## Auth0 Dashboard Configuration For MCD to work, configure your Auth0 application: -1. **Allowed Callback URLs**: Add all tenant callback URLs +1. **Allowed Callback URLs**: Add all callback URLs ``` - https://tenant-a.myapp.com/auth/callback - https://tenant-b.myapp.com/auth/callback + https://brand-1.yourapp.com/auth/callback + https://brand-2.yourapp.com/auth/callback ``` -2. **Allowed Logout URLs**: Add all tenant base URLs +2. **Allowed Logout URLs**: Add all base URLs ``` - https://tenant-a.myapp.com - https://tenant-b.myapp.com + https://brand-1.yourapp.com + https://brand-2.yourapp.com ``` 3. **Allowed Web Origins** (if using SPA features): ``` - https://tenant-a.myapp.com - https://tenant-b.myapp.com + https://brand-1.yourapp.com + https://brand-2.yourapp.com ``` ## Error Handling @@ -184,10 +190,10 @@ async def domain_resolver(context: DomainResolverContext) -> str: domain = DOMAIN_MAP.get(host) if not domain: # Option 1: Return default - return "default.auth0.com" + return DEFAULT_DOMAIN # Option 2: Raise error (will return 500 to user) - # raise DomainResolverError(f"Unknown tenant: {host}") + # raise DomainResolverError(f"Unknown host: {host}") return domain ``` @@ -204,35 +210,56 @@ When MCD is enabled, the SDK: ## Session Behavior in Resolver Mode -In resolver mode, sessions are bound to the domain that created them. On each request, the SDK compares the session's stored domain against the current resolved domain: +In resolver mode, sessions are bound to the domain that created them. On each request, the SDK compares the session's stored domain against the current resolved domain. If the domains do not match: -- `get_user()` and `get_session()` return `None` on domain mismatch. -- `get_access_token()` raises `AccessTokenError` on domain mismatch. +- `get_user()` and `get_session()` return `None`. +- `get_access_token()` raises `AccessTokenError` (code `MISSING_SESSION_DOMAIN` or `DOMAIN_MISMATCH`). +- `get_access_token_for_connection()` raises `AccessTokenForConnectionError` (same codes as above). +- `start_link_user()` and `start_unlink_user()` raise `StartLinkUserError`. - Token refresh uses the session's stored domain, not the current request domain. -> **Note:** When moving from a static domain to a resolver function, existing sessions that lack a `domain` field continue to work. The SDK uses a three-tier fallback to determine the session's domain: (1) `session.domain`, (2) the static domain if configured, (3) the hostname extracted from the user's `iss` claim. New sessions store the resolved domain automatically. See [Legacy Sessions](#legacy-sessions) for details. +All domain mismatch errors use the message: **"Session domain does not match the current domain."** + +> **Note:** If a login was started before the switch to resolver mode and completes after, the SDK falls back to the current resolved domain for token exchange. The resulting session will store the resolved domain and work normally going forward. ## Legacy Sessions When moving from a static domain setup to resolver mode, existing sessions can continue to work if the resolver returns the same Auth0 domain that was used for those legacy sessions. -The SDK determines the session's domain using a fallback chain: +The SDK uses a three-tier fallback to determine the session's domain: 1. **`session.domain`** — new sessions created after MCD was enabled store this field. 2. **Static domain** — if a static `domain` string was configured, it is used as a fallback. 3. **User's issuer claim** — the hostname is extracted from the `iss` claim in the user's - ID token (e.g., `https://tenant.auth0.com/` yields `tenant.auth0.com`). + ID token (e.g., `https://login.brand-1.com/` yields `login.brand-1.com`). + +This means legacy sessions created before MCD support will still work as long as the +resolver returns a domain that matches one of the fallback values. In most cases, the +issuer claim already matches the Auth0 domain, so no re-authentication is needed. -In most cases, the issuer claim already matches the Auth0 domain, so legacy sessions work -without re-authentication. If the resolver returns a different domain that does not match -any fallback tier, the user will need to sign in again. +If the resolver returns a different domain that does not match any tier, the SDK treats +the session as belonging to another domain and the user will need to sign in again. This +is intentional to keep sessions isolated per domain. ## Discovery Cache The SDK caches OIDC metadata and JWKS per domain in memory (LRU eviction, 600-second TTL, up to 100 domains). This avoids repeated network calls when serving multiple domains. The cache is shared across all requests to the same `AuthClient` instance. -## Security Considerations +Most applications can keep the defaults, but you may want to adjust in these cases: +- Increase `max_entries` if one process handles more than 100 distinct Auth0 domains during the TTL window. This is most common in MCD deployments that work with many custom domains. +- Decrease `max_entries` if memory usage matters more than avoiding repeated discovery. +- Increase TTL if the same domains are reused frequently and you want to reduce repeated discovery and JWKS fetches after cache entries expire. +- Decrease TTL if you want the SDK to pick up Auth0 metadata or signing key changes sooner. + +Rule of thumb: set `max_entries` to cover the number of distinct Auth0 domains a single process is expected to use during the TTL window, with some headroom. + +## Security Best Practices + +> **The domain resolver is a security-critical component.** A misconfigured resolver can lead to authentication bypass on the relying party (RP) or expose the application to Server-Side Request Forgery (SSRF). The SDK trusts the resolved domain to fetch OIDC metadata and verification keys. It is the customer's responsibility to ensure the resolver cannot be influenced by untrusted input. + +**Single Tenant Limitation:** +The domain resolver is intended solely for multiple custom domains belonging to the same Auth0 tenant. It is not a supported mechanism for connecting multiple Auth0 tenants to a single application. - **Session Isolation**: Sessions are bound to their origin domain. A session created on one custom domain cannot be used on another. - **Issuer Validation**: Token issuer is validated against the expected domain (with normalization for trailing slashes and case) @@ -241,30 +268,66 @@ The SDK caches OIDC metadata and JWKS per domain in memory (LRU eviction, 600-se ### Use an Allowlist in Your Resolver -The SDK passes request headers to your domain resolver via `DomainResolverContext`. These headers come directly from the HTTP request and can be spoofed. Always use a mapping or allowlist — never construct domains from raw header values: +The SDK passes request headers to your domain resolver via `DomainResolverContext`. These headers come directly from the HTTP request and can be spoofed by an attacker (e.g., `Host: evil.com` or `X-Forwarded-Host: evil.com`). + +The SDK uses the resolved domain to fetch OIDC metadata and JWKS. If an attacker can influence the resolved domain, they could point the SDK at an OIDC provider they control. + +**Always use a mapping or allowlist — never construct domains from raw header values:** ```python -# Safe: unknown hosts fall back to default +# Safe: allowlist lookup — unknown hosts fall back to default +DOMAIN_MAP = { + "brand-1.yourapp.com": "login.brand-1.com", + "brand-2.yourapp.com": "login.brand-2.com", +} + async def domain_resolver(context: DomainResolverContext) -> str: host = context.request_headers.get("host", "").split(":")[0] return DOMAIN_MAP.get(host, DEFAULT_DOMAIN) +``` -# Risky: attacker sends Host: evil.myapp.com → evil.auth0.com +```python +# Risky: constructs domain from raw input — attacker can influence resolved domain async def domain_resolver(context: DomainResolverContext) -> str: - tenant = context.request_headers.get("host", "").split(".")[0] - return f"{tenant}.auth0.com" + host = context.request_headers.get("host", "").split(":")[0] + subdomain = host.split(".")[0] + return f"login.{subdomain}.com" # attacker sends Host: evil.yourapp.com -> login.evil.com ``` +### Secure Proxy Requirement + +When using Multiple Custom Domains (MCD), your application must be deployed behind a secure reverse proxy (e.g., Cloudflare, Nginx, or AWS ALB). The proxy must be configured to sanitize and overwrite `Host` and `X-Forwarded-Host` headers before they reach your application. + +Without a trusted proxy layer to validate these headers, an attacker can manipulate the domain resolution process. This can result in authentication bypass or Server-Side Request Forgery (SSRF). + ### Trust Forwarded Headers Only Behind a Proxy -Only use `x-forwarded-host` when behind a trusted reverse proxy. Consider adding `TrustedHostMiddleware`: +If your application is directly exposed to the internet (not behind a reverse proxy), do not trust `x-forwarded-host` or `x-forwarded-proto` — any client can set these headers. + +Only use forwarded headers when your application runs behind a trusted reverse proxy (nginx, AWS ALB, Cloudflare, etc.) that sets these headers and strips any client-provided values. + +```python +# Only trust x-forwarded-host if behind a trusted proxy +async def domain_resolver(context: DomainResolverContext) -> str: + headers = context.request_headers or {} + + if BEHIND_TRUSTED_PROXY: + host = headers.get("x-forwarded-host") or headers.get("host", "") + else: + host = headers.get("host", "") + + host = host.split(":")[0] + return DOMAIN_MAP.get(host, DEFAULT_DOMAIN) +``` + +Consider adding `TrustedHostMiddleware` to reject unexpected `Host` headers: ```python from starlette.middleware.trustedhost import TrustedHostMiddleware app.add_middleware( TrustedHostMiddleware, - allowed_hosts=["acme.myapp.com", "globex.myapp.com"] + allowed_hosts=["brand-1.yourapp.com", "brand-2.yourapp.com"] ) ``` @@ -280,26 +343,27 @@ from auth0_fastapi import Auth0Config, AuthClient from auth0_fastapi.server.routes import router, register_auth_routes from auth0_server_python.auth_types import DomainResolverContext -app = FastAPI(title="Multi-Tenant App") +app = FastAPI(title="Multi-Domain App") app.add_middleware(SessionMiddleware, secret_key=os.environ["SESSION_SECRET"]) -# Tenant configuration -TENANT_DOMAINS = { - "acme.myapp.com": "acme-corp.auth0.com", - "globex.myapp.com": "globex-inc.auth0.com", +# Domain configuration +DOMAIN_MAP = { + "brand-1.yourapp.com": "login.brand-1.com", + "brand-2.yourapp.com": "login.brand-2.com", } +DEFAULT_DOMAIN = "login.yourapp.com" async def domain_resolver(context: DomainResolverContext) -> str: host = context.request_headers.get("x-forwarded-host") or \ context.request_headers.get("host", "") host = host.split(":")[0] - return TENANT_DOMAINS.get(host, "default.auth0.com") + return DOMAIN_MAP.get(host, DEFAULT_DOMAIN) config = Auth0Config( domain=domain_resolver, client_id=os.environ["AUTH0_CLIENT_ID"], client_secret=os.environ["AUTH0_CLIENT_SECRET"], - app_base_url="https://myapp.com", + app_base_url="https://yourapp.com", secret=os.environ["SESSION_SECRET"], ) @@ -312,7 +376,7 @@ app.include_router(router) @app.get("/") async def home(): - return {"message": "Multi-tenant app"} + return {"message": "Multi-domain app"} @app.get("/profile") async def profile(session=Depends(auth_client.require_session)): diff --git a/src/auth0_fastapi/stores/stateful_state_store.py b/src/auth0_fastapi/stores/stateful_state_store.py index 9f14704..25ad493 100644 --- a/src/auth0_fastapi/stores/stateful_state_store.py +++ b/src/auth0_fastapi/stores/stateful_state_store.py @@ -6,6 +6,8 @@ from auth0_server_python.store.abstract import StateStore from fastapi import Response +from ..util import normalize_url + class StatefulStateStore(StateStore): """ @@ -98,19 +100,39 @@ async def delete_by_logout_token( options: Optional[dict[str, Any]] = None, ) -> None: """ - Iterates over the session store keys and deletes sessions matching the logout token claims. - This method assumes the underlying store provides a 'keys' method. + Delete sessions matching the logout token claims. + + Per the OIDC Back-Channel Logout spec, either ``sid`` or ``sub`` + (or both) will be present. Matching uses OR logic: a session + is considered a match if either claim matches. + + When ``iss`` is present in claims, the token's issuer is validated + against the session's stored domain before deletion. + This prevents cross-domain session deletion in MCD deployments. """ - # Example assumes the session store has an async keys() method. + target_sid = claims.get("sid") + target_sub = claims.get("sub") + target_iss = claims.get("iss") + session_keys = await self.store.keys() for key in session_keys: data = await self.store.get(key) - if data: - try: - state = StateData.parse_raw(data) - internal = state.internal.dict() if state.internal else {} - user = state.user.dict() if state.user else {} - if internal.get("sid") == claims.get("sid") and user.get("sub") == claims.get("sub"): - await self.store.delete(key) - except Exception: + if not data: + continue + try: + state = StateData.parse_raw(data) + internal = state.internal.dict() if state.internal else {} + user = state.user.dict() if state.user else {} + + # Validate issuer matches session domain (prevents cross-domain deletion in MCD) + if target_iss and state.domain: + if normalize_url(target_iss) != normalize_url(state.domain): + continue + + # OR logic: match on sid OR sub + matches_sid = target_sid and internal.get("sid") == target_sid + matches_sub = target_sub and user.get("sub") == target_sub + if matches_sid or matches_sub: await self.store.delete(key) + except Exception: + continue diff --git a/src/auth0_fastapi/util/__init__.py b/src/auth0_fastapi/util/__init__.py index 834fe95..bf1022b 100644 --- a/src/auth0_fastapi/util/__init__.py +++ b/src/auth0_fastapi/util/__init__.py @@ -68,6 +68,44 @@ def to_safe_redirect(dangerous_redirect: str, safe_base_url: str) -> Optional[st return None +def normalize_url(value: str) -> str: + """ + Normalize a URL or domain string for comparison. + + Returns: + Normalized ``https://`` string, or empty string if input + is empty. + """ + if not value: + return "" + + value = value.strip() + + # Ensure a scheme is present so urlparse can extract the host + if "://" not in value: + value = f"https://{value}" + + parsed = urlparse(value) + + # Lowercase scheme and host + scheme = (parsed.scheme or "https").lower() + if scheme == "http": + scheme = "https" + + host = (parsed.hostname or "").lower() + if not host: + return "" + + # Remove default port + port = parsed.port + if port and ((scheme == "https" and port == 443) or (scheme == "http" and port == 80)): + port = None + + netloc = f"{host}:{port}" if port else host + + return f"{scheme}://{netloc}" + + def build_request_base_url(request: "Request") -> str: """ Build base URL from request headers. From 0afe6768da11f58f286c73273e8e1c095d2847c5 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Mon, 6 Apr 2026 20:32:27 +0530 Subject: [PATCH 08/11] Refactor logout token handling to prevent cross-domain session deletion in MCD deployments --- .ruff.toml | 2 +- src/auth0_fastapi/stores/stateful_state_store.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.ruff.toml b/.ruff.toml index 6940770..ebc1b4c 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -13,7 +13,7 @@ select = [ "A", # flake8-annotations "C", # flake8-coding¯ ] -ignore = ["B904"] +ignore = ["B904", "S110", "S112"] [per-file-ignores] "__init__.py" = ["F401", "F811"] diff --git a/src/auth0_fastapi/stores/stateful_state_store.py b/src/auth0_fastapi/stores/stateful_state_store.py index 25ad493..e07f9de 100644 --- a/src/auth0_fastapi/stores/stateful_state_store.py +++ b/src/auth0_fastapi/stores/stateful_state_store.py @@ -110,9 +110,9 @@ async def delete_by_logout_token( against the session's stored domain before deletion. This prevents cross-domain session deletion in MCD deployments. """ - target_sid = claims.get("sid") - target_sub = claims.get("sub") - target_iss = claims.get("iss") + claim_sid = claims.get("sid") + claim_sub = claims.get("sub") + claim_iss = claims.get("iss") session_keys = await self.store.keys() for key in session_keys: @@ -125,13 +125,13 @@ async def delete_by_logout_token( user = state.user.dict() if state.user else {} # Validate issuer matches session domain (prevents cross-domain deletion in MCD) - if target_iss and state.domain: - if normalize_url(target_iss) != normalize_url(state.domain): + if claim_iss and state.domain: + if normalize_url(claim_iss) != normalize_url(state.domain): continue # OR logic: match on sid OR sub - matches_sid = target_sid and internal.get("sid") == target_sid - matches_sub = target_sub and user.get("sub") == target_sub + matches_sid = claim_sid and internal.get("sid") == claim_sid + matches_sub = claim_sub and user.get("sub") == claim_sub if matches_sid or matches_sub: await self.store.delete(key) except Exception: From 6b9dc02b5dfb37ed2af228aa9512f332a62b5914 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Mon, 6 Apr 2026 20:39:31 +0530 Subject: [PATCH 09/11] refactor: Reorganize import statements for consistency across auth client and test files --- src/auth0_fastapi/auth/auth_client.py | 7 ++++--- src/auth0_fastapi/test/test_auth_client.py | 5 +++-- src/auth0_fastapi/test/test_routes.py | 3 ++- src/auth0_fastapi/test/test_stateful_store.py | 3 ++- src/auth0_fastapi/test/test_stateless_store.py | 5 +++-- 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index 8d132d8..36e7280 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -2,9 +2,6 @@ # Imported from auth0-server-python from typing import Optional -from auth0_fastapi.config import Auth0Config -from auth0_fastapi.stores.cookie_transaction_store import CookieTransactionStore -from auth0_fastapi.stores.stateless_state_store import StatelessStateStore from auth0_server_python.auth_server.server_client import ServerClient from auth0_server_python.auth_types import ( CompleteConnectAccountResponse, @@ -14,6 +11,10 @@ ) from fastapi import HTTPException, Request, Response, status +from auth0_fastapi.config import Auth0Config +from auth0_fastapi.stores.cookie_transaction_store import CookieTransactionStore +from auth0_fastapi.stores.stateless_state_store import StatelessStateStore + class AuthClient: """ diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index 50eec35..606fe79 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -2,11 +2,12 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from auth0_fastapi.auth.auth_client import AuthClient -from auth0_fastapi.config import Auth0Config from auth0_server_python.auth_types import CompleteConnectAccountResponse, ConnectAccountOptions from fastapi import HTTPException, Request, Response +from auth0_fastapi.auth.auth_client import AuthClient +from auth0_fastapi.config import Auth0Config + @pytest.fixture def auth_config(): diff --git a/src/auth0_fastapi/test/test_routes.py b/src/auth0_fastapi/test/test_routes.py index 0df3285..9abd978 100644 --- a/src/auth0_fastapi/test/test_routes.py +++ b/src/auth0_fastapi/test/test_routes.py @@ -1,10 +1,11 @@ from unittest.mock import AsyncMock, Mock import pytest +from fastapi import HTTPException + from auth0_fastapi.auth.auth_client import AuthClient from auth0_fastapi.config import Auth0Config from auth0_fastapi.server.routes import get_auth_client, register_auth_routes -from fastapi import HTTPException @pytest.fixture diff --git a/src/auth0_fastapi/test/test_stateful_store.py b/src/auth0_fastapi/test/test_stateful_store.py index 8f9d48b..52a3fe3 100644 --- a/src/auth0_fastapi/test/test_stateful_store.py +++ b/src/auth0_fastapi/test/test_stateful_store.py @@ -1,10 +1,11 @@ from unittest.mock import AsyncMock, Mock import pytest -from auth0_fastapi.stores.stateful_state_store import StatefulStateStore from auth0_server_python.auth_types import StateData from fastapi import Request, Response +from auth0_fastapi.stores.stateful_state_store import StatefulStateStore + @pytest.fixture def mock_request(): diff --git a/src/auth0_fastapi/test/test_stateless_store.py b/src/auth0_fastapi/test/test_stateless_store.py index 5b11812..95c8456 100644 --- a/src/auth0_fastapi/test/test_stateless_store.py +++ b/src/auth0_fastapi/test/test_stateless_store.py @@ -1,11 +1,12 @@ from unittest.mock import Mock, patch import pytest -from auth0_fastapi.stores.cookie_transaction_store import CookieTransactionStore -from auth0_fastapi.stores.stateless_state_store import StatelessStateStore from auth0_server_python.auth_types import TransactionData from fastapi import Request, Response +from auth0_fastapi.stores.cookie_transaction_store import CookieTransactionStore +from auth0_fastapi.stores.stateless_state_store import StatelessStateStore + @pytest.fixture def mock_request(): From 51977f5ee64cb1452e37558c6bcad0a53e18b6cb Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 8 Apr 2026 21:58:26 +0530 Subject: [PATCH 10/11] chore: Update ruff to 0.15.9 and migrate .ruff.toml to lint section format --- .ruff.toml | 10 +- poetry.lock | 296 +++++-------------------------------------------- pyproject.toml | 4 +- 3 files changed, 37 insertions(+), 273 deletions(-) diff --git a/.ruff.toml b/.ruff.toml index ebc1b4c..e2513b1 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,22 +1,24 @@ line-length = 120 target-version = "py39" + +[lint] select = [ "E", # pycodestyle errors "W", # pycodestyle warnings "F", # pyflakes - "I", # isortbear + "I", # isort "C4", # flake8-comprehensions "UP", # pyupgrade "S", # bandit (security) "DTZ", # flake8-datetimez "G", # flake8-logging-format "A", # flake8-annotations - "C", # flake8-coding¯ + "C", # flake8-coding ] ignore = ["B904", "S110", "S112"] -[per-file-ignores] +[lint.per-file-ignores] "__init__.py" = ["F401", "F811"] "src/auth0_fastapi/config.py" = ["E501"] "src/auth0_fastapi/server/routes.py" = ["C901"] -"src/auth0_fastapi/test/**/*.py" = ["F841", "S101", "COM812","S105", "S106"] \ No newline at end of file +"src/auth0_fastapi/test/**/*.py" = ["F841", "S101", "COM812","S105", "S106"] diff --git a/poetry.lock b/poetry.lock index b259707..24f9d85 100644 --- a/poetry.lock +++ b/poetry.lock @@ -46,21 +46,21 @@ trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python [[package]] name = "auth0-server-python" -version = "1.0.0b7" +version = "1.0.0b9" description = "Auth0 server-side Python SDK" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "auth0_server_python-1.0.0b7-py3-none-any.whl", hash = "sha256:5e290759324214ab16f30854236955f87b72afbeabcf6de1106389496c658822"}, - {file = "auth0_server_python-1.0.0b7.tar.gz", hash = "sha256:36110c3e0d3ae590e006d0573c7f6a19709f6ff5f8001451bbcc694cd951dac6"}, + {file = "auth0_server_python-1.0.0b9-py3-none-any.whl", hash = "sha256:23afbd9260582ac6972f78f9c6f027b62873edf5096e324bb7704c9c114cc7f5"}, + {file = "auth0_server_python-1.0.0b9.tar.gz", hash = "sha256:47bd2a1036eddd11ec66267fffd0e551443161d613d1ccbc614ba6e5e742dfaf"}, ] [package.dependencies] authlib = ">=1.2,<2.0" cryptography = ">=43.0.1" httpx = ">=0.28.1,<0.29.0" -jwcrypto = ">=1.5.6,<2.0.0" +jwcrypto = ">=1.5.7,<2.0.0" pydantic = ">=2.10.6,<3.0.0" pyjwt = ">=2.8.0" @@ -196,7 +196,6 @@ description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" groups = ["dev"] -markers = "python_full_version < \"3.14.0\" or platform_python_implementation == \"PyPy\"" files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, @@ -205,22 +204,6 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} -[[package]] -name = "click" -version = "8.3.1" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version >= \"3.14\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, - {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - [[package]] name = "colorama" version = "0.4.6" @@ -241,7 +224,6 @@ description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" groups = ["dev"] -markers = "python_full_version < \"3.14.0\" or platform_python_implementation == \"PyPy\"" files = [ {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, @@ -355,112 +337,6 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] -[[package]] -name = "coverage" -version = "7.13.1" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version >= \"3.14\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147"}, - {file = "coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d"}, - {file = "coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0"}, - {file = "coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90"}, - {file = "coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d"}, - {file = "coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b"}, - {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6"}, - {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e"}, - {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae"}, - {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29"}, - {file = "coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f"}, - {file = "coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1"}, - {file = "coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88"}, - {file = "coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3"}, - {file = "coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9"}, - {file = "coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee"}, - {file = "coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf"}, - {file = "coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3"}, - {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef"}, - {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851"}, - {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb"}, - {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba"}, - {file = "coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19"}, - {file = "coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a"}, - {file = "coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c"}, - {file = "coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3"}, - {file = "coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e"}, - {file = "coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c"}, - {file = "coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62"}, - {file = "coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968"}, - {file = "coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e"}, - {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f"}, - {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee"}, - {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf"}, - {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c"}, - {file = "coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7"}, - {file = "coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6"}, - {file = "coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c"}, - {file = "coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78"}, - {file = "coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b"}, - {file = "coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd"}, - {file = "coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992"}, - {file = "coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4"}, - {file = "coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a"}, - {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766"}, - {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4"}, - {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398"}, - {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784"}, - {file = "coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461"}, - {file = "coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500"}, - {file = "coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9"}, - {file = "coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc"}, - {file = "coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a"}, - {file = "coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4"}, - {file = "coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6"}, - {file = "coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1"}, - {file = "coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd"}, - {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c"}, - {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0"}, - {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e"}, - {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53"}, - {file = "coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842"}, - {file = "coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2"}, - {file = "coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09"}, - {file = "coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894"}, - {file = "coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a"}, - {file = "coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f"}, - {file = "coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909"}, - {file = "coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4"}, - {file = "coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75"}, - {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9"}, - {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465"}, - {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864"}, - {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9"}, - {file = "coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5"}, - {file = "coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a"}, - {file = "coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0"}, - {file = "coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a"}, - {file = "coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6"}, - {file = "coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673"}, - {file = "coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5"}, - {file = "coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d"}, - {file = "coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8"}, - {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486"}, - {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564"}, - {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7"}, - {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416"}, - {file = "coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f"}, - {file = "coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79"}, - {file = "coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4"}, - {file = "coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573"}, - {file = "coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd"}, -] - -[package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] - [[package]] name = "cryptography" version = "43.0.3" @@ -468,7 +344,6 @@ description = "cryptography is a package which provides cryptographic recipes an optional = false python-versions = ">=3.7" groups = ["main"] -markers = "python_full_version < \"3.14.0\" or platform_python_implementation == \"PyPy\"" files = [ {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, @@ -512,84 +387,6 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] -[[package]] -name = "cryptography" -version = "46.0.3" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.8" -groups = ["main"] -markers = "python_version >= \"3.14\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"}, - {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"}, - {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"}, - {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"}, - {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"}, - {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"}, - {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"}, - {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"}, - {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"}, - {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"}, - {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"}, - {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"}, - {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"}, - {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"}, - {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"}, - {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"}, - {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"}, - {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"}, - {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"}, - {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"}, - {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"}, -] - -[package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] -docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox[uv] (>=2024.4.15)"] -pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] -sdist = ["build (>=1.0.0)"] -ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] -test-randomorder = ["pytest-randomly"] - [[package]] name = "exceptiongroup" version = "1.3.1" @@ -713,40 +510,26 @@ description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" groups = ["dev"] -markers = "python_full_version < \"3.14.0\" or platform_python_implementation == \"PyPy\"" files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] -[[package]] -name = "iniconfig" -version = "2.3.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version >= \"3.14\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, - {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, -] - [[package]] name = "jwcrypto" -version = "1.5.6" +version = "1.5.7" description = "Implementation of JOSE Web standards" optional = false -python-versions = ">= 3.8" +python-versions = ">=3.8" groups = ["main"] files = [ - {file = "jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789"}, - {file = "jwcrypto-1.5.6.tar.gz", hash = "sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039"}, + {file = "jwcrypto-1.5.7-py3-none-any.whl", hash = "sha256:729463fefe28b6de5cf1ebfda3e94f1a1b41d2799148ef98a01cb9678ebe2bb0"}, + {file = "jwcrypto-1.5.7.tar.gz", hash = "sha256:70204d7cca406eda8c82352e3c41ba2d946610dafd19e54403f0a1f4f18633c6"}, ] [package.dependencies] cryptography = ">=3.4" -typing-extensions = ">=4.5.0" +typing_extensions = ">=4.5.0" [[package]] name = "packaging" @@ -1044,31 +827,30 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] name = "ruff" -version = "0.14.13" +version = "0.15.9" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.14.13-py3-none-linux_armv6l.whl", hash = "sha256:76f62c62cd37c276cb03a275b198c7c15bd1d60c989f944db08a8c1c2dbec18b"}, - {file = "ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914a8023ece0528d5cc33f5a684f5f38199bbb566a04815c2c211d8f40b5d0ed"}, - {file = "ruff-0.14.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d24899478c35ebfa730597a4a775d430ad0d5631b8647a3ab368c29b7e7bd063"}, - {file = "ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aaf3870f14d925bbaf18b8a2347ee0ae7d95a2e490e4d4aea6813ed15ebc80e"}, - {file = "ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac5b7f63dd3b27cc811850f5ffd8fff845b00ad70e60b043aabf8d6ecc304e09"}, - {file = "ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2b1097750d90ba82ce4ba676e85230a0ed694178ca5e61aa9b459970b3eb9"}, - {file = "ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d0bf87705acbbcb8d4c24b2d77fbb73d40210a95c3903b443cd9e30824a5032"}, - {file = "ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3eb5da8e2c9e9f13431032fdcbe7681de9ceda5835efee3269417c13f1fed5c"}, - {file = "ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:642442b42957093811cd8d2140dfadd19c7417030a7a68cf8d51fcdd5f217427"}, - {file = "ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4acdf009f32b46f6e8864af19cbf6841eaaed8638e65c8dac845aea0d703c841"}, - {file = "ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:591a7f68860ea4e003917d19b5c4f5ac39ff558f162dc753a2c5de897fd5502c"}, - {file = "ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:774c77e841cc6e046fc3e91623ce0903d1cd07e3a36b1a9fe79b81dab3de506b"}, - {file = "ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:61f4e40077a1248436772bb6512db5fc4457fe4c49e7a94ea7c5088655dd21ae"}, - {file = "ruff-0.14.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6d02f1428357fae9e98ac7aa94b7e966fd24151088510d32cf6f902d6c09235e"}, - {file = "ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e399341472ce15237be0c0ae5fbceca4b04cd9bebab1a2b2c979e015455d8f0c"}, - {file = "ruff-0.14.13-py3-none-win32.whl", hash = "sha256:ef720f529aec113968b45dfdb838ac8934e519711da53a0456038a0efecbd680"}, - {file = "ruff-0.14.13-py3-none-win_amd64.whl", hash = "sha256:6070bd026e409734b9257e03e3ef18c6e1a216f0435c6751d7a8ec69cb59abef"}, - {file = "ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247"}, - {file = "ruff-0.14.13.tar.gz", hash = "sha256:83cd6c0763190784b99650a20fec7633c59f6ebe41c5cc9d45ee42749563ad47"}, + {file = "ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1"}, + {file = "ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7"}, + {file = "ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8"}, + {file = "ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59"}, + {file = "ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745"}, + {file = "ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901"}, + {file = "ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9"}, + {file = "ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5"}, + {file = "ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6"}, + {file = "ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840"}, + {file = "ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed"}, + {file = "ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71"}, + {file = "ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677"}, + {file = "ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c"}, + {file = "ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec"}, + {file = "ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d"}, + {file = "ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53"}, + {file = "ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2"}, ] [[package]] @@ -1078,7 +860,6 @@ description = "The little ASGI library that shines." optional = false python-versions = ">=3.9" groups = ["main"] -markers = "python_full_version < \"3.14.0\" or platform_python_implementation == \"PyPy\"" files = [ {file = "starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f"}, {file = "starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284"}, @@ -1091,25 +872,6 @@ typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\"" [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] -[[package]] -name = "starlette" -version = "0.50.0" -description = "The little ASGI library that shines." -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "python_version >= \"3.14\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca"}, - {file = "starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca"}, -] - -[package.dependencies] -anyio = ">=3.6.2,<5" - -[package.extras] -full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] - [[package]] name = "tomli" version = "2.4.0" @@ -1219,4 +981,4 @@ standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3) [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "dc0d5cad0d345f1947efe844574cc056ea42d0c33961b0dee6e530c77e0ae3f3" +content-hash = "a0b90d90247b469fe6083b11078c71974f9f6ddf00e9e5a09c06649270aebd33" diff --git a/pyproject.toml b/pyproject.toml index 00ccb67..3b5907f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ packages = [ [tool.poetry.dependencies] python = ">=3.9" -auth0-server-python = ">=1.0.0b7" +auth0-server-python = ">=1.0.0b9" fastapi = ">=0.115.11" pydantic = "^2.12.5" @@ -23,7 +23,7 @@ pytest-cov = "^4.0" pytest-asyncio = "^0.20.3" pytest-mock = "^3.14.0" uvicorn = "^0.39.0" -ruff = "^0.14.0" +ruff = ">=0.14.0" [tool.pytest.ini_options] addopts = "--cov=auth0_fastapi --cov-report=term-missing --cov-report=html" From 08019512644bd63c78c4279684b6419ade970b9b Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 8 Apr 2026 22:10:48 +0530 Subject: [PATCH 11/11] trigger scan