auth0-api-python 1.0.0b5__tar.gz → 1.0.0b7__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.0b5
3
+ Version: 1.0.0b7
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,9 @@ 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) ; python_version == "3.9"
19
+ Requires-Dist: ada-url (>=1.30.0,<2.0.0) ; python_version >= "3.10"
17
20
  Requires-Dist: authlib (>=1.0,<2.0)
18
21
  Requires-Dist: httpx (>=0.28.1,<0.29.0)
19
22
  Requires-Dist: requests (>=2.31.0,<3.0.0)
@@ -49,6 +52,15 @@ This SDK provides comprehensive support for securing APIs with Auth0-issued acce
49
52
 
50
53
  - [Docs Site](https://auth0.com/docs) - explore our docs site and learn more about Auth0.
51
54
 
55
+ ## Related SDKs
56
+
57
+ This library is part of Auth0's Python ecosystem for server-side authentication and API security. Related SDKs:
58
+
59
+ - **[auth0-auth-js](https://github.com/auth0/auth0-auth-js)** - JavaScript/TypeScript monorepo containing:
60
+ - `@auth0/auth0-auth-js` - Core authentication client (low-level primitives)
61
+ - `@auth0/auth0-api-js` - Server-side API security (Node.js equivalent of this library)
62
+ - `@auth0/auth0-server-js` - Server-side web app authentication (session management)
63
+
52
64
  ## Getting Started
53
65
 
54
66
  ### 1. Install the SDK
@@ -134,6 +146,95 @@ asyncio.run(main())
134
146
 
135
147
  More info https://auth0.com/docs/secure/tokens/token-vault
136
148
 
149
+ ### 5. Custom Token Exchange (Early Access)
150
+
151
+ > [!NOTE]
152
+ > 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.
153
+
154
+ 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).
155
+
156
+ Custom Token Exchange allows you to exchange a subject token for Auth0 tokens using RFC 8693. This is useful for:
157
+ - Getting Auth0 tokens for another audience
158
+ - Integrating external identity providers
159
+ - Migrating to Auth0
160
+
161
+ ```python
162
+ import asyncio
163
+
164
+ from auth0_api_python import ApiClient, ApiClientOptions
165
+
166
+ async def main():
167
+ api_client = ApiClient(ApiClientOptions(
168
+ domain="<AUTH0_DOMAIN>",
169
+ audience="<AUTH0_AUDIENCE>",
170
+ client_id="<AUTH0_CLIENT_ID>",
171
+ client_secret="<AUTH0_CLIENT_SECRET>",
172
+ timeout=10.0 # Optional: HTTP timeout in seconds (default: 10.0)
173
+ ))
174
+
175
+ subject_token = "..." # Token from your legacy system or external source
176
+
177
+ result = await api_client.get_token_by_exchange_profile(
178
+ subject_token=subject_token,
179
+ subject_token_type="urn:example:subject-token",
180
+ audience="https://api.example.com", # Optional - omit if your Action or tenant configuration sets the audience
181
+ scope="openid profile email", # Optional
182
+ requested_token_type="urn:ietf:params:oauth:token-type:access_token" # Optional
183
+ )
184
+
185
+ # Result contains access_token, expires_in, expires_at
186
+ # id_token, refresh_token, and scope are profile/Action dependent (not guaranteed; scope may be empty)
187
+
188
+ asyncio.run(main())
189
+ ```
190
+
191
+ **Important:**
192
+ - Client authentication is sent via HTTP Basic (`client_id`/`client_secret`), not in the form body.
193
+ - Do not prefix `subject_token` with "Bearer " - send the raw token value only (checked case-insensitively).
194
+ - 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.
195
+ - If neither an explicit `audience` nor tenant/Action logic sets it, you may receive a token not targeted at your API.
196
+
197
+ #### Additional Parameters
198
+
199
+ 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:
200
+
201
+ ```python
202
+ result = await api_client.get_token_by_exchange_profile(
203
+ subject_token=subject_token,
204
+ subject_token_type="urn:example:subject-token",
205
+ audience="https://api.example.com",
206
+ extra={
207
+ "device_id": "device-12345",
208
+ "session_id": "sess-abc"
209
+ }
210
+ )
211
+ ```
212
+
213
+ > [!WARNING]
214
+ > 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.
215
+
216
+ #### Error Handling
217
+
218
+ ```python
219
+ from auth0_api_python import GetTokenByExchangeProfileError, ApiError
220
+
221
+ try:
222
+ result = await api_client.get_token_by_exchange_profile(
223
+ subject_token=subject_token,
224
+ subject_token_type="urn:example:subject-token"
225
+ )
226
+ except GetTokenByExchangeProfileError as e:
227
+ # Validation errors (invalid token format, missing credentials, reserved params, etc.)
228
+ print(f"Validation error: {e}")
229
+ except ApiError as e:
230
+ # Token endpoint errors (invalid_grant, network issues, malformed responses, etc.)
231
+ print(f"API error: {e.code} - {e.message} (status: {e.status_code})")
232
+ ```
233
+
234
+ **Related SDKs:** [auth0-auth-js](https://github.com/auth0/auth0-auth-js) (see `@auth0/auth0-api-js` package for Node.js equivalent)
235
+
236
+ More info: https://auth0.com/docs/authenticate/custom-token-exchange
237
+
137
238
  #### Requiring Additional Claims
138
239
 
139
240
  If your application demands extra claims, specify them with `required_claims`:
@@ -147,7 +248,7 @@ decoded_and_verified_token = await api_client.verify_access_token(
147
248
 
148
249
  If the token lacks `my_custom_claim` or fails any standard check (issuer mismatch, expired token, invalid signature), the method raises a `VerifyAccessTokenError`.
149
250
 
150
- ### 5. DPoP Authentication
251
+ ### 6. DPoP Authentication
151
252
 
152
253
  > [!NOTE]
153
254
  > 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
@@ -113,6 +122,95 @@ asyncio.run(main())
113
122
 
114
123
  More info https://auth0.com/docs/secure/tokens/token-vault
115
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
+
116
214
  #### Requiring Additional Claims
117
215
 
118
216
  If your application demands extra claims, specify them with `required_claims`:
@@ -126,7 +224,7 @@ decoded_and_verified_token = await api_client.verify_access_token(
126
224
 
127
225
  If the token lacks `my_custom_claim` or fails any standard check (issuer mismatch, expired token, invalid signature), the method raises a `VerifyAccessTokenError`.
128
226
 
129
- ### 5. DPoP Authentication
227
+ ### 6. DPoP Authentication
130
228
 
131
229
  > [!NOTE]
132
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.b5"
3
+ version = "1.0.0.b7"
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,19 @@ 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 = [
19
+ {version = "^1.30.0", python = ">=3.10"},
20
+ {version = "^1.27.0", python = ">=3.9,<3.10"}
21
+ ]
19
22
 
20
23
  [tool.poetry.group.dev.dependencies]
21
24
  pytest = "^8.0"
22
25
  pytest-cov = "^4.0"
23
- pytest-asyncio = "^0.20.3"
24
- pytest-mock = "^3.14.0"
26
+ pytest-asyncio = "^0.25.3"
27
+ pytest-mock = "^3.15.1"
25
28
  pytest-httpx = "^0.35.0"
26
- ruff = "^0.1.0"
29
+ ruff = ">=0.1"
30
+ freezegun = "^1.5.5"
27
31
 
28
32
  [tool.pytest.ini_options]
29
33
  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,5 +1,6 @@
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
 
4
5
  import httpx
5
6
  from authlib.jose import JsonWebKey, JsonWebToken
@@ -9,6 +10,7 @@ from .errors import (
9
10
  ApiError,
10
11
  BaseAuthError,
11
12
  GetAccessTokenForConnectionError,
13
+ GetTokenByExchangeProfileError,
12
14
  InvalidAuthSchemeError,
13
15
  InvalidDpopProofError,
14
16
  MissingAuthorizationError,
@@ -24,6 +26,20 @@ from .utils import (
24
26
  sha256_base64url,
25
27
  )
26
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
+
27
43
 
28
44
  class ApiClient:
29
45
  """
@@ -62,6 +78,12 @@ class ApiClient:
62
78
  • If scheme is 'DPoP', verifies both access token and DPoP proof
63
79
  • If scheme is 'Bearer', verifies only the access token
64
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
+
65
87
  Args:
66
88
  headers: HTTP headers dict containing (header keys should be lowercase):
67
89
  - "authorization": The Authorization header value (required)
@@ -78,6 +100,9 @@ class ApiClient:
78
100
  InvalidDpopProofError: If DPoP verification fails
79
101
  VerifyAccessTokenError: If access token verification fails
80
102
  """
103
+ # Normalize header keys to lowercase for robust access
104
+ headers = {k.lower(): v for k, v in headers.items()}
105
+
81
106
  authorization_header = headers.get("authorization", "")
82
107
  dpop_proof = headers.get("dpop")
83
108
 
@@ -86,22 +111,16 @@ class ApiClient:
86
111
  raise self._prepare_error(
87
112
  InvalidAuthSchemeError("")
88
113
  )
89
- else :
114
+ else:
90
115
  raise self._prepare_error(MissingAuthorizationError())
91
116
 
92
-
93
- parts = authorization_header.split(" ")
117
+ # Split authorization header on first whitespace
118
+ parts = authorization_header.split(None, 1)
94
119
  if len(parts) != 2:
95
- if len(parts) < 2:
96
- raise self._prepare_error(MissingAuthorizationError())
97
- elif len(parts) > 2:
98
- raise self._prepare_error(
99
- InvalidAuthSchemeError("")
100
- )
120
+ raise self._prepare_error(MissingAuthorizationError())
101
121
 
102
122
  scheme, token = parts
103
-
104
- scheme = scheme.strip().lower()
123
+ scheme = scheme.lower()
105
124
 
106
125
  if self.is_dpop_required() and scheme != "dpop":
107
126
  raise self._prepare_error(
@@ -431,7 +450,11 @@ class ApiClient:
431
450
 
432
451
  token_endpoint = metadata.get("token_endpoint")
433
452
  if not token_endpoint:
434
- raise GetAccessTokenForConnectionError("Token endpoint missing in OIDC metadata")
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
+ )
435
458
 
436
459
  # Prepare parameters
437
460
  params = {
@@ -448,7 +471,7 @@ class ApiClient:
448
471
  params["login_hint"] = options["login_hint"]
449
472
 
450
473
  try:
451
- async with httpx.AsyncClient() as client:
474
+ async with httpx.AsyncClient(timeout=httpx.Timeout(self.options.timeout)) as client:
452
475
  response = await client.post(
453
476
  token_endpoint,
454
477
  data=params,
@@ -456,8 +479,9 @@ class ApiClient:
456
479
  )
457
480
 
458
481
  if response.status_code != 200:
459
- error_data = response.json() if "json" in response.headers.get(
460
- "content-type", "").lower() else {}
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 {}
461
485
  raise ApiError(
462
486
  error_data.get("error", "connection_token_error"),
463
487
  error_data.get(
@@ -467,7 +491,7 @@ class ApiClient:
467
491
 
468
492
  try:
469
493
  token_endpoint_response = response.json()
470
- except Exception:
494
+ except ValueError:
471
495
  raise ApiError("invalid_json", "Token endpoint returned invalid JSON.")
472
496
 
473
497
  access_token = token_endpoint_response.get("access_token")
@@ -501,8 +525,248 @@ class ApiClient:
501
525
  exc
502
526
  )
503
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
+
504
726
  # ===== Private Methods =====
505
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
+
506
770
  async def _discover(self) -> dict[str, Any]:
507
771
  """Lazy-load OIDC discovery metadata."""
508
772
  if self._metadata is None:
@@ -17,8 +17,9 @@ 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: Optional required if you want to use get_access_token_for_connection.
21
- client_secret: Optional required if you want to use get_access_token_for_connection.
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).
22
23
  """
23
24
  def __init__(
24
25
  self,
@@ -31,6 +32,7 @@ class ApiClientOptions:
31
32
  dpop_iat_offset: int = 300,
32
33
  client_id: Optional[str] = None,
33
34
  client_secret: Optional[str] = None,
35
+ timeout: float = 10.0,
34
36
  ):
35
37
  self.domain = domain
36
38
  self.audience = audience
@@ -41,3 +43,4 @@ class ApiClientOptions:
41
43
  self.dpop_iat_offset = dpop_iat_offset
42
44
  self.client_id = client_id
43
45
  self.client_secret = client_secret
46
+ self.timeout = timeout
@@ -106,6 +106,16 @@ class GetAccessTokenForConnectionError(BaseAuthError):
106
106
  return "get_access_token_for_connection_error"
107
107
 
108
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
+
109
119
  class ApiError(BaseAuthError):
110
120
  """
111
121
  Error raised when an API request to Auth0 fails.