From 3f6ff793d3acb1bedc988a19452b4f4e7576e18f Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 2 Jul 2026 23:02:17 +0530 Subject: [PATCH 1/8] Added CTE support for fastapi --- README.md | 46 +++ examples/CustomTokenExchange.md | 210 +++++++++++++ poetry.lock | 345 +-------------------- pyproject.toml | 2 +- requirements.txt | 2 +- src/auth0_fastapi/auth/auth_client.py | 28 ++ src/auth0_fastapi/errors/__init__.py | 2 + src/auth0_fastapi/test/test_auth_client.py | 155 ++++++++- 8 files changed, 447 insertions(+), 343 deletions(-) create mode 100644 examples/CustomTokenExchange.md diff --git a/README.md b/README.md index fd5400a..c14cf70 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). +- **Custom Token Exchange**: Exchange tokens from external identity providers or legacy systems for Auth0 tokens, with or without establishing a session. - **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. @@ -269,6 +270,51 @@ 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/). +### Custom Token Exchange + +If you're migrating from a legacy authentication system or integrating with a custom identity provider, you can exchange external tokens for Auth0 tokens using [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) Token Exchange, without a browser redirect: + +```python +from fastapi import Request, Response +from auth0_server_python.auth_types import LoginWithCustomTokenExchangeOptions + +@app.post("/auth/token-exchange") +async def token_exchange_login(request: Request, response: Response): + result = await auth_client.login_with_custom_token_exchange( + LoginWithCustomTokenExchangeOptions( + subject_token="token-from-external-idp", + subject_token_type="urn:acme:legacy-session-token", + ), + store_options={"request": request, "response": response}, + ) + + # The user is now logged in; the session cookie is set on `response`. + return {"user": result.state_data["user"]} +``` + +> [!IMPORTANT] +> `store_options={"request": request, "response": response}` is required for `login_with_custom_token_exchange()` — `response` is where the session cookie gets written. Omitting it raises a `ValueError`. + +For advanced token exchange scenarios (e.g. calling a downstream API without affecting the caller's own session), use `custom_token_exchange()` directly. Unlike the login variant above, `store_options` is optional here since no cookie is read or written — it's only needed if you've configured [Multiple Custom Domains](#multiple-custom-domains-mcd): + +```python +from auth0_server_python.auth_types import CustomTokenExchangeOptions + +@app.post("/api/exchange") +async def exchange_token(): + result = await auth_client.custom_token_exchange( + CustomTokenExchangeOptions( + subject_token="token-from-external-system", + subject_token_type="urn:acme:legacy-session-token", + audience="https://downstream-api.example.com", + ) + ) + + return {"access_token": result.access_token} +``` + +For actor tokens (delegation), error handling, and token type URI guidance, see [examples/CustomTokenExchange.md](./examples/CustomTokenExchange.md). + ### Multiple Custom Domains (MCD) For applications using multiple custom domains on the same Auth0 tenant, pass a callable instead of a static domain string: diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md new file mode 100644 index 0000000..500d522 --- /dev/null +++ b/examples/CustomTokenExchange.md @@ -0,0 +1,210 @@ +# Custom Token Exchange + +Custom Token Exchange lets your FastAPI backend exchange a token from an external identity provider or legacy authentication system for Auth0 tokens, without a browser redirect. This implements **OAuth 2.0 Token Exchange** ([RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693)). + +> **NOTE**: For configuration requirements on the Auth0 side (token-exchange profiles, Actions, reserved namespaces), see the [official Auth0 documentation](https://auth0.com/docs/authenticate/custom-token-exchange). + +`AuthClient` exposes two methods for this: + +| Method | Session effect | Use case | +|---|---|---| +| `custom_token_exchange()` | None — the current user session (if any) is untouched | Calling a downstream API with a different audience/scope; service-to-service delegation | +| `login_with_custom_token_exchange()` | Establishes a full Auth0 session, same as completing `/auth/callback` | Logging a user into your app using a token issued by an external system | + +Both methods are programmatic — there is no dedicated route mounted by the SDK. You call them from your own route handler. + +## 1. Basic Token Exchange (no session) + +Exchange a token for Auth0 tokens without affecting the caller's session: + +```python +from fastapi import APIRouter, Request, Response +from auth0_server_python.auth_types import CustomTokenExchangeOptions + +from auth0_fastapi.auth.auth_client import AuthClient + +router = APIRouter() + +@router.post("/api/exchange") +async def exchange_token(request: Request, response: Response): + auth_client: AuthClient = request.app.state.auth_client + + result = await auth_client.custom_token_exchange( + CustomTokenExchangeOptions( + subject_token="token-from-external-system", + subject_token_type="urn:acme:legacy-session-token", + audience="https://downstream-api.example.com", + scope="read:data write:data", + ), + store_options={"request": request, "response": response}, + ) + + return { + "access_token": result.access_token, + "expires_in": result.expires_in, + } +``` + +`store_options` is optional here — `custom_token_exchange()` does not read or write any cookies. Pass it (as `{"request": request, "response": response}`) only if you've configured [Multiple Custom Domains](../README.md#multiple-custom-domains-mcd) with a domain resolver, since the resolver needs the incoming request to pick a domain. Otherwise it can be omitted entirely, which is useful for service-to-service or background-job scenarios where there is no `Request`/`Response` at all: + +```python +from auth0_server_python.auth_types import CustomTokenExchangeOptions + +# No FastAPI Request/Response involved — e.g. a background worker +result = await auth_client.custom_token_exchange( + CustomTokenExchangeOptions( + subject_token="service-token", + subject_token_type="urn:acme:service-token", + audience="https://downstream-api.example.com", + ) +) +``` + +## 2. Login with Custom Token Exchange (establishes a session) + +Exchange a token AND log the user into your FastAPI app: + +```python +from fastapi import APIRouter, Request, Response +from auth0_server_python.auth_types import LoginWithCustomTokenExchangeOptions + +router = APIRouter() + +@router.post("/auth/token-exchange") +async def token_exchange_login(request: Request, response: Response): + auth_client: AuthClient = request.app.state.auth_client + + result = await auth_client.login_with_custom_token_exchange( + LoginWithCustomTokenExchangeOptions( + subject_token="token-from-external-idp", + subject_token_type="urn:acme:corporate-idp-token", + ), + store_options={"request": request, "response": response}, + ) + + # The session cookie is now set on `response`. Subsequent requests + # can use `Depends(auth_client.require_session)` as usual. + user = result.state_data["user"] + return {"user": user} +``` + +Passing `response` in `store_options` is required here — it is how the session store writes the `Set-Cookie` header. Omitting it will raise a `ValueError` from the underlying state store. + +Once this route completes, protected routes work immediately: + +```python +from fastapi import Depends + +@router.get("/api/me") +async def get_profile(session: dict = Depends(auth_client.require_session)): + return {"user": session["user"]} +``` + +> **TIP**: Use `login_with_custom_token_exchange()` for user-migration or external-IdP login flows. Use `custom_token_exchange()` for pure service-to-service or downstream-API scenarios where the caller's own session should not change. + +## 3. Actor Tokens (Delegation) + +Pass `actor_token`/`actor_token_type` to represent a party acting on behalf of the subject. Auth0 surfaces this as the [`act` claim](https://datatracker.ietf.org/doc/html/rfc8693#section-4.1) on the response: + +```python +result = await auth_client.custom_token_exchange( + CustomTokenExchangeOptions( + subject_token="user-access-token", + subject_token_type="urn:ietf:params:oauth:token-type:access_token", + actor_token="service-access-token", + actor_token_type="urn:ietf:params:oauth:token-type:access_token", + audience="https://downstream-api.example.com", + ), + store_options={"request": request, "response": response}, +) + +if result.act: + print(f"Acting party: {result.act['sub']}") +``` + +When you use `login_with_custom_token_exchange()` with an actor token, the `act` claim is persisted on the session user and can be read back later: + +```python +result = await auth_client.login_with_custom_token_exchange( + LoginWithCustomTokenExchangeOptions( + subject_token="user-access-token", + subject_token_type="urn:ietf:params:oauth:token-type:access_token", + actor_token="service-access-token", + actor_token_type="urn:ietf:params:oauth:token-type:access_token", + ), + store_options={"request": request, "response": response}, +) + +user = result.state_data["user"] +if user.get("act"): + print(f"Acting party: {user['act']['sub']}") +``` + +> **NOTE**: When an `actor_token` is present, Auth0 does not issue a refresh token (`offline_access` is dropped). The acting party is fixed at exchange time and is not re-emitted on a later token refresh. + +## 4. Error Handling + +Register the SDK's exception handler once, and `CustomTokenExchangeError` will be mapped to an HTTP `400` JSON response automatically: + +```python +from auth0_fastapi.errors import register_exception_handlers + +register_exception_handlers(app) +``` + +Or handle it inline if a route needs custom behavior: + +```python +from auth0_fastapi.errors import CustomTokenExchangeError, CustomTokenExchangeErrorCode + +@router.post("/api/exchange") +async def exchange_token(request: Request, response: Response): + try: + result = await auth_client.custom_token_exchange( + CustomTokenExchangeOptions( + subject_token=request.headers.get("x-external-token", ""), + subject_token_type="urn:acme:legacy-session-token", + ), + store_options={"request": request, "response": response}, + ) + except CustomTokenExchangeError as e: + if e.code == CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT: + raise HTTPException(status_code=400, detail="Invalid subject token") + raise + + return {"access_token": result.access_token} +``` + +### Common Error Codes + +- `INVALID_TOKEN_FORMAT`: token is empty, whitespace-only, or has a `"Bearer "` prefix +- `MISSING_ACTOR_TOKEN_TYPE`: `actor_token` provided without `actor_token_type` +- `MISSING_ACTOR_TOKEN`: `actor_token_type` provided without `actor_token` +- `TOKEN_EXCHANGE_FAILED`: general token exchange failure (e.g., Auth0 returned an OAuth error) +- `INVALID_RESPONSE`: Auth0 returned a non-JSON response + +`INVALID_TOKEN_FORMAT` is raised client-side before any network call — malformed tokens never reach Auth0. + +## 5. Token Type URIs + +Use standard URNs when the subject token type is covered by RFC 8693; otherwise use your own namespace: + +```python +# Standard token types +"urn:ietf:params:oauth:token-type:jwt" +"urn:ietf:params:oauth:token-type:access_token" +"urn:ietf:params:oauth:token-type:id_token" +"urn:ietf:params:oauth:token-type:refresh_token" + +# Custom token types (your own namespace) +"urn:acme:legacy-session-token" +"urn:company:corporate-idp-token" +``` + +> **NOTE**: Reserved namespaces (`urn:ietf`, `urn:auth0`, `urn:okta`, `http(s)://auth0.com`, `http(s)://okta.com`) cannot be used as a *custom* `subject_token_type` when configuring a token-exchange profile in the Auth0 Dashboard. + +## Additional Resources + +- [Auth0 Custom Token Exchange Documentation](https://auth0.com/docs/authenticate/custom-token-exchange) +- [RFC 8693 - OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693) +- [auth0-server-python: Custom Token Exchange](https://github.com/auth0/auth0-server-python/blob/main/examples/CustomTokenExchange.md) — the underlying protocol implementation this SDK wraps diff --git a/poetry.lock b/poetry.lock index 1a3bf3c..5904266 100644 --- a/poetry.lock +++ b/poetry.lock @@ -31,7 +31,6 @@ description = "High-level concurrency and networking framework on top of asyncio optional = false python-versions = ">=3.9" groups = ["main"] -markers = "python_full_version < \"3.14.0\" or platform_python_implementation == \"PyPy\"" files = [ {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, @@ -45,35 +44,16 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] -[[package]] -name = "anyio" -version = "4.13.0" -description = "High-level concurrency and networking framework on top of asyncio or Trio" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "python_version >= \"3.14\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708"}, - {file = "anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"}, -] - -[package.dependencies] -idna = ">=2.8" - -[package.extras] -trio = ["trio (>=0.32.0)"] - [[package]] name = "auth0-server-python" -version = "1.0.0b10" +version = "1.0.0b12" description = "Auth0 server-side Python SDK" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "auth0_server_python-1.0.0b10-py3-none-any.whl", hash = "sha256:3a126cc27e1f9188384b7805154fa77cfd26874c753551c2daa34360ed29c31a"}, - {file = "auth0_server_python-1.0.0b10.tar.gz", hash = "sha256:2f8a0fd8267b7d907b3a44073842a2d710022e28e27bbf37211a221ef31f5e5d"}, + {file = "auth0_server_python-1.0.0b12-py3-none-any.whl", hash = "sha256:fbd9dd0072597535ce88e1bad9646bc5311c27f0c0d2ca02a3c10a19cca83397"}, + {file = "auth0_server_python-1.0.0b12.tar.gz", hash = "sha256:53ecd96cf2535a5004c265528c235e97556e840f4c988b560403cd2fa629bf52"}, ] [package.dependencies] @@ -91,7 +71,6 @@ description = "The ultimate Python library in building OAuth and OpenID Connect optional = false python-versions = ">=3.9" groups = ["main"] -markers = "python_full_version < \"3.14.0\" or platform_python_implementation == \"PyPy\"" files = [ {file = "authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab"}, {file = "authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd"}, @@ -100,23 +79,6 @@ files = [ [package.dependencies] cryptography = "*" -[[package]] -name = "authlib" -version = "1.7.2" -description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "python_version >= \"3.14\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f"}, - {file = "authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231"}, -] - -[package.dependencies] -cryptography = "*" -joserfc = ">=1.6.0" - [[package]] name = "certifi" version = "2026.4.22" @@ -234,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"}, @@ -243,22 +204,6 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} -[[package]] -name = "click" -version = "8.3.3" -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.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"}, - {file = "click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - [[package]] name = "colorama" version = "0.4.6" @@ -279,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"}, @@ -393,126 +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.5" -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.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5"}, - {file = "coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf"}, - {file = "coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8"}, - {file = "coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4"}, - {file = "coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d"}, - {file = "coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930"}, - {file = "coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d"}, - {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40"}, - {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878"}, - {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400"}, - {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0"}, - {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0"}, - {file = "coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58"}, - {file = "coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e"}, - {file = "coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d"}, - {file = "coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587"}, - {file = "coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642"}, - {file = "coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b"}, - {file = "coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686"}, - {file = "coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743"}, - {file = "coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75"}, - {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209"}, - {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a"}, - {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e"}, - {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd"}, - {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8"}, - {file = "coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf"}, - {file = "coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9"}, - {file = "coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028"}, - {file = "coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01"}, - {file = "coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422"}, - {file = "coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f"}, - {file = "coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5"}, - {file = "coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376"}, - {file = "coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256"}, - {file = "coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c"}, - {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5"}, - {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09"}, - {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9"}, - {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf"}, - {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c"}, - {file = "coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf"}, - {file = "coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810"}, - {file = "coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de"}, - {file = "coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1"}, - {file = "coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3"}, - {file = "coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26"}, - {file = "coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3"}, - {file = "coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b"}, - {file = "coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a"}, - {file = "coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969"}, - {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161"}, - {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15"}, - {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1"}, - {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6"}, - {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17"}, - {file = "coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85"}, - {file = "coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b"}, - {file = "coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664"}, - {file = "coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d"}, - {file = "coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0"}, - {file = "coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806"}, - {file = "coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3"}, - {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9"}, - {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd"}, - {file = "coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606"}, - {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e"}, - {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0"}, - {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87"}, - {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479"}, - {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2"}, - {file = "coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a"}, - {file = "coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819"}, - {file = "coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911"}, - {file = "coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f"}, - {file = "coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e"}, - {file = "coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a"}, - {file = "coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510"}, - {file = "coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247"}, - {file = "coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6"}, - {file = "coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0"}, - {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882"}, - {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740"}, - {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16"}, - {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0"}, - {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0"}, - {file = "coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc"}, - {file = "coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633"}, - {file = "coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8"}, - {file = "coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b"}, - {file = "coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c"}, - {file = "coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9"}, - {file = "coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29"}, - {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607"}, - {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90"}, - {file = "coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3"}, - {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab"}, - {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562"}, - {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2"}, - {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea"}, - {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a"}, - {file = "coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215"}, - {file = "coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43"}, - {file = "coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45"}, - {file = "coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61"}, - {file = "coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179"}, -] - -[package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] - [[package]] name = "cryptography" version = "43.0.3" @@ -520,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"}, @@ -564,72 +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 = "48.0.0" -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.9" -groups = ["main"] -markers = "python_version >= \"3.14\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5"}, - {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321"}, - {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74"}, - {file = "cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4"}, - {file = "cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7"}, - {file = "cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057"}, - {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae"}, - {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c"}, - {file = "cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f"}, - {file = "cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12"}, - {file = "cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239"}, - {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c"}, - {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4"}, - {file = "cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd"}, - {file = "cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a"}, - {file = "cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920"}, -] - -[package.dependencies] -cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} - -[package.extras] -ssh = ["bcrypt (>=3.1.5)"] - [[package]] name = "exceptiongroup" version = "1.3.1" @@ -656,7 +413,6 @@ description = "FastAPI framework, high performance, easy to learn, fast to code, optional = false python-versions = ">=3.9" groups = ["main"] -markers = "python_full_version < \"3.14.0\" or platform_python_implementation == \"PyPy\"" files = [ {file = "fastapi-0.128.8-py3-none-any.whl", hash = "sha256:5618f492d0fe973a778f8fec97723f598aa9deee495040a8d51aaf3cf123ecf1"}, {file = "fastapi-0.128.8.tar.gz", hash = "sha256:3171f9f328c4a218f0a8d2ba8310ac3a55d1ee12c28c949650288aee25966007"}, @@ -674,31 +430,6 @@ all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (> standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] -[[package]] -name = "fastapi" -version = "0.136.1" -description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "python_version >= \"3.14\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f"}, - {file = "fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f"}, -] - -[package.dependencies] -annotated-doc = ">=0.0.2" -pydantic = ">=2.9.0" -starlette = ">=0.46.0" -typing-extensions = ">=4.8.0" -typing-inspection = ">=0.4.2" - -[package.extras] -all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "fastar (>=0.9.0)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] -standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] - [[package]] name = "h11" version = "0.16.0" @@ -780,44 +511,11 @@ 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 = "joserfc" -version = "1.6.5" -description = "The ultimate Python library for JOSE RFCs, including JWS, JWE, JWK, JWA, JWT" -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "python_version >= \"3.14\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e"}, - {file = "joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48"}, -] - -[package.dependencies] -cryptography = ">=45.0.1" - -[package.extras] -drafts = ["pycryptodome"] - [[package]] name = "jwcrypto" version = "1.5.7" @@ -869,25 +567,12 @@ description = "C parser in Python" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "python_full_version < \"3.14.0\" and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" files = [ {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, ] -[[package]] -name = "pycparser" -version = "3.0" -description = "C parser in Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "python_version >= \"3.14\" and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" -files = [ - {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, - {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, -] - [[package]] name = "pydantic" version = "2.13.4" @@ -1178,7 +863,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"}, @@ -1191,25 +875,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 = "1.0.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-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b"}, - {file = "starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149"}, -] - -[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.1" @@ -1319,4 +984,4 @@ standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3) [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "a0b90d90247b469fe6083b11078c71974f9f6ddf00e9e5a09c06649270aebd33" +content-hash = "f281e18a8a9d8ef3480028ab3d1f344fdcb723603361b5a64a1845d70d0e1c4f" diff --git a/pyproject.toml b/pyproject.toml index 9753546..8738718 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ packages = [ [tool.poetry.dependencies] python = ">=3.9" -auth0-server-python = ">=1.0.0b9" +auth0-server-python = ">=1.0.0b11" fastapi = ">=0.115.11" pydantic = "^2.12.5" diff --git a/requirements.txt b/requirements.txt index 0006fdd..ebb9b18 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -auth0-server-python>=1.0.0b7 +auth0-server-python>=1.0.0b11 fastapi>=0.115.11 pydantic>=2.12.5 uvicorn>=0.39.0 diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index 36e7280..05e68b9 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -6,8 +6,12 @@ from auth0_server_python.auth_types import ( CompleteConnectAccountResponse, ConnectAccountOptions, + CustomTokenExchangeOptions, + LoginWithCustomTokenExchangeOptions, + LoginWithCustomTokenExchangeResult, LogoutOptions, StartInteractiveLoginOptions, + TokenExchangeResponse, ) from fastapi import HTTPException, Request, Response, status @@ -94,6 +98,30 @@ async def complete_login( """ return await self.client.complete_interactive_login(callback_url, store_options=store_options) + async def custom_token_exchange( + self, + options: CustomTokenExchangeOptions, + store_options: dict = None, + ) -> TokenExchangeResponse: + """ + Performs an RFC 8693 token exchange for the given subject token. + Does not create or modify the current session. + Returns the raw TokenExchangeResponse. + """ + return await self.client.custom_token_exchange(options, store_options=store_options) + + async def login_with_custom_token_exchange( + self, + options: LoginWithCustomTokenExchangeOptions, + store_options: dict = None, + ) -> LoginWithCustomTokenExchangeResult: + """ + Performs an RFC 8693 token exchange for the given subject token and + establishes a session for the resulting user. + Returns the LoginWithCustomTokenExchangeResult containing the session state. + """ + return await self.client.login_with_custom_token_exchange(options, store_options=store_options) + async def start_connect_account( self, connection: str, diff --git a/src/auth0_fastapi/errors/__init__.py b/src/auth0_fastapi/errors/__init__.py index 184bfeb..0e82368 100644 --- a/src/auth0_fastapi/errors/__init__.py +++ b/src/auth0_fastapi/errors/__init__.py @@ -5,6 +5,8 @@ ApiError, Auth0Error, BackchannelLogoutError, + CustomTokenExchangeError, + CustomTokenExchangeErrorCode, IssuerValidationError, MissingRequiredArgumentError, MissingTransactionError, diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index 606fe79..c07fc2c 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -2,7 +2,15 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from auth0_server_python.auth_types import CompleteConnectAccountResponse, ConnectAccountOptions +from auth0_server_python.auth_types import ( + CompleteConnectAccountResponse, + ConnectAccountOptions, + CustomTokenExchangeOptions, + LoginWithCustomTokenExchangeOptions, + LoginWithCustomTokenExchangeResult, + TokenExchangeResponse, +) +from auth0_server_python.error import CustomTokenExchangeError, CustomTokenExchangeErrorCode from fastapi import HTTPException, Request, Response from auth0_fastapi.auth.auth_client import AuthClient @@ -256,6 +264,151 @@ async def test_backchannel_logout_with_invalid_token(self, auth_client): await auth_client.handle_backchannel_logout(invalid_token) +class TestCustomTokenExchange: + """Test Custom Token Exchange (RFC 8693) security and functionality.""" + + @pytest.mark.asyncio + async def test_custom_token_exchange_success(self, auth_client, mock_request, mock_response): + """Test that custom_token_exchange delegates to the underlying client and returns its result.""" + options = CustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:legacy-session-token", + ) + mock_result = TokenExchangeResponse( + access_token="new_access_token", + token_type="Bearer", + expires_in=3600, + ) + + with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange: + mock_exchange.return_value = mock_result + + result = await auth_client.custom_token_exchange( + options, + store_options={"request": mock_request, "response": mock_response}, + ) + + assert result == mock_result + mock_exchange.assert_called_once_with( + options, + store_options={"request": mock_request, "response": mock_response}, + ) + + @pytest.mark.asyncio + async def test_custom_token_exchange_does_not_touch_session(self, auth_client): + """Test that custom_token_exchange never calls get_session or writes to the state store.""" + options = CustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:legacy-session-token", + ) + + with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange, \ + patch.object(auth_client.client, 'get_session', new_callable=AsyncMock) as mock_get_session: + mock_exchange.return_value = TokenExchangeResponse( + access_token="token", token_type="Bearer", expires_in=3600 + ) + + await auth_client.custom_token_exchange(options) + + mock_get_session.assert_not_called() + + @pytest.mark.asyncio + async def test_custom_token_exchange_with_actor_token(self, auth_client): + """Test delegation/impersonation via actor_token and actor_token_type.""" + options = CustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:legacy-session-token", + actor_token="actor-token", + actor_token_type="urn:acme:actor-token", + ) + mock_result = TokenExchangeResponse( + access_token="new_access_token", + token_type="Bearer", + expires_in=3600, + act={"sub": "actor-subject"}, + ) + + with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange: + mock_exchange.return_value = mock_result + + result = await auth_client.custom_token_exchange(options) + + assert result.act == {"sub": "actor-subject"} + call_args = mock_exchange.call_args[0][0] + assert call_args.actor_token == "actor-token" + assert call_args.actor_token_type == "urn:acme:actor-token" + + @pytest.mark.asyncio + async def test_custom_token_exchange_error_propagates(self, auth_client): + """Test that CustomTokenExchangeError from the underlying client is not swallowed or wrapped.""" + options = CustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:legacy-session-token", + ) + + with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange: + mock_exchange.side_effect = CustomTokenExchangeError( + CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT, + "subject_token_type must be a valid URI", + ) + + with pytest.raises(CustomTokenExchangeError): + await auth_client.custom_token_exchange(options) + + @pytest.mark.asyncio + async def test_login_with_custom_token_exchange_success(self, auth_client, mock_request, mock_response): + """Test that login_with_custom_token_exchange delegates to the underlying client and returns its result.""" + options = LoginWithCustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:corporate-idp-token", + ) + mock_result = LoginWithCustomTokenExchangeResult( + state_data={"user": {"sub": "test_user"}}, + ) + + with patch.object( + auth_client.client, 'login_with_custom_token_exchange', new_callable=AsyncMock + ) as mock_login_exchange: + mock_login_exchange.return_value = mock_result + + result = await auth_client.login_with_custom_token_exchange( + options, + store_options={"request": mock_request, "response": mock_response}, + ) + + assert result == mock_result + mock_login_exchange.assert_called_once_with( + options, + store_options={"request": mock_request, "response": mock_response}, + ) + + @pytest.mark.asyncio + async def test_login_with_custom_token_exchange_passes_store_options_for_session_write( + self, auth_client, mock_request, mock_response + ): + """Test that request and response are forwarded so the state store can write the session cookie.""" + options = LoginWithCustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:corporate-idp-token", + ) + + with patch.object( + auth_client.client, 'login_with_custom_token_exchange', new_callable=AsyncMock + ) as mock_login_exchange: + mock_login_exchange.return_value = LoginWithCustomTokenExchangeResult( + state_data={"user": {"sub": "test_user"}} + ) + + await auth_client.login_with_custom_token_exchange( + options, + store_options={"request": mock_request, "response": mock_response}, + ) + + call_kwargs = mock_login_exchange.call_args.kwargs + assert call_kwargs['store_options']['request'] is mock_request + assert call_kwargs['store_options']['response'] is mock_response + + class TestAccountLinking: """Test account linking/unlinking security and functionality.""" From 73a915dfef27a65a2dd2ae39febcd27664a4e1ae Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Sun, 5 Jul 2026 21:16:37 +0530 Subject: [PATCH 2/8] Added missing code snippet line --- examples/CustomTokenExchange.md | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md index 500d522..046c38a 100644 --- a/examples/CustomTokenExchange.md +++ b/examples/CustomTokenExchange.md @@ -155,6 +155,7 @@ register_exception_handlers(app) Or handle it inline if a route needs custom behavior: ```python +from fastapi import HTTPException from auth0_fastapi.errors import CustomTokenExchangeError, CustomTokenExchangeErrorCode @router.post("/api/exchange") From 1d4c2a42523b7ecf8c56331a56e312ec5c4feee2 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Sun, 12 Jul 2026 15:19:14 +0530 Subject: [PATCH 3/8] Added docstrings for primary methods for cte --- src/auth0_fastapi/auth/auth_client.py | 36 +++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index 05e68b9..00b7506 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -106,7 +106,22 @@ async def custom_token_exchange( """ Performs an RFC 8693 token exchange for the given subject token. Does not create or modify the current session. - Returns the raw TokenExchangeResponse. + + Args: + options: Subject/actor token details and exchange parameters + (subject_token, subject_token_type, audience, scope, actor_token, + actor_token_type, organization, authorization_params). + store_options: Optional options passed to the Transaction and State + Store. Only required when using Multiple Custom Domains, where + the domain resolver needs the incoming request. + + Returns: + The raw TokenExchangeResponse (access_token, expires_in, and, when + an actor_token was supplied, the decoded act claim). + + Raises: + CustomTokenExchangeError: If the exchange fails or the subject/actor + token parameters are invalid (see CustomTokenExchangeErrorCode). """ return await self.client.custom_token_exchange(options, store_options=store_options) @@ -118,7 +133,24 @@ async def login_with_custom_token_exchange( """ Performs an RFC 8693 token exchange for the given subject token and establishes a session for the resulting user. - Returns the LoginWithCustomTokenExchangeResult containing the session state. + + Args: + options: Subject/actor token details and exchange parameters + (subject_token, subject_token_type, audience, scope, actor_token, + actor_token_type, organization, authorization_params). + store_options: Options passed to the Transaction and State Store. + Must include {"request": request, "response": response} so the + session cookie can be written on response. + + Returns: + The LoginWithCustomTokenExchangeResult containing the session state + (including the resulting user). + + Raises: + CustomTokenExchangeError: If the exchange fails or the subject/actor + token parameters are invalid (see CustomTokenExchangeErrorCode). + ValueError: If store_options is missing the response needed to + write the session cookie. """ return await self.client.login_with_custom_token_exchange(options, store_options=store_options) From ad62e08a07b1909bf818ac28ce7dec857f6638f1 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 16 Jul 2026 19:28:52 +0530 Subject: [PATCH 4/8] Updating docs and rearranging functions as per feedback --- examples/CustomTokenExchange.md | 27 +- src/auth0_fastapi/auth/auth_client.py | 112 +++---- src/auth0_fastapi/test/test_auth_client.py | 331 ++++++++++++--------- 3 files changed, 261 insertions(+), 209 deletions(-) diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md index 046c38a..88ac195 100644 --- a/examples/CustomTokenExchange.md +++ b/examples/CustomTokenExchange.md @@ -178,15 +178,26 @@ async def exchange_token(request: Request, response: Response): ### Common Error Codes -- `INVALID_TOKEN_FORMAT`: token is empty, whitespace-only, or has a `"Bearer "` prefix -- `MISSING_ACTOR_TOKEN_TYPE`: `actor_token` provided without `actor_token_type` -- `MISSING_ACTOR_TOKEN`: `actor_token_type` provided without `actor_token` -- `TOKEN_EXCHANGE_FAILED`: general token exchange failure (e.g., Auth0 returned an OAuth error) -- `INVALID_RESPONSE`: Auth0 returned a non-JSON response +See the [auth0-server-python Custom Token Exchange doc](https://github.com/auth0/auth0-server-python/blob/main/examples/CustomTokenExchange.md#common-error-codes) for the full, up-to-date list of `CustomTokenExchangeErrorCode` values and what triggers each one. -`INVALID_TOKEN_FORMAT` is raised client-side before any network call — malformed tokens never reach Auth0. +`INVALID_TOKEN_FORMAT` is raised client-side before any network call for an empty or whitespace-only `subject_token`/`actor_token`, or one with a `"Bearer "` prefix. Other malformed-but-nonempty values (including a `subject_token_type` that isn't a valid URI) are not checked client-side and are sent to Auth0, which rejects them. -## 5. Token Type URIs +## 5. Organization Support + +Specify an organization when exchanging tokens: + +```python +result = await auth_client.custom_token_exchange( + CustomTokenExchangeOptions( + subject_token="token-from-external-system", + subject_token_type="urn:acme:legacy-session-token", + audience="https://downstream-api.example.com", + organization="org_abc1234", + ) +) +``` + +## 6. Token Type URIs Use standard URNs when the subject token type is covered by RFC 8693; otherwise use your own namespace: @@ -202,7 +213,7 @@ Use standard URNs when the subject token type is covered by RFC 8693; otherwise "urn:company:corporate-idp-token" ``` -> **NOTE**: Reserved namespaces (`urn:ietf`, `urn:auth0`, `urn:okta`, `http(s)://auth0.com`, `http(s)://okta.com`) cannot be used as a *custom* `subject_token_type` when configuring a token-exchange profile in the Auth0 Dashboard. +> **NOTE**: See the [official Auth0 documentation](https://auth0.com/docs/authenticate/custom-token-exchange) for which namespaces are reserved and cannot be used as a *custom* `subject_token_type` when configuring a token-exchange profile in the Auth0 Dashboard. ## Additional Resources diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index 00b7506..b445968 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -98,62 +98,6 @@ async def complete_login( """ return await self.client.complete_interactive_login(callback_url, store_options=store_options) - async def custom_token_exchange( - self, - options: CustomTokenExchangeOptions, - store_options: dict = None, - ) -> TokenExchangeResponse: - """ - Performs an RFC 8693 token exchange for the given subject token. - Does not create or modify the current session. - - Args: - options: Subject/actor token details and exchange parameters - (subject_token, subject_token_type, audience, scope, actor_token, - actor_token_type, organization, authorization_params). - store_options: Optional options passed to the Transaction and State - Store. Only required when using Multiple Custom Domains, where - the domain resolver needs the incoming request. - - Returns: - The raw TokenExchangeResponse (access_token, expires_in, and, when - an actor_token was supplied, the decoded act claim). - - Raises: - CustomTokenExchangeError: If the exchange fails or the subject/actor - token parameters are invalid (see CustomTokenExchangeErrorCode). - """ - return await self.client.custom_token_exchange(options, store_options=store_options) - - async def login_with_custom_token_exchange( - self, - options: LoginWithCustomTokenExchangeOptions, - store_options: dict = None, - ) -> LoginWithCustomTokenExchangeResult: - """ - Performs an RFC 8693 token exchange for the given subject token and - establishes a session for the resulting user. - - Args: - options: Subject/actor token details and exchange parameters - (subject_token, subject_token_type, audience, scope, actor_token, - actor_token_type, organization, authorization_params). - store_options: Options passed to the Transaction and State Store. - Must include {"request": request, "response": response} so the - session cookie can be written on response. - - Returns: - The LoginWithCustomTokenExchangeResult containing the session state - (including the resulting user). - - Raises: - CustomTokenExchangeError: If the exchange fails or the subject/actor - token parameters are invalid (see CustomTokenExchangeErrorCode). - ValueError: If store_options is missing the response needed to - write the session cookie. - """ - return await self.client.login_with_custom_token_exchange(options, store_options=store_options) - async def start_connect_account( self, connection: str, @@ -279,3 +223,59 @@ async def require_session( raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Please log in") return session + + async def custom_token_exchange( + self, + options: CustomTokenExchangeOptions, + store_options: dict = None, + ) -> TokenExchangeResponse: + """ + Performs an RFC 8693 token exchange for the given subject token. + Does not create or modify the current session. + + Args: + options: Subject/actor token details and exchange parameters + (subject_token, subject_token_type, audience, scope, actor_token, + actor_token_type, organization, authorization_params). + store_options: Optional options passed to the Transaction and State + Store. Only required when using Multiple Custom Domains, where + the domain resolver needs the incoming request. + + Returns: + The raw TokenExchangeResponse (access_token, expires_in, and, when + an actor_token was supplied, the decoded act claim). + + Raises: + CustomTokenExchangeError: If the exchange fails or the subject/actor + token parameters are invalid (see CustomTokenExchangeErrorCode). + """ + return await self.client.custom_token_exchange(options, store_options=store_options) + + async def login_with_custom_token_exchange( + self, + options: LoginWithCustomTokenExchangeOptions, + store_options: dict = None, + ) -> LoginWithCustomTokenExchangeResult: + """ + Performs an RFC 8693 token exchange for the given subject token and + establishes a session for the resulting user. + + Args: + options: Subject/actor token details and exchange parameters + (subject_token, subject_token_type, audience, scope, actor_token, + actor_token_type, organization, authorization_params). + store_options: Options passed to the Transaction and State Store. + Must include {"request": request, "response": response} so the + session cookie can be written on response. + + Returns: + The LoginWithCustomTokenExchangeResult containing the session state + (including the resulting user). + + Raises: + CustomTokenExchangeError: If the exchange fails or the subject/actor + token parameters are invalid (see CustomTokenExchangeErrorCode). + ValueError: If store_options is missing the response needed to + write the session cookie. + """ + return await self.client.login_with_custom_token_exchange(options, store_options=store_options) diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index c07fc2c..32c75db 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -264,151 +264,6 @@ async def test_backchannel_logout_with_invalid_token(self, auth_client): await auth_client.handle_backchannel_logout(invalid_token) -class TestCustomTokenExchange: - """Test Custom Token Exchange (RFC 8693) security and functionality.""" - - @pytest.mark.asyncio - async def test_custom_token_exchange_success(self, auth_client, mock_request, mock_response): - """Test that custom_token_exchange delegates to the underlying client and returns its result.""" - options = CustomTokenExchangeOptions( - subject_token="external-token", - subject_token_type="urn:acme:legacy-session-token", - ) - mock_result = TokenExchangeResponse( - access_token="new_access_token", - token_type="Bearer", - expires_in=3600, - ) - - with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange: - mock_exchange.return_value = mock_result - - result = await auth_client.custom_token_exchange( - options, - store_options={"request": mock_request, "response": mock_response}, - ) - - assert result == mock_result - mock_exchange.assert_called_once_with( - options, - store_options={"request": mock_request, "response": mock_response}, - ) - - @pytest.mark.asyncio - async def test_custom_token_exchange_does_not_touch_session(self, auth_client): - """Test that custom_token_exchange never calls get_session or writes to the state store.""" - options = CustomTokenExchangeOptions( - subject_token="external-token", - subject_token_type="urn:acme:legacy-session-token", - ) - - with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange, \ - patch.object(auth_client.client, 'get_session', new_callable=AsyncMock) as mock_get_session: - mock_exchange.return_value = TokenExchangeResponse( - access_token="token", token_type="Bearer", expires_in=3600 - ) - - await auth_client.custom_token_exchange(options) - - mock_get_session.assert_not_called() - - @pytest.mark.asyncio - async def test_custom_token_exchange_with_actor_token(self, auth_client): - """Test delegation/impersonation via actor_token and actor_token_type.""" - options = CustomTokenExchangeOptions( - subject_token="external-token", - subject_token_type="urn:acme:legacy-session-token", - actor_token="actor-token", - actor_token_type="urn:acme:actor-token", - ) - mock_result = TokenExchangeResponse( - access_token="new_access_token", - token_type="Bearer", - expires_in=3600, - act={"sub": "actor-subject"}, - ) - - with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange: - mock_exchange.return_value = mock_result - - result = await auth_client.custom_token_exchange(options) - - assert result.act == {"sub": "actor-subject"} - call_args = mock_exchange.call_args[0][0] - assert call_args.actor_token == "actor-token" - assert call_args.actor_token_type == "urn:acme:actor-token" - - @pytest.mark.asyncio - async def test_custom_token_exchange_error_propagates(self, auth_client): - """Test that CustomTokenExchangeError from the underlying client is not swallowed or wrapped.""" - options = CustomTokenExchangeOptions( - subject_token="external-token", - subject_token_type="urn:acme:legacy-session-token", - ) - - with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange: - mock_exchange.side_effect = CustomTokenExchangeError( - CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT, - "subject_token_type must be a valid URI", - ) - - with pytest.raises(CustomTokenExchangeError): - await auth_client.custom_token_exchange(options) - - @pytest.mark.asyncio - async def test_login_with_custom_token_exchange_success(self, auth_client, mock_request, mock_response): - """Test that login_with_custom_token_exchange delegates to the underlying client and returns its result.""" - options = LoginWithCustomTokenExchangeOptions( - subject_token="external-token", - subject_token_type="urn:acme:corporate-idp-token", - ) - mock_result = LoginWithCustomTokenExchangeResult( - state_data={"user": {"sub": "test_user"}}, - ) - - with patch.object( - auth_client.client, 'login_with_custom_token_exchange', new_callable=AsyncMock - ) as mock_login_exchange: - mock_login_exchange.return_value = mock_result - - result = await auth_client.login_with_custom_token_exchange( - options, - store_options={"request": mock_request, "response": mock_response}, - ) - - assert result == mock_result - mock_login_exchange.assert_called_once_with( - options, - store_options={"request": mock_request, "response": mock_response}, - ) - - @pytest.mark.asyncio - async def test_login_with_custom_token_exchange_passes_store_options_for_session_write( - self, auth_client, mock_request, mock_response - ): - """Test that request and response are forwarded so the state store can write the session cookie.""" - options = LoginWithCustomTokenExchangeOptions( - subject_token="external-token", - subject_token_type="urn:acme:corporate-idp-token", - ) - - with patch.object( - auth_client.client, 'login_with_custom_token_exchange', new_callable=AsyncMock - ) as mock_login_exchange: - mock_login_exchange.return_value = LoginWithCustomTokenExchangeResult( - state_data={"user": {"sub": "test_user"}} - ) - - await auth_client.login_with_custom_token_exchange( - options, - store_options={"request": mock_request, "response": mock_response}, - ) - - call_kwargs = mock_login_exchange.call_args.kwargs - assert call_kwargs['store_options']['request'] is mock_request - assert call_kwargs['store_options']['response'] is mock_response - - class TestAccountLinking: """Test account linking/unlinking security and functionality.""" @@ -772,3 +627,189 @@ async def domain_resolver(context): # 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 + + +class TestCustomTokenExchange: + """Test Custom Token Exchange (RFC 8693) security and functionality.""" + + @pytest.mark.asyncio + async def test_custom_token_exchange_success(self, auth_client, mock_request, mock_response): + """Test that custom_token_exchange delegates to the underlying client and returns its result.""" + options = CustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:legacy-session-token", + ) + mock_result = TokenExchangeResponse( + access_token="new_access_token", + token_type="Bearer", + expires_in=3600, + ) + + with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange: + mock_exchange.return_value = mock_result + + result = await auth_client.custom_token_exchange( + options, + store_options={"request": mock_request, "response": mock_response}, + ) + + assert result == mock_result + mock_exchange.assert_called_once_with( + options, + store_options={"request": mock_request, "response": mock_response}, + ) + + @pytest.mark.asyncio + async def test_custom_token_exchange_does_not_touch_session(self, auth_client): + """Test that custom_token_exchange never calls get_session or writes to the state store.""" + options = CustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:legacy-session-token", + ) + + with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange, \ + patch.object(auth_client.client, 'get_session', new_callable=AsyncMock) as mock_get_session: + mock_exchange.return_value = TokenExchangeResponse( + access_token="token", token_type="Bearer", expires_in=3600 + ) + + await auth_client.custom_token_exchange(options) + + mock_get_session.assert_not_called() + + @pytest.mark.asyncio + async def test_custom_token_exchange_with_actor_token(self, auth_client): + """Test delegation/impersonation via actor_token and actor_token_type.""" + options = CustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:legacy-session-token", + actor_token="actor-token", + actor_token_type="urn:acme:actor-token", + ) + mock_result = TokenExchangeResponse( + access_token="new_access_token", + token_type="Bearer", + expires_in=3600, + act={"sub": "actor-subject"}, + ) + + with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange: + mock_exchange.return_value = mock_result + + result = await auth_client.custom_token_exchange(options) + + assert result.act == {"sub": "actor-subject"} + call_args = mock_exchange.call_args[0][0] + assert call_args.actor_token == "actor-token" + assert call_args.actor_token_type == "urn:acme:actor-token" + + @pytest.mark.asyncio + async def test_custom_token_exchange_error_propagates(self, auth_client): + """Test that CustomTokenExchangeError from the underlying client is not swallowed or wrapped.""" + options = CustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:legacy-session-token", + ) + + with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange: + mock_exchange.side_effect = CustomTokenExchangeError( + CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT, + "subject_token cannot be empty or whitespace-only", + ) + + with pytest.raises(CustomTokenExchangeError): + await auth_client.custom_token_exchange(options) + + @pytest.mark.asyncio + async def test_login_with_custom_token_exchange_success(self, auth_client, mock_request, mock_response): + """Test that login_with_custom_token_exchange delegates to the underlying client and returns its result.""" + options = LoginWithCustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:corporate-idp-token", + ) + mock_result = LoginWithCustomTokenExchangeResult( + state_data={"user": {"sub": "test_user"}}, + ) + + with patch.object( + auth_client.client, 'login_with_custom_token_exchange', new_callable=AsyncMock + ) as mock_login_exchange: + mock_login_exchange.return_value = mock_result + + result = await auth_client.login_with_custom_token_exchange( + options, + store_options={"request": mock_request, "response": mock_response}, + ) + + assert result == mock_result + mock_login_exchange.assert_called_once_with( + options, + store_options={"request": mock_request, "response": mock_response}, + ) + + @pytest.mark.asyncio + async def test_login_with_custom_token_exchange_passes_store_options_for_session_write( + self, auth_client, mock_request, mock_response + ): + """Test that request and response are forwarded so the state store can write the session cookie.""" + options = LoginWithCustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:corporate-idp-token", + ) + + with patch.object( + auth_client.client, 'login_with_custom_token_exchange', new_callable=AsyncMock + ) as mock_login_exchange: + mock_login_exchange.return_value = LoginWithCustomTokenExchangeResult( + state_data={"user": {"sub": "test_user"}} + ) + + await auth_client.login_with_custom_token_exchange( + options, + store_options={"request": mock_request, "response": mock_response}, + ) + + call_kwargs = mock_login_exchange.call_args.kwargs + assert call_kwargs['store_options']['request'] is mock_request + assert call_kwargs['store_options']['response'] is mock_response + + @pytest.mark.asyncio + async def test_login_with_custom_token_exchange_forwards_actor_token(self, auth_client): + """Test that actor_token/actor_token_type are forwarded to the underlying client.""" + options = LoginWithCustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:corporate-idp-token", + actor_token="actor-token", + actor_token_type="urn:acme:actor-token", + ) + + with patch.object( + auth_client.client, 'login_with_custom_token_exchange', new_callable=AsyncMock + ) as mock_login_exchange: + mock_login_exchange.return_value = LoginWithCustomTokenExchangeResult( + state_data={"user": {"sub": "test_user", "act": {"sub": "actor-subject"}}} + ) + + await auth_client.login_with_custom_token_exchange(options) + + call_args = mock_login_exchange.call_args[0][0] + assert call_args.actor_token == "actor-token" + assert call_args.actor_token_type == "urn:acme:actor-token" + + @pytest.mark.asyncio + async def test_login_with_custom_token_exchange_missing_response_raises_value_error(self, auth_client): + """Test that omitting response in store_options surfaces the ValueError from the state store.""" + options = LoginWithCustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:corporate-idp-token", + ) + + with patch.object( + auth_client.client, 'login_with_custom_token_exchange', new_callable=AsyncMock + ) as mock_login_exchange: + mock_login_exchange.side_effect = ValueError( + "Response object is required in store options for stateless storage." + ) + + with pytest.raises(ValueError): + await auth_client.login_with_custom_token_exchange(options, store_options={}) From 29603ba8b10c3acb1b625467d50e4f872a9b1f3f Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 16 Jul 2026 21:11:17 +0530 Subject: [PATCH 5/8] Organized examples --- examples/CustomTokenExchange.md | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md index 88ac195..235c0b7 100644 --- a/examples/CustomTokenExchange.md +++ b/examples/CustomTokenExchange.md @@ -142,6 +142,19 @@ if user.get("act"): > **NOTE**: When an `actor_token` is present, Auth0 does not issue a refresh token (`offline_access` is dropped). The acting party is fixed at exchange time and is not re-emitted on a later token refresh. +Specify an organization when exchanging tokens: + +```python +result = await auth_client.custom_token_exchange( + CustomTokenExchangeOptions( + subject_token="token-from-external-system", + subject_token_type="urn:acme:legacy-session-token", + audience="https://downstream-api.example.com", + organization="org_abc1234", + ) +) +``` + ## 4. Error Handling Register the SDK's exception handler once, and `CustomTokenExchangeError` will be mapped to an HTTP `400` JSON response automatically: @@ -182,22 +195,7 @@ See the [auth0-server-python Custom Token Exchange doc](https://github.com/auth0 `INVALID_TOKEN_FORMAT` is raised client-side before any network call for an empty or whitespace-only `subject_token`/`actor_token`, or one with a `"Bearer "` prefix. Other malformed-but-nonempty values (including a `subject_token_type` that isn't a valid URI) are not checked client-side and are sent to Auth0, which rejects them. -## 5. Organization Support - -Specify an organization when exchanging tokens: - -```python -result = await auth_client.custom_token_exchange( - CustomTokenExchangeOptions( - subject_token="token-from-external-system", - subject_token_type="urn:acme:legacy-session-token", - audience="https://downstream-api.example.com", - organization="org_abc1234", - ) -) -``` - -## 6. Token Type URIs +## 5. Token Type URIs Use standard URNs when the subject token type is covered by RFC 8693; otherwise use your own namespace: From eae9a7d87a55b1f99c68a9ef087ea1aab9e8dae0 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 16 Jul 2026 21:15:24 +0530 Subject: [PATCH 6/8] Rearranged docs --- examples/CustomTokenExchange.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md index 235c0b7..2a09775 100644 --- a/examples/CustomTokenExchange.md +++ b/examples/CustomTokenExchange.md @@ -140,8 +140,6 @@ if user.get("act"): print(f"Acting party: {user['act']['sub']}") ``` -> **NOTE**: When an `actor_token` is present, Auth0 does not issue a refresh token (`offline_access` is dropped). The acting party is fixed at exchange time and is not re-emitted on a later token refresh. - Specify an organization when exchanging tokens: ```python @@ -154,6 +152,8 @@ result = await auth_client.custom_token_exchange( ) ) ``` +> **NOTE**: When an `actor_token` is present, Auth0 does not issue a refresh token (`offline_access` is dropped). The acting party is fixed at exchange time and is not re-emitted on a later token refresh. + ## 4. Error Handling From 9dc994f144abbc3aa4ac6c3423e6f23d26db6ffd Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 16 Jul 2026 21:30:22 +0530 Subject: [PATCH 7/8] Trimmed doc that is already present in core sdk --- examples/CustomTokenExchange.md | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md index 2a09775..f16e6ba 100644 --- a/examples/CustomTokenExchange.md +++ b/examples/CustomTokenExchange.md @@ -197,21 +197,9 @@ See the [auth0-server-python Custom Token Exchange doc](https://github.com/auth0 ## 5. Token Type URIs -Use standard URNs when the subject token type is covered by RFC 8693; otherwise use your own namespace: +`subject_token_type` and `actor_token_type` accept any URI — a standard RFC 8693 URN (e.g. `urn:ietf:params:oauth:token-type:jwt`) or your own namespace (e.g. `urn:acme:legacy-session-token`). -```python -# Standard token types -"urn:ietf:params:oauth:token-type:jwt" -"urn:ietf:params:oauth:token-type:access_token" -"urn:ietf:params:oauth:token-type:id_token" -"urn:ietf:params:oauth:token-type:refresh_token" - -# Custom token types (your own namespace) -"urn:acme:legacy-session-token" -"urn:company:corporate-idp-token" -``` - -> **NOTE**: See the [official Auth0 documentation](https://auth0.com/docs/authenticate/custom-token-exchange) for which namespaces are reserved and cannot be used as a *custom* `subject_token_type` when configuring a token-exchange profile in the Auth0 Dashboard. +See the [auth0-server-python Custom Token Exchange doc](https://github.com/auth0/auth0-server-python/blob/main/examples/CustomTokenExchange.md) for the full set of standard token-type URNs, and the [official Auth0 documentation](https://auth0.com/docs/authenticate/custom-token-exchange) for which namespaces are reserved and cannot be used as a *custom* `subject_token_type` when configuring a token-exchange profile in the Auth0 Dashboard. ## Additional Resources From 208c4fd540845629e3cad0e91144576c3aa4b02b Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 24 Jul 2026 12:51:46 +0530 Subject: [PATCH 8/8] Updated docs and removed actor token references to raise via separate PR --- README.md | 49 ++--------------- examples/CustomTokenExchange.md | 63 ++-------------------- src/auth0_fastapi/auth/auth_client.py | 19 ++++--- src/auth0_fastapi/test/test_auth_client.py | 49 ----------------- 4 files changed, 18 insertions(+), 162 deletions(-) diff --git a/README.md b/README.md index 7ba49f5..4d53017 100644 --- a/README.md +++ b/README.md @@ -271,51 +271,6 @@ 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/). -### Custom Token Exchange - -If you're migrating from a legacy authentication system or integrating with a custom identity provider, you can exchange external tokens for Auth0 tokens using [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) Token Exchange, without a browser redirect: - -```python -from fastapi import Request, Response -from auth0_server_python.auth_types import LoginWithCustomTokenExchangeOptions - -@app.post("/auth/token-exchange") -async def token_exchange_login(request: Request, response: Response): - result = await auth_client.login_with_custom_token_exchange( - LoginWithCustomTokenExchangeOptions( - subject_token="token-from-external-idp", - subject_token_type="urn:acme:legacy-session-token", - ), - store_options={"request": request, "response": response}, - ) - - # The user is now logged in; the session cookie is set on `response`. - return {"user": result.state_data["user"]} -``` - -> [!IMPORTANT] -> `store_options={"request": request, "response": response}` is required for `login_with_custom_token_exchange()` — `response` is where the session cookie gets written. Omitting it raises a `ValueError`. - -For advanced token exchange scenarios (e.g. calling a downstream API without affecting the caller's own session), use `custom_token_exchange()` directly. Unlike the login variant above, `store_options` is optional here since no cookie is read or written — it's only needed if you've configured [Multiple Custom Domains](#multiple-custom-domains-mcd): - -```python -from auth0_server_python.auth_types import CustomTokenExchangeOptions - -@app.post("/api/exchange") -async def exchange_token(): - result = await auth_client.custom_token_exchange( - CustomTokenExchangeOptions( - subject_token="token-from-external-system", - subject_token_type="urn:acme:legacy-session-token", - audience="https://downstream-api.example.com", - ) - ) - - return {"access_token": result.access_token} -``` - -For actor tokens (delegation), error handling, and token type URI guidance, see [examples/CustomTokenExchange.md](./examples/CustomTokenExchange.md). - ### Multiple Custom Domains (MCD) For applications using multiple custom domains on the same Auth0 tenant, pass a callable instead of a static domain string: @@ -354,6 +309,10 @@ When an enterprise connection has "Use ID Token for Session Expiry" enabled, Aut For detailed behavior and how to read the value, see [examples/Sessions.md](./examples/Sessions.md). +### Custom Token Exchange + +Exchange a token from an external identity provider or legacy system for Auth0 tokens using [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) Token Exchange, without a browser redirect — with or without establishing a session. See [examples/CustomTokenExchange.md](./examples/CustomTokenExchange.md) for setup and code samples. + ## Feedback ### Contributing diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md index f16e6ba..cdffc87 100644 --- a/examples/CustomTokenExchange.md +++ b/examples/CustomTokenExchange.md @@ -8,7 +8,7 @@ Custom Token Exchange lets your FastAPI backend exchange a token from an externa | Method | Session effect | Use case | |---|---|---| -| `custom_token_exchange()` | None — the current user session (if any) is untouched | Calling a downstream API with a different audience/scope; service-to-service delegation | +| `custom_token_exchange()` | None — the current user session (if any) is untouched | Calling a downstream API with a different audience/scope | | `login_with_custom_token_exchange()` | Establishes a full Auth0 session, same as completing `/auth/callback` | Logging a user into your app using a token issued by an external system | Both methods are programmatic — there is no dedicated route mounted by the SDK. You call them from your own route handler. @@ -102,60 +102,7 @@ async def get_profile(session: dict = Depends(auth_client.require_session)): > **TIP**: Use `login_with_custom_token_exchange()` for user-migration or external-IdP login flows. Use `custom_token_exchange()` for pure service-to-service or downstream-API scenarios where the caller's own session should not change. -## 3. Actor Tokens (Delegation) - -Pass `actor_token`/`actor_token_type` to represent a party acting on behalf of the subject. Auth0 surfaces this as the [`act` claim](https://datatracker.ietf.org/doc/html/rfc8693#section-4.1) on the response: - -```python -result = await auth_client.custom_token_exchange( - CustomTokenExchangeOptions( - subject_token="user-access-token", - subject_token_type="urn:ietf:params:oauth:token-type:access_token", - actor_token="service-access-token", - actor_token_type="urn:ietf:params:oauth:token-type:access_token", - audience="https://downstream-api.example.com", - ), - store_options={"request": request, "response": response}, -) - -if result.act: - print(f"Acting party: {result.act['sub']}") -``` - -When you use `login_with_custom_token_exchange()` with an actor token, the `act` claim is persisted on the session user and can be read back later: - -```python -result = await auth_client.login_with_custom_token_exchange( - LoginWithCustomTokenExchangeOptions( - subject_token="user-access-token", - subject_token_type="urn:ietf:params:oauth:token-type:access_token", - actor_token="service-access-token", - actor_token_type="urn:ietf:params:oauth:token-type:access_token", - ), - store_options={"request": request, "response": response}, -) - -user = result.state_data["user"] -if user.get("act"): - print(f"Acting party: {user['act']['sub']}") -``` - -Specify an organization when exchanging tokens: - -```python -result = await auth_client.custom_token_exchange( - CustomTokenExchangeOptions( - subject_token="token-from-external-system", - subject_token_type="urn:acme:legacy-session-token", - audience="https://downstream-api.example.com", - organization="org_abc1234", - ) -) -``` -> **NOTE**: When an `actor_token` is present, Auth0 does not issue a refresh token (`offline_access` is dropped). The acting party is fixed at exchange time and is not re-emitted on a later token refresh. - - -## 4. Error Handling +## 3. Error Handling Register the SDK's exception handler once, and `CustomTokenExchangeError` will be mapped to an HTTP `400` JSON response automatically: @@ -193,11 +140,11 @@ async def exchange_token(request: Request, response: Response): See the [auth0-server-python Custom Token Exchange doc](https://github.com/auth0/auth0-server-python/blob/main/examples/CustomTokenExchange.md#common-error-codes) for the full, up-to-date list of `CustomTokenExchangeErrorCode` values and what triggers each one. -`INVALID_TOKEN_FORMAT` is raised client-side before any network call for an empty or whitespace-only `subject_token`/`actor_token`, or one with a `"Bearer "` prefix. Other malformed-but-nonempty values (including a `subject_token_type` that isn't a valid URI) are not checked client-side and are sent to Auth0, which rejects them. +`INVALID_TOKEN_FORMAT` is raised client-side before any network call for an empty or whitespace-only `subject_token`, or one with a `"Bearer "` prefix. Other malformed-but-nonempty values (including a `subject_token_type` that isn't a valid URI) are not checked client-side and are sent to Auth0, which rejects them. -## 5. Token Type URIs +## 4. Token Type URIs -`subject_token_type` and `actor_token_type` accept any URI — a standard RFC 8693 URN (e.g. `urn:ietf:params:oauth:token-type:jwt`) or your own namespace (e.g. `urn:acme:legacy-session-token`). +`subject_token_type` accepts any URI — a standard RFC 8693 URN (e.g. `urn:ietf:params:oauth:token-type:jwt`) or your own namespace (e.g. `urn:acme:legacy-session-token`). See the [auth0-server-python Custom Token Exchange doc](https://github.com/auth0/auth0-server-python/blob/main/examples/CustomTokenExchange.md) for the full set of standard token-type URNs, and the [official Auth0 documentation](https://auth0.com/docs/authenticate/custom-token-exchange) for which namespaces are reserved and cannot be used as a *custom* `subject_token_type` when configuring a token-exchange profile in the Auth0 Dashboard. diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index b445968..f9468a3 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -234,19 +234,18 @@ async def custom_token_exchange( Does not create or modify the current session. Args: - options: Subject/actor token details and exchange parameters - (subject_token, subject_token_type, audience, scope, actor_token, - actor_token_type, organization, authorization_params). + options: Subject token details and exchange parameters + (subject_token, subject_token_type, audience, scope, + organization, authorization_params). store_options: Optional options passed to the Transaction and State Store. Only required when using Multiple Custom Domains, where the domain resolver needs the incoming request. Returns: - The raw TokenExchangeResponse (access_token, expires_in, and, when - an actor_token was supplied, the decoded act claim). + The raw TokenExchangeResponse (access_token, expires_in). Raises: - CustomTokenExchangeError: If the exchange fails or the subject/actor + CustomTokenExchangeError: If the exchange fails or the subject token parameters are invalid (see CustomTokenExchangeErrorCode). """ return await self.client.custom_token_exchange(options, store_options=store_options) @@ -261,9 +260,9 @@ async def login_with_custom_token_exchange( establishes a session for the resulting user. Args: - options: Subject/actor token details and exchange parameters - (subject_token, subject_token_type, audience, scope, actor_token, - actor_token_type, organization, authorization_params). + options: Subject token details and exchange parameters + (subject_token, subject_token_type, audience, scope, + organization, authorization_params). store_options: Options passed to the Transaction and State Store. Must include {"request": request, "response": response} so the session cookie can be written on response. @@ -273,7 +272,7 @@ async def login_with_custom_token_exchange( (including the resulting user). Raises: - CustomTokenExchangeError: If the exchange fails or the subject/actor + CustomTokenExchangeError: If the exchange fails or the subject token parameters are invalid (see CustomTokenExchangeErrorCode). ValueError: If store_options is missing the response needed to write the session cookie. diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index 32c75db..8d028c7 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -677,32 +677,6 @@ async def test_custom_token_exchange_does_not_touch_session(self, auth_client): mock_get_session.assert_not_called() - @pytest.mark.asyncio - async def test_custom_token_exchange_with_actor_token(self, auth_client): - """Test delegation/impersonation via actor_token and actor_token_type.""" - options = CustomTokenExchangeOptions( - subject_token="external-token", - subject_token_type="urn:acme:legacy-session-token", - actor_token="actor-token", - actor_token_type="urn:acme:actor-token", - ) - mock_result = TokenExchangeResponse( - access_token="new_access_token", - token_type="Bearer", - expires_in=3600, - act={"sub": "actor-subject"}, - ) - - with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange: - mock_exchange.return_value = mock_result - - result = await auth_client.custom_token_exchange(options) - - assert result.act == {"sub": "actor-subject"} - call_args = mock_exchange.call_args[0][0] - assert call_args.actor_token == "actor-token" - assert call_args.actor_token_type == "urn:acme:actor-token" - @pytest.mark.asyncio async def test_custom_token_exchange_error_propagates(self, auth_client): """Test that CustomTokenExchangeError from the underlying client is not swallowed or wrapped.""" @@ -773,29 +747,6 @@ async def test_login_with_custom_token_exchange_passes_store_options_for_session assert call_kwargs['store_options']['request'] is mock_request assert call_kwargs['store_options']['response'] is mock_response - @pytest.mark.asyncio - async def test_login_with_custom_token_exchange_forwards_actor_token(self, auth_client): - """Test that actor_token/actor_token_type are forwarded to the underlying client.""" - options = LoginWithCustomTokenExchangeOptions( - subject_token="external-token", - subject_token_type="urn:acme:corporate-idp-token", - actor_token="actor-token", - actor_token_type="urn:acme:actor-token", - ) - - with patch.object( - auth_client.client, 'login_with_custom_token_exchange', new_callable=AsyncMock - ) as mock_login_exchange: - mock_login_exchange.return_value = LoginWithCustomTokenExchangeResult( - state_data={"user": {"sub": "test_user", "act": {"sub": "actor-subject"}}} - ) - - await auth_client.login_with_custom_token_exchange(options) - - call_args = mock_login_exchange.call_args[0][0] - assert call_args.actor_token == "actor-token" - assert call_args.actor_token_type == "urn:acme:actor-token" - @pytest.mark.asyncio async def test_login_with_custom_token_exchange_missing_response_raises_value_error(self, auth_client): """Test that omitting response in store_options surfaces the ValueError from the state store."""