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.
- {auth0_api_python-1.0.0b4 → auth0_api_python-1.0.0b6}/PKG-INFO +132 -4
- {auth0_api_python-1.0.0b4 → auth0_api_python-1.0.0b6}/README.md +127 -1
- {auth0_api_python-1.0.0b4 → auth0_api_python-1.0.0b6}/pyproject.toml +6 -5
- {auth0_api_python-1.0.0b4 → auth0_api_python-1.0.0b6}/src/auth0_api_python/__init__.py +4 -1
- {auth0_api_python-1.0.0b4 → auth0_api_python-1.0.0b6}/src/auth0_api_python/api_client.py +387 -12
- {auth0_api_python-1.0.0b4 → auth0_api_python-1.0.0b6}/src/auth0_api_python/config.py +17 -8
- {auth0_api_python-1.0.0b4 → auth0_api_python-1.0.0b6}/src/auth0_api_python/errors.py +46 -0
- {auth0_api_python-1.0.0b4 → auth0_api_python-1.0.0b6}/LICENSE +0 -0
- {auth0_api_python-1.0.0b4 → auth0_api_python-1.0.0b6}/src/auth0_api_python/token_utils.py +0 -0
- {auth0_api_python-1.0.0b4 → 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
|
|
@@ -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
|
-
###
|
|
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
|
-
###
|
|
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.
|
|
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,11 +1,16 @@
|
|
|
1
1
|
import time
|
|
2
|
-
from
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|