From 85e33ae355e7164a62f45a7fbf7aa037979e2e21 Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Wed, 27 Aug 2025 15:37:16 +0100 Subject: [PATCH 01/10] Add token vault subject_token_type access_token to api sdk --- packages/auth0_api_python/README.md | 30 ++++++ .../src/auth0_api_python/api_client.py | 83 +++++++++++++- .../src/auth0_api_python/config.py | 12 +++ .../src/auth0_api_python/errors.py | 29 +++++ .../auth0_api_python/tests/test_api_client.py | 101 ++++++++++++++++++ 5 files changed, 254 insertions(+), 1 deletion(-) diff --git a/packages/auth0_api_python/README.md b/packages/auth0_api_python/README.md index 97f7cd8..6068784 100644 --- a/packages/auth0_api_python/README.md +++ b/packages/auth0_api_python/README.md @@ -85,6 +85,36 @@ asyncio.run(main()) In this example, the returned dictionary contains the decoded claims (like `sub`, `scope`, etc.) from the verified token. +### 4. Get an access token for a connection + +If you need to get an access token for an upstream idp via a connection, you can use the `get_token_for_connection` method: + +```python +import asyncio + +from auth0_api_python import ApiClient, ApiClientOptions + +async def main(): + api_client = ApiClient(ApiClientOptions( + domain="", + audience="", + associated_client={ + "client_id": "", + "client_secret": "" + } + )) + connection = "my-connection" # The Auth0 connection to the upstream idp + access_token = "..." # The Auth0 access token to exchange + + connection_access_token = await api_client.get_token_for_connection({"connection": connection, "access_token": access_token}) + # The returned token is the access token for the upstream idp + print(connection_access_token) + +asyncio.run(main()) +``` + +More info https://auth0.com/docs/secure/tokens/token-vault + #### Requiring Additional Claims If your application demands extra claims, specify them with `required_claims`: diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index 0eb7a22..7e687af 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -1,5 +1,6 @@ import time -from typing import Any, Optional +import httpx +from typing import Any, Optional, List, Dict from authlib.jose import JsonWebKey, JsonWebToken @@ -11,6 +12,8 @@ MissingAuthorizationError, MissingRequiredArgumentError, VerifyAccessTokenError, + GetTokenForConnectionError, + ApiError, ) from .utils import ( calculate_jwk_thumbprint, @@ -550,3 +553,81 @@ def _build_www_authenticate( ("WWW-Authenticate", 'Bearer realm="api"'), ("WWW-Authenticate", f'DPoP algs="{algs}"'), ] + + async def get_token_for_connection(self, options: Dict[str, Any]) -> Dict[str, Any]: + """ + Retrieves a token for a connection. + + Args: + options: Options for retrieving an access token for a connection. + Must include 'connection' and 'access_token' keys. + May optionally include 'login_hint'. + + Raises: + GetTokenForConnectionError: If there was an issue requesting the access token. + ApiError: If the token exchange endpoint returns an error. + + Returns: + Dictionary containing the token response with access_token, expires_in, and scope. + """ + # Constants + SUBJECT_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token" + REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "http://auth0.com/oauth/token-type/federated-connection-access-token" + GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token" + + connection = options.get("connection") + access_token = options.get("access_token") + + if not connection: + raise MissingRequiredArgumentError("connection") + + if not access_token: + raise MissingRequiredArgumentError("access_token") + + associated_client = self.options.associated_client + if not associated_client: + raise GetTokenForConnectionError("You must configure the SDK with an associated_client to use get_token_for_connection.") + + metadata = await self._discover() + + token_endpoint = metadata.get("token_endpoint") + if not token_endpoint: + raise GetTokenForConnectionError("Token endpoint missing in OIDC metadata") + + # Prepare parameters + params = { + "connection": connection, + "requested_token_type": REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN, + "grant_type": GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN, + "client_id": associated_client["client_id"], + "subject_token": access_token, + "subject_token_type": SUBJECT_TYPE_ACCESS_TOKEN, + } + + # Add login_hint if provided + if "login_hint" in associated_client and associated_client["login_hint"]: + params["login_hint"] = options["login_hint"] + + async with httpx.AsyncClient() as client: + response = await client.post( + token_endpoint, + data=params, + auth=(associated_client["client_id"], associated_client["client_secret"]) + ) + + if response.status_code != 200: + error_data = response.json() if response.headers.get( + "content-type") == "application/json" else {} + raise ApiError( + error_data.get("error", "connection_token_error"), + error_data.get( + "error_description", f"Failed to get token for connection: {response.status_code}") + ) + + token_endpoint_response = response.json() + + return { + "access_token": token_endpoint_response.get("access_token"), + "expires_at": int(time.time()) + int(token_endpoint_response.get("expires_in", 3600)), + "scope": token_endpoint_response.get("scope", "") + } diff --git a/packages/auth0_api_python/src/auth0_api_python/config.py b/packages/auth0_api_python/src/auth0_api_python/config.py index 0cd555a..d027c40 100644 --- a/packages/auth0_api_python/src/auth0_api_python/config.py +++ b/packages/auth0_api_python/src/auth0_api_python/config.py @@ -5,6 +5,9 @@ from typing import Callable, Optional +from auth0_api_python.errors import MissingRequiredArgumentError + + class ApiClientOptions: """ Configuration for the ApiClient. @@ -17,6 +20,8 @@ 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). + associated_client: Optional required if you want to use get_token_for_connection. + Must be a dict with 'client_id' and 'client_secret' keys. """ def __init__( self, @@ -27,6 +32,7 @@ def __init__( dpop_required: bool = False, dpop_iat_leeway: int = 30, dpop_iat_offset: int = 300, + associated_client: Optional[dict] = None, ): self.domain = domain self.audience = audience @@ -35,3 +41,9 @@ def __init__( self.dpop_required = dpop_required self.dpop_iat_leeway = dpop_iat_leeway self.dpop_iat_offset = dpop_iat_offset + self.associated_client = associated_client + if associated_client: + if not associated_client.get("client_id"): + raise MissingRequiredArgumentError("associated_client.client_id") + if not associated_client.get("client_secret"): + raise MissingRequiredArgumentError("associated_client.client_secret") \ No newline at end of file diff --git a/packages/auth0_api_python/src/auth0_api_python/errors.py b/packages/auth0_api_python/src/auth0_api_python/errors.py index e696c15..57a3c74 100644 --- a/packages/auth0_api_python/src/auth0_api_python/errors.py +++ b/packages/auth0_api_python/src/auth0_api_python/errors.py @@ -94,3 +94,32 @@ def get_status_code(self) -> int: def get_error_code(self) -> str: return "invalid_request" + + +class GetTokenForConnectionError(Exception): + """Error raised when getting a token for a connection fails.""" + code = "get_token_for_connection_error" + + def __init__(self, message: str): + super().__init__(message) + self.name = self.__class__.__name__ + + +class ApiError(Exception): + """ + Error raised when an API request to Auth0 fails. + Contains details about the original error from Auth0. + """ + + def __init__(self, code: str, message: str, cause=None): + super().__init__(message) + self.code = code + self.cause = cause + + # Extract additional error details if available + if cause: + self.error = getattr(cause, "error", None) + self.error_description = getattr(cause, "error_description", None) + else: + self.error = None + self.error_description = None \ No newline at end of file diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index afa3c99..d565f48 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -11,6 +11,8 @@ MissingAuthorizationError, MissingRequiredArgumentError, VerifyAccessTokenError, + GetTokenForConnectionError, + ApiError, ) from auth0_api_python.token_utils import ( PRIVATE_EC_JWK, @@ -1589,3 +1591,102 @@ async def test_verify_request_fail_multiple_dpop_proofs(): assert "multiple" in str(err.value).lower() +@pytest.mark.asyncio +async def test_get_token_for_connection_success(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" + } + ) + httpx_mock.add_response( + method="POST", + url="https://auth0.local/oauth/token", + json={"access_token": "abc123", "expires_in": 3600, "scope": "openid"} + ) + options = ApiClientOptions( + domain="auth0.local", + audience="my-audience", + associated_client={"client_id": "cid", "client_secret": "csecret"} + ) + api_client = ApiClient(options) + result = await api_client.get_token_for_connection({ + "connection": "test-conn", + "access_token": "user-token" + }) + assert result["access_token"] == "abc123" + assert result["scope"] == "openid" + assert isinstance(result["expires_at"], int) + + +@pytest.mark.asyncio +async def test_get_token_for_connection_missing_connection(): + options = ApiClientOptions( + domain="auth0.local", + audience="my-audience", + associated_client={"client_id": "cid", "client_secret": "csecret"} + ) + api_client = ApiClient(options) + with pytest.raises(MissingRequiredArgumentError): + await api_client.get_token_for_connection({ + "access_token": "user-token" + }) + + +@pytest.mark.asyncio +async def test_get_token_for_connection_missing_access_token(): + options = ApiClientOptions( + domain="auth0.local", + audience="my-audience", + associated_client={"client_id": "cid", "client_secret": "csecret"} + ) + api_client = ApiClient(options) + with pytest.raises(MissingRequiredArgumentError): + await api_client.get_token_for_connection({ + "connection": "test-conn" + }) + + +@pytest.mark.asyncio +async def test_get_token_for_connection_no_associated_client(): + options = ApiClientOptions( + domain="auth0.local", + audience="my-audience" + # associated_client missing + ) + api_client = ApiClient(options) + with pytest.raises(GetTokenForConnectionError): + await api_client.get_token_for_connection({ + "connection": "test-conn", + "access_token": "user-token" + }) + + +@pytest.mark.asyncio +async def test_get_token_for_connection_token_endpoint_error(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" + } + ) + httpx_mock.add_response( + method="POST", + url="https://auth0.local/oauth/token", + status_code=400, + json={"error": "invalid_request", "error_description": "Bad request"} + ) + options = ApiClientOptions( + domain="auth0.local", + audience="my-audience", + associated_client={"client_id": "cid", "client_secret": "csecret"} + ) + api_client = ApiClient(options) + with pytest.raises(ApiError) as err: + await api_client.get_token_for_connection({ + "connection": "test-conn", + "access_token": "user-token" + }) + assert err.value.code == "invalid_request" From 1ab2c3a61a4bc20377270c6c52cf9b29230a5fb3 Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Thu, 28 Aug 2025 10:04:05 +0100 Subject: [PATCH 02/10] fix lint errors --- .../src/auth0_api_python/api_client.py | 17 ++++++++--------- .../src/auth0_api_python/config.py | 3 +-- .../src/auth0_api_python/errors.py | 2 +- .../auth0_api_python/tests/test_api_client.py | 4 ++-- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index 7e687af..64c3cc6 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -1,19 +1,19 @@ import time -import httpx -from typing import Any, Optional, List, Dict +from typing import Any, Optional +import httpx from authlib.jose import JsonWebKey, JsonWebToken from .config import ApiClientOptions from .errors import ( + ApiError, BaseAuthError, + GetTokenForConnectionError, InvalidAuthSchemeError, InvalidDpopProofError, MissingAuthorizationError, MissingRequiredArgumentError, VerifyAccessTokenError, - GetTokenForConnectionError, - ApiError, ) from .utils import ( calculate_jwk_thumbprint, @@ -554,7 +554,7 @@ def _build_www_authenticate( ("WWW-Authenticate", f'DPoP algs="{algs}"'), ] - async def get_token_for_connection(self, options: Dict[str, Any]) -> Dict[str, Any]: + async def get_token_for_connection(self, options: dict[str, Any]) -> dict[str, Any]: """ Retrieves a token for a connection. @@ -571,10 +571,9 @@ async def get_token_for_connection(self, options: Dict[str, Any]) -> Dict[str, A Dictionary containing the token response with access_token, expires_in, and scope. """ # Constants - SUBJECT_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token" - REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "http://auth0.com/oauth/token-type/federated-connection-access-token" - GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token" - + SUBJECT_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token" # noqa S105 + REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "http://auth0.com/oauth/token-type/federated-connection-access-token" # noqa S105 + GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token" # noqa S105 connection = options.get("connection") access_token = options.get("access_token") diff --git a/packages/auth0_api_python/src/auth0_api_python/config.py b/packages/auth0_api_python/src/auth0_api_python/config.py index d027c40..21edf6b 100644 --- a/packages/auth0_api_python/src/auth0_api_python/config.py +++ b/packages/auth0_api_python/src/auth0_api_python/config.py @@ -4,7 +4,6 @@ from typing import Callable, Optional - from auth0_api_python.errors import MissingRequiredArgumentError @@ -46,4 +45,4 @@ def __init__( if not associated_client.get("client_id"): raise MissingRequiredArgumentError("associated_client.client_id") if not associated_client.get("client_secret"): - raise MissingRequiredArgumentError("associated_client.client_secret") \ No newline at end of file + raise MissingRequiredArgumentError("associated_client.client_secret") diff --git a/packages/auth0_api_python/src/auth0_api_python/errors.py b/packages/auth0_api_python/src/auth0_api_python/errors.py index 57a3c74..88dff43 100644 --- a/packages/auth0_api_python/src/auth0_api_python/errors.py +++ b/packages/auth0_api_python/src/auth0_api_python/errors.py @@ -122,4 +122,4 @@ def __init__(self, code: str, message: str, cause=None): self.error_description = getattr(cause, "error_description", None) else: self.error = None - self.error_description = None \ No newline at end of file + self.error_description = None diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index d565f48..e00d24e 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -6,13 +6,13 @@ from auth0_api_python.api_client import ApiClient from auth0_api_python.config import ApiClientOptions from auth0_api_python.errors import ( + ApiError, + GetTokenForConnectionError, InvalidAuthSchemeError, InvalidDpopProofError, MissingAuthorizationError, MissingRequiredArgumentError, VerifyAccessTokenError, - GetTokenForConnectionError, - ApiError, ) from auth0_api_python.token_utils import ( PRIVATE_EC_JWK, From 3246a05a583334413065a3e87646c0ea0ad01e09 Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Fri, 29 Aug 2025 10:38:30 +0100 Subject: [PATCH 03/10] Updates per PR feedback --- packages/auth0_api_python/README.md | 4 +- .../src/auth0_api_python/api_client.py | 12 ++-- .../src/auth0_api_python/config.py | 2 +- .../src/auth0_api_python/errors.py | 4 +- .../auth0_api_python/tests/test_api_client.py | 57 +++++++++++++++---- 5 files changed, 56 insertions(+), 23 deletions(-) diff --git a/packages/auth0_api_python/README.md b/packages/auth0_api_python/README.md index 6068784..006c57b 100644 --- a/packages/auth0_api_python/README.md +++ b/packages/auth0_api_python/README.md @@ -87,7 +87,7 @@ In this example, the returned dictionary contains the decoded claims (like `sub` ### 4. Get an access token for a connection -If you need to get an access token for an upstream idp via a connection, you can use the `get_token_for_connection` method: +If you need to get an access token for an upstream idp via a connection, you can use the `get_access_token_for_connection` method: ```python import asyncio @@ -106,7 +106,7 @@ async def main(): connection = "my-connection" # The Auth0 connection to the upstream idp access_token = "..." # The Auth0 access token to exchange - connection_access_token = await api_client.get_token_for_connection({"connection": connection, "access_token": access_token}) + connection_access_token = await api_client.get_access_token_for_connection({"connection": connection, "access_token": access_token}) # The returned token is the access token for the upstream idp print(connection_access_token) diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index 64c3cc6..aaea5fb 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -8,7 +8,7 @@ from .errors import ( ApiError, BaseAuthError, - GetTokenForConnectionError, + GetAccessTokenForConnectionError, InvalidAuthSchemeError, InvalidDpopProofError, MissingAuthorizationError, @@ -554,7 +554,7 @@ def _build_www_authenticate( ("WWW-Authenticate", f'DPoP algs="{algs}"'), ] - async def get_token_for_connection(self, options: dict[str, Any]) -> dict[str, Any]: + async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict[str, Any]: """ Retrieves a token for a connection. @@ -564,7 +564,7 @@ async def get_token_for_connection(self, options: dict[str, Any]) -> dict[str, A May optionally include 'login_hint'. Raises: - GetTokenForConnectionError: If there was an issue requesting the access token. + GetAccessTokenForConnectionError: If there was an issue requesting the access token. ApiError: If the token exchange endpoint returns an error. Returns: @@ -585,13 +585,13 @@ async def get_token_for_connection(self, options: dict[str, Any]) -> dict[str, A associated_client = self.options.associated_client if not associated_client: - raise GetTokenForConnectionError("You must configure the SDK with an associated_client to use get_token_for_connection.") + raise GetAccessTokenForConnectionError("You must configure the SDK with an associated_client to use get_access_token_for_connection.") metadata = await self._discover() token_endpoint = metadata.get("token_endpoint") if not token_endpoint: - raise GetTokenForConnectionError("Token endpoint missing in OIDC metadata") + raise GetAccessTokenForConnectionError("Token endpoint missing in OIDC metadata") # Prepare parameters params = { @@ -604,7 +604,7 @@ async def get_token_for_connection(self, options: dict[str, Any]) -> dict[str, A } # Add login_hint if provided - if "login_hint" in associated_client and associated_client["login_hint"]: + if "login_hint" in options and options["login_hint"]: params["login_hint"] = options["login_hint"] async with httpx.AsyncClient() as client: diff --git a/packages/auth0_api_python/src/auth0_api_python/config.py b/packages/auth0_api_python/src/auth0_api_python/config.py index 21edf6b..2ede8c5 100644 --- a/packages/auth0_api_python/src/auth0_api_python/config.py +++ b/packages/auth0_api_python/src/auth0_api_python/config.py @@ -19,7 +19,7 @@ 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). - associated_client: Optional required if you want to use get_token_for_connection. + associated_client: Optional required if you want to use get_access_token_for_connection. Must be a dict with 'client_id' and 'client_secret' keys. """ def __init__( diff --git a/packages/auth0_api_python/src/auth0_api_python/errors.py b/packages/auth0_api_python/src/auth0_api_python/errors.py index 88dff43..245752f 100644 --- a/packages/auth0_api_python/src/auth0_api_python/errors.py +++ b/packages/auth0_api_python/src/auth0_api_python/errors.py @@ -96,9 +96,9 @@ def get_error_code(self) -> str: return "invalid_request" -class GetTokenForConnectionError(Exception): +class GetAccessTokenForConnectionError(Exception): """Error raised when getting a token for a connection fails.""" - code = "get_token_for_connection_error" + code = "get_access_token_for_connection_error" def __init__(self, message: str): super().__init__(message) diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index e00d24e..b0e5bfc 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -1,13 +1,14 @@ import base64 import json import time +import urllib import pytest from auth0_api_python.api_client import ApiClient from auth0_api_python.config import ApiClientOptions from auth0_api_python.errors import ( ApiError, - GetTokenForConnectionError, + GetAccessTokenForConnectionError, InvalidAuthSchemeError, InvalidDpopProofError, MissingAuthorizationError, @@ -1592,7 +1593,7 @@ async def test_verify_request_fail_multiple_dpop_proofs(): @pytest.mark.asyncio -async def test_get_token_for_connection_success(httpx_mock: HTTPXMock): +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", @@ -1611,7 +1612,7 @@ async def test_get_token_for_connection_success(httpx_mock: HTTPXMock): associated_client={"client_id": "cid", "client_secret": "csecret"} ) api_client = ApiClient(options) - result = await api_client.get_token_for_connection({ + result = await api_client.get_access_token_for_connection({ "connection": "test-conn", "access_token": "user-token" }) @@ -1619,9 +1620,41 @@ async def test_get_token_for_connection_success(httpx_mock: HTTPXMock): assert result["scope"] == "openid" assert isinstance(result["expires_at"], int) +@pytest.mark.asyncio +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", + json={ + "token_endpoint": "https://auth0.local/oauth/token" + } + ) + httpx_mock.add_response( + method="POST", + url="https://auth0.local/oauth/token", + json={"access_token": "abc123", "expires_in": 3600, "scope": "openid"} + ) + options = ApiClientOptions( + domain="auth0.local", + audience="my-audience", + associated_client={"client_id": "cid", "client_secret": "csecret"} + ) + api_client = ApiClient(options) + result = await api_client.get_access_token_for_connection({ + "connection": "test-conn", + "access_token": "user-token", + "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()) + assert form_data["login_hint"] == ["user@example.com"] + + + @pytest.mark.asyncio -async def test_get_token_for_connection_missing_connection(): +async def test_get_access_token_for_connection_missing_connection(): options = ApiClientOptions( domain="auth0.local", audience="my-audience", @@ -1629,13 +1662,13 @@ async def test_get_token_for_connection_missing_connection(): ) api_client = ApiClient(options) with pytest.raises(MissingRequiredArgumentError): - await api_client.get_token_for_connection({ + await api_client.get_access_token_for_connection({ "access_token": "user-token" }) @pytest.mark.asyncio -async def test_get_token_for_connection_missing_access_token(): +async def test_get_access_token_for_connection_missing_access_token(): options = ApiClientOptions( domain="auth0.local", audience="my-audience", @@ -1643,28 +1676,28 @@ async def test_get_token_for_connection_missing_access_token(): ) api_client = ApiClient(options) with pytest.raises(MissingRequiredArgumentError): - await api_client.get_token_for_connection({ + await api_client.get_access_token_for_connection({ "connection": "test-conn" }) @pytest.mark.asyncio -async def test_get_token_for_connection_no_associated_client(): +async def test_get_access_token_for_connection_no_associated_client(): options = ApiClientOptions( domain="auth0.local", audience="my-audience" # associated_client missing ) api_client = ApiClient(options) - with pytest.raises(GetTokenForConnectionError): - await api_client.get_token_for_connection({ + with pytest.raises(GetAccessTokenForConnectionError): + await api_client.get_access_token_for_connection({ "connection": "test-conn", "access_token": "user-token" }) @pytest.mark.asyncio -async def test_get_token_for_connection_token_endpoint_error(httpx_mock: HTTPXMock): +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", @@ -1685,7 +1718,7 @@ async def test_get_token_for_connection_token_endpoint_error(httpx_mock: HTTPXMo ) api_client = ApiClient(options) with pytest.raises(ApiError) as err: - await api_client.get_token_for_connection({ + await api_client.get_access_token_for_connection({ "connection": "test-conn", "access_token": "user-token" }) From 89892b08776cabc9a603784a0f9e76f2abc9b8bb Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Fri, 29 Aug 2025 10:40:00 +0100 Subject: [PATCH 04/10] Move get_access_token_for_connection above private methods --- .../src/auth0_api_python/api_client.py | 154 +++++++++--------- 1 file changed, 77 insertions(+), 77 deletions(-) diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index aaea5fb..ae40806 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -393,6 +393,83 @@ async def verify_dpop_proof( return claims + async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict[str, Any]: + """ + Retrieves a token for a connection. + + Args: + options: Options for retrieving an access token for a connection. + Must include 'connection' and 'access_token' keys. + May optionally include 'login_hint'. + + Raises: + GetAccessTokenForConnectionError: If there was an issue requesting the access token. + ApiError: If the token exchange endpoint returns an error. + + Returns: + Dictionary containing the token response with access_token, expires_in, and scope. + """ + # Constants + SUBJECT_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token" # noqa S105 + REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "http://auth0.com/oauth/token-type/federated-connection-access-token" # noqa S105 + GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token" # noqa S105 + connection = options.get("connection") + access_token = options.get("access_token") + + if not connection: + raise MissingRequiredArgumentError("connection") + + if not access_token: + raise MissingRequiredArgumentError("access_token") + + associated_client = self.options.associated_client + if not associated_client: + raise GetAccessTokenForConnectionError("You must configure the SDK with an associated_client to use get_access_token_for_connection.") + + metadata = await self._discover() + + token_endpoint = metadata.get("token_endpoint") + if not token_endpoint: + raise GetAccessTokenForConnectionError("Token endpoint missing in OIDC metadata") + + # Prepare parameters + params = { + "connection": connection, + "requested_token_type": REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN, + "grant_type": GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN, + "client_id": associated_client["client_id"], + "subject_token": access_token, + "subject_token_type": SUBJECT_TYPE_ACCESS_TOKEN, + } + + # Add login_hint if provided + if "login_hint" in options and options["login_hint"]: + params["login_hint"] = options["login_hint"] + + async with httpx.AsyncClient() as client: + response = await client.post( + token_endpoint, + data=params, + auth=(associated_client["client_id"], associated_client["client_secret"]) + ) + + if response.status_code != 200: + error_data = response.json() if response.headers.get( + "content-type") == "application/json" else {} + raise ApiError( + error_data.get("error", "connection_token_error"), + error_data.get( + "error_description", f"Failed to get token for connection: {response.status_code}") + ) + + token_endpoint_response = response.json() + + return { + "access_token": token_endpoint_response.get("access_token"), + "expires_at": int(time.time()) + int(token_endpoint_response.get("expires_in", 3600)), + "scope": token_endpoint_response.get("scope", "") + } + # ===== Private Methods ===== async def _discover(self) -> dict[str, Any]: @@ -553,80 +630,3 @@ def _build_www_authenticate( ("WWW-Authenticate", 'Bearer realm="api"'), ("WWW-Authenticate", f'DPoP algs="{algs}"'), ] - - async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict[str, Any]: - """ - Retrieves a token for a connection. - - Args: - options: Options for retrieving an access token for a connection. - Must include 'connection' and 'access_token' keys. - May optionally include 'login_hint'. - - Raises: - GetAccessTokenForConnectionError: If there was an issue requesting the access token. - ApiError: If the token exchange endpoint returns an error. - - Returns: - Dictionary containing the token response with access_token, expires_in, and scope. - """ - # Constants - SUBJECT_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token" # noqa S105 - REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "http://auth0.com/oauth/token-type/federated-connection-access-token" # noqa S105 - GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token" # noqa S105 - connection = options.get("connection") - access_token = options.get("access_token") - - if not connection: - raise MissingRequiredArgumentError("connection") - - if not access_token: - raise MissingRequiredArgumentError("access_token") - - associated_client = self.options.associated_client - if not associated_client: - raise GetAccessTokenForConnectionError("You must configure the SDK with an associated_client to use get_access_token_for_connection.") - - metadata = await self._discover() - - token_endpoint = metadata.get("token_endpoint") - if not token_endpoint: - raise GetAccessTokenForConnectionError("Token endpoint missing in OIDC metadata") - - # Prepare parameters - params = { - "connection": connection, - "requested_token_type": REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN, - "grant_type": GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN, - "client_id": associated_client["client_id"], - "subject_token": access_token, - "subject_token_type": SUBJECT_TYPE_ACCESS_TOKEN, - } - - # Add login_hint if provided - if "login_hint" in options and options["login_hint"]: - params["login_hint"] = options["login_hint"] - - async with httpx.AsyncClient() as client: - response = await client.post( - token_endpoint, - data=params, - auth=(associated_client["client_id"], associated_client["client_secret"]) - ) - - if response.status_code != 200: - error_data = response.json() if response.headers.get( - "content-type") == "application/json" else {} - raise ApiError( - error_data.get("error", "connection_token_error"), - error_data.get( - "error_description", f"Failed to get token for connection: {response.status_code}") - ) - - token_endpoint_response = response.json() - - return { - "access_token": token_endpoint_response.get("access_token"), - "expires_at": int(time.time()) + int(token_endpoint_response.get("expires_in", 3600)), - "scope": token_endpoint_response.get("scope", "") - } From 1f300a53d8ea256388ca561248d479df26791507 Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Fri, 29 Aug 2025 10:54:03 +0100 Subject: [PATCH 05/10] Remove associated_client --- packages/auth0_api_python/README.md | 6 ++--- .../src/auth0_api_python/api_client.py | 11 +++++---- .../src/auth0_api_python/config.py | 17 +++++--------- .../auth0_api_python/tests/test_api_client.py | 23 ++++++++++++------- 4 files changed, 29 insertions(+), 28 deletions(-) diff --git a/packages/auth0_api_python/README.md b/packages/auth0_api_python/README.md index 006c57b..bd13f5f 100644 --- a/packages/auth0_api_python/README.md +++ b/packages/auth0_api_python/README.md @@ -98,10 +98,8 @@ async def main(): api_client = ApiClient(ApiClientOptions( domain="", audience="", - associated_client={ - "client_id": "", - "client_secret": "" - } + client_id="", + client_secret="", )) connection = "my-connection" # The Auth0 connection to the upstream idp access_token = "..." # The Auth0 access token to exchange diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index ae40806..acf2615 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -422,9 +422,10 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict if not access_token: raise MissingRequiredArgumentError("access_token") - associated_client = self.options.associated_client - if not associated_client: - raise GetAccessTokenForConnectionError("You must configure the SDK with an associated_client to use get_access_token_for_connection.") + client_id = self.options.client_id + client_secret = self.options.client_secret + if not client_id or not client_secret: + raise GetAccessTokenForConnectionError("You must configure the SDK with a client_id and client_secret to use get_access_token_for_connection.") metadata = await self._discover() @@ -437,7 +438,7 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict "connection": connection, "requested_token_type": REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN, "grant_type": GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN, - "client_id": associated_client["client_id"], + "client_id": client_id, "subject_token": access_token, "subject_token_type": SUBJECT_TYPE_ACCESS_TOKEN, } @@ -450,7 +451,7 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict response = await client.post( token_endpoint, data=params, - auth=(associated_client["client_id"], associated_client["client_secret"]) + auth=(client_id, client_secret) ) if response.status_code != 200: diff --git a/packages/auth0_api_python/src/auth0_api_python/config.py b/packages/auth0_api_python/src/auth0_api_python/config.py index 2ede8c5..30e7d1f 100644 --- a/packages/auth0_api_python/src/auth0_api_python/config.py +++ b/packages/auth0_api_python/src/auth0_api_python/config.py @@ -4,8 +4,6 @@ from typing import Callable, Optional -from auth0_api_python.errors import MissingRequiredArgumentError - class ApiClientOptions: """ @@ -19,8 +17,8 @@ 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). - associated_client: Optional required if you want to use get_access_token_for_connection. - Must be a dict with 'client_id' and 'client_secret' keys. + 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. """ def __init__( self, @@ -31,7 +29,8 @@ def __init__( dpop_required: bool = False, dpop_iat_leeway: int = 30, dpop_iat_offset: int = 300, - associated_client: Optional[dict] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, ): self.domain = domain self.audience = audience @@ -40,9 +39,5 @@ def __init__( self.dpop_required = dpop_required self.dpop_iat_leeway = dpop_iat_leeway self.dpop_iat_offset = dpop_iat_offset - self.associated_client = associated_client - if associated_client: - if not associated_client.get("client_id"): - raise MissingRequiredArgumentError("associated_client.client_id") - if not associated_client.get("client_secret"): - raise MissingRequiredArgumentError("associated_client.client_secret") + self.client_id = client_id + self.client_secret = client_secret diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index b0e5bfc..c63a304 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -1609,7 +1609,8 @@ async def test_get_access_token_for_connection_success(httpx_mock: HTTPXMock): options = ApiClientOptions( domain="auth0.local", audience="my-audience", - associated_client={"client_id": "cid", "client_secret": "csecret"} + client_id="cid", + client_secret="csecret", ) api_client = ApiClient(options) result = await api_client.get_access_token_for_connection({ @@ -1637,7 +1638,8 @@ async def test_get_access_token_for_connection_with_login_hint(httpx_mock: HTTPX options = ApiClientOptions( domain="auth0.local", audience="my-audience", - associated_client={"client_id": "cid", "client_secret": "csecret"} + client_id="cid", + client_secret="csecret", ) api_client = ApiClient(options) result = await api_client.get_access_token_for_connection({ @@ -1658,7 +1660,8 @@ async def test_get_access_token_for_connection_missing_connection(): options = ApiClientOptions( domain="auth0.local", audience="my-audience", - associated_client={"client_id": "cid", "client_secret": "csecret"} + client_id="cid", + client_secret="csecret", ) api_client = ApiClient(options) with pytest.raises(MissingRequiredArgumentError): @@ -1672,7 +1675,8 @@ async def test_get_access_token_for_connection_missing_access_token(): options = ApiClientOptions( domain="auth0.local", audience="my-audience", - associated_client={"client_id": "cid", "client_secret": "csecret"} + client_id="cid", + client_secret="csecret", ) api_client = ApiClient(options) with pytest.raises(MissingRequiredArgumentError): @@ -1682,19 +1686,21 @@ async def test_get_access_token_for_connection_missing_access_token(): @pytest.mark.asyncio -async def test_get_access_token_for_connection_no_associated_client(): +async def test_get_access_token_for_connection_no_client_id(): options = ApiClientOptions( domain="auth0.local", audience="my-audience" - # associated_client missing + # client_id missing ) api_client = ApiClient(options) - with pytest.raises(GetAccessTokenForConnectionError): + with pytest.raises(GetAccessTokenForConnectionError) as err: await api_client.get_access_token_for_connection({ "connection": "test-conn", "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) + @pytest.mark.asyncio async def test_get_access_token_for_connection_token_endpoint_error(httpx_mock: HTTPXMock): @@ -1714,7 +1720,8 @@ async def test_get_access_token_for_connection_token_endpoint_error(httpx_mock: options = ApiClientOptions( domain="auth0.local", audience="my-audience", - associated_client={"client_id": "cid", "client_secret": "csecret"} + client_id="cid", + client_secret="csecret", ) api_client = ApiClient(options) with pytest.raises(ApiError) as err: From 807024962ee7bb056d709efa08db42eefbc54b04 Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Tue, 9 Sep 2025 16:30:52 +0100 Subject: [PATCH 06/10] Updates per PR review --- packages/auth0_api_python/README.md | 2 +- .../src/auth0_api_python/api_client.py | 3 ++- .../src/auth0_api_python/errors.py | 23 ++++++++++++------- .../auth0_api_python/tests/test_api_client.py | 1 + 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/auth0_api_python/README.md b/packages/auth0_api_python/README.md index bd13f5f..5181447 100644 --- a/packages/auth0_api_python/README.md +++ b/packages/auth0_api_python/README.md @@ -126,7 +126,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`. -### 4. DPoP Authentication +### 5. 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/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index acf2615..e076b5f 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -460,7 +460,8 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict raise ApiError( error_data.get("error", "connection_token_error"), error_data.get( - "error_description", f"Failed to get token for connection: {response.status_code}") + "error_description", f"Failed to get token for connection: {response.status_code}"), + response.status_code ) token_endpoint_response = response.json() diff --git a/packages/auth0_api_python/src/auth0_api_python/errors.py b/packages/auth0_api_python/src/auth0_api_python/errors.py index 245752f..4c8735b 100644 --- a/packages/auth0_api_python/src/auth0_api_python/errors.py +++ b/packages/auth0_api_python/src/auth0_api_python/errors.py @@ -96,30 +96,37 @@ def get_error_code(self) -> str: return "invalid_request" -class GetAccessTokenForConnectionError(Exception): +class GetAccessTokenForConnectionError(BaseAuthError): """Error raised when getting a token for a connection fails.""" - code = "get_access_token_for_connection_error" - def __init__(self, message: str): - super().__init__(message) - self.name = self.__class__.__name__ + def get_status_code(self) -> int: + return 400 + def get_error_code(self) -> str: + return "get_access_token_for_connection_error" -class ApiError(Exception): + +class ApiError(BaseAuthError): """ Error raised when an API request to Auth0 fails. Contains details about the original error from Auth0. """ - def __init__(self, code: str, message: str, cause=None): + def __init__(self, code: str, message: str, status_code=500, cause=None): super().__init__(message) self.code = code + self.status_code = status_code self.cause = cause - # Extract additional error details if available if cause: self.error = getattr(cause, "error", None) self.error_description = getattr(cause, "error_description", None) else: self.error = None self.error_description = None + + def get_status_code(self) -> int: + return self.status_code + + def get_error_code(self) -> str: + return self.code \ No newline at end of file diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index c63a304..2363ab5 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -1730,3 +1730,4 @@ async def test_get_access_token_for_connection_token_endpoint_error(httpx_mock: "access_token": "user-token" }) assert err.value.code == "invalid_request" + assert err.value.status_code == 400 From b5ea3cd4a50be2c31b38c0f284523bbe67a4f0cf Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Tue, 9 Sep 2025 16:45:57 +0100 Subject: [PATCH 07/10] fix lint --- packages/auth0_api_python/src/auth0_api_python/errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/auth0_api_python/src/auth0_api_python/errors.py b/packages/auth0_api_python/src/auth0_api_python/errors.py index 4c8735b..9924cdd 100644 --- a/packages/auth0_api_python/src/auth0_api_python/errors.py +++ b/packages/auth0_api_python/src/auth0_api_python/errors.py @@ -129,4 +129,4 @@ def get_status_code(self) -> int: return self.status_code def get_error_code(self) -> str: - return self.code \ No newline at end of file + return self.code From 67e3437f07854e059cba9eefb6be4422cfc3bb83 Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Wed, 10 Sep 2025 10:29:53 +0100 Subject: [PATCH 08/10] add network error handling --- .../src/auth0_api_python/api_client.py | 58 +++++++++++------- .../auth0_api_python/tests/test_api_client.py | 59 +++++++++++++++++++ 2 files changed, 96 insertions(+), 21 deletions(-) diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index e076b5f..2d893e5 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -447,30 +447,46 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict if "login_hint" in options and options["login_hint"]: params["login_hint"] = options["login_hint"] - async with httpx.AsyncClient() as client: - response = await client.post( - token_endpoint, - data=params, - auth=(client_id, client_secret) - ) - - if response.status_code != 200: - error_data = response.json() if response.headers.get( - "content-type") == "application/json" else {} - raise ApiError( - error_data.get("error", "connection_token_error"), - error_data.get( - "error_description", f"Failed to get token for connection: {response.status_code}"), - response.status_code + try: + async with httpx.AsyncClient() as client: + response = await client.post( + token_endpoint, + data=params, + auth=(client_id, client_secret) ) - token_endpoint_response = response.json() + if response.status_code != 200: + error_data = response.json() if response.headers.get( + "content-type") == "application/json" else {} + raise ApiError( + error_data.get("error", "connection_token_error"), + error_data.get( + "error_description", f"Failed to get token for connection: {response.status_code}"), + response.status_code + ) + + token_endpoint_response = response.json() + + return { + "access_token": token_endpoint_response.get("access_token"), + "expires_at": int(time.time()) + int(token_endpoint_response.get("expires_in", 3600)), + "scope": token_endpoint_response.get("scope", "") + } - return { - "access_token": token_endpoint_response.get("access_token"), - "expires_at": int(time.time()) + int(token_endpoint_response.get("expires_in", 3600)), - "scope": token_endpoint_response.get("scope", "") - } + 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 ===== diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index 2363ab5..426922a 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -3,6 +3,7 @@ import time import urllib +import httpx import pytest from auth0_api_python.api_client import ApiClient from auth0_api_python.config import ApiClientOptions @@ -1731,3 +1732,61 @@ async def test_get_access_token_for_connection_token_endpoint_error(httpx_mock: }) assert err.value.code == "invalid_request" assert err.value.status_code == 400 + +@pytest.mark.asyncio +async def test_get_access_token_for_connection_timeout_error(httpx_mock: HTTPXMock): + # 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"} + ) + # Simulate timeout on POST + httpx_mock.add_exception( + method="POST", + url="https://auth0.local/oauth/token", + exception=httpx.TimeoutException("Request timed out") + ) + 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 == "timeout_error" + assert "timed out" in str(err.value) + +@pytest.mark.asyncio +async def test_get_access_token_for_connection_network_error(httpx_mock: HTTPXMock): + # 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"} + ) + # 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")) + ) + 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 == "network_error" + assert "network error" in str(err.value).lower() From 6e3b2392f760182aeb31128e0c33d62763439e37 Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Wed, 10 Sep 2025 10:33:59 +0100 Subject: [PATCH 09/10] add error handling for unexpected json content type in errors --- .../src/auth0_api_python/api_client.py | 4 +-- .../auth0_api_python/tests/test_api_client.py | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index 2d893e5..c28c2fe 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -456,8 +456,8 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict ) if response.status_code != 200: - error_data = response.json() if response.headers.get( - "content-type") == "application/json" else {} + error_data = response.json() if "json" in response.headers.get( + "content-type", "").lower() else {} raise ApiError( error_data.get("error", "connection_token_error"), error_data.get( diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index 426922a..9ac9835 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -1790,3 +1790,33 @@ async def test_get_access_token_for_connection_network_error(httpx_mock: HTTPXMo }) assert err.value.code == "network_error" assert "network error" in str(err.value).lower() + +@pytest.mark.asyncio +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"} + ) + httpx_mock.add_response( + method="POST", + url="https://auth0.local/oauth/token", + status_code=400, + content=json.dumps({"error": "invalid_request", "error_description": "Bad request"}), + headers={"Content-Type": "text/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_request" + assert err.value.status_code == 400 + assert "bad request" in str(err.value).lower() \ No newline at end of file From 0e1c4c2b7c7d119e8bd018dafbbef410e36dba6f Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Wed, 10 Sep 2025 10:44:38 +0100 Subject: [PATCH 10/10] Add handling for unexpected successful token response --- .../src/auth0_api_python/api_client.py | 19 +++- .../auth0_api_python/tests/test_api_client.py | 89 ++++++++++++++++++- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index c28c2fe..422a3dd 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -465,11 +465,24 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict response.status_code ) - token_endpoint_response = response.json() + try: + token_endpoint_response = response.json() + except Exception: + raise ApiError("invalid_json", "Token endpoint returned invalid JSON.") + + access_token = token_endpoint_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) + + expires_in_raw = token_endpoint_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) return { - "access_token": token_endpoint_response.get("access_token"), - "expires_at": int(time.time()) + int(token_endpoint_response.get("expires_in", 3600)), + "access_token": access_token, + "expires_at": int(time.time()) + expires_in, "scope": token_endpoint_response.get("scope", "") } diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index 9ac9835..7bfbb35 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -1819,4 +1819,91 @@ async def test_get_access_token_for_connection_error_text_json_content_type(http }) assert err.value.code == "invalid_request" assert err.value.status_code == 400 - assert "bad request" in str(err.value).lower() \ No newline at end of file + 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"} + ) + 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"} + ) + 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="https://auth0.local/.well-known/openid-configuration", + json={"token_endpoint": "https://auth0.local/oauth/token"} + ) + 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 + ) + 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="https://auth0.local/.well-known/openid-configuration", + json={"token_endpoint": "https://auth0.local/oauth/token"} + ) + httpx_mock.add_response( + method="POST", + url="https://auth0.local/oauth/token", + 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