diff --git a/.ruff.toml b/.ruff.toml index 6940770..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"] +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/.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: {} diff --git a/README.md b/README.md index 372a7b9..fd5400a 100644 --- a/README.md +++ b/README.md @@ -10,23 +10,24 @@ ## 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 - **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 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.) @@ -39,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 @@ -84,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) @@ -108,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** @@ -116,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 @@ -163,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: @@ -180,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", @@ -192,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: @@ -202,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`: @@ -220,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 } ) @@ -233,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, @@ -241,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. @@ -265,6 +269,38 @@ 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 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 + +async def domain_resolver(context: DomainResolverContext) -> str: + """Resolve Auth0 domain based on request host.""" + host = context.request_headers.get("host", "").split(":")[0] + return { + "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://yourapp.com", + secret="YOUR_SESSION_SECRET", +) +``` + +When using MCD, the SDK automatically: +- Builds dynamic `redirect_uri` based on the incoming request host +- 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). + ## Feedback ### Contributing @@ -297,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 new file mode 100644 index 0000000..e490139 --- /dev/null +++ b/examples/MultipleCustomDomains.md @@ -0,0 +1,384 @@ +# Multiple Custom Domains (MCD) + +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://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 + +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 + +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://brand-1.yourapp.com/auth/login") + context.request_headers: Dict of request headers + + Returns: + 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) + + return DEFAULT_DOMAIN +``` + +### 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://yourapp.com", + secret="your-32-character-secret-key!!", +) + +auth_client = AuthClient(config) +``` + +## 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 (Recommended) + +Map request hostnames directly to Auth0 domains using an allowlist: + +```python +DOMAIN_MAP = { + "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_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 Best Practices](#security-best-practices). + +```python +async def domain_resolver(context: DomainResolverContext) -> str: + host = context.request_headers.get("host", "").split(":")[0] + + # Extract subdomain: "brand-1.yourapp.com" -> "brand-1" + parts = host.split(".") + if len(parts) >= 3: + subdomain = parts[0] + return f"login.{subdomain}.com" # attacker sends Host: evil.yourapp.com -> login.evil.com + + return DEFAULT_DOMAIN +``` + +### Pattern 3: Database Lookup + +Fetch domain from database: + +```python +async def domain_resolver(context: DomainResolverContext) -> str: + host = context.request_headers.get("host", "").split(":")[0] + subdomain = host.split(".")[0] + + # Lookup in database (use caching in production) + domain_config = await get_domain_config(subdomain) + if domain_config: + return domain_config.auth0_domain + + return DEFAULT_DOMAIN +``` + +### Pattern 4: Environment-Based Configuration + +Use environment variables for domain configuration: + +```python +import os +import json + +# 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] + subdomain = host.split(".")[0] + return DOMAIN_MAP.get(subdomain, 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_DOMAIN) +``` + +## Auth0 Dashboard Configuration + +For MCD to work, configure your Auth0 application: + +1. **Allowed Callback URLs**: Add all callback URLs + ``` + https://brand-1.yourapp.com/auth/callback + https://brand-2.yourapp.com/auth/callback + ``` + +2. **Allowed Logout URLs**: Add all base URLs + ``` + https://brand-1.yourapp.com + https://brand-2.yourapp.com + ``` + +3. **Allowed Web Origins** (if using SPA features): + ``` + https://brand-1.yourapp.com + https://brand-2.yourapp.com + ``` + +## Error Handling + +Handle domain resolver errors gracefully: + +```python +from auth0_server_python.error 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_DOMAIN + + # Option 2: Raise error (will return 500 to user) + # raise DomainResolverError(f"Unknown host: {host}") + + return domain +``` + +## How It Works + +When MCD is enabled, the SDK: + +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 + +## 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. If the domains do not match: + +- `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. + +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 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://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. + +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. + +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) +- **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 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: 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) +``` + +```python +# Risky: constructs domain from raw input — attacker can influence resolved domain +async def domain_resolver(context: DomainResolverContext) -> str: + 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 + +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=["brand-1.yourapp.com", "brand-2.yourapp.com"] +) +``` + +## 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-Domain App") +app.add_middleware(SessionMiddleware, secret_key=os.environ["SESSION_SECRET"]) + +# 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 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://yourapp.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-domain app"} + +@app.get("/profile") +async def profile(session=Depends(auth_client.require_session)): + return {"user": session.user} +``` 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" diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index be42a8b..36e7280 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( @@ -136,11 +141,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/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/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 3167ee4..48a2e05 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)) @@ -136,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/stores/stateful_state_store.py b/src/auth0_fastapi/stores/stateful_state_store.py index 9f14704..e07f9de 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. + 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: 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 claim_iss and state.domain: + if normalize_url(claim_iss) != normalize_url(state.domain): + continue + + # OR logic: match on sid OR 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: + continue diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index e7b4d5b..606fe79 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -242,7 +242,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): @@ -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..9abd978 100644 --- a/src/auth0_fastapi/test/test_routes.py +++ b/src/auth0_fastapi/test/test_routes.py @@ -540,3 +540,282 @@ 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 + + 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 + # ========================================================================= + + @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..bf1022b 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,64 @@ def to_safe_redirect(dangerous_redirect: str, safe_base_url: str) -> Optional[st if route_origin == safe_origin: return route_url 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. + 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 host.endswith(':443') and proto == 'https': + host = host[:-4] + elif host.endswith(':80') and proto == 'http': + host = host[:-3] + + return f"{proto}://{host}"