auth0-api-python 1.0.0b5__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.
- {auth0_api_python-1.0.0b5 → auth0_api_python-1.0.0b6}/PKG-INFO +104 -4
- {auth0_api_python-1.0.0b5 → auth0_api_python-1.0.0b6}/README.md +99 -1
- {auth0_api_python-1.0.0b5 → auth0_api_python-1.0.0b6}/pyproject.toml +6 -5
- {auth0_api_python-1.0.0b5 → auth0_api_python-1.0.0b6}/src/auth0_api_python/__init__.py +4 -1
- {auth0_api_python-1.0.0b5 → auth0_api_python-1.0.0b6}/src/auth0_api_python/api_client.py +281 -17
- {auth0_api_python-1.0.0b5 → auth0_api_python-1.0.0b6}/src/auth0_api_python/config.py +5 -2
- {auth0_api_python-1.0.0b5 → auth0_api_python-1.0.0b6}/src/auth0_api_python/errors.py +10 -0
- {auth0_api_python-1.0.0b5 → auth0_api_python-1.0.0b6}/LICENSE +0 -0
- {auth0_api_python-1.0.0b5 → auth0_api_python-1.0.0b6}/src/auth0_api_python/token_utils.py +0 -0
- {auth0_api_python-1.0.0b5 → auth0_api_python-1.0.0b6}/src/auth0_api_python/utils.py +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: auth0-api-python
|
|
3
|
-
Version: 1.0.
|
|
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
|
-
|
|
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
|
|
@@ -134,6 +145,95 @@ asyncio.run(main())
|
|
|
134
145
|
|
|
135
146
|
More info https://auth0.com/docs/secure/tokens/token-vault
|
|
136
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
|
+
|
|
137
237
|
#### Requiring Additional Claims
|
|
138
238
|
|
|
139
239
|
If your application demands extra claims, specify them with `required_claims`:
|
|
@@ -147,7 +247,7 @@ decoded_and_verified_token = await api_client.verify_access_token(
|
|
|
147
247
|
|
|
148
248
|
If the token lacks `my_custom_claim` or fails any standard check (issuer mismatch, expired token, invalid signature), the method raises a `VerifyAccessTokenError`.
|
|
149
249
|
|
|
150
|
-
###
|
|
250
|
+
### 6. DPoP Authentication
|
|
151
251
|
|
|
152
252
|
> [!NOTE]
|
|
153
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
|
|
@@ -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
|
-
###
|
|
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.
|
|
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.
|
|
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.
|
|
24
|
-
pytest-mock = "^3.
|
|
23
|
+
pytest-asyncio = "^0.25.3"
|
|
24
|
+
pytest-mock = "^3.15.1"
|
|
25
25
|
pytest-httpx = "^0.35.0"
|
|
26
|
-
ruff = "
|
|
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,5 +1,6 @@
|
|
|
1
1
|
import time
|
|
2
|
-
from
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
460
|
-
|
|
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
|
|
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:
|
|
21
|
-
client_secret:
|
|
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.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|