auth0-api-python 1.0.0b4__tar.gz → 1.0.0b6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: auth0-api-python
3
- Version: 1.0.0b4
3
+ Version: 1.0.0b6
4
4
  Summary: SDK for verifying access tokens and securing APIs with Auth0, using Authlib.
5
5
  License: MIT
6
+ License-File: LICENSE
6
7
  Author: Auth0
7
8
  Author-email: support@auth0.com
8
9
  Requires-Python: >=3.9,<4.0
@@ -13,7 +14,8 @@ Classifier: Programming Language :: Python :: 3.10
13
14
  Classifier: Programming Language :: Python :: 3.11
14
15
  Classifier: Programming Language :: Python :: 3.12
15
16
  Classifier: Programming Language :: Python :: 3.13
16
- Requires-Dist: ada-url (>=1.25.0,<2.0.0)
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Requires-Dist: ada-url (>=1.27.0,<2.0.0)
17
19
  Requires-Dist: authlib (>=1.0,<2.0)
18
20
  Requires-Dist: httpx (>=0.28.1,<0.29.0)
19
21
  Requires-Dist: requests (>=2.31.0,<3.0.0)
@@ -49,6 +51,15 @@ This SDK provides comprehensive support for securing APIs with Auth0-issued acce
49
51
 
50
52
  - [Docs Site](https://auth0.com/docs) - explore our docs site and learn more about Auth0.
51
53
 
54
+ ## Related SDKs
55
+
56
+ This library is part of Auth0's Python ecosystem for server-side authentication and API security. Related SDKs:
57
+
58
+ - **[auth0-auth-js](https://github.com/auth0/auth0-auth-js)** - JavaScript/TypeScript monorepo containing:
59
+ - `@auth0/auth0-auth-js` - Core authentication client (low-level primitives)
60
+ - `@auth0/auth0-api-js` - Server-side API security (Node.js equivalent of this library)
61
+ - `@auth0/auth0-server-js` - Server-side web app authentication (session management)
62
+
52
63
  ## Getting Started
53
64
 
54
65
  ### 1. Install the SDK
@@ -106,6 +117,123 @@ asyncio.run(main())
106
117
 
107
118
  In this example, the returned dictionary contains the decoded claims (like `sub`, `scope`, etc.) from the verified token.
108
119
 
120
+ ### 4. Get an access token for a connection
121
+
122
+ 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:
123
+
124
+ ```python
125
+ import asyncio
126
+
127
+ from auth0_api_python import ApiClient, ApiClientOptions
128
+
129
+ async def main():
130
+ api_client = ApiClient(ApiClientOptions(
131
+ domain="<AUTH0_DOMAIN>",
132
+ audience="<AUTH0_AUDIENCE>",
133
+ client_id="<AUTH0_CLIENT_ID>",
134
+ client_secret="<AUTH0_CLIENT_SECRET>",
135
+ ))
136
+ connection = "my-connection" # The Auth0 connection to the upstream idp
137
+ access_token = "..." # The Auth0 access token to exchange
138
+
139
+ connection_access_token = await api_client.get_access_token_for_connection({"connection": connection, "access_token": access_token})
140
+ # The returned token is the access token for the upstream idp
141
+ print(connection_access_token)
142
+
143
+ asyncio.run(main())
144
+ ```
145
+
146
+ More info https://auth0.com/docs/secure/tokens/token-vault
147
+
148
+ ### 5. Custom Token Exchange (Early Access)
149
+
150
+ > [!NOTE]
151
+ > 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.
152
+
153
+ 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).
154
+
155
+ Custom Token Exchange allows you to exchange a subject token for Auth0 tokens using RFC 8693. This is useful for:
156
+ - Getting Auth0 tokens for another audience
157
+ - Integrating external identity providers
158
+ - Migrating to Auth0
159
+
160
+ ```python
161
+ import asyncio
162
+
163
+ from auth0_api_python import ApiClient, ApiClientOptions
164
+
165
+ async def main():
166
+ api_client = ApiClient(ApiClientOptions(
167
+ domain="<AUTH0_DOMAIN>",
168
+ audience="<AUTH0_AUDIENCE>",
169
+ client_id="<AUTH0_CLIENT_ID>",
170
+ client_secret="<AUTH0_CLIENT_SECRET>",
171
+ timeout=10.0 # Optional: HTTP timeout in seconds (default: 10.0)
172
+ ))
173
+
174
+ subject_token = "..." # Token from your legacy system or external source
175
+
176
+ result = await api_client.get_token_by_exchange_profile(
177
+ subject_token=subject_token,
178
+ subject_token_type="urn:example:subject-token",
179
+ audience="https://api.example.com", # Optional - omit if your Action or tenant configuration sets the audience
180
+ scope="openid profile email", # Optional
181
+ requested_token_type="urn:ietf:params:oauth:token-type:access_token" # Optional
182
+ )
183
+
184
+ # Result contains access_token, expires_in, expires_at
185
+ # id_token, refresh_token, and scope are profile/Action dependent (not guaranteed; scope may be empty)
186
+
187
+ asyncio.run(main())
188
+ ```
189
+
190
+ **Important:**
191
+ - Client authentication is sent via HTTP Basic (`client_id`/`client_secret`), not in the form body.
192
+ - Do not prefix `subject_token` with "Bearer " - send the raw token value only (checked case-insensitively).
193
+ - 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.
194
+ - If neither an explicit `audience` nor tenant/Action logic sets it, you may receive a token not targeted at your API.
195
+
196
+ #### Additional Parameters
197
+
198
+ 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:
199
+
200
+ ```python
201
+ result = await api_client.get_token_by_exchange_profile(
202
+ subject_token=subject_token,
203
+ subject_token_type="urn:example:subject-token",
204
+ audience="https://api.example.com",
205
+ extra={
206
+ "device_id": "device-12345",
207
+ "session_id": "sess-abc"
208
+ }
209
+ )
210
+ ```
211
+
212
+ > [!WARNING]
213
+ > 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.
214
+
215
+ #### Error Handling
216
+
217
+ ```python
218
+ from auth0_api_python import GetTokenByExchangeProfileError, ApiError
219
+
220
+ try:
221
+ result = await api_client.get_token_by_exchange_profile(
222
+ subject_token=subject_token,
223
+ subject_token_type="urn:example:subject-token"
224
+ )
225
+ except GetTokenByExchangeProfileError as e:
226
+ # Validation errors (invalid token format, missing credentials, reserved params, etc.)
227
+ print(f"Validation error: {e}")
228
+ except ApiError as e:
229
+ # Token endpoint errors (invalid_grant, network issues, malformed responses, etc.)
230
+ print(f"API error: {e.code} - {e.message} (status: {e.status_code})")
231
+ ```
232
+
233
+ **Related SDKs:** [auth0-auth-js](https://github.com/auth0/auth0-auth-js) (see `@auth0/auth0-api-js` package for Node.js equivalent)
234
+
235
+ More info: https://auth0.com/docs/authenticate/custom-token-exchange
236
+
109
237
  #### Requiring Additional Claims
110
238
 
111
239
  If your application demands extra claims, specify them with `required_claims`:
@@ -119,7 +247,7 @@ decoded_and_verified_token = await api_client.verify_access_token(
119
247
 
120
248
  If the token lacks `my_custom_claim` or fails any standard check (issuer mismatch, expired token, invalid signature), the method raises a `VerifyAccessTokenError`.
121
249
 
122
- ### 4. DPoP Authentication
250
+ ### 6. DPoP Authentication
123
251
 
124
252
  > [!NOTE]
125
253
  > 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.
@@ -28,6 +28,15 @@ This SDK provides comprehensive support for securing APIs with Auth0-issued acce
28
28
 
29
29
  - [Docs Site](https://auth0.com/docs) - explore our docs site and learn more about Auth0.
30
30
 
31
+ ## Related SDKs
32
+
33
+ This library is part of Auth0's Python ecosystem for server-side authentication and API security. Related SDKs:
34
+
35
+ - **[auth0-auth-js](https://github.com/auth0/auth0-auth-js)** - JavaScript/TypeScript monorepo containing:
36
+ - `@auth0/auth0-auth-js` - Core authentication client (low-level primitives)
37
+ - `@auth0/auth0-api-js` - Server-side API security (Node.js equivalent of this library)
38
+ - `@auth0/auth0-server-js` - Server-side web app authentication (session management)
39
+
31
40
  ## Getting Started
32
41
 
33
42
  ### 1. Install the SDK
@@ -85,6 +94,123 @@ asyncio.run(main())
85
94
 
86
95
  In this example, the returned dictionary contains the decoded claims (like `sub`, `scope`, etc.) from the verified token.
87
96
 
97
+ ### 4. Get an access token for a connection
98
+
99
+ 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:
100
+
101
+ ```python
102
+ import asyncio
103
+
104
+ from auth0_api_python import ApiClient, ApiClientOptions
105
+
106
+ async def main():
107
+ api_client = ApiClient(ApiClientOptions(
108
+ domain="<AUTH0_DOMAIN>",
109
+ audience="<AUTH0_AUDIENCE>",
110
+ client_id="<AUTH0_CLIENT_ID>",
111
+ client_secret="<AUTH0_CLIENT_SECRET>",
112
+ ))
113
+ connection = "my-connection" # The Auth0 connection to the upstream idp
114
+ access_token = "..." # The Auth0 access token to exchange
115
+
116
+ connection_access_token = await api_client.get_access_token_for_connection({"connection": connection, "access_token": access_token})
117
+ # The returned token is the access token for the upstream idp
118
+ print(connection_access_token)
119
+
120
+ asyncio.run(main())
121
+ ```
122
+
123
+ More info https://auth0.com/docs/secure/tokens/token-vault
124
+
125
+ ### 5. Custom Token Exchange (Early Access)
126
+
127
+ > [!NOTE]
128
+ > 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.
129
+
130
+ 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).
131
+
132
+ Custom Token Exchange allows you to exchange a subject token for Auth0 tokens using RFC 8693. This is useful for:
133
+ - Getting Auth0 tokens for another audience
134
+ - Integrating external identity providers
135
+ - Migrating to Auth0
136
+
137
+ ```python
138
+ import asyncio
139
+
140
+ from auth0_api_python import ApiClient, ApiClientOptions
141
+
142
+ async def main():
143
+ api_client = ApiClient(ApiClientOptions(
144
+ domain="<AUTH0_DOMAIN>",
145
+ audience="<AUTH0_AUDIENCE>",
146
+ client_id="<AUTH0_CLIENT_ID>",
147
+ client_secret="<AUTH0_CLIENT_SECRET>",
148
+ timeout=10.0 # Optional: HTTP timeout in seconds (default: 10.0)
149
+ ))
150
+
151
+ subject_token = "..." # Token from your legacy system or external source
152
+
153
+ result = await api_client.get_token_by_exchange_profile(
154
+ subject_token=subject_token,
155
+ subject_token_type="urn:example:subject-token",
156
+ audience="https://api.example.com", # Optional - omit if your Action or tenant configuration sets the audience
157
+ scope="openid profile email", # Optional
158
+ requested_token_type="urn:ietf:params:oauth:token-type:access_token" # Optional
159
+ )
160
+
161
+ # Result contains access_token, expires_in, expires_at
162
+ # id_token, refresh_token, and scope are profile/Action dependent (not guaranteed; scope may be empty)
163
+
164
+ asyncio.run(main())
165
+ ```
166
+
167
+ **Important:**
168
+ - Client authentication is sent via HTTP Basic (`client_id`/`client_secret`), not in the form body.
169
+ - Do not prefix `subject_token` with "Bearer " - send the raw token value only (checked case-insensitively).
170
+ - 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.
171
+ - If neither an explicit `audience` nor tenant/Action logic sets it, you may receive a token not targeted at your API.
172
+
173
+ #### Additional Parameters
174
+
175
+ 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:
176
+
177
+ ```python
178
+ result = await api_client.get_token_by_exchange_profile(
179
+ subject_token=subject_token,
180
+ subject_token_type="urn:example:subject-token",
181
+ audience="https://api.example.com",
182
+ extra={
183
+ "device_id": "device-12345",
184
+ "session_id": "sess-abc"
185
+ }
186
+ )
187
+ ```
188
+
189
+ > [!WARNING]
190
+ > 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.
191
+
192
+ #### Error Handling
193
+
194
+ ```python
195
+ from auth0_api_python import GetTokenByExchangeProfileError, ApiError
196
+
197
+ try:
198
+ result = await api_client.get_token_by_exchange_profile(
199
+ subject_token=subject_token,
200
+ subject_token_type="urn:example:subject-token"
201
+ )
202
+ except GetTokenByExchangeProfileError as e:
203
+ # Validation errors (invalid token format, missing credentials, reserved params, etc.)
204
+ print(f"Validation error: {e}")
205
+ except ApiError as e:
206
+ # Token endpoint errors (invalid_grant, network issues, malformed responses, etc.)
207
+ print(f"API error: {e.code} - {e.message} (status: {e.status_code})")
208
+ ```
209
+
210
+ **Related SDKs:** [auth0-auth-js](https://github.com/auth0/auth0-auth-js) (see `@auth0/auth0-api-js` package for Node.js equivalent)
211
+
212
+ More info: https://auth0.com/docs/authenticate/custom-token-exchange
213
+
88
214
  #### Requiring Additional Claims
89
215
 
90
216
  If your application demands extra claims, specify them with `required_claims`:
@@ -98,7 +224,7 @@ decoded_and_verified_token = await api_client.verify_access_token(
98
224
 
99
225
  If the token lacks `my_custom_claim` or fails any standard check (issuer mismatch, expired token, invalid signature), the method raises a `VerifyAccessTokenError`.
100
226
 
101
- ### 4. DPoP Authentication
227
+ ### 6. DPoP Authentication
102
228
 
103
229
  > [!NOTE]
104
230
  > 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.
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "auth0-api-python"
3
- version = "1.0.0.b4"
3
+ version = "1.0.0.b6"
4
4
  description = "SDK for verifying access tokens and securing APIs with Auth0, using Authlib."
5
5
  authors = ["Auth0 <support@auth0.com>"]
6
6
  license = "MIT"
@@ -15,15 +15,16 @@ python = "^3.9"
15
15
  authlib = "^1.0" # For JWT/OIDC features
16
16
  requests = "^2.31.0" # If you use requests for HTTP calls (e.g., discovery)
17
17
  httpx = "^0.28.1"
18
- ada-url = "^1.25.0"
18
+ ada-url = "^1.27.0"
19
19
 
20
20
  [tool.poetry.group.dev.dependencies]
21
21
  pytest = "^8.0"
22
22
  pytest-cov = "^4.0"
23
- pytest-asyncio = "^0.20.3"
24
- pytest-mock = "^3.14.0"
23
+ pytest-asyncio = "^0.25.3"
24
+ pytest-mock = "^3.15.1"
25
25
  pytest-httpx = "^0.35.0"
26
- ruff = "^0.1.0"
26
+ ruff = ">=0.1,<0.15"
27
+ freezegun = "^1.5.5"
27
28
 
28
29
  [tool.pytest.ini_options]
29
30
  addopts = "--cov=src --cov-report=term-missing:skip-covered --cov-report=xml"
@@ -7,8 +7,11 @@ in server-side APIs, using Authlib for OIDC discovery and JWKS fetching.
7
7
 
8
8
  from .api_client import ApiClient
9
9
  from .config import ApiClientOptions
10
+ from .errors import ApiError, GetTokenByExchangeProfileError
10
11
 
11
12
  __all__ = [
12
13
  "ApiClient",
13
- "ApiClientOptions"
14
+ "ApiClientOptions",
15
+ "ApiError",
16
+ "GetTokenByExchangeProfileError"
14
17
  ]
@@ -1,11 +1,16 @@
1
1
  import time
2
- from typing import Any, Optional
2
+ from collections.abc import Mapping, Sequence
3
+ from typing import Any, Optional, Union
3
4
 
5
+ import httpx
4
6
  from authlib.jose import JsonWebKey, JsonWebToken
5
7
 
6
8
  from .config import ApiClientOptions
7
9
  from .errors import (
10
+ ApiError,
8
11
  BaseAuthError,
12
+ GetAccessTokenForConnectionError,
13
+ GetTokenByExchangeProfileError,
9
14
  InvalidAuthSchemeError,
10
15
  InvalidDpopProofError,
11
16
  MissingAuthorizationError,
@@ -21,6 +26,20 @@ from .utils import (
21
26
  sha256_base64url,
22
27
  )
23
28
 
29
+ # Token Exchange constants
30
+ TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" # noqa: S105
31
+ MAX_ARRAY_VALUES_PER_KEY = 20 # DoS protection for extra parameter arrays
32
+
33
+ # OAuth parameter denylist - parameters that cannot be overridden via extras
34
+ RESERVED_PARAMS = frozenset([
35
+ "grant_type", "client_id", "client_secret", "client_assertion",
36
+ "client_assertion_type", "subject_token", "subject_token_type",
37
+ "requested_token_type", "actor_token", "actor_token_type",
38
+ "subject_issuer", "audience", "aud", "resource", "resources",
39
+ "resource_indicator", "scope", "connection", "login_hint",
40
+ "organization", "assertion",
41
+ ])
42
+
24
43
 
25
44
  class ApiClient:
26
45
  """
@@ -59,6 +78,12 @@ class ApiClient:
59
78
  • If scheme is 'DPoP', verifies both access token and DPoP proof
60
79
  • If scheme is 'Bearer', verifies only the access token
61
80
 
81
+ Note:
82
+ Authorization header parsing uses split(None, 1) to correctly handle
83
+ tabs and multiple spaces per HTTP specs. Malformed headers with multiple
84
+ spaces now raise VerifyAccessTokenError during JWT parsing (previously
85
+ raised InvalidAuthSchemeError).
86
+
62
87
  Args:
63
88
  headers: HTTP headers dict containing (header keys should be lowercase):
64
89
  - "authorization": The Authorization header value (required)
@@ -75,6 +100,9 @@ class ApiClient:
75
100
  InvalidDpopProofError: If DPoP verification fails
76
101
  VerifyAccessTokenError: If access token verification fails
77
102
  """
103
+ # Normalize header keys to lowercase for robust access
104
+ headers = {k.lower(): v for k, v in headers.items()}
105
+
78
106
  authorization_header = headers.get("authorization", "")
79
107
  dpop_proof = headers.get("dpop")
80
108
 
@@ -83,22 +111,16 @@ class ApiClient:
83
111
  raise self._prepare_error(
84
112
  InvalidAuthSchemeError("")
85
113
  )
86
- else :
114
+ else:
87
115
  raise self._prepare_error(MissingAuthorizationError())
88
116
 
89
-
90
- parts = authorization_header.split(" ")
117
+ # Split authorization header on first whitespace
118
+ parts = authorization_header.split(None, 1)
91
119
  if len(parts) != 2:
92
- if len(parts) < 2:
93
- raise self._prepare_error(MissingAuthorizationError())
94
- elif len(parts) > 2:
95
- raise self._prepare_error(
96
- InvalidAuthSchemeError("")
97
- )
120
+ raise self._prepare_error(MissingAuthorizationError())
98
121
 
99
122
  scheme, token = parts
100
-
101
- scheme = scheme.strip().lower()
123
+ scheme = scheme.lower()
102
124
 
103
125
  if self.is_dpop_required() and scheme != "dpop":
104
126
  raise self._prepare_error(
@@ -390,8 +412,361 @@ class ApiClient:
390
412
 
391
413
  return claims
392
414
 
415
+ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict[str, Any]:
416
+ """
417
+ Retrieves a token for a connection.
418
+
419
+ Args:
420
+ options: Options for retrieving an access token for a connection.
421
+ Must include 'connection' and 'access_token' keys.
422
+ May optionally include 'login_hint'.
423
+
424
+ Raises:
425
+ GetAccessTokenForConnectionError: If there was an issue requesting the access token.
426
+ ApiError: If the token exchange endpoint returns an error.
427
+
428
+ Returns:
429
+ Dictionary containing the token response with access_token, expires_in, and scope.
430
+ """
431
+ # Constants
432
+ SUBJECT_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token" # noqa S105
433
+ REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "http://auth0.com/oauth/token-type/federated-connection-access-token" # noqa S105
434
+ GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token" # noqa S105
435
+ connection = options.get("connection")
436
+ access_token = options.get("access_token")
437
+
438
+ if not connection:
439
+ raise MissingRequiredArgumentError("connection")
440
+
441
+ if not access_token:
442
+ raise MissingRequiredArgumentError("access_token")
443
+
444
+ client_id = self.options.client_id
445
+ client_secret = self.options.client_secret
446
+ if not client_id or not client_secret:
447
+ raise GetAccessTokenForConnectionError("You must configure the SDK with a client_id and client_secret to use get_access_token_for_connection.")
448
+
449
+ metadata = await self._discover()
450
+
451
+ token_endpoint = metadata.get("token_endpoint")
452
+ if not token_endpoint:
453
+ raise GetAccessTokenForConnectionError(
454
+ "Token endpoint missing in OIDC metadata. "
455
+ "Verify your domain configuration and that the OIDC discovery endpoint "
456
+ f"(https://{self.options.domain}/.well-known/openid-configuration) is accessible"
457
+ )
458
+
459
+ # Prepare parameters
460
+ params = {
461
+ "connection": connection,
462
+ "requested_token_type": REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN,
463
+ "grant_type": GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN,
464
+ "client_id": client_id,
465
+ "subject_token": access_token,
466
+ "subject_token_type": SUBJECT_TYPE_ACCESS_TOKEN,
467
+ }
468
+
469
+ # Add login_hint if provided
470
+ if "login_hint" in options and options["login_hint"]:
471
+ params["login_hint"] = options["login_hint"]
472
+
473
+ try:
474
+ async with httpx.AsyncClient(timeout=httpx.Timeout(self.options.timeout)) as client:
475
+ response = await client.post(
476
+ token_endpoint,
477
+ data=params,
478
+ auth=(client_id, client_secret)
479
+ )
480
+
481
+ if response.status_code != 200:
482
+ # Lenient check for JSON error responses (handles application/json, text/json, etc.)
483
+ content_type = response.headers.get("content-type", "").lower()
484
+ error_data = response.json() if "json" in content_type else {}
485
+ raise ApiError(
486
+ error_data.get("error", "connection_token_error"),
487
+ error_data.get(
488
+ "error_description", f"Failed to get token for connection: {response.status_code}"),
489
+ response.status_code
490
+ )
491
+
492
+ try:
493
+ token_endpoint_response = response.json()
494
+ except ValueError:
495
+ raise ApiError("invalid_json", "Token endpoint returned invalid JSON.")
496
+
497
+ access_token = token_endpoint_response.get("access_token")
498
+ if not isinstance(access_token, str) or not access_token:
499
+ raise ApiError("invalid_response", "Missing or invalid access_token in response.", 502)
500
+
501
+ expires_in_raw = token_endpoint_response.get("expires_in", 3600)
502
+ try:
503
+ expires_in = int(expires_in_raw)
504
+ except (TypeError, ValueError):
505
+ raise ApiError("invalid_response", "expires_in is not an integer.", 502)
506
+
507
+ return {
508
+ "access_token": access_token,
509
+ "expires_at": int(time.time()) + expires_in,
510
+ "scope": token_endpoint_response.get("scope", "")
511
+ }
512
+
513
+ except httpx.TimeoutException as exc:
514
+ raise ApiError(
515
+ "timeout_error",
516
+ f"Request to token endpoint timed out: {str(exc)}",
517
+ 504,
518
+ exc
519
+ )
520
+ except httpx.HTTPError as exc:
521
+ raise ApiError(
522
+ "network_error",
523
+ f"Network error occurred: {str(exc)}",
524
+ 502,
525
+ exc
526
+ )
527
+
528
+ async def get_token_by_exchange_profile(
529
+ self,
530
+ subject_token: str,
531
+ subject_token_type: str,
532
+ audience: Optional[str] = None,
533
+ scope: Optional[str] = None,
534
+ requested_token_type: Optional[str] = None,
535
+ extra: Optional[Mapping[str, Union[str, Sequence[str]]]] = None
536
+ ) -> dict[str, Any]:
537
+ """
538
+ Exchange a subject token for an Auth0 token using RFC 8693.
539
+
540
+ The matching Token Exchange Profile is selected by subject_token_type.
541
+ This method requires a confidential client (client_id and client_secret must be configured).
542
+
543
+ Args:
544
+ subject_token: The token to be exchanged
545
+ subject_token_type: URI identifying the token type (must match a Token Exchange Profile)
546
+ audience: Optional target API identifier for the exchanged tokens
547
+ scope: Optional space-separated OAuth 2.0 scopes to request
548
+ requested_token_type: Optional type of token to issue (defaults to access token)
549
+ extra: Optional additional parameters sent as form fields to Auth0.
550
+ All values are converted to strings before sending.
551
+ Arrays are limited to 20 values per key for DoS protection.
552
+ Cannot override reserved OAuth parameters (case-insensitive check).
553
+
554
+ Returns:
555
+ Dictionary containing:
556
+ - access_token (str): The Auth0 access token
557
+ - expires_in (int): Token lifetime in seconds
558
+ - expires_at (int): Unix timestamp when token expires
559
+ - id_token (str, optional): OpenID Connect ID token
560
+ - refresh_token (str, optional): Refresh token
561
+ - scope (str, optional): Granted scopes
562
+ - token_type (str, optional): Token type (typically "Bearer")
563
+ - issued_token_type (str, optional): RFC 8693 issued token type identifier
564
+
565
+ Raises:
566
+ MissingRequiredArgumentError: If required parameters are missing
567
+ GetTokenByExchangeProfileError: If client credentials not configured, validation fails,
568
+ or reserved parameters are supplied in extra
569
+ ApiError: If the token endpoint returns an error
570
+
571
+ Example:
572
+ async def example():
573
+ result = await api_client.get_token_by_exchange_profile(
574
+ subject_token=token,
575
+ subject_token_type="urn:example:subject-token",
576
+ audience="https://api.backend.com"
577
+ )
578
+
579
+ References:
580
+ - Custom Token Exchange: https://auth0.com/docs/authenticate/custom-token-exchange
581
+ - RFC 8693: https://datatracker.ietf.org/doc/html/rfc8693
582
+ - Related SDK: https://github.com/auth0/auth0-auth-js
583
+ """
584
+ # Validate required parameters
585
+ if not subject_token:
586
+ raise MissingRequiredArgumentError("subject_token")
587
+ if not subject_token_type:
588
+ raise MissingRequiredArgumentError("subject_token_type")
589
+
590
+ # Validate subject token format (fail fast to ensure token integrity)
591
+ tok = subject_token
592
+ if not isinstance(tok, str) or not tok.strip():
593
+ raise GetTokenByExchangeProfileError("subject_token cannot be blank or whitespace")
594
+ if tok != tok.strip():
595
+ raise GetTokenByExchangeProfileError(
596
+ "subject_token must not include leading or trailing whitespace"
597
+ )
598
+ if tok.lower().startswith("bearer "):
599
+ raise GetTokenByExchangeProfileError(
600
+ "subject_token must not include the 'Bearer ' prefix (case-insensitive check)"
601
+ )
602
+
603
+ # Require client credentials
604
+ client_id = self.options.client_id
605
+ client_secret = self.options.client_secret
606
+ if not client_id or not client_secret:
607
+ raise GetTokenByExchangeProfileError(
608
+ "Client credentials are required to use get_token_by_exchange_profile. "
609
+ "Configure client_id and client_secret in ApiClientOptions to use this feature"
610
+ )
611
+
612
+ # Discover token endpoint
613
+ metadata = await self._discover()
614
+ token_endpoint = metadata.get("token_endpoint")
615
+ if not token_endpoint:
616
+ raise GetTokenByExchangeProfileError(
617
+ "Token endpoint missing in OIDC metadata. "
618
+ "Verify your domain configuration and that the OIDC discovery endpoint "
619
+ f"(https://{self.options.domain}/.well-known/openid-configuration) is accessible"
620
+ )
621
+
622
+ # Build request parameters (client_id sent via HTTP Basic auth only)
623
+ params = {
624
+ "grant_type": TOKEN_EXCHANGE_GRANT_TYPE,
625
+ "subject_token": subject_token,
626
+ "subject_token_type": subject_token_type,
627
+ }
628
+
629
+ # Add optional parameters
630
+ if audience:
631
+ params["audience"] = audience
632
+ if scope:
633
+ params["scope"] = scope
634
+ if requested_token_type:
635
+ params["requested_token_type"] = requested_token_type
636
+
637
+ # Append extra parameters with validation
638
+ if extra:
639
+ self._apply_extra(params, extra)
640
+
641
+ # Make token exchange request
642
+ try:
643
+ async with httpx.AsyncClient(timeout=httpx.Timeout(self.options.timeout)) as client:
644
+ response = await client.post(
645
+ token_endpoint,
646
+ data=params,
647
+ auth=(client_id, client_secret)
648
+ )
649
+
650
+ if response.status_code != 200:
651
+ error_data = {}
652
+ try:
653
+ # Lenient check for JSON error responses (handles application/json, text/json, etc.)
654
+ content_type = response.headers.get("content-type", "").lower()
655
+ if "json" in content_type:
656
+ error_data = response.json()
657
+ except ValueError:
658
+ pass # Ignore JSON parse errors, use generic error message below
659
+
660
+ raise ApiError(
661
+ error_data.get("error", "token_exchange_error"),
662
+ error_data.get(
663
+ "error_description",
664
+ f"Failed to exchange token of type '{subject_token_type}'"
665
+ + (f" for audience '{audience}'" if audience else "")
666
+ ),
667
+ response.status_code
668
+ )
669
+
670
+ try:
671
+ token_response = response.json()
672
+ except ValueError:
673
+ raise ApiError("invalid_json", "Token endpoint returned invalid JSON.", 502)
674
+
675
+ # Validate required fields
676
+ access_token = token_response.get("access_token")
677
+ if not isinstance(access_token, str) or not access_token:
678
+ raise ApiError(
679
+ "invalid_response",
680
+ "Missing or invalid access_token in response.",
681
+ 502
682
+ )
683
+
684
+ # Lenient policy: coerce numeric strings like "3600" to int
685
+ # Reject non-numeric values (e.g., "not-a-number", None, objects)
686
+ # Reject negative values (prevent accidental "already expired" tokens)
687
+ expires_in_raw = token_response.get("expires_in", 3600)
688
+ try:
689
+ expires_in = int(expires_in_raw)
690
+ except (TypeError, ValueError):
691
+ raise ApiError("invalid_response", "expires_in is not an integer.", 502)
692
+
693
+ if expires_in < 0:
694
+ raise ApiError("invalid_response", "expires_in cannot be negative.", 502)
695
+
696
+ # Build response with required fields
697
+ result = {
698
+ "access_token": access_token,
699
+ "expires_in": expires_in,
700
+ "expires_at": int(time.time()) + expires_in,
701
+ }
702
+
703
+ # Add optional fields if present (preserves falsy values like empty scope)
704
+ optional_fields = ["scope", "id_token", "refresh_token", "token_type", "issued_token_type"]
705
+ for field in optional_fields:
706
+ if field in token_response:
707
+ result[field] = token_response[field]
708
+
709
+ return result
710
+
711
+ except httpx.TimeoutException as exc:
712
+ raise ApiError(
713
+ "timeout_error",
714
+ f"Request to token endpoint timed out: {str(exc)}",
715
+ 504,
716
+ exc
717
+ )
718
+ except httpx.HTTPError as exc:
719
+ raise ApiError(
720
+ "network_error",
721
+ f"Network error occurred: {str(exc)}",
722
+ 502,
723
+ exc
724
+ )
725
+
393
726
  # ===== Private Methods =====
394
727
 
728
+ def _apply_extra(
729
+ self,
730
+ params: dict[str, Any],
731
+ extra: Mapping[str, Union[str, Sequence[str]]]
732
+ ) -> None:
733
+ """
734
+ Apply extra parameters to the params dict with validation.
735
+
736
+ Args:
737
+ params: The parameters dict to append to
738
+ extra: Additional parameters to append (accepts str or sequences like list/tuple)
739
+
740
+ Raises:
741
+ GetTokenByExchangeProfileError: If reserved parameter, unsupported type, or array size limit exceeded
742
+ """
743
+ # Pre-compute lowercase reserved params for case-insensitive matching
744
+ reserved_lower = {p.lower() for p in RESERVED_PARAMS}
745
+
746
+ for k, v in extra.items():
747
+ key = str(k)
748
+ # Case-insensitive check against reserved params
749
+ if key.lower() in reserved_lower:
750
+ raise GetTokenByExchangeProfileError(
751
+ f"Parameter '{k}' is reserved and cannot be overridden"
752
+ )
753
+
754
+ # Handle sequences (list, tuple, etc.) but reject mappings/sets/bytes
755
+ if isinstance(v, (dict, set, bytes)):
756
+ raise GetTokenByExchangeProfileError(
757
+ f"Parameter '{k}' has unsupported type {type(v).__name__}. "
758
+ "Only strings, numbers, booleans, and sequences (list/tuple) are allowed"
759
+ )
760
+ elif isinstance(v, (list, tuple)):
761
+ if len(v) > MAX_ARRAY_VALUES_PER_KEY:
762
+ raise GetTokenByExchangeProfileError(
763
+ f"Parameter '{k}' exceeds maximum array size of {MAX_ARRAY_VALUES_PER_KEY}"
764
+ )
765
+ # Convert sequence items to strings
766
+ params[key] = [str(x) for x in v]
767
+ else:
768
+ params[key] = str(v)
769
+
395
770
  async def _discover(self) -> dict[str, Any]:
396
771
  """Lazy-load OIDC discovery metadata."""
397
772
  if self._metadata is None:
@@ -17,16 +17,22 @@ class ApiClientOptions:
17
17
  dpop_required: Whether DPoP is required (default: False, allows both Bearer and DPoP).
18
18
  dpop_iat_leeway: Leeway in seconds for DPoP proof iat claim (default: 30).
19
19
  dpop_iat_offset: Maximum age in seconds for DPoP proof iat claim (default: 300).
20
+ client_id: Required for get_access_token_for_connection and get_token_by_exchange_profile.
21
+ client_secret: Required for get_access_token_for_connection and get_token_by_exchange_profile.
22
+ timeout: HTTP timeout in seconds for token endpoint requests (default: 10.0).
20
23
  """
21
24
  def __init__(
22
- self,
23
- domain: str,
24
- audience: str,
25
- custom_fetch: Optional[Callable[..., object]] = None,
26
- dpop_enabled: bool = True,
27
- dpop_required: bool = False,
28
- dpop_iat_leeway: int = 30,
29
- dpop_iat_offset: int = 300,
25
+ self,
26
+ domain: str,
27
+ audience: str,
28
+ custom_fetch: Optional[Callable[..., object]] = None,
29
+ dpop_enabled: bool = True,
30
+ dpop_required: bool = False,
31
+ dpop_iat_leeway: int = 30,
32
+ dpop_iat_offset: int = 300,
33
+ client_id: Optional[str] = None,
34
+ client_secret: Optional[str] = None,
35
+ timeout: float = 10.0,
30
36
  ):
31
37
  self.domain = domain
32
38
  self.audience = audience
@@ -35,3 +41,6 @@ class ApiClientOptions:
35
41
  self.dpop_required = dpop_required
36
42
  self.dpop_iat_leeway = dpop_iat_leeway
37
43
  self.dpop_iat_offset = dpop_iat_offset
44
+ self.client_id = client_id
45
+ self.client_secret = client_secret
46
+ self.timeout = timeout
@@ -94,3 +94,49 @@ class MissingAuthorizationError(BaseAuthError):
94
94
 
95
95
  def get_error_code(self) -> str:
96
96
  return "invalid_request"
97
+
98
+
99
+ class GetAccessTokenForConnectionError(BaseAuthError):
100
+ """Error raised when getting a token for a connection fails."""
101
+
102
+ def get_status_code(self) -> int:
103
+ return 400
104
+
105
+ def get_error_code(self) -> str:
106
+ return "get_access_token_for_connection_error"
107
+
108
+
109
+ class GetTokenByExchangeProfileError(BaseAuthError):
110
+ """Error raised when getting a token via exchange profile fails."""
111
+
112
+ def get_status_code(self) -> int:
113
+ return 400
114
+
115
+ def get_error_code(self) -> str:
116
+ return "get_token_by_exchange_profile_error"
117
+
118
+
119
+ class ApiError(BaseAuthError):
120
+ """
121
+ Error raised when an API request to Auth0 fails.
122
+ Contains details about the original error from Auth0.
123
+ """
124
+
125
+ def __init__(self, code: str, message: str, status_code=500, cause=None):
126
+ super().__init__(message)
127
+ self.code = code
128
+ self.status_code = status_code
129
+ self.cause = cause
130
+
131
+ if cause:
132
+ self.error = getattr(cause, "error", None)
133
+ self.error_description = getattr(cause, "error_description", None)
134
+ else:
135
+ self.error = None
136
+ self.error_description = None
137
+
138
+ def get_status_code(self) -> int:
139
+ return self.status_code
140
+
141
+ def get_error_code(self) -> str:
142
+ return self.code