auth0-server-python 1.0.0b10__tar.gz → 1.0.0b12__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_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/PKG-INFO +11 -1
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/README.md +10 -0
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/pyproject.toml +1 -1
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_server/server_client.py +150 -6
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_types/__init__.py +30 -41
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/error/__init__.py +27 -0
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/tests/test_server_client.py +2023 -35
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/utils/helpers.py +82 -2
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/LICENSE +0 -0
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_schemes/__init__.py +0 -0
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_schemes/bearer_auth.py +0 -0
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_server/__init__.py +0 -0
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_server/mfa_client.py +0 -0
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_server/my_account_client.py +0 -0
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/encryption/__init__.py +0 -0
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/encryption/encrypt.py +0 -0
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/store/__init__.py +0 -0
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/store/abstract.py +0 -0
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/telemetry.py +0 -0
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/tests/test_mfa_client.py +0 -0
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/tests/test_my_account_client.py +0 -0
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/tests/test_telemetry.py +0 -0
- {auth0_server_python-1.0.0b10 → auth0_server_python-1.0.0b12}/src/auth0_server_python/utils/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: auth0-server-python
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.0b12
|
|
4
4
|
Summary: Auth0 server-side Python SDK
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -129,6 +129,10 @@ async def callback(request: Request):
|
|
|
129
129
|
return RedirectResponse(url="/")
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
+
#### Organizations
|
|
133
|
+
|
|
134
|
+
The SDK supports [Auth0 Organizations](https://auth0.com/docs/organizations) with first-class `organization` and `invitation` parameters on `ServerClient` and `StartInteractiveLoginOptions`. Token claim validation is enforced automatically at callback. For setup, invitation flows, error handling, and reading org data from the session, see [examples/InteractiveLogin.md](examples/InteractiveLogin.md#8-organizations).
|
|
135
|
+
|
|
132
136
|
### 4. Login with Custom Token Exchange
|
|
133
137
|
|
|
134
138
|
If you're migrating from a legacy authentication system or integrating with a custom identity provider, you can exchange external tokens for Auth0 tokens using the OAuth 2.0 Token Exchange specification (RFC 8693):
|
|
@@ -198,6 +202,12 @@ The SDK handles per-domain OIDC discovery, JWKS fetching, issuer validation, and
|
|
|
198
202
|
|
|
199
203
|
For more details and examples, see [examples/MultipleCustomDomains.md](examples/MultipleCustomDomains.md).
|
|
200
204
|
|
|
205
|
+
### 6. Session Expiry from the Upstream IdP
|
|
206
|
+
|
|
207
|
+
For enterprise connections, the upstream identity provider can cap how long a user's session lives. When the connection is configured to honor it, Auth0 includes a `session_expiry` claim in the ID token, and the SDK enforces this ceiling on every session read. Once it is reached, `get_user()` and `get_session()` return `None`, and `get_access_token()` raises an `AccessTokenError` with code `session_expired`. If the asserted ceiling is already in the past at login, `complete_interactive_login()` raises a `SessionExpiredError` instead of persisting an already-expired session.
|
|
208
|
+
|
|
209
|
+
For more details and examples, see [examples/RetrievingData.md](examples/RetrievingData.md#session-expiry-from-the-upstream-idp).
|
|
210
|
+
|
|
201
211
|
## Feedback
|
|
202
212
|
|
|
203
213
|
### Contributing
|
|
@@ -104,6 +104,10 @@ async def callback(request: Request):
|
|
|
104
104
|
return RedirectResponse(url="/")
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
#### Organizations
|
|
108
|
+
|
|
109
|
+
The SDK supports [Auth0 Organizations](https://auth0.com/docs/organizations) with first-class `organization` and `invitation` parameters on `ServerClient` and `StartInteractiveLoginOptions`. Token claim validation is enforced automatically at callback. For setup, invitation flows, error handling, and reading org data from the session, see [examples/InteractiveLogin.md](examples/InteractiveLogin.md#8-organizations).
|
|
110
|
+
|
|
107
111
|
### 4. Login with Custom Token Exchange
|
|
108
112
|
|
|
109
113
|
If you're migrating from a legacy authentication system or integrating with a custom identity provider, you can exchange external tokens for Auth0 tokens using the OAuth 2.0 Token Exchange specification (RFC 8693):
|
|
@@ -173,6 +177,12 @@ The SDK handles per-domain OIDC discovery, JWKS fetching, issuer validation, and
|
|
|
173
177
|
|
|
174
178
|
For more details and examples, see [examples/MultipleCustomDomains.md](examples/MultipleCustomDomains.md).
|
|
175
179
|
|
|
180
|
+
### 6. Session Expiry from the Upstream IdP
|
|
181
|
+
|
|
182
|
+
For enterprise connections, the upstream identity provider can cap how long a user's session lives. When the connection is configured to honor it, Auth0 includes a `session_expiry` claim in the ID token, and the SDK enforces this ceiling on every session read. Once it is reached, `get_user()` and `get_session()` return `None`, and `get_access_token()` raises an `AccessTokenError` with code `session_expired`. If the asserted ceiling is already in the past at login, `complete_interactive_login()` raises a `SessionExpiredError` instead of persisting an already-expired session.
|
|
183
|
+
|
|
184
|
+
For more details and examples, see [examples/RetrievingData.md](examples/RetrievingData.md#session-expiry-from-the-upstream-idp).
|
|
185
|
+
|
|
176
186
|
## Feedback
|
|
177
187
|
|
|
178
188
|
### Contributing
|
|
@@ -54,13 +54,16 @@ from auth0_server_python.error import (
|
|
|
54
54
|
MfaRequiredError,
|
|
55
55
|
MissingRequiredArgumentError,
|
|
56
56
|
MissingTransactionError,
|
|
57
|
+
OrganizationTokenValidationError,
|
|
57
58
|
PollingApiError,
|
|
59
|
+
SessionExpiredError,
|
|
58
60
|
StartLinkUserError,
|
|
59
61
|
)
|
|
60
62
|
from auth0_server_python.telemetry import Telemetry
|
|
61
63
|
from auth0_server_python.utils import PKCE, URL, State
|
|
62
64
|
from auth0_server_python.utils.helpers import (
|
|
63
65
|
build_domain_resolver_context,
|
|
66
|
+
validate_org_claims,
|
|
64
67
|
validate_resolved_domain_value,
|
|
65
68
|
)
|
|
66
69
|
|
|
@@ -96,6 +99,7 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
96
99
|
state_identifier: str = "_a0_session",
|
|
97
100
|
authorization_params: Optional[dict[str, Any]] = None,
|
|
98
101
|
pushed_authorization_requests: bool = False,
|
|
102
|
+
organization: Optional[str] = None,
|
|
99
103
|
):
|
|
100
104
|
"""
|
|
101
105
|
Initialize the Auth0 server client.
|
|
@@ -112,6 +116,9 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
112
116
|
state_identifier: Identifier for state data
|
|
113
117
|
authorization_params: Default parameters for authorization requests
|
|
114
118
|
pushed_authorization_requests: Whether to use Pushed Authorization Requests
|
|
119
|
+
organization: Default organization for all login flows from this client.
|
|
120
|
+
Can be an org ID (e.g. 'org_abc123') or an org name (e.g. 'acme-corp').
|
|
121
|
+
Per-login values passed in StartInteractiveLoginOptions always override this.
|
|
115
122
|
"""
|
|
116
123
|
if not secret:
|
|
117
124
|
raise MissingRequiredArgumentError("secret")
|
|
@@ -146,6 +153,7 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
146
153
|
self._secret = secret
|
|
147
154
|
self._default_authorization_params = authorization_params or {}
|
|
148
155
|
self._pushed_authorization_requests = pushed_authorization_requests # store the flag
|
|
156
|
+
self._organization = organization
|
|
149
157
|
|
|
150
158
|
# Initialize stores
|
|
151
159
|
self._transaction_store = transaction_store
|
|
@@ -207,6 +215,7 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
207
215
|
|
|
208
216
|
return value.rstrip('/')
|
|
209
217
|
|
|
218
|
+
|
|
210
219
|
async def _resolve_current_domain(self, store_options=None) -> str:
|
|
211
220
|
"""Resolve domain from resolver function or return static domain."""
|
|
212
221
|
if self._domain_resolver:
|
|
@@ -502,6 +511,16 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
502
511
|
merged_scope = self._merge_scope_with_defaults(requested_scope, audience)
|
|
503
512
|
auth_params["scope"] = merged_scope
|
|
504
513
|
|
|
514
|
+
# Typed org/invitation fields win over anything already in auth_params from authorization_params.
|
|
515
|
+
resolved_org = options.organization or self._organization
|
|
516
|
+
if resolved_org and not resolved_org.strip():
|
|
517
|
+
raise InvalidArgumentError("organization", "organization must not be blank")
|
|
518
|
+
if resolved_org:
|
|
519
|
+
auth_params["organization"] = resolved_org
|
|
520
|
+
|
|
521
|
+
if options.invitation:
|
|
522
|
+
auth_params["invitation"] = options.invitation
|
|
523
|
+
|
|
505
524
|
# Build the transaction data to store with domain
|
|
506
525
|
transaction_data = TransactionData(
|
|
507
526
|
code_verifier=code_verifier,
|
|
@@ -509,6 +528,7 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
509
528
|
audience=audience,
|
|
510
529
|
domain=origin_domain,
|
|
511
530
|
redirect_uri=auth_params.get("redirect_uri"),
|
|
531
|
+
organization=resolved_org,
|
|
512
532
|
)
|
|
513
533
|
|
|
514
534
|
# Store the transaction data
|
|
@@ -637,9 +657,32 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
637
657
|
# Use the userinfo field from the token_response for user claims
|
|
638
658
|
user_info = token_response.get("userinfo")
|
|
639
659
|
user_claims = None
|
|
660
|
+
# IPSIE session_expiry ceiling, read from the verified ID token claims.
|
|
661
|
+
session_expires_at = None
|
|
662
|
+
# ID token `iat`, used to detect a ceiling that is already past at login.
|
|
663
|
+
issued_at = None
|
|
640
664
|
id_token = token_response.get("id_token")
|
|
641
665
|
|
|
666
|
+
expected_org = transaction_data.organization
|
|
667
|
+
|
|
668
|
+
if not user_info and not id_token and expected_org:
|
|
669
|
+
raise OrganizationTokenValidationError(
|
|
670
|
+
"Organization was requested but the token response included neither an ID token nor userinfo; "
|
|
671
|
+
"cannot verify organization membership"
|
|
672
|
+
)
|
|
673
|
+
|
|
642
674
|
if user_info:
|
|
675
|
+
if not isinstance(user_info, dict):
|
|
676
|
+
if expected_org:
|
|
677
|
+
raise OrganizationTokenValidationError(
|
|
678
|
+
"Userinfo response is not a valid claims dictionary; cannot verify organization membership"
|
|
679
|
+
)
|
|
680
|
+
raise ApiError(
|
|
681
|
+
"invalid_response",
|
|
682
|
+
"Userinfo response is not a valid claims dictionary"
|
|
683
|
+
)
|
|
684
|
+
if expected_org:
|
|
685
|
+
validate_org_claims(user_info, expected_org)
|
|
643
686
|
user_claims = UserClaims.parse_obj(user_info)
|
|
644
687
|
elif id_token:
|
|
645
688
|
# Fetch JWKS for signature verification
|
|
@@ -656,7 +699,13 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
656
699
|
if self._normalize_url(token_issuer) != self._normalize_url(origin_issuer):
|
|
657
700
|
raise IssuerValidationError("ID token issuer mismatch. Ensure your Auth0 domain is configured correctly.")
|
|
658
701
|
|
|
702
|
+
# Organization claim validation — mandatory when org was requested.
|
|
703
|
+
if expected_org:
|
|
704
|
+
validate_org_claims(claims, expected_org)
|
|
705
|
+
|
|
659
706
|
user_claims = UserClaims.parse_obj(claims)
|
|
707
|
+
session_expires_at = user_claims.session_expiry
|
|
708
|
+
issued_at = claims.get("iat")
|
|
660
709
|
except ValueError as e:
|
|
661
710
|
raise ApiError("jwks_key_not_found", str(e))
|
|
662
711
|
except jwt.InvalidSignatureError as e:
|
|
@@ -685,6 +734,11 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
685
734
|
)
|
|
686
735
|
|
|
687
736
|
|
|
737
|
+
# Refuse to persist a session whose ceiling is already in the past.
|
|
738
|
+
if State.is_session_ceiling_in_past(session_expires_at, issued_at):
|
|
739
|
+
await self._transaction_store.delete(transaction_identifier, options=store_options)
|
|
740
|
+
raise SessionExpiredError()
|
|
741
|
+
|
|
688
742
|
# Build a token set using the token response data
|
|
689
743
|
token_set = TokenSet(
|
|
690
744
|
audience=transaction_data.audience or self.DEFAULT_AUDIENCE_STATE_KEY,
|
|
@@ -708,7 +762,8 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
708
762
|
domain=origin_domain,
|
|
709
763
|
internal={
|
|
710
764
|
"sid": sid,
|
|
711
|
-
"created_at": int(time.time())
|
|
765
|
+
"created_at": int(time.time()),
|
|
766
|
+
"session_expires_at": session_expires_at
|
|
712
767
|
}
|
|
713
768
|
)
|
|
714
769
|
|
|
@@ -734,6 +789,23 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
734
789
|
# Methods for retrieving user information, session data, and logout operations.
|
|
735
790
|
# ============================================================================
|
|
736
791
|
|
|
792
|
+
async def _is_session_expired_by_ceiling(
|
|
793
|
+
self, state_data_dict: dict, store_options: Optional[dict[str, Any]] = None
|
|
794
|
+
) -> bool:
|
|
795
|
+
"""
|
|
796
|
+
Enforce the IPSIE session_expiry ceiling on a session read.
|
|
797
|
+
|
|
798
|
+
Returns True (and deletes the stored session) when the upstream
|
|
799
|
+
IdP-asserted ceiling has been reached. Sessions without a
|
|
800
|
+
session_expires_at value are never expired on this basis.
|
|
801
|
+
"""
|
|
802
|
+
internal = state_data_dict.get("internal") or {}
|
|
803
|
+
session_expires_at = internal.get("session_expires_at")
|
|
804
|
+
if State.is_session_ceiling_reached(session_expires_at):
|
|
805
|
+
await self._state_store.delete(self._state_identifier, options=store_options)
|
|
806
|
+
return True
|
|
807
|
+
return False
|
|
808
|
+
|
|
737
809
|
async def get_user(self, store_options: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]:
|
|
738
810
|
"""
|
|
739
811
|
Retrieves the user from the store, or None if no user found.
|
|
@@ -760,6 +832,10 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
760
832
|
if self._normalize_url(session_domain) != self._normalize_url(current_domain):
|
|
761
833
|
return None
|
|
762
834
|
|
|
835
|
+
# IPSIE: force re-auth once the upstream IdP session ceiling passes.
|
|
836
|
+
if await self._is_session_expired_by_ceiling(state_data, store_options):
|
|
837
|
+
return None
|
|
838
|
+
|
|
763
839
|
return state_data.get("user")
|
|
764
840
|
return None
|
|
765
841
|
|
|
@@ -789,6 +865,10 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
789
865
|
if self._normalize_url(session_domain) != self._normalize_url(current_domain):
|
|
790
866
|
return None
|
|
791
867
|
|
|
868
|
+
# IPSIE: force re-auth once the upstream IdP session ceiling passes.
|
|
869
|
+
if await self._is_session_expired_by_ceiling(state_data, store_options):
|
|
870
|
+
return None
|
|
871
|
+
|
|
792
872
|
session_data = {k: v for k, v in state_data.items()
|
|
793
873
|
if k != "internal"}
|
|
794
874
|
return session_data
|
|
@@ -972,6 +1052,12 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
972
1052
|
|
|
973
1053
|
merged_scope = self._merge_scope_with_defaults(scope, audience)
|
|
974
1054
|
|
|
1055
|
+
# Once the session ceiling has passed, fail instead of serving or refreshing a token.
|
|
1056
|
+
internal = (state_data_dict or {}).get("internal") or {}
|
|
1057
|
+
if State.is_session_ceiling_reached(internal.get("session_expires_at")):
|
|
1058
|
+
await self._state_store.delete(self._state_identifier, options=store_options)
|
|
1059
|
+
raise SessionExpiredError()
|
|
1060
|
+
|
|
975
1061
|
# Find matching token set
|
|
976
1062
|
token_set = None
|
|
977
1063
|
if state_data_dict and "token_sets" in state_data_dict:
|
|
@@ -1283,7 +1369,10 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
1283
1369
|
while time.time() < end_time:
|
|
1284
1370
|
# Make token request
|
|
1285
1371
|
try:
|
|
1286
|
-
token_response = await self.backchannel_authentication_grant(
|
|
1372
|
+
token_response = await self.backchannel_authentication_grant(
|
|
1373
|
+
auth_req_id,
|
|
1374
|
+
store_options=store_options,
|
|
1375
|
+
)
|
|
1287
1376
|
return token_response
|
|
1288
1377
|
|
|
1289
1378
|
except Exception as e:
|
|
@@ -2243,10 +2332,45 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
2243
2332
|
https://datatracker.ietf.org/doc/html/rfc8693
|
|
2244
2333
|
"""
|
|
2245
2334
|
try:
|
|
2246
|
-
# Validate options (Pydantic handles this automatically)
|
|
2247
2335
|
if not isinstance(options, CustomTokenExchangeOptions):
|
|
2248
2336
|
options = CustomTokenExchangeOptions(**options)
|
|
2249
2337
|
|
|
2338
|
+
if not options.subject_token.strip():
|
|
2339
|
+
raise CustomTokenExchangeError(
|
|
2340
|
+
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
|
|
2341
|
+
"subject_token cannot be empty or whitespace-only"
|
|
2342
|
+
)
|
|
2343
|
+
if not options.subject_token_type.strip():
|
|
2344
|
+
raise CustomTokenExchangeError(
|
|
2345
|
+
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
|
|
2346
|
+
"subject_token_type cannot be empty or whitespace-only"
|
|
2347
|
+
)
|
|
2348
|
+
if options.subject_token.strip().startswith("Bearer "):
|
|
2349
|
+
raise CustomTokenExchangeError(
|
|
2350
|
+
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
|
|
2351
|
+
"subject_token should not include 'Bearer ' prefix"
|
|
2352
|
+
)
|
|
2353
|
+
if options.actor_token is not None and not options.actor_token.strip():
|
|
2354
|
+
raise CustomTokenExchangeError(
|
|
2355
|
+
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
|
|
2356
|
+
"actor_token cannot be empty or whitespace-only"
|
|
2357
|
+
)
|
|
2358
|
+
if options.actor_token and options.actor_token.strip().startswith("Bearer "):
|
|
2359
|
+
raise CustomTokenExchangeError(
|
|
2360
|
+
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
|
|
2361
|
+
"actor_token should not include 'Bearer ' prefix"
|
|
2362
|
+
)
|
|
2363
|
+
if options.actor_token and not options.actor_token_type:
|
|
2364
|
+
raise CustomTokenExchangeError(
|
|
2365
|
+
CustomTokenExchangeErrorCode.MISSING_ACTOR_TOKEN_TYPE,
|
|
2366
|
+
"actor_token_type is required when actor_token is provided"
|
|
2367
|
+
)
|
|
2368
|
+
if options.actor_token_type and not options.actor_token:
|
|
2369
|
+
raise CustomTokenExchangeError(
|
|
2370
|
+
CustomTokenExchangeErrorCode.MISSING_ACTOR_TOKEN,
|
|
2371
|
+
"actor_token is required when actor_token_type is provided"
|
|
2372
|
+
)
|
|
2373
|
+
|
|
2250
2374
|
# Resolve domain
|
|
2251
2375
|
domain = await self._resolve_current_domain(store_options)
|
|
2252
2376
|
metadata = await self._get_oidc_metadata_cached(domain)
|
|
@@ -2309,8 +2433,25 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
2309
2433
|
"Failed to parse token response as JSON"
|
|
2310
2434
|
)
|
|
2311
2435
|
|
|
2312
|
-
|
|
2313
|
-
|
|
2436
|
+
token_response = TokenExchangeResponse(**token_data)
|
|
2437
|
+
|
|
2438
|
+
# Surface the actor claim for delegation exchanges. Best-effort:
|
|
2439
|
+
# a decode/verify hiccup must not fail an exchange the token
|
|
2440
|
+
# endpoint already accepted, so act stays None on any failure.
|
|
2441
|
+
if options.actor_token and token_response.id_token:
|
|
2442
|
+
try:
|
|
2443
|
+
jwks = await self._get_jwks_cached(domain, metadata)
|
|
2444
|
+
claims = await self._verify_and_decode_jwt(
|
|
2445
|
+
token_response.id_token, jwks, audience=self._client_id
|
|
2446
|
+
)
|
|
2447
|
+
# Apply the same normalized issuer check the login path uses
|
|
2448
|
+
# before trusting any claim from the token.
|
|
2449
|
+
if self._normalize_url(claims.get("iss", "")) == self._normalize_url(metadata.get("issuer")):
|
|
2450
|
+
token_response.act = claims.get("act")
|
|
2451
|
+
except Exception:
|
|
2452
|
+
token_response.act = None
|
|
2453
|
+
|
|
2454
|
+
return token_response
|
|
2314
2455
|
|
|
2315
2456
|
except ValidationError as e:
|
|
2316
2457
|
raise CustomTokenExchangeError(
|
|
@@ -2386,6 +2527,7 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
2386
2527
|
# Extract user claims from ID token if present
|
|
2387
2528
|
user_claims = None
|
|
2388
2529
|
sid = PKCE.generate_random_string(32) # Default sid
|
|
2530
|
+
|
|
2389
2531
|
if token_response.id_token:
|
|
2390
2532
|
# Fetch JWKS and verify ID token signature
|
|
2391
2533
|
jwks = await self._get_jwks_cached(domain, metadata)
|
|
@@ -2402,6 +2544,8 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
2402
2544
|
"ID token issuer mismatch. Ensure your Auth0 domain is configured correctly."
|
|
2403
2545
|
)
|
|
2404
2546
|
|
|
2547
|
+
# UserClaims allows extra fields, so any act claim in the
|
|
2548
|
+
# verified id_token is carried onto the session user here.
|
|
2405
2549
|
user_claims = UserClaims.parse_obj(claims)
|
|
2406
2550
|
# Extract sid from token if available
|
|
2407
2551
|
sid = claims.get("sid", sid)
|
|
@@ -2467,7 +2611,7 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
2467
2611
|
return result
|
|
2468
2612
|
|
|
2469
2613
|
except Exception as e:
|
|
2470
|
-
if isinstance(e, (CustomTokenExchangeError, ApiError)):
|
|
2614
|
+
if isinstance(e, (CustomTokenExchangeError, ApiError, IssuerValidationError)):
|
|
2471
2615
|
raise
|
|
2472
2616
|
raise CustomTokenExchangeError(
|
|
2473
2617
|
CustomTokenExchangeErrorCode.TOKEN_EXCHANGE_FAILED,
|
|
@@ -5,7 +5,10 @@ These Pydantic models provide type safety and validation for all SDK data struct
|
|
|
5
5
|
|
|
6
6
|
from typing import Any, Literal, Optional, Union
|
|
7
7
|
|
|
8
|
-
from pydantic import BaseModel, Field, field_validator
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator
|
|
9
|
+
|
|
10
|
+
# Upper bound (Unix seconds) for a plausible session_expiry
|
|
11
|
+
SESSION_EXPIRY_MAX_PLAUSIBLE = 10_000_000_000
|
|
9
12
|
|
|
10
13
|
|
|
11
14
|
class UserClaims(BaseModel):
|
|
@@ -22,10 +25,22 @@ class UserClaims(BaseModel):
|
|
|
22
25
|
email: Optional[str] = None
|
|
23
26
|
email_verified: Optional[bool] = None
|
|
24
27
|
org_id: Optional[str] = None
|
|
28
|
+
org_name: Optional[str] = None
|
|
29
|
+
# IPSIE SL1 claim: upstream IdP-asserted RP session ceiling (Unix seconds).
|
|
30
|
+
session_expiry: Optional[int] = None
|
|
25
31
|
|
|
26
32
|
class Config:
|
|
27
33
|
extra = "allow" # Allow additional fields not defined in the model
|
|
28
34
|
|
|
35
|
+
@field_validator('session_expiry', mode='before')
|
|
36
|
+
@classmethod
|
|
37
|
+
def _sanitize_session_expiry(cls, value: Any) -> Optional[int]:
|
|
38
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
39
|
+
return None
|
|
40
|
+
if value <= 0 or value >= SESSION_EXPIRY_MAX_PLAUSIBLE:
|
|
41
|
+
return None
|
|
42
|
+
return value
|
|
43
|
+
|
|
29
44
|
|
|
30
45
|
class TokenSet(BaseModel):
|
|
31
46
|
"""
|
|
@@ -54,6 +69,10 @@ class InternalStateData(BaseModel):
|
|
|
54
69
|
"""
|
|
55
70
|
sid: str
|
|
56
71
|
created_at: int
|
|
72
|
+
# IPSIE session_expiry ceiling (Unix seconds), stamped at session creation
|
|
73
|
+
# from the ID token's session_expiry claim. None when the upstream IdP did
|
|
74
|
+
# not assert one — in which case existing session behavior is unchanged.
|
|
75
|
+
session_expires_at: Optional[int] = None
|
|
57
76
|
|
|
58
77
|
|
|
59
78
|
class SessionData(BaseModel):
|
|
@@ -91,6 +110,7 @@ class TransactionData(BaseModel):
|
|
|
91
110
|
auth_session: Optional[str] = None
|
|
92
111
|
redirect_uri: Optional[str] = None
|
|
93
112
|
domain: Optional[str] = None
|
|
113
|
+
organization: Optional[str] = None
|
|
94
114
|
|
|
95
115
|
class Config:
|
|
96
116
|
extra = "allow" # Allow additional fields not defined in the model
|
|
@@ -128,6 +148,7 @@ class ServerClientOptionsBase(BaseModel):
|
|
|
128
148
|
transaction_identifier: Optional[str] = "_a0_tx"
|
|
129
149
|
state_identifier: Optional[str] = "_a0_session"
|
|
130
150
|
custom_fetch: Optional[Any] = None # Function type hint would be more complex
|
|
151
|
+
organization: Optional[str] = None
|
|
131
152
|
|
|
132
153
|
|
|
133
154
|
class ServerClientOptionsWithSecret(ServerClientOptionsBase):
|
|
@@ -147,6 +168,8 @@ class StartInteractiveLoginOptions(BaseModel):
|
|
|
147
168
|
pushed_authorization_requests: Optional[bool] = False
|
|
148
169
|
app_state: Optional[Any] = None
|
|
149
170
|
authorization_params: Optional[dict[str, Any]] = None
|
|
171
|
+
organization: Optional[str] = None
|
|
172
|
+
invitation: Optional[str] = None
|
|
150
173
|
|
|
151
174
|
|
|
152
175
|
class LogoutOptions(BaseModel):
|
|
@@ -257,8 +280,8 @@ class CustomTokenExchangeOptions(BaseModel):
|
|
|
257
280
|
organization: Organization identifier for the token exchange (optional)
|
|
258
281
|
authorization_params: Additional OAuth parameters (optional)
|
|
259
282
|
"""
|
|
260
|
-
subject_token: str
|
|
261
|
-
subject_token_type: str
|
|
283
|
+
subject_token: str
|
|
284
|
+
subject_token_type: str
|
|
262
285
|
audience: Optional[str] = None
|
|
263
286
|
scope: Optional[str] = None
|
|
264
287
|
actor_token: Optional[str] = None
|
|
@@ -266,24 +289,6 @@ class CustomTokenExchangeOptions(BaseModel):
|
|
|
266
289
|
organization: Optional[str] = None
|
|
267
290
|
authorization_params: Optional[dict[str, Any]] = None
|
|
268
291
|
|
|
269
|
-
@field_validator('subject_token', 'actor_token')
|
|
270
|
-
@classmethod
|
|
271
|
-
def validate_token_format(cls, v: Optional[str]) -> Optional[str]:
|
|
272
|
-
"""Validate token doesn't have Bearer prefix and isn't whitespace-only."""
|
|
273
|
-
if v is not None:
|
|
274
|
-
if not v.strip():
|
|
275
|
-
raise ValueError("Token cannot be empty or whitespace-only")
|
|
276
|
-
if v.strip().startswith("Bearer "):
|
|
277
|
-
raise ValueError("Token should not include 'Bearer ' prefix")
|
|
278
|
-
return v
|
|
279
|
-
|
|
280
|
-
@model_validator(mode='after')
|
|
281
|
-
def validate_actor_token_type(self) -> 'CustomTokenExchangeOptions':
|
|
282
|
-
"""Ensure actor_token_type is provided if actor_token is present."""
|
|
283
|
-
if self.actor_token and not self.actor_token_type:
|
|
284
|
-
raise ValueError("actor_token_type is required when actor_token is provided")
|
|
285
|
-
return self
|
|
286
|
-
|
|
287
292
|
|
|
288
293
|
class TokenExchangeResponse(BaseModel):
|
|
289
294
|
"""
|
|
@@ -297,6 +302,7 @@ class TokenExchangeResponse(BaseModel):
|
|
|
297
302
|
issued_token_type: Format of issued token
|
|
298
303
|
id_token: OpenID Connect ID token (optional)
|
|
299
304
|
refresh_token: Refresh token (optional)
|
|
305
|
+
act: Actor claim for delegation/impersonation exchanges (optional)
|
|
300
306
|
"""
|
|
301
307
|
access_token: str
|
|
302
308
|
token_type: str = "Bearer"
|
|
@@ -305,6 +311,7 @@ class TokenExchangeResponse(BaseModel):
|
|
|
305
311
|
issued_token_type: Optional[str] = None
|
|
306
312
|
id_token: Optional[str] = None
|
|
307
313
|
refresh_token: Optional[str] = None
|
|
314
|
+
act: Optional[dict[str, Any]] = None
|
|
308
315
|
|
|
309
316
|
|
|
310
317
|
class LoginWithCustomTokenExchangeOptions(BaseModel):
|
|
@@ -313,8 +320,8 @@ class LoginWithCustomTokenExchangeOptions(BaseModel):
|
|
|
313
320
|
|
|
314
321
|
Combines token exchange parameters with session management.
|
|
315
322
|
"""
|
|
316
|
-
subject_token: str
|
|
317
|
-
subject_token_type: str
|
|
323
|
+
subject_token: str
|
|
324
|
+
subject_token_type: str
|
|
318
325
|
audience: Optional[str] = None
|
|
319
326
|
scope: Optional[str] = None
|
|
320
327
|
actor_token: Optional[str] = None
|
|
@@ -322,24 +329,6 @@ class LoginWithCustomTokenExchangeOptions(BaseModel):
|
|
|
322
329
|
organization: Optional[str] = None
|
|
323
330
|
authorization_params: Optional[dict[str, Any]] = None
|
|
324
331
|
|
|
325
|
-
@field_validator('subject_token', 'actor_token')
|
|
326
|
-
@classmethod
|
|
327
|
-
def validate_token_format(cls, v: Optional[str]) -> Optional[str]:
|
|
328
|
-
"""Validate token doesn't have Bearer prefix and isn't whitespace-only."""
|
|
329
|
-
if v is not None:
|
|
330
|
-
if not v.strip():
|
|
331
|
-
raise ValueError("Token cannot be empty or whitespace-only")
|
|
332
|
-
if v.strip().startswith("Bearer "):
|
|
333
|
-
raise ValueError("Token should not include 'Bearer ' prefix")
|
|
334
|
-
return v
|
|
335
|
-
|
|
336
|
-
@model_validator(mode='after')
|
|
337
|
-
def validate_actor_token_type(self) -> 'LoginWithCustomTokenExchangeOptions':
|
|
338
|
-
"""Ensure actor_token_type is provided if actor_token is present."""
|
|
339
|
-
if self.actor_token and not self.actor_token_type:
|
|
340
|
-
raise ValueError("actor_token_type is required when actor_token is provided")
|
|
341
|
-
return self
|
|
342
|
-
|
|
343
332
|
|
|
344
333
|
class LoginWithCustomTokenExchangeResult(BaseModel):
|
|
345
334
|
"""
|
|
@@ -198,6 +198,19 @@ class AccessTokenErrorCode:
|
|
|
198
198
|
INCORRECT_AUDIENCE = "incorrect_audience"
|
|
199
199
|
MISSING_SESSION_DOMAIN = "missing_session_domain"
|
|
200
200
|
DOMAIN_MISMATCH = "domain_mismatch"
|
|
201
|
+
SESSION_EXPIRED = "session_expired"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class OrganizationTokenValidationError(Auth0Error):
|
|
205
|
+
"""
|
|
206
|
+
Raised when org_id or org_name claim in the ID token fails validation
|
|
207
|
+
against the organization value that was requested at login.
|
|
208
|
+
"""
|
|
209
|
+
code = "organization_token_validation_error"
|
|
210
|
+
|
|
211
|
+
def __init__(self, message: str):
|
|
212
|
+
super().__init__(message)
|
|
213
|
+
self.name = "OrganizationTokenValidationError"
|
|
201
214
|
|
|
202
215
|
|
|
203
216
|
class AccessTokenForConnectionErrorCode:
|
|
@@ -210,6 +223,19 @@ class AccessTokenForConnectionErrorCode:
|
|
|
210
223
|
DOMAIN_MISMATCH = "domain_mismatch"
|
|
211
224
|
|
|
212
225
|
|
|
226
|
+
class SessionExpiredError(Auth0Error):
|
|
227
|
+
"""
|
|
228
|
+
Error raised when a session is rejected at login because its
|
|
229
|
+
session_expiry ceiling is already in the past.
|
|
230
|
+
"""
|
|
231
|
+
code = AccessTokenErrorCode.SESSION_EXPIRED
|
|
232
|
+
|
|
233
|
+
def __init__(self, message: Optional[str] = None, cause=None):
|
|
234
|
+
super().__init__(message or "The session has expired and the user must re-authenticate.")
|
|
235
|
+
self.name = "SessionExpiredError"
|
|
236
|
+
self.cause = cause
|
|
237
|
+
|
|
238
|
+
|
|
213
239
|
class CustomTokenExchangeError(Auth0Error):
|
|
214
240
|
"""
|
|
215
241
|
Error raised during custom token exchange operations.
|
|
@@ -225,6 +251,7 @@ class CustomTokenExchangeErrorCode:
|
|
|
225
251
|
"""Error codes for custom token exchange operations."""
|
|
226
252
|
INVALID_TOKEN_FORMAT = "invalid_token_format"
|
|
227
253
|
MISSING_ACTOR_TOKEN_TYPE = "missing_actor_token_type"
|
|
254
|
+
MISSING_ACTOR_TOKEN = "missing_actor_token"
|
|
228
255
|
TOKEN_EXCHANGE_FAILED = "token_exchange_failed"
|
|
229
256
|
INVALID_RESPONSE = "invalid_response"
|
|
230
257
|
|