diff --git a/.gitignore b/.gitignore index 707996e..7a66604 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ setup.py test.py test-script.py .coverage -coverage.xml \ No newline at end of file +coverage.xml + +# IDE +.idea/ diff --git a/README.md b/README.md index ae25a8d..9496359 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,15 @@ This SDK provides comprehensive support for securing APIs with Auth0-issued acce - [Docs Site](https://auth0.com/docs) - explore our docs site and learn more about Auth0. +## Related SDKs + +This library is part of Auth0's Python ecosystem for server-side authentication and API security. Related SDKs: + +- **[auth0-auth-js](https://github.com/auth0/auth0-auth-js)** - JavaScript/TypeScript monorepo containing: + - `@auth0/auth0-auth-js` - Core authentication client (low-level primitives) + - `@auth0/auth0-api-js` - Server-side API security (Node.js equivalent of this library) + - `@auth0/auth0-server-js` - Server-side web app authentication (session management) + ## Getting Started ### 1. Install the SDK @@ -113,6 +122,95 @@ asyncio.run(main()) More info https://auth0.com/docs/secure/tokens/token-vault +### 5. Custom Token Exchange (Early Access) + +> [!NOTE] +> This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access) for Enterprise customers. Please reach out to Auth0 support to get it enabled for your tenant. + +This feature requires a [confidential client](https://auth0.com/docs/get-started/applications/confidential-and-public-applications#confidential-applications) (both `client_id` and `client_secret` must be configured). + +Custom Token Exchange allows you to exchange a subject token for Auth0 tokens using RFC 8693. This is useful for: +- Getting Auth0 tokens for another audience +- Integrating external identity providers +- Migrating to Auth0 + +```python +import asyncio + +from auth0_api_python import ApiClient, ApiClientOptions + +async def main(): + api_client = ApiClient(ApiClientOptions( + domain="", + audience="", + client_id="", + client_secret="", + timeout=10.0 # Optional: HTTP timeout in seconds (default: 10.0) + )) + + subject_token = "..." # Token from your legacy system or external source + + result = await api_client.get_token_by_exchange_profile( + subject_token=subject_token, + subject_token_type="urn:example:subject-token", + audience="https://api.example.com", # Optional - omit if your Action or tenant configuration sets the audience + scope="openid profile email", # Optional + requested_token_type="urn:ietf:params:oauth:token-type:access_token" # Optional + ) + + # Result contains access_token, expires_in, expires_at + # id_token, refresh_token, and scope are profile/Action dependent (not guaranteed; scope may be empty) + +asyncio.run(main()) +``` + +**Important:** +- Client authentication is sent via HTTP Basic (`client_id`/`client_secret`), not in the form body. +- Do not prefix `subject_token` with "Bearer " - send the raw token value only (checked case-insensitively). +- The `subject_token_type` must match a Token Exchange Profile configured in Auth0. This URI identifies which profile will process the exchange and **must not use reserved OAuth namespaces (IETF or vendor-controlled)**. Use your own collision-resistant namespace. See the [Custom Token Exchange documentation](https://auth0.com/docs/authenticate/custom-token-exchange) for naming guidance. +- If neither an explicit `audience` nor tenant/Action logic sets it, you may receive a token not targeted at your API. + +#### Additional Parameters + +You can pass additional parameters for your Token Exchange Profile or Actions via the `extra` parameter. These are sent as form fields to Auth0 and may be inspected by Actions: + +```python +result = await api_client.get_token_by_exchange_profile( + subject_token=subject_token, + subject_token_type="urn:example:subject-token", + audience="https://api.example.com", + extra={ + "device_id": "device-12345", + "session_id": "sess-abc" + } +) +``` + +> [!WARNING] +> Extra parameters are sent as form fields and may appear in logs. Do not include secrets or sensitive data. Reserved OAuth parameter names (like `grant_type`, `client_id`, `scope`) cannot be used and will raise an error. Arrays are supported but limited to 20 values per key to prevent abuse. + +#### Error Handling + +```python +from auth0_api_python import GetTokenByExchangeProfileError, ApiError + +try: + result = await api_client.get_token_by_exchange_profile( + subject_token=subject_token, + subject_token_type="urn:example:subject-token" + ) +except GetTokenByExchangeProfileError as e: + # Validation errors (invalid token format, missing credentials, reserved params, etc.) + print(f"Validation error: {e}") +except ApiError as e: + # Token endpoint errors (invalid_grant, network issues, malformed responses, etc.) + print(f"API error: {e.code} - {e.message} (status: {e.status_code})") +``` + +**Related SDKs:** [auth0-auth-js](https://github.com/auth0/auth0-auth-js) (see `@auth0/auth0-api-js` package for Node.js equivalent) + +More info: https://auth0.com/docs/authenticate/custom-token-exchange + #### Requiring Additional Claims If your application demands extra claims, specify them with `required_claims`: @@ -126,7 +224,7 @@ decoded_and_verified_token = await api_client.verify_access_token( If the token lacks `my_custom_claim` or fails any standard check (issuer mismatch, expired token, invalid signature), the method raises a `VerifyAccessTokenError`. -### 5. DPoP Authentication +### 6. DPoP Authentication > [!NOTE] > This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant. diff --git a/poetry.lock b/poetry.lock index cafc3d1..6b1aaa2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -352,7 +352,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"}, @@ -466,112 +465,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.11.0" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_full_version >= \"3.14.0\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "coverage-7.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb53f1e8adeeb2e78962bade0c08bfdc461853c7969706ed901821e009b35e31"}, - {file = "coverage-7.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9a03ec6cb9f40a5c360f138b88266fd8f58408d71e89f536b4f91d85721d075"}, - {file = "coverage-7.11.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d7f0616c557cbc3d1c2090334eddcbb70e1ae3a40b07222d62b3aa47f608fab"}, - {file = "coverage-7.11.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e44a86a47bbdf83b0a3ea4d7df5410d6b1a0de984fbd805fa5101f3624b9abe0"}, - {file = "coverage-7.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:596763d2f9a0ee7eec6e643e29660def2eef297e1de0d334c78c08706f1cb785"}, - {file = "coverage-7.11.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef55537ff511b5e0a43edb4c50a7bf7ba1c3eea20b4f49b1490f1e8e0e42c591"}, - {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cbabd8f4d0d3dc571d77ae5bdbfa6afe5061e679a9d74b6797c48d143307088"}, - {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e24045453384e0ae2a587d562df2a04d852672eb63051d16096d3f08aa4c7c2f"}, - {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:7161edd3426c8d19bdccde7d49e6f27f748f3c31cc350c5de7c633fea445d866"}, - {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d4ed4de17e692ba6415b0587bc7f12bc80915031fc9db46a23ce70fc88c9841"}, - {file = "coverage-7.11.0-cp310-cp310-win32.whl", hash = "sha256:765c0bc8fe46f48e341ef737c91c715bd2a53a12792592296a095f0c237e09cf"}, - {file = "coverage-7.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:24d6f3128f1b2d20d84b24f4074475457faedc3d4613a7e66b5e769939c7d969"}, - {file = "coverage-7.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d58ecaa865c5b9fa56e35efc51d1014d4c0d22838815b9fce57a27dd9576847"}, - {file = "coverage-7.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b679e171f1c104a5668550ada700e3c4937110dbdd153b7ef9055c4f1a1ee3cc"}, - {file = "coverage-7.11.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca61691ba8c5b6797deb221a0d09d7470364733ea9c69425a640f1f01b7c5bf0"}, - {file = "coverage-7.11.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aef1747ede4bd8ca9cfc04cc3011516500c6891f1b33a94add3253f6f876b7b7"}, - {file = "coverage-7.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1839d08406e4cba2953dcc0ffb312252f14d7c4c96919f70167611f4dee2623"}, - {file = "coverage-7.11.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e0eb0a2dcc62478eb5b4cbb80b97bdee852d7e280b90e81f11b407d0b81c4287"}, - {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc1fbea96343b53f65d5351d8fd3b34fd415a2670d7c300b06d3e14a5af4f552"}, - {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:214b622259dd0cf435f10241f1333d32caa64dbc27f8790ab693428a141723de"}, - {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:258d9967520cca899695d4eb7ea38be03f06951d6ca2f21fb48b1235f791e601"}, - {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cf9e6ff4ca908ca15c157c409d608da77a56a09877b97c889b98fb2c32b6465e"}, - {file = "coverage-7.11.0-cp311-cp311-win32.whl", hash = "sha256:fcc15fc462707b0680cff6242c48625da7f9a16a28a41bb8fd7a4280920e676c"}, - {file = "coverage-7.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:865965bf955d92790f1facd64fe7ff73551bd2c1e7e6b26443934e9701ba30b9"}, - {file = "coverage-7.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:5693e57a065760dcbeb292d60cc4d0231a6d4b6b6f6a3191561e1d5e8820b745"}, - {file = "coverage-7.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1"}, - {file = "coverage-7.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007"}, - {file = "coverage-7.11.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46"}, - {file = "coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893"}, - {file = "coverage-7.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115"}, - {file = "coverage-7.11.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415"}, - {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186"}, - {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d"}, - {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d"}, - {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2"}, - {file = "coverage-7.11.0-cp312-cp312-win32.whl", hash = "sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5"}, - {file = "coverage-7.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0"}, - {file = "coverage-7.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad"}, - {file = "coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1"}, - {file = "coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be"}, - {file = "coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d"}, - {file = "coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82"}, - {file = "coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52"}, - {file = "coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b"}, - {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4"}, - {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd"}, - {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc"}, - {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48"}, - {file = "coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040"}, - {file = "coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05"}, - {file = "coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a"}, - {file = "coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b"}, - {file = "coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37"}, - {file = "coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de"}, - {file = "coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f"}, - {file = "coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c"}, - {file = "coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa"}, - {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740"}, - {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef"}, - {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0"}, - {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca"}, - {file = "coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2"}, - {file = "coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268"}, - {file = "coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836"}, - {file = "coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497"}, - {file = "coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e"}, - {file = "coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1"}, - {file = "coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca"}, - {file = "coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd"}, - {file = "coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43"}, - {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777"}, - {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2"}, - {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d"}, - {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4"}, - {file = "coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721"}, - {file = "coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad"}, - {file = "coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479"}, - {file = "coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f"}, - {file = "coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e"}, - {file = "coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44"}, - {file = "coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3"}, - {file = "coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b"}, - {file = "coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d"}, - {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2"}, - {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e"}, - {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996"}, - {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11"}, - {file = "coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73"}, - {file = "coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547"}, - {file = "coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3"}, - {file = "coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68"}, - {file = "coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050"}, -] - -[package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] - [[package]] name = "cryptography" version = "43.0.3" @@ -579,7 +472,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"}, @@ -623,84 +515,6 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] -[[package]] -name = "cryptography" -version = "46.0.3" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.8" -groups = ["main"] -markers = "python_full_version >= \"3.14.0\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"}, - {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"}, - {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"}, - {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"}, - {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"}, - {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"}, - {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"}, - {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"}, - {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"}, - {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"}, - {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"}, - {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"}, - {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"}, - {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"}, - {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"}, - {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"}, - {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"}, - {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"}, - {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"}, - {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"}, - {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"}, -] - -[package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] -docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox[uv] (>=2024.4.15)"] -pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] -sdist = ["build (>=1.0.0)"] -ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] -test-randomorder = ["pytest-randomly"] - [[package]] name = "exceptiongroup" version = "1.3.0" @@ -720,6 +534,21 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "freezegun" +version = "1.5.5" +description = "Let your Python tests travel through time" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2"}, + {file = "freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a"}, +] + +[package.dependencies] +python-dateutil = ">=2.7" + [[package]] name = "h11" version = "0.16.0" @@ -801,25 +630,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_full_version >= \"3.14.0\" 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 = "packaging" version = "25.0" @@ -975,6 +790,21 @@ pytest = ">=6.2.5" [package.extras] dev = ["pre-commit", "pytest-asyncio", "tox"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "requests" version = "2.32.5" @@ -1026,6 +856,18 @@ files = [ {file = "ruff-0.14.1.tar.gz", hash = "sha256:1dd86253060c4772867c61791588627320abcb6ed1577a90ef432ee319729b69"}, ] +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1125,4 +967,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "24fbadf6b9fd608a5384d20941d11e01e17b9bd0ea03c730b4793f0bb3910ea0" +content-hash = "e6faabc92b5b9734e376ac2260997d83b62fb4d492b12cf6581b484d6ce15b42" diff --git a/pyproject.toml b/pyproject.toml index 32bc490..fe8db6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ pytest-asyncio = "^0.25.3" pytest-mock = "^3.15.1" pytest-httpx = "^0.35.0" ruff = ">=0.1,<0.15" +freezegun = "^1.5.5" [tool.pytest.ini_options] addopts = "--cov=src --cov-report=term-missing:skip-covered --cov-report=xml" diff --git a/requirements.txt b/requirements.txt index 7903045..c930a3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,7 @@ +# This file is maintained manually for SCA scanning (.github/workflows/sca_scan.yml) +# Source of truth for dependencies: pyproject.toml + poetry.lock +# Future: Consider auto-generating via poetry export in CI + authlib>=1.6.5 httpx>=0.28.1 ada-url>=1.27.0 @@ -6,4 +10,5 @@ pytest-cov>=4.0 pytest-asyncio>=0.25.3 pytest-mock>=3.15.1 pytest-httpx>=0.35.0 +freezegun>=1.5.5 diff --git a/src/auth0_api_python/__init__.py b/src/auth0_api_python/__init__.py index f487dd8..20faa8c 100644 --- a/src/auth0_api_python/__init__.py +++ b/src/auth0_api_python/__init__.py @@ -7,8 +7,11 @@ from .api_client import ApiClient from .config import ApiClientOptions +from .errors import ApiError, GetTokenByExchangeProfileError __all__ = [ "ApiClient", - "ApiClientOptions" + "ApiClientOptions", + "ApiError", + "GetTokenByExchangeProfileError" ] diff --git a/src/auth0_api_python/api_client.py b/src/auth0_api_python/api_client.py index 422a3dd..d9faafb 100644 --- a/src/auth0_api_python/api_client.py +++ b/src/auth0_api_python/api_client.py @@ -1,5 +1,6 @@ import time -from typing import Any, Optional +from collections.abc import Mapping, Sequence +from typing import Any, Optional, Union import httpx from authlib.jose import JsonWebKey, JsonWebToken @@ -9,6 +10,7 @@ ApiError, BaseAuthError, GetAccessTokenForConnectionError, + GetTokenByExchangeProfileError, InvalidAuthSchemeError, InvalidDpopProofError, MissingAuthorizationError, @@ -24,6 +26,20 @@ sha256_base64url, ) +# Token Exchange constants +TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" # noqa: S105 +MAX_ARRAY_VALUES_PER_KEY = 20 # DoS protection for extra parameter arrays + +# OAuth parameter denylist - parameters that cannot be overridden via extras +RESERVED_PARAMS = frozenset([ + "grant_type", "client_id", "client_secret", "client_assertion", + "client_assertion_type", "subject_token", "subject_token_type", + "requested_token_type", "actor_token", "actor_token_type", + "subject_issuer", "audience", "aud", "resource", "resources", + "resource_indicator", "scope", "connection", "login_hint", + "organization", "assertion", +]) + class ApiClient: """ @@ -62,6 +78,12 @@ async def verify_request( • If scheme is 'DPoP', verifies both access token and DPoP proof • If scheme is 'Bearer', verifies only the access token + Note: + Authorization header parsing uses split(None, 1) to correctly handle + tabs and multiple spaces per HTTP specs. Malformed headers with multiple + spaces now raise VerifyAccessTokenError during JWT parsing (previously + raised InvalidAuthSchemeError). + Args: headers: HTTP headers dict containing (header keys should be lowercase): - "authorization": The Authorization header value (required) @@ -78,6 +100,9 @@ async def verify_request( InvalidDpopProofError: If DPoP verification fails VerifyAccessTokenError: If access token verification fails """ + # Normalize header keys to lowercase for robust access + headers = {k.lower(): v for k, v in headers.items()} + authorization_header = headers.get("authorization", "") dpop_proof = headers.get("dpop") @@ -86,22 +111,16 @@ async def verify_request( raise self._prepare_error( InvalidAuthSchemeError("") ) - else : + else: raise self._prepare_error(MissingAuthorizationError()) - - parts = authorization_header.split(" ") + # Split authorization header on first whitespace + parts = authorization_header.split(None, 1) if len(parts) != 2: - if len(parts) < 2: - raise self._prepare_error(MissingAuthorizationError()) - elif len(parts) > 2: - raise self._prepare_error( - InvalidAuthSchemeError("") - ) + raise self._prepare_error(MissingAuthorizationError()) scheme, token = parts - - scheme = scheme.strip().lower() + scheme = scheme.lower() if self.is_dpop_required() and scheme != "dpop": raise self._prepare_error( @@ -431,7 +450,11 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict token_endpoint = metadata.get("token_endpoint") if not token_endpoint: - raise GetAccessTokenForConnectionError("Token endpoint missing in OIDC metadata") + raise GetAccessTokenForConnectionError( + "Token endpoint missing in OIDC metadata. " + "Verify your domain configuration and that the OIDC discovery endpoint " + f"(https://{self.options.domain}/.well-known/openid-configuration) is accessible" + ) # Prepare parameters params = { @@ -448,7 +471,7 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict params["login_hint"] = options["login_hint"] try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=httpx.Timeout(self.options.timeout)) as client: response = await client.post( token_endpoint, data=params, @@ -456,8 +479,9 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict ) if response.status_code != 200: - error_data = response.json() if "json" in response.headers.get( - "content-type", "").lower() else {} + # Lenient check for JSON error responses (handles application/json, text/json, etc.) + content_type = response.headers.get("content-type", "").lower() + error_data = response.json() if "json" in content_type else {} raise ApiError( error_data.get("error", "connection_token_error"), error_data.get( @@ -467,7 +491,7 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict try: token_endpoint_response = response.json() - except Exception: + except ValueError: raise ApiError("invalid_json", "Token endpoint returned invalid JSON.") access_token = token_endpoint_response.get("access_token") @@ -501,8 +525,248 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict exc ) + async def get_token_by_exchange_profile( + self, + subject_token: str, + subject_token_type: str, + audience: Optional[str] = None, + scope: Optional[str] = None, + requested_token_type: Optional[str] = None, + extra: Optional[Mapping[str, Union[str, Sequence[str]]]] = None + ) -> dict[str, Any]: + """ + Exchange a subject token for an Auth0 token using RFC 8693. + + The matching Token Exchange Profile is selected by subject_token_type. + This method requires a confidential client (client_id and client_secret must be configured). + + Args: + subject_token: The token to be exchanged + subject_token_type: URI identifying the token type (must match a Token Exchange Profile) + audience: Optional target API identifier for the exchanged tokens + scope: Optional space-separated OAuth 2.0 scopes to request + requested_token_type: Optional type of token to issue (defaults to access token) + extra: Optional additional parameters sent as form fields to Auth0. + All values are converted to strings before sending. + Arrays are limited to 20 values per key for DoS protection. + Cannot override reserved OAuth parameters (case-insensitive check). + + Returns: + Dictionary containing: + - access_token (str): The Auth0 access token + - expires_in (int): Token lifetime in seconds + - expires_at (int): Unix timestamp when token expires + - id_token (str, optional): OpenID Connect ID token + - refresh_token (str, optional): Refresh token + - scope (str, optional): Granted scopes + - token_type (str, optional): Token type (typically "Bearer") + - issued_token_type (str, optional): RFC 8693 issued token type identifier + + Raises: + MissingRequiredArgumentError: If required parameters are missing + GetTokenByExchangeProfileError: If client credentials not configured, validation fails, + or reserved parameters are supplied in extra + ApiError: If the token endpoint returns an error + + Example: + async def example(): + result = await api_client.get_token_by_exchange_profile( + subject_token=token, + subject_token_type="urn:example:subject-token", + audience="https://api.backend.com" + ) + + References: + - Custom Token Exchange: https://auth0.com/docs/authenticate/custom-token-exchange + - RFC 8693: https://datatracker.ietf.org/doc/html/rfc8693 + - Related SDK: https://github.com/auth0/auth0-auth-js + """ + # Validate required parameters + if not subject_token: + raise MissingRequiredArgumentError("subject_token") + if not subject_token_type: + raise MissingRequiredArgumentError("subject_token_type") + + # Validate subject token format (fail fast to ensure token integrity) + tok = subject_token + if not isinstance(tok, str) or not tok.strip(): + raise GetTokenByExchangeProfileError("subject_token cannot be blank or whitespace") + if tok != tok.strip(): + raise GetTokenByExchangeProfileError( + "subject_token must not include leading or trailing whitespace" + ) + if tok.lower().startswith("bearer "): + raise GetTokenByExchangeProfileError( + "subject_token must not include the 'Bearer ' prefix (case-insensitive check)" + ) + + # Require client credentials + client_id = self.options.client_id + client_secret = self.options.client_secret + if not client_id or not client_secret: + raise GetTokenByExchangeProfileError( + "Client credentials are required to use get_token_by_exchange_profile. " + "Configure client_id and client_secret in ApiClientOptions to use this feature" + ) + + # Discover token endpoint + metadata = await self._discover() + token_endpoint = metadata.get("token_endpoint") + if not token_endpoint: + raise GetTokenByExchangeProfileError( + "Token endpoint missing in OIDC metadata. " + "Verify your domain configuration and that the OIDC discovery endpoint " + f"(https://{self.options.domain}/.well-known/openid-configuration) is accessible" + ) + + # Build request parameters (client_id sent via HTTP Basic auth only) + params = { + "grant_type": TOKEN_EXCHANGE_GRANT_TYPE, + "subject_token": subject_token, + "subject_token_type": subject_token_type, + } + + # Add optional parameters + if audience: + params["audience"] = audience + if scope: + params["scope"] = scope + if requested_token_type: + params["requested_token_type"] = requested_token_type + + # Append extra parameters with validation + if extra: + self._apply_extra(params, extra) + + # Make token exchange request + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(self.options.timeout)) as client: + response = await client.post( + token_endpoint, + data=params, + auth=(client_id, client_secret) + ) + + if response.status_code != 200: + error_data = {} + try: + # Lenient check for JSON error responses (handles application/json, text/json, etc.) + content_type = response.headers.get("content-type", "").lower() + if "json" in content_type: + error_data = response.json() + except ValueError: + pass # Ignore JSON parse errors, use generic error message below + + raise ApiError( + error_data.get("error", "token_exchange_error"), + error_data.get( + "error_description", + f"Failed to exchange token of type '{subject_token_type}'" + + (f" for audience '{audience}'" if audience else "") + ), + response.status_code + ) + + try: + token_response = response.json() + except ValueError: + raise ApiError("invalid_json", "Token endpoint returned invalid JSON.", 502) + + # Validate required fields + access_token = token_response.get("access_token") + if not isinstance(access_token, str) or not access_token: + raise ApiError( + "invalid_response", + "Missing or invalid access_token in response.", + 502 + ) + + # Lenient policy: coerce numeric strings like "3600" to int + # Reject non-numeric values (e.g., "not-a-number", None, objects) + # Reject negative values (prevent accidental "already expired" tokens) + expires_in_raw = token_response.get("expires_in", 3600) + try: + expires_in = int(expires_in_raw) + except (TypeError, ValueError): + raise ApiError("invalid_response", "expires_in is not an integer.", 502) + + if expires_in < 0: + raise ApiError("invalid_response", "expires_in cannot be negative.", 502) + + # Build response with required fields + result = { + "access_token": access_token, + "expires_in": expires_in, + "expires_at": int(time.time()) + expires_in, + } + + # Add optional fields if present (preserves falsy values like empty scope) + optional_fields = ["scope", "id_token", "refresh_token", "token_type", "issued_token_type"] + for field in optional_fields: + if field in token_response: + result[field] = token_response[field] + + return result + + except httpx.TimeoutException as exc: + raise ApiError( + "timeout_error", + f"Request to token endpoint timed out: {str(exc)}", + 504, + exc + ) + except httpx.HTTPError as exc: + raise ApiError( + "network_error", + f"Network error occurred: {str(exc)}", + 502, + exc + ) + # ===== Private Methods ===== + def _apply_extra( + self, + params: dict[str, Any], + extra: Mapping[str, Union[str, Sequence[str]]] + ) -> None: + """ + Apply extra parameters to the params dict with validation. + + Args: + params: The parameters dict to append to + extra: Additional parameters to append (accepts str or sequences like list/tuple) + + Raises: + GetTokenByExchangeProfileError: If reserved parameter, unsupported type, or array size limit exceeded + """ + # Pre-compute lowercase reserved params for case-insensitive matching + reserved_lower = {p.lower() for p in RESERVED_PARAMS} + + for k, v in extra.items(): + key = str(k) + # Case-insensitive check against reserved params + if key.lower() in reserved_lower: + raise GetTokenByExchangeProfileError( + f"Parameter '{k}' is reserved and cannot be overridden" + ) + + # Handle sequences (list, tuple, etc.) but reject mappings/sets/bytes + if isinstance(v, (dict, set, bytes)): + raise GetTokenByExchangeProfileError( + f"Parameter '{k}' has unsupported type {type(v).__name__}. " + "Only strings, numbers, booleans, and sequences (list/tuple) are allowed" + ) + elif isinstance(v, (list, tuple)): + if len(v) > MAX_ARRAY_VALUES_PER_KEY: + raise GetTokenByExchangeProfileError( + f"Parameter '{k}' exceeds maximum array size of {MAX_ARRAY_VALUES_PER_KEY}" + ) + # Convert sequence items to strings + params[key] = [str(x) for x in v] + else: + params[key] = str(v) + async def _discover(self) -> dict[str, Any]: """Lazy-load OIDC discovery metadata.""" if self._metadata is None: diff --git a/src/auth0_api_python/config.py b/src/auth0_api_python/config.py index e5f0703..5c312fd 100644 --- a/src/auth0_api_python/config.py +++ b/src/auth0_api_python/config.py @@ -17,8 +17,9 @@ class ApiClientOptions: dpop_required: Whether DPoP is required (default: False, allows both Bearer and DPoP). dpop_iat_leeway: Leeway in seconds for DPoP proof iat claim (default: 30). dpop_iat_offset: Maximum age in seconds for DPoP proof iat claim (default: 300). - client_id: Optional required if you want to use get_access_token_for_connection. - client_secret: Optional required if you want to use get_access_token_for_connection. + client_id: Required for get_access_token_for_connection and get_token_by_exchange_profile. + client_secret: Required for get_access_token_for_connection and get_token_by_exchange_profile. + timeout: HTTP timeout in seconds for token endpoint requests (default: 10.0). """ def __init__( self, @@ -31,6 +32,7 @@ def __init__( dpop_iat_offset: int = 300, client_id: Optional[str] = None, client_secret: Optional[str] = None, + timeout: float = 10.0, ): self.domain = domain self.audience = audience @@ -41,3 +43,4 @@ def __init__( self.dpop_iat_offset = dpop_iat_offset self.client_id = client_id self.client_secret = client_secret + self.timeout = timeout diff --git a/src/auth0_api_python/errors.py b/src/auth0_api_python/errors.py index 9924cdd..0b4af64 100644 --- a/src/auth0_api_python/errors.py +++ b/src/auth0_api_python/errors.py @@ -106,6 +106,16 @@ def get_error_code(self) -> str: return "get_access_token_for_connection_error" +class GetTokenByExchangeProfileError(BaseAuthError): + """Error raised when getting a token via exchange profile fails.""" + + def get_status_code(self) -> int: + return 400 + + def get_error_code(self) -> str: + return "get_token_by_exchange_profile_error" + + class ApiError(BaseAuthError): """ Error raised when an API request to Auth0 fails. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4fb119a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,213 @@ +"""Shared test fixtures and helpers for auth0-api-python tests.""" + +import base64 +import urllib.parse +from typing import Optional + +import pytest +from pytest_httpx import HTTPXMock + +from auth0_api_python import ApiClient, ApiClientOptions +from auth0_api_python.errors import ApiError + +# ===== Constants ===== + +DISCOVERY_URL = "https://auth0.local/.well-known/openid-configuration" +JWKS_URL = "https://auth0.local/.well-known/jwks.json" +TOKEN_ENDPOINT = "https://auth0.local/oauth/token" + +# ===== Fixtures ===== + +@pytest.fixture +def api_client_confidential(): + """Fixture for creating a confidential API client with credentials.""" + return ApiClient(ApiClientOptions( + domain="auth0.local", + audience="my-audience", + client_id="cid", + client_secret="csecret", + )) + + +@pytest.fixture +def mock_discovery(httpx_mock: HTTPXMock): + """Fixture for mocking OIDC discovery endpoint.""" + httpx_mock.add_response( + method="GET", + url=DISCOVERY_URL, + json={"token_endpoint": TOKEN_ENDPOINT}, + ) + return httpx_mock + + +# ===== Helper Functions ===== + +def last_form(httpx_mock: HTTPXMock) -> dict[str, list[str]]: + """Helper to read the last posted form data.""" + req = httpx_mock.get_requests()[-1] + return urllib.parse.parse_qs(req.content.decode()) + + +def last_auth_header(httpx_mock: HTTPXMock) -> Optional[str]: + """Get the Authorization header from the last request.""" + return httpx_mock.get_requests()[-1].headers.get("authorization") + + +def mock_token_response( + httpx_mock: HTTPXMock, + token_json: Optional[dict] = None, + status: int = 200, + content_type: str = "application/json" +): + """ + Mock both discovery and token endpoint responses. + + Args: + httpx_mock: The pytest-httpx mock + token_json: JSON response body for token endpoint (if status 200) + status: HTTP status code for token endpoint + content_type: Content-Type header for token endpoint + """ + # Guard against misuse: token_json should only be provided for status 200 + if status != 200 and token_json is not None: + raise AssertionError("token_json is only used when status=200") + + # Mock discovery + httpx_mock.add_response( + method="GET", + url=DISCOVERY_URL, + json={"token_endpoint": TOKEN_ENDPOINT}, + ) + + # Mock token endpoint + if status == 200 and token_json is not None: + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + json=token_json, + status_code=status, + headers={"Content-Type": content_type}, + ) + else: + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + status_code=status, + content="error", + headers={"Content-Type": content_type}, + ) + + +def token_success(**overrides) -> dict: + """ + Factory for successful token response. + + Returns a base successful response with optional field overrides. + """ + base = { + "access_token": "t", + "expires_in": 3600, + } + base.update(overrides) + return base + + +# ===== Assertion Helpers ===== + +def assert_api_error( + exc: Exception, + *, + code: Optional[str] = None, + status: Optional[int] = None, + contains: Optional[str] = None +): + """ + Assert that an exception is an ApiError with expected properties. + + Args: + exc: The exception to check + code: Expected error code + status: Expected HTTP status code + contains: String that should appear in error message (case-insensitive) + """ + assert isinstance(exc, ApiError), f"Expected ApiError, got {type(exc).__name__}" + if code is not None: + assert exc.code == code, f"Expected code '{code}', got '{exc.code}'" + if status is not None: + assert exc.status_code == status, f"Expected status {status}, got {exc.status_code}" + if contains is not None: + assert contains.lower() in str(exc).lower(), f"Expected '{contains}' in error message: {exc}" + + +def assert_http_basic_auth(httpx_mock: HTTPXMock, username: str, password: str): + """Assert that HTTP Basic auth header is present and correct.""" + auth_header = last_auth_header(httpx_mock) + assert auth_header is not None, "Authorization header missing" + assert auth_header.startswith("Basic "), f"Expected Basic auth, got: {auth_header}" + + # Decode and verify credentials + encoded = auth_header.split(" ")[1] + decoded = base64.b64decode(encoded).decode("utf-8") + expected = f"{username}:{password}" + assert decoded == expected, f"Expected '{expected}', got '{decoded}'" + + +def assert_form_post( + httpx_mock: HTTPXMock, + *, + expect_fields: Optional[dict[str, list[str]]] = None, + forbid_fields: Optional[list[str]] = None, + expect_basic_auth: Optional[tuple[str, str]] = None, + expect_url: Optional[str] = None +): + """ + Assert properties of the last form POST request. + + Args: + httpx_mock: The pytest-httpx mock + expect_fields: Dict of field names to expected values + forbid_fields: List of field names that must NOT be present + expect_basic_auth: Tuple of (username, password) for Basic auth verification + expect_url: Expected URL (defaults to TOKEN_ENDPOINT if not specified) + """ + req = httpx_mock.get_requests()[-1] + + # Verify request method is POST + assert req.method == "POST", f"Expected POST request, got {req.method}" + + # Verify URL + expected_url = expect_url or TOKEN_ENDPOINT + assert str(req.url) == expected_url, f"Expected URL {expected_url}, got {req.url}" + + # Verify Content-Type + content_type = req.headers.get("content-type", "") + assert "application/x-www-form-urlencoded" in content_type, \ + f"Expected form-encoded content type, got {content_type}" + + form = last_form(httpx_mock) + + # Check expected fields + if expect_fields: + for key, value in expect_fields.items(): + assert key in form, f"Expected field '{key}' not in form: {form.keys()}" + assert form[key] == value, f"Field '{key}': expected {value}, got {form[key]}" + + # Check forbidden fields + if forbid_fields: + for key in forbid_fields: + assert key not in form, f"Forbidden field '{key}' found in form" + + # Check Basic auth + if expect_basic_auth: + assert_http_basic_auth(httpx_mock, expect_basic_auth[0], expect_basic_auth[1]) + + +def assert_no_requests(httpx_mock: HTTPXMock): + """ + Assert that no HTTP requests were made (useful for validation short-circuit tests). + + Args: + httpx_mock: The pytest-httpx mock + """ + requests = httpx_mock.get_requests() + assert len(requests) == 0, f"Expected no requests, but {len(requests)} were made" diff --git a/tests/test_api_client.py b/tests/test_api_client.py index a8cace8..71c2cc7 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -1,10 +1,22 @@ import base64 import json import time -import urllib import httpx import pytest + +# Import shared helpers and constants from conftest +from conftest import ( + DISCOVERY_URL, + JWKS_URL, + TOKEN_ENDPOINT, + assert_api_error, + assert_form_post, + assert_no_requests, + last_form, + token_success, +) +from freezegun import freeze_time from pytest_httpx import HTTPXMock from auth0_api_python.api_client import ApiClient @@ -12,6 +24,7 @@ from auth0_api_python.errors import ( ApiError, GetAccessTokenForConnectionError, + GetTokenByExchangeProfileError, InvalidAuthSchemeError, InvalidDpopProofError, MissingAuthorizationError, @@ -30,6 +43,9 @@ # Create public RSA JWK by selecting only public key components PUBLIC_RSA_JWK = {k: PRIVATE_JWK[k] for k in ["kty", "n", "e", "alg", "use", "kid"] if k in PRIVATE_JWK} + +# ===== Tests ===== + @pytest.mark.asyncio async def test_init_missing_args(): """ @@ -50,15 +66,15 @@ async def test_verify_access_token_successfully(httpx_mock: HTTPXMock): """ httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/openid-configuration", + url=DISCOVERY_URL, json={ "issuer": "https://auth0.local/", - "jwks_uri": "https://auth0.local/.well-known/jwks.json" + "jwks_uri": JWKS_URL } ) httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/jwks.json", + url=JWKS_URL, json={ "keys": [ { @@ -98,15 +114,15 @@ async def test_verify_access_token_fail_no_iss(httpx_mock: HTTPXMock): httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/openid-configuration", + url=DISCOVERY_URL, json={ "issuer": "https://auth0.local/", - "jwks_uri": "https://auth0.local/.well-known/jwks.json" + "jwks_uri": JWKS_URL } ) httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/jwks.json", + url=JWKS_URL, json={ "keys": [ { @@ -149,15 +165,15 @@ async def test_verify_access_token_fail_invalid_iss(httpx_mock: HTTPXMock): """ httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/openid-configuration", + url=DISCOVERY_URL, json={ "issuer": "https://auth0.local/", - "jwks_uri": "https://auth0.local/.well-known/jwks.json" + "jwks_uri": JWKS_URL } ) httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/jwks.json", + url=JWKS_URL, json={ "keys": [ { @@ -197,16 +213,16 @@ async def test_verify_access_token_fail_no_aud(httpx_mock: HTTPXMock): """ httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/openid-configuration", + url=DISCOVERY_URL, json={ "issuer": "https://auth0.local/", - "jwks_uri": "https://auth0.local/.well-known/jwks.json" + "jwks_uri": JWKS_URL } ) httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/jwks.json", + url=JWKS_URL, json={ "keys": [ { @@ -248,15 +264,15 @@ async def test_verify_access_token_fail_invalid_aud(httpx_mock: HTTPXMock): httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/openid-configuration", + url=DISCOVERY_URL, json={ "issuer": "https://auth0.local/", - "jwks_uri": "https://auth0.local/.well-known/jwks.json" + "jwks_uri": JWKS_URL } ) httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/jwks.json", + url=JWKS_URL, json={ "keys": [ { @@ -300,15 +316,15 @@ async def test_verify_access_token_fail_no_iat(httpx_mock: HTTPXMock): """ httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/openid-configuration", + url=DISCOVERY_URL, json={ "issuer": "https://auth0.local/", - "jwks_uri": "https://auth0.local/.well-known/jwks.json" + "jwks_uri": JWKS_URL } ) httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/jwks.json", + url=JWKS_URL, json={ "keys": [ { @@ -349,15 +365,15 @@ async def test_verify_access_token_fail_no_exp(httpx_mock: HTTPXMock): """ httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/openid-configuration", + url=DISCOVERY_URL, json={ "issuer": "https://auth0.local/", - "jwks_uri": "https://auth0.local/.well-known/jwks.json" + "jwks_uri": JWKS_URL } ) httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/jwks.json", + url=JWKS_URL, json={ "keys": [ { @@ -1076,7 +1092,7 @@ async def test_verify_dpop_proof_htu_url_normalization_case_sensitivity(): @pytest.mark.asyncio -async def test_verify_dpop_proof_htu_trailing_slash_mismatch(): +async def test_verify_dpop_proof_htu_trailing_slash_mismatch(): """ Test that HTU URLs with trailing slash differences cause verification failure. """ @@ -1292,9 +1308,9 @@ async def test_verify_request_bearer_scheme_success(httpx_mock: HTTPXMock): # Mock OIDC discovery httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/openid-configuration", + url=DISCOVERY_URL, json={ - "jwks_uri": "https://auth0.local/.well-known/jwks.json", + "jwks_uri": JWKS_URL, "issuer": "https://auth0.local/", }, ) @@ -1302,7 +1318,7 @@ async def test_verify_request_bearer_scheme_success(httpx_mock: HTTPXMock): # Mock JWKS endpoint httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/jwks.json", + url=JWKS_URL, json={"keys": [PUBLIC_RSA_JWK]}, ) @@ -1336,9 +1352,9 @@ async def test_verify_request_dpop_scheme_success(httpx_mock: HTTPXMock): # Mock OIDC discovery httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/openid-configuration", + url=DISCOVERY_URL, json={ - "jwks_uri": "https://auth0.local/.well-known/jwks.json", + "jwks_uri": JWKS_URL, "issuer": "https://auth0.local/", }, ) @@ -1346,7 +1362,7 @@ async def test_verify_request_dpop_scheme_success(httpx_mock: HTTPXMock): # Mock JWKS endpoint httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/jwks.json", + url=JWKS_URL, json={"keys": [PUBLIC_RSA_JWK]}, ) @@ -1377,6 +1393,95 @@ async def test_verify_request_dpop_scheme_success(httpx_mock: HTTPXMock): assert result["aud"] == "my-audience" assert result["iss"] == "https://auth0.local/" +@pytest.mark.asyncio +async def test_verify_request_header_normalization(httpx_mock: HTTPXMock): + """ + Test that header key normalization works (uppercase Authorization header). + """ + # Mock OIDC discovery + httpx_mock.add_response( + method="GET", + url=DISCOVERY_URL, + json={ + "jwks_uri": JWKS_URL, + "issuer": "https://auth0.local/", + }, + ) + + # Mock JWKS endpoint + httpx_mock.add_response( + method="GET", + url=JWKS_URL, + json={"keys": [PUBLIC_RSA_JWK]}, + ) + + # Generate a valid Bearer token + token = await generate_token( + domain="auth0.local", + user_id="test_user", + audience="my-audience", + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + # Test with uppercase header key + result = await api_client.verify_request( + headers={"Authorization": f"Bearer {token}"}, # Uppercase + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "sub" in result + assert result["aud"] == "my-audience" + + +@pytest.mark.asyncio +async def test_verify_request_dpop_header_case_insensitive(httpx_mock: HTTPXMock): + """Test that DPoP header is case-insensitive.""" + httpx_mock.add_response( + method="GET", + url=DISCOVERY_URL, + json={ + "jwks_uri": JWKS_URL, + "issuer": "https://auth0.local/" + } + ) + httpx_mock.add_response( + method="GET", + url=JWKS_URL, + json={"keys": [PUBLIC_RSA_JWK]} + ) + + access_token = await generate_token_with_cnf( + domain="auth0.local", + user_id="user_123", + audience="my-audience" + ) + proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + # Test with uppercase "DPoP" header key + result = await api_client.verify_request( + headers={ + "authorization": f"DPoP {access_token}", + "DPoP": proof # Uppercase + }, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert result["aud"] == "my-audience" + assert result["sub"] == "user_123" + # --- Configuration & Error Handling Tests --- @@ -1418,9 +1523,9 @@ async def test_verify_request_fail_dpop_enabled_bearer_with_cnf_conflict(httpx_m # Mock OIDC discovery httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/openid-configuration", + url=DISCOVERY_URL, json={ - "jwks_uri": "https://auth0.local/.well-known/jwks.json", + "jwks_uri": JWKS_URL, "issuer": "https://auth0.local/", }, ) @@ -1428,7 +1533,7 @@ async def test_verify_request_fail_dpop_enabled_bearer_with_cnf_conflict(httpx_m # Mock JWKS endpoint httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/jwks.json", + url=JWKS_URL, json={"keys": [PUBLIC_RSA_JWK]}, ) @@ -1537,10 +1642,11 @@ async def test_verify_request_with_multiple_spaces_in_authorization(): api_client = ApiClient( ApiClientOptions(domain="auth0.local", audience="my-audience") ) - with pytest.raises(InvalidAuthSchemeError) as err: + # split(None, 1) handles extra spaces between scheme and token gracefully, + # but malformed tokens with spaces inside fail during JWT parsing + with pytest.raises(VerifyAccessTokenError) as err: await api_client.verify_request({"authorization": "Bearer token with extra spaces"}) - assert err.value.get_status_code() == 400 - assert "invalid_request" in str(err.value.get_error_code()).lower() + assert "failed to parse token" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_request_fail_missing_dpop_header(): @@ -1593,18 +1699,53 @@ async def test_verify_request_fail_multiple_dpop_proofs(): assert "multiple" in str(err.value).lower() +@pytest.mark.parametrize( + "dpop_required,auth_header,dpop_header,expected_error", + [ + (True, "Bearer token", None, InvalidAuthSchemeError), # DPoP required but Bearer provided + (True, "DPoP token", None, InvalidAuthSchemeError), # DPoP required but no DPoP header + ], + ids=["dpop-required-bearer-rejected", "dpop-required-missing-dpop-header"] +) +@pytest.mark.asyncio +async def test_verify_request_dpop_required_mismatch(dpop_required, auth_header, dpop_header, expected_error): + """ + Parametric test for DPoP required mode mismatches. + """ + api_client = ApiClient( + ApiClientOptions( + domain="auth0.local", + audience="my-audience", + dpop_required=dpop_required + ) + ) + + headers = {"authorization": auth_header} + if dpop_header: + headers["dpop"] = dpop_header + + with pytest.raises(expected_error) as err: + await api_client.verify_request( + headers=headers, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert err.value.get_status_code() == 400 + assert "invalid_request" in str(err.value.get_error_code()).lower() + @pytest.mark.asyncio async def test_get_access_token_for_connection_success(httpx_mock: HTTPXMock): httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/openid-configuration", + url=DISCOVERY_URL, json={ - "token_endpoint": "https://auth0.local/oauth/token" + "token_endpoint": TOKEN_ENDPOINT } ) httpx_mock.add_response( method="POST", - url="https://auth0.local/oauth/token", + url=TOKEN_ENDPOINT, json={"access_token": "abc123", "expires_in": 3600, "scope": "openid"} ) options = ApiClientOptions( @@ -1626,14 +1767,14 @@ async def test_get_access_token_for_connection_success(httpx_mock: HTTPXMock): async def test_get_access_token_for_connection_with_login_hint(httpx_mock: HTTPXMock): httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/openid-configuration", + url=DISCOVERY_URL, json={ - "token_endpoint": "https://auth0.local/oauth/token" + "token_endpoint": TOKEN_ENDPOINT } ) httpx_mock.add_response( method="POST", - url="https://auth0.local/oauth/token", + url=TOKEN_ENDPOINT, json={"access_token": "abc123", "expires_in": 3600, "scope": "openid"} ) options = ApiClientOptions( @@ -1649,8 +1790,7 @@ async def test_get_access_token_for_connection_with_login_hint(httpx_mock: HTTPX "login_hint": "user@example.com" }) assert result["access_token"] == "abc123" - request = httpx_mock.get_requests()[-1] - form_data = urllib.parse.parse_qs(request.content.decode()) + form_data = last_form(httpx_mock) assert form_data["login_hint"] == ["user@example.com"] @pytest.mark.asyncio @@ -1695,20 +1835,20 @@ async def test_get_access_token_for_connection_no_client_id(): "access_token": "user-token" }) - assert "You must configure the SDK with a client_id and client_secret to use get_access_token_for_connection." == str(err.value) + assert "client_id and client_secret" in str(err.value).lower() @pytest.mark.asyncio async def test_get_access_token_for_connection_token_endpoint_error(httpx_mock: HTTPXMock): httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/openid-configuration", + url=DISCOVERY_URL, json={ - "token_endpoint": "https://auth0.local/oauth/token" + "token_endpoint": TOKEN_ENDPOINT } ) httpx_mock.add_response( method="POST", - url="https://auth0.local/oauth/token", + url=TOKEN_ENDPOINT, status_code=400, json={"error": "invalid_request", "error_description": "Bad request"} ) @@ -1732,13 +1872,13 @@ async def test_get_access_token_for_connection_timeout_error(httpx_mock: HTTPXMo # Mock OIDC discovery httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/openid-configuration", - json={"token_endpoint": "https://auth0.local/oauth/token"} + url=DISCOVERY_URL, + json={"token_endpoint": TOKEN_ENDPOINT} ) # Simulate timeout on POST httpx_mock.add_exception( method="POST", - url="https://auth0.local/oauth/token", + url=TOKEN_ENDPOINT, exception=httpx.TimeoutException("Request timed out") ) options = ApiClientOptions( @@ -1761,14 +1901,14 @@ async def test_get_access_token_for_connection_network_error(httpx_mock: HTTPXMo # Mock OIDC discovery httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/openid-configuration", - json={"token_endpoint": "https://auth0.local/oauth/token"} + url=DISCOVERY_URL, + json={"token_endpoint": TOKEN_ENDPOINT} ) # Simulate HTTPError on POST httpx_mock.add_exception( method="POST", - url="https://auth0.local/oauth/token", - exception=httpx.RequestError("Network unreachable", request=httpx.Request("POST", "https://auth0.local/oauth/token")) + url=TOKEN_ENDPOINT, + exception=httpx.RequestError("Network unreachable", request=httpx.Request("POST", TOKEN_ENDPOINT)) ) options = ApiClientOptions( domain="auth0.local", @@ -1789,12 +1929,12 @@ async def test_get_access_token_for_connection_network_error(httpx_mock: HTTPXMo async def test_get_access_token_for_connection_error_text_json_content_type(httpx_mock: HTTPXMock): httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/openid-configuration", - json={"token_endpoint": "https://auth0.local/oauth/token"} + url=DISCOVERY_URL, + json={"token_endpoint": TOKEN_ENDPOINT} ) httpx_mock.add_response( method="POST", - url="https://auth0.local/oauth/token", + url=TOKEN_ENDPOINT, status_code=400, content=json.dumps({"error": "invalid_request", "error_description": "Bad request"}), headers={"Content-Type": "text/json"} @@ -1815,89 +1955,863 @@ async def test_get_access_token_for_connection_error_text_json_content_type(http assert err.value.status_code == 400 assert "bad request" in str(err.value).lower() - @pytest.mark.asyncio - async def test_get_access_token_for_connection_invalid_json(httpx_mock: HTTPXMock): - httpx_mock.add_response( - method="GET", - url="https://auth0.local/.well-known/openid-configuration", - json={"token_endpoint": "https://auth0.local/oauth/token"} + +@pytest.mark.asyncio +async def test_get_access_token_for_connection_invalid_json(httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="GET", + url=DISCOVERY_URL, + json={"token_endpoint": TOKEN_ENDPOINT} + ) + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + status_code=200, + content="not a json", # Invalid JSON + headers={"Content-Type": "application/json"} + ) + options = ApiClientOptions( + domain="auth0.local", + audience="my-audience", + client_id="cid", + client_secret="csecret", + ) + api_client = ApiClient(options) + with pytest.raises(ApiError) as err: + await api_client.get_access_token_for_connection({ + "connection": "test-conn", + "access_token": "user-token" + }) + assert err.value.code == "invalid_json" + assert "invalid json" in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_get_access_token_for_connection_invalid_access_token_type(httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="GET", + url=DISCOVERY_URL, + json={"token_endpoint": TOKEN_ENDPOINT} + ) + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + status_code=200, + json={"access_token": 12345, "expires_in": 3600} # access_token not a string + ) + options = ApiClientOptions( + domain="auth0.local", + audience="my-audience", + client_id="cid", + client_secret="csecret", + ) + api_client = ApiClient(options) + with pytest.raises(ApiError) as err: + await api_client.get_access_token_for_connection({ + "connection": "test-conn", + "access_token": "user-token" + }) + assert err.value.code == "invalid_response" + assert "access_token" in str(err.value).lower() + assert err.value.status_code == 502 + + +@pytest.mark.asyncio +async def test_get_access_token_for_connection_expires_in_not_integer(httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="GET", + url=DISCOVERY_URL, + json={"token_endpoint": TOKEN_ENDPOINT} + ) + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + status_code=200, + json={"access_token": "abc123", "expires_in": "not-an-int"} + ) + options = ApiClientOptions( + domain="auth0.local", + audience="my-audience", + client_id="cid", + client_secret="csecret", + ) + api_client = ApiClient(options) + with pytest.raises(ApiError) as err: + await api_client.get_access_token_for_connection({ + "connection": "test-conn", + "access_token": "user-token" + }) + assert err.value.code == "invalid_response" + assert "expires_in" in str(err.value).lower() + assert err.value.status_code == 502 + + +# ===== Custom Token Exchange Tests ===== + + +@pytest.mark.asyncio +async def test_get_token_by_exchange_profile_success(httpx_mock: HTTPXMock): + """Test successful token exchange via profile.""" + httpx_mock.add_response( + method="GET", + url=DISCOVERY_URL, + json={"token_endpoint": TOKEN_ENDPOINT} + ) + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + json={ + "access_token": "exchanged_token", + "expires_in": 3600, + "scope": "openid profile", + "id_token": "id_token_value", + "refresh_token": "refresh_token_value", + "token_type": "Bearer", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token" + } + ) + + api_client = ApiClient(ApiClientOptions( + domain="auth0.local", + audience="my-audience", + client_id="cid", + client_secret="csecret" + )) + + result = await api_client.get_token_by_exchange_profile( + subject_token="custom-token-123", + subject_token_type="urn:example:custom-token", + audience="https://api.example.com", + scope="openid profile" + ) + + assert result["access_token"] == "exchanged_token" + assert result["expires_in"] == 3600 + assert result["scope"] == "openid profile" + assert result["id_token"] == "id_token_value" + assert result["refresh_token"] == "refresh_token_value" + assert result["token_type"] == "Bearer" + assert result["issued_token_type"] == "urn:ietf:params:oauth:token-type:access_token" + assert isinstance(result["expires_at"], int) + + # Verify request parameters + form_data = last_form(httpx_mock) + assert form_data["grant_type"] == ["urn:ietf:params:oauth:grant-type:token-exchange"] + assert form_data["subject_token"] == ["custom-token-123"] + assert form_data["subject_token_type"] == ["urn:example:custom-token"] + assert form_data["audience"] == ["https://api.example.com"] + + +@freeze_time("2025-01-01T00:00:00Z") +@pytest.mark.asyncio +async def test_sets_expires_at(mock_discovery, api_client_confidential, httpx_mock): + """Test that expires_at is set deterministically.""" + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + json={"access_token": "t", "expires_in": 3600} + ) + result = await api_client_confidential.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x" + ) + assert result["expires_at"] == 1735693200 + + +@pytest.mark.parametrize( + "kwargs,exc,msg", + [ + ({"subject_token": "", "subject_token_type": "urn:x"}, MissingRequiredArgumentError, "subject_token"), + ({"subject_token": "t", "subject_token_type": ""}, MissingRequiredArgumentError, "subject_token_type"), + ({"subject_token": " ", "subject_token_type": "urn:x"}, GetTokenByExchangeProfileError, "whitespace"), + ({"subject_token": " token ", "subject_token_type": "urn:x"}, GetTokenByExchangeProfileError, "leading or trailing whitespace"), + ({"subject_token": "Bearer abc", "subject_token_type": "urn:x"}, GetTokenByExchangeProfileError, "Bearer"), + ({"subject_token": "bearer abc", "subject_token_type": "urn:x"}, GetTokenByExchangeProfileError, "Bearer"), + ], + ids=["missing-token", "missing-type", "blank", "surrounding-whitespace", "bearer-prefix", "bearer-prefix-lowercase"], +) +@pytest.mark.asyncio +async def test_exchange_profile_input_validation(api_client_confidential, kwargs, exc, msg): + """Test input validation for get_token_by_exchange_profile.""" + with pytest.raises(exc) as err: + await api_client_confidential.get_token_by_exchange_profile(**kwargs) + assert msg.lower() in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_validation_short_circuits(api_client_confidential, httpx_mock): + """Test that validation errors prevent network requests.""" + with pytest.raises(GetTokenByExchangeProfileError): + await api_client_confidential.get_token_by_exchange_profile(subject_token=" ", subject_token_type="urn:x") + # Verify no network requests were made (validation failed before discovery) + assert_no_requests(httpx_mock) + + +@pytest.mark.parametrize( + "opts, description", + [ + (ApiClientOptions(domain="auth0.local", audience="my-audience", client_secret="csecret"), "missing client_id"), + (ApiClientOptions(domain="auth0.local", audience="my-audience", client_id="cid"), "missing client_secret"), + (ApiClientOptions(domain="auth0.local", audience="my-audience"), "missing both"), + ], + ids=["missing-client_id", "missing-client_secret", "missing-both"] +) +@pytest.mark.asyncio +async def test_get_token_by_exchange_profile_missing_credentials(opts, description): + """Test that missing client credentials raise error.""" + api_client = ApiClient(opts) + + with pytest.raises(GetTokenByExchangeProfileError) as err: + await api_client.get_token_by_exchange_profile( + subject_token="token", + subject_token_type="urn:example:type" ) - httpx_mock.add_response( - method="POST", - url="https://auth0.local/oauth/token", - status_code=200, - content="not a json", # Invalid JSON - headers={"Content-Type": "application/json"} + assert "client credentials" in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_get_token_by_exchange_profile_uses_http_basic_auth(httpx_mock: HTTPXMock): + """Test that client credentials are sent via HTTP Basic auth, not form body.""" + httpx_mock.add_response( + method="GET", + url=DISCOVERY_URL, + json={"token_endpoint": TOKEN_ENDPOINT} + ) + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + json={"access_token": "token", "expires_in": 3600} + ) + + api_client = ApiClient(ApiClientOptions( + domain="auth0.local", + audience="my-audience", + client_id="test_client", + client_secret="test_secret" + )) + + await api_client.get_token_by_exchange_profile( + subject_token="token", + subject_token_type="urn:example:type" + ) + + # Verify HTTP Basic auth is used and credentials are NOT in form body + assert_form_post( + httpx_mock, + forbid_fields=["client_id", "client_secret"], + expect_basic_auth=("test_client", "test_secret") + ) + + +@pytest.mark.parametrize( + "denied_param", + [ + "grant_type", "client_id", "client_secret", "subject_token", + "subject_token_type", "audience", "scope", "connection" + ], + ids=["grant_type", "client_id", "client_secret", "subject_token", + "subject_token_type", "audience", "scope", "connection"] +) +@pytest.mark.asyncio +async def test_get_token_by_exchange_profile_extra_params_denylist(httpx_mock: HTTPXMock, denied_param): + """Test that reserved extra parameters fail fast.""" + httpx_mock.add_response( + method="GET", + url=DISCOVERY_URL, + json={"token_endpoint": TOKEN_ENDPOINT} + ) + + api_client = ApiClient(ApiClientOptions( + domain="auth0.local", + audience="my-audience", + client_id="cid", + client_secret="csecret" + )) + + with pytest.raises(GetTokenByExchangeProfileError) as err: + await api_client.get_token_by_exchange_profile( + subject_token="token", + subject_token_type="urn:example:type", + extra={denied_param: "should_fail"} ) - options = ApiClientOptions( - domain="auth0.local", - audience="my-audience", - client_id="cid", - client_secret="csecret", + + assert "reserved" in str(err.value).lower() + assert denied_param in str(err.value) + + +@pytest.mark.asyncio +async def test_extra_array_exact_limit_passes(mock_discovery, api_client_confidential, httpx_mock): + """Test that array with exactly MAX_ARRAY_VALUES_PER_KEY passes.""" + from auth0_api_python.api_client import MAX_ARRAY_VALUES_PER_KEY + + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + json={"access_token": "t", "expires_in": 3600} + ) + + exact_size = list(map(str, range(MAX_ARRAY_VALUES_PER_KEY))) + result = await api_client_confidential.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x", + extra={"roles": exact_size} + ) + + # Verify it passes + assert result["access_token"] == "t" + form_data = last_form(httpx_mock) + assert len(form_data["roles"]) == MAX_ARRAY_VALUES_PER_KEY + + +@pytest.mark.asyncio +async def test_extra_array_limit(mock_discovery, api_client_confidential): + """Test that array size limit is enforced (DoS protection).""" + from auth0_api_python.api_client import MAX_ARRAY_VALUES_PER_KEY + + # Create array exceeding limit + big = list(map(str, range(MAX_ARRAY_VALUES_PER_KEY + 1))) + + with pytest.raises(GetTokenByExchangeProfileError) as err: + await api_client_confidential.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x", + extra={"roles": big} ) - api_client = ApiClient(options) - with pytest.raises(ApiError) as err: - await api_client.get_access_token_for_connection({ - "connection": "test-conn", - "access_token": "user-token" - }) - assert err.value.code == "invalid_json" - assert "invalid json" in str(err.value).lower() - - @pytest.mark.asyncio - async def test_get_access_token_for_connection_invalid_access_token_type(httpx_mock: HTTPXMock): - httpx_mock.add_response( - method="GET", - url="https://auth0.local/.well-known/openid-configuration", - json={"token_endpoint": "https://auth0.local/oauth/token"} + assert "maximum array size" in str(err.value).lower() + + +@pytest.mark.parametrize( + "param_name", + ["Scope", "RESOURCE", "Audience", "GRANT_TYPE", "Subject_Token"], + ids=["Scope", "RESOURCE", "Audience", "GRANT_TYPE", "Subject_Token"] +) +@pytest.mark.asyncio +async def test_extra_reserved_case_insensitive_parametric(mock_discovery, api_client_confidential, param_name): + """Test that reserved parameter check is case-insensitive for multiple params.""" + with pytest.raises(GetTokenByExchangeProfileError) as err: + await api_client_confidential.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x", + extra={param_name: "value"} ) - httpx_mock.add_response( - method="POST", - url="https://auth0.local/oauth/token", - status_code=200, - json={"access_token": 12345, "expires_in": 3600} # access_token not a string + assert "reserved" in str(err.value).lower() + assert param_name in str(err.value) + + +@pytest.mark.asyncio +async def test_extra_reserved_case_insensitive(mock_discovery, api_client_confidential): + """Test that reserved parameter check is case-insensitive.""" + with pytest.raises(GetTokenByExchangeProfileError) as err: + await api_client_confidential.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x", + extra={"Client_ID": "x"} ) - options = ApiClientOptions( - domain="auth0.local", - audience="my-audience", - client_id="cid", - client_secret="csecret", + assert "reserved" in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_extra_mixed_type_array(mock_discovery, api_client_confidential, httpx_mock): + """Test that mixed type arrays are coerced to strings.""" + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + json={"access_token": "t", "expires_in": 3600} + ) + + await api_client_confidential.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x", + extra={"values": [1, "2", 3.5]} + ) + + # Verify mixed types are all stringified + form_data = last_form(httpx_mock) + assert form_data["values"] == ["1", "2", "3.5"] + + +@pytest.mark.asyncio +async def test_extra_numeric_string_array(mock_discovery, api_client_confidential, httpx_mock): + """Test that numeric strings in arrays are preserved.""" + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + json={"access_token": "t", "expires_in": 3600} + ) + + await api_client_confidential.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x", + extra={"ids": ["1", "2", "3"]} + ) + + # Verify string arrays are preserved + form_data = last_form(httpx_mock) + assert form_data["ids"] == ["1", "2", "3"] + + +@pytest.mark.asyncio +async def test_extra_tuple_support(mock_discovery, api_client_confidential, httpx_mock): + """Test that tuple values are accepted and converted to strings.""" + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + json=token_success() + ) + + await api_client_confidential.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x", + extra={"roles": ("admin", "user", "viewer")} + ) + + # Verify tuple was converted to list of strings + form_data = last_form(httpx_mock) + assert form_data["roles"] == ["admin", "user", "viewer"] + + +@pytest.mark.parametrize( + "invalid_extra, expected_type_name", + [ + ({"metadata": {"key": "value"}}, "dict"), + ({"tags": {"admin", "user"}}, "set"), + ({"data": b"binary"}, "bytes"), + ], + ids=["dict", "set", "bytes"] +) +@pytest.mark.asyncio +async def test_extra_invalid_types_rejected(mock_discovery, api_client_confidential, invalid_extra, expected_type_name): + """Test that unsupported types in 'extra' params are rejected.""" + with pytest.raises(GetTokenByExchangeProfileError) as err: + await api_client_confidential.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x", + extra=invalid_extra ) - api_client = ApiClient(options) - with pytest.raises(ApiError) as err: - await api_client.get_access_token_for_connection({ - "connection": "test-conn", - "access_token": "user-token" - }) - assert err.value.code == "invalid_response" - assert "access_token" in str(err.value).lower() - assert err.value.status_code == 502 + assert "unsupported type" in str(err.value).lower() + assert expected_type_name in str(err.value).lower() + + +@pytest.mark.parametrize( + "value,expected", + [ + ("string", "string"), + (42, "42"), + (3.14, "3.14"), + (True, "True"), + (False, "False"), + (None, "None"), + ], + ids=["str", "int", "float", "bool-true", "bool-false", "none"] +) +@pytest.mark.asyncio +async def test_extra_value_types_stringification(mock_discovery, api_client_confidential, httpx_mock, value, expected): + """Test that various extra param value types are stringified.""" + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + json=token_success() + ) + + await api_client_confidential.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x", + extra={"param": value} + ) + + form_data = last_form(httpx_mock) + assert form_data["param"] == [expected] + - @pytest.mark.asyncio - async def test_get_access_token_for_connection_expires_in_not_integer(httpx_mock: HTTPXMock): +@pytest.mark.asyncio +async def test_optional_fields_preserve_falsy_values(mock_discovery, api_client_confidential, httpx_mock): + """Test that optional fields preserve legitimate falsy values like empty scope.""" + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + json={ + "access_token": "t", + "expires_in": 3600, + "scope": "", # Empty scope should be preserved + "token_type": "Bearer" + } + ) + + result = await api_client_confidential.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x" + ) + + # Verify empty scope is preserved (not dropped) + assert result["scope"] == "" + assert result["token_type"] == "Bearer" + assert "id_token" not in result # Not present, shouldn't be in result + assert "refresh_token" not in result + + +@pytest.mark.parametrize( + "discovery_status, discovery_json, token_exception, expected_exc, contains", + [ + (200, {"issuer": "https://auth0.local/"}, None, GetTokenByExchangeProfileError, "token endpoint"), # Missing endpoint + (200, {"token_endpoint": TOKEN_ENDPOINT}, httpx.TimeoutException("timeout"), ApiError, "timeout"), # Token timeout + (200, {"token_endpoint": TOKEN_ENDPOINT}, httpx.RequestError("unreachable", request=httpx.Request("POST", TOKEN_ENDPOINT)), ApiError, "network"), # Token network + (500, None, None, (ApiError, httpx.HTTPStatusError), None), # Discovery 500 + ], + ids=["missing-endpoint", "token-timeout", "token-network", "discovery-500"] +) +@pytest.mark.asyncio +async def test_exchange_failure_matrix(httpx_mock, discovery_status, discovery_json, token_exception, expected_exc, contains): + """Test comprehensive failure scenarios for discovery and token endpoints.""" + # Setup discovery response + if discovery_json: httpx_mock.add_response( method="GET", - url="https://auth0.local/.well-known/openid-configuration", - json={"token_endpoint": "https://auth0.local/oauth/token"} + url=DISCOVERY_URL, + status_code=discovery_status, + json=discovery_json ) + else: httpx_mock.add_response( + method="GET", + url=DISCOVERY_URL, + status_code=discovery_status + ) + + # Setup token exception if provided + if token_exception: + httpx_mock.add_exception( method="POST", - url="https://auth0.local/oauth/token", - status_code=200, - json={"access_token": "abc123", "expires_in": "not-an-int"} + url=TOKEN_ENDPOINT, + exception=token_exception ) - options = ApiClientOptions( - domain="auth0.local", - audience="my-audience", - client_id="cid", - client_secret="csecret", + + api_client = ApiClient(ApiClientOptions( + domain="auth0.local", + audience="my-audience", + client_id="cid", + client_secret="csecret" + )) + + with pytest.raises(expected_exc) as err: + await api_client.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x" + ) + + if contains: + assert contains in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_get_token_by_exchange_profile_api_error(httpx_mock: HTTPXMock): + """Test handling of API errors from token endpoint.""" + httpx_mock.add_response( + method="GET", + url=DISCOVERY_URL, + json={"token_endpoint": TOKEN_ENDPOINT} + ) + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + status_code=400, + json={"error": "invalid_grant", "error_description": "Invalid subject token"} + ) + + api_client = ApiClient(ApiClientOptions( + domain="auth0.local", + audience="my-audience", + client_id="cid", + client_secret="csecret" + )) + + with pytest.raises(ApiError) as err: + await api_client.get_token_by_exchange_profile( + subject_token="token", + subject_token_type="urn:example:type" + ) + assert err.value.code == "invalid_grant" + assert err.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_token_endpoint_non_200_non_json(mock_discovery, api_client_confidential, httpx_mock): + """Test that non-200 with non-JSON body defaults to generic error code.""" + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + status_code=400, + content="Bad Request", + headers={"Content-Type": "text/plain"} + ) + + with pytest.raises(ApiError) as err: + await api_client_confidential.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x" ) - api_client = ApiClient(options) - with pytest.raises(ApiError) as err: - await api_client.get_access_token_for_connection({ - "connection": "test-conn", - "access_token": "user-token" - }) - assert err.value.code == "invalid_response" - assert "expires_in" in str(err.value).lower() + assert_api_error(err.value, code="token_exchange_error", status=400) + + +@pytest.mark.asyncio +async def test_token_endpoint_200_non_json(mock_discovery, api_client_confidential, httpx_mock): + """Test that 200 with non-JSON body raises invalid_json error.""" + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + status_code=200, + content="not json", + headers={"Content-Type": "text/plain"} + ) + + with pytest.raises(ApiError) as err: + await api_client_confidential.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x" + ) + assert_api_error(err.value, code="invalid_json", status=502) + + +@pytest.mark.asyncio +async def test_response_empty_id_token_preserved(mock_discovery, api_client_confidential, httpx_mock): + """Test that empty but present id_token is preserved.""" + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + json={ + "access_token": "t", + "expires_in": 3600, + "id_token": "", # Empty but present + "refresh_token": "" # Empty but present + } + ) + + result = await api_client_confidential.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x" + ) + + # Verify empty strings are preserved + assert result["id_token"] == "" + assert result["refresh_token"] == "" + + +@pytest.mark.parametrize( + "response_json, expect_success, expected_expires_in, expected_error, contains", + [ + # Success cases: expires_in coercion + ({"access_token": "t", "expires_in": "3600"}, True, 3600, None, None), + ({"access_token": "t", "expires_in": 3600}, True, 3600, None, None), + ({"access_token": "t", "expires_in": 0}, True, 0, None, None), + ({"access_token": "t", "expires_in": 999999999}, True, 999999999, None, None), # Very large value + + # Error cases: missing/invalid access_token + ({}, False, None, ApiError, "access_token"), + ({"access_token": ""}, False, None, ApiError, "access_token"), + ({"access_token": None}, False, None, ApiError, "access_token"), + ({"access_token": 123}, False, None, ApiError, "access_token"), + + # Error cases: invalid expires_in + ({"access_token": "t", "expires_in": "not-a-number"}, False, None, ApiError, "expires_in"), + ({"access_token": "t", "expires_in": "x"}, False, None, ApiError, "expires_in"), + ({"access_token": "t", "expires_in": "3600.5"}, False, None, ApiError, "expires_in"), # String float rejected + ({"access_token": "t", "expires_in": -100}, False, None, ApiError, "negative"), + ], + ids=[ + "expires_in_numeric_string", + "expires_in_int", + "expires_in_zero", + "expires_in_very_large", + "missing_access_token", + "empty_access_token", + "null_access_token", + "wrong_type_access_token", + "invalid_expires_in_string", + "invalid_expires_in_char", + "invalid_expires_in_float_string", + "negative_expires_in", + ] +) +@freeze_time("2024-01-15 12:00:00") +@pytest.mark.asyncio +async def test_token_response_parsing( + mock_discovery, api_client_confidential, httpx_mock, + response_json, expect_success, expected_expires_in, expected_error, contains +): + """Test token endpoint response parsing and validation.""" + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + json=response_json + ) + + if expect_success: + result = await api_client_confidential.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x" + ) + assert result["expires_in"] == expected_expires_in + assert isinstance(result["expires_in"], int) + assert result["access_token"] == "t" + + # Verify expires_at calculation (deterministic with frozen time) + import time + expected_expires_at = int(time.time()) + expected_expires_in + assert result["expires_at"] == expected_expires_at + else: + with pytest.raises(expected_error) as err: + await api_client_confidential.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x" + ) assert err.value.status_code == 502 + assert contains in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_token_endpoint_network_error(httpx_mock: HTTPXMock): + """Test that network error (not timeout) raises ApiError.""" + httpx_mock.add_response( + method="GET", + url=DISCOVERY_URL, + json={"token_endpoint": TOKEN_ENDPOINT} + ) + httpx_mock.add_exception( + method="POST", + url=TOKEN_ENDPOINT, + exception=httpx.RequestError("Network unreachable", request=httpx.Request("POST", TOKEN_ENDPOINT)) + ) + + api_client = ApiClient(ApiClientOptions( + domain="auth0.local", + audience="my-audience", + client_id="cid", + client_secret="csecret" + )) + + with pytest.raises(ApiError) as err: + await api_client.get_token_by_exchange_profile( + subject_token="t", + subject_token_type="urn:x" + ) + assert err.value.code == "network_error" + assert err.value.status_code == 502 + + +@pytest.mark.parametrize( + "call_kwargs, expected_fields, forbidden_fields", + [ + ( + {"audience": "https://api.example.com"}, + {"audience": ["https://api.example.com"]}, + ["scope", "requested_token_type"] + ), + ( + {"scope": "openid profile", "requested_token_type": "urn:ietf:params:oauth:token-type:access_token"}, + {"scope": ["openid profile"], "requested_token_type": ["urn:ietf:params:oauth:token-type:access_token"]}, + [] + ), + ( + {}, # No optional args + {}, + ["audience", "scope", "requested_token_type"] + ), + ( + {"extra": {"device_id": "dev123", "roles": ["admin", "user"]}}, + {"device_id": ["dev123"], "roles": ["admin", "user"]}, + [] + ), + ], + ids=["with_audience", "with_scope_and_type", "no_optionals", "with_extra_params"] +) +@pytest.mark.asyncio +async def test_request_wiring( + mock_discovery, api_client_confidential, httpx_mock, + call_kwargs, expected_fields, forbidden_fields +): + """Test that all optional and extra parameters are correctly wired into the form post.""" + httpx_mock.add_response( + method="POST", + url=TOKEN_ENDPOINT, + json=token_success() + ) + + # Base args are constant + base_args = { + "subject_token": "t", + "subject_token_type": "urn:x" + } + + # Make the call + await api_client_confidential.get_token_by_exchange_profile( + **base_args, + **call_kwargs + ) + + # Use the helper to check everything at once + assert_form_post( + httpx_mock, + expect_fields={ + "grant_type": ["urn:ietf:params:oauth:grant-type:token-exchange"], + "subject_token": ["t"], + "subject_token_type": ["urn:x"], + **expected_fields + }, + forbid_fields=["client_id", "client_secret"] + forbidden_fields, + expect_basic_auth=("cid", "csecret") + ) + + +@pytest.mark.asyncio +async def test_get_token_by_exchange_profile_timeout(httpx_mock: HTTPXMock): + """Test timeout handling.""" + httpx_mock.add_response( + method="GET", + url=DISCOVERY_URL, + json={"token_endpoint": TOKEN_ENDPOINT} + ) + httpx_mock.add_exception(httpx.TimeoutException("timeout"), method="POST") + + api_client = ApiClient(ApiClientOptions( + domain="auth0.local", + audience="my-audience", + client_id="cid", + client_secret="csecret" + )) + + with pytest.raises(ApiError) as err: + await api_client.get_token_by_exchange_profile( + subject_token="token", + subject_token_type="urn:example:type" + ) + assert err.value.code == "timeout_error" + assert err.value.status_code == 504 + +@pytest.mark.asyncio +async def test_get_token_by_exchange_profile_custom_timeout_honored(httpx_mock: HTTPXMock): + """Test that custom timeout option is honored.""" + httpx_mock.add_response( + method="GET", + url=DISCOVERY_URL, + json={"token_endpoint": TOKEN_ENDPOINT} + ) + # Simulate slow response that will timeout with tiny timeout value + httpx_mock.add_exception(httpx.TimeoutException("timeout"), method="POST") + + # Set very small timeout to prove the option is used + api_client = ApiClient(ApiClientOptions( + domain="auth0.local", + audience="my-audience", + client_id="cid", + client_secret="csecret", + timeout=0.001 # 1ms timeout + )) + + with pytest.raises(ApiError) as err: + await api_client.get_token_by_exchange_profile( + subject_token="token", + subject_token_type="urn:example:type" + ) + assert err.value.code == "timeout_error" + assert err.value.status_code == 504 + +