auth0-server-python 1.0.0b11__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.0b11 → auth0_server_python-1.0.0b12}/PKG-INFO +7 -1
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/README.md +6 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/pyproject.toml +1 -1
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_server/server_client.py +46 -1
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_types/__init__.py +19 -1
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/error/__init__.py +14 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/tests/test_server_client.py +682 -1
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/utils/helpers.py +40 -1
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/LICENSE +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_schemes/__init__.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_schemes/bearer_auth.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_server/__init__.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_server/mfa_client.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_server/my_account_client.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/encryption/__init__.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/encryption/encrypt.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/store/__init__.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/store/abstract.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/telemetry.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/tests/test_mfa_client.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/tests/test_my_account_client.py +0 -0
- {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/tests/test_telemetry.py +0 -0
- {auth0_server_python-1.0.0b11 → 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
|
|
@@ -202,6 +202,12 @@ The SDK handles per-domain OIDC discovery, JWKS fetching, issuer validation, and
|
|
|
202
202
|
|
|
203
203
|
For more details and examples, see [examples/MultipleCustomDomains.md](examples/MultipleCustomDomains.md).
|
|
204
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
|
+
|
|
205
211
|
## Feedback
|
|
206
212
|
|
|
207
213
|
### Contributing
|
|
@@ -177,6 +177,12 @@ The SDK handles per-domain OIDC discovery, JWKS fetching, issuer validation, and
|
|
|
177
177
|
|
|
178
178
|
For more details and examples, see [examples/MultipleCustomDomains.md](examples/MultipleCustomDomains.md).
|
|
179
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
|
+
|
|
180
186
|
## Feedback
|
|
181
187
|
|
|
182
188
|
### Contributing
|
|
@@ -56,6 +56,7 @@ from auth0_server_python.error import (
|
|
|
56
56
|
MissingTransactionError,
|
|
57
57
|
OrganizationTokenValidationError,
|
|
58
58
|
PollingApiError,
|
|
59
|
+
SessionExpiredError,
|
|
59
60
|
StartLinkUserError,
|
|
60
61
|
)
|
|
61
62
|
from auth0_server_python.telemetry import Telemetry
|
|
@@ -656,7 +657,12 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
656
657
|
# Use the userinfo field from the token_response for user claims
|
|
657
658
|
user_info = token_response.get("userinfo")
|
|
658
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
|
|
659
664
|
id_token = token_response.get("id_token")
|
|
665
|
+
|
|
660
666
|
expected_org = transaction_data.organization
|
|
661
667
|
|
|
662
668
|
if not user_info and not id_token and expected_org:
|
|
@@ -698,6 +704,8 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
698
704
|
validate_org_claims(claims, expected_org)
|
|
699
705
|
|
|
700
706
|
user_claims = UserClaims.parse_obj(claims)
|
|
707
|
+
session_expires_at = user_claims.session_expiry
|
|
708
|
+
issued_at = claims.get("iat")
|
|
701
709
|
except ValueError as e:
|
|
702
710
|
raise ApiError("jwks_key_not_found", str(e))
|
|
703
711
|
except jwt.InvalidSignatureError as e:
|
|
@@ -726,6 +734,11 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
726
734
|
)
|
|
727
735
|
|
|
728
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
|
+
|
|
729
742
|
# Build a token set using the token response data
|
|
730
743
|
token_set = TokenSet(
|
|
731
744
|
audience=transaction_data.audience or self.DEFAULT_AUDIENCE_STATE_KEY,
|
|
@@ -749,7 +762,8 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
749
762
|
domain=origin_domain,
|
|
750
763
|
internal={
|
|
751
764
|
"sid": sid,
|
|
752
|
-
"created_at": int(time.time())
|
|
765
|
+
"created_at": int(time.time()),
|
|
766
|
+
"session_expires_at": session_expires_at
|
|
753
767
|
}
|
|
754
768
|
)
|
|
755
769
|
|
|
@@ -775,6 +789,23 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
775
789
|
# Methods for retrieving user information, session data, and logout operations.
|
|
776
790
|
# ============================================================================
|
|
777
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
|
+
|
|
778
809
|
async def get_user(self, store_options: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]:
|
|
779
810
|
"""
|
|
780
811
|
Retrieves the user from the store, or None if no user found.
|
|
@@ -801,6 +832,10 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
801
832
|
if self._normalize_url(session_domain) != self._normalize_url(current_domain):
|
|
802
833
|
return None
|
|
803
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
|
+
|
|
804
839
|
return state_data.get("user")
|
|
805
840
|
return None
|
|
806
841
|
|
|
@@ -830,6 +865,10 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
830
865
|
if self._normalize_url(session_domain) != self._normalize_url(current_domain):
|
|
831
866
|
return None
|
|
832
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
|
+
|
|
833
872
|
session_data = {k: v for k, v in state_data.items()
|
|
834
873
|
if k != "internal"}
|
|
835
874
|
return session_data
|
|
@@ -1013,6 +1052,12 @@ class ServerClient(Generic[TStoreOptions]):
|
|
|
1013
1052
|
|
|
1014
1053
|
merged_scope = self._merge_scope_with_defaults(scope, audience)
|
|
1015
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
|
+
|
|
1016
1061
|
# Find matching token set
|
|
1017
1062
|
token_set = None
|
|
1018
1063
|
if state_data_dict and "token_sets" in state_data_dict:
|
|
@@ -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
|
|
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):
|
|
@@ -23,10 +26,21 @@ class UserClaims(BaseModel):
|
|
|
23
26
|
email_verified: Optional[bool] = None
|
|
24
27
|
org_id: Optional[str] = None
|
|
25
28
|
org_name: Optional[str] = None
|
|
29
|
+
# IPSIE SL1 claim: upstream IdP-asserted RP session ceiling (Unix seconds).
|
|
30
|
+
session_expiry: Optional[int] = None
|
|
26
31
|
|
|
27
32
|
class Config:
|
|
28
33
|
extra = "allow" # Allow additional fields not defined in the model
|
|
29
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
|
+
|
|
30
44
|
|
|
31
45
|
class TokenSet(BaseModel):
|
|
32
46
|
"""
|
|
@@ -55,6 +69,10 @@ class InternalStateData(BaseModel):
|
|
|
55
69
|
"""
|
|
56
70
|
sid: str
|
|
57
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
|
|
58
76
|
|
|
59
77
|
|
|
60
78
|
class SessionData(BaseModel):
|
|
@@ -198,6 +198,7 @@ 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"
|
|
201
202
|
|
|
202
203
|
|
|
203
204
|
class OrganizationTokenValidationError(Auth0Error):
|
|
@@ -222,6 +223,19 @@ class AccessTokenForConnectionErrorCode:
|
|
|
222
223
|
DOMAIN_MISMATCH = "domain_mismatch"
|
|
223
224
|
|
|
224
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
|
+
|
|
225
239
|
class CustomTokenExchangeError(Auth0Error):
|
|
226
240
|
"""
|
|
227
241
|
Error raised during custom token exchange operations.
|
|
@@ -27,6 +27,7 @@ from auth0_server_python.auth_types import (
|
|
|
27
27
|
StartInteractiveLoginOptions,
|
|
28
28
|
StateData,
|
|
29
29
|
TransactionData,
|
|
30
|
+
UserClaims,
|
|
30
31
|
)
|
|
31
32
|
from auth0_server_python.error import (
|
|
32
33
|
AccessTokenError,
|
|
@@ -46,6 +47,7 @@ from auth0_server_python.error import (
|
|
|
46
47
|
MissingTransactionError,
|
|
47
48
|
OrganizationTokenValidationError,
|
|
48
49
|
PollingApiError,
|
|
50
|
+
SessionExpiredError,
|
|
49
51
|
StartLinkUserError,
|
|
50
52
|
)
|
|
51
53
|
from auth0_server_python.utils import PKCE, State
|
|
@@ -5183,7 +5185,9 @@ async def test_get_access_token_mfa_required_with_enroll_requirements(mocker):
|
|
|
5183
5185
|
ServerClient._fetch_oidc_metadata = original_fetch
|
|
5184
5186
|
|
|
5185
5187
|
|
|
5186
|
-
#
|
|
5188
|
+
# =============================================================================
|
|
5189
|
+
# Organization and Invitation Tests
|
|
5190
|
+
# =============================================================================
|
|
5187
5191
|
|
|
5188
5192
|
def _make_org_client(mocker, transaction_data: TransactionData, **extra):
|
|
5189
5193
|
"""Helper: build a ServerClient with mocked stores and standard JWT mocks."""
|
|
@@ -6123,3 +6127,680 @@ async def test_client_level_org_used_when_options_org_is_none_not_set(mocker):
|
|
|
6123
6127
|
await client.start_interactive_login(StartInteractiveLoginOptions())
|
|
6124
6128
|
|
|
6125
6129
|
assert stored_tx.organization == "org_default"
|
|
6130
|
+
|
|
6131
|
+
# =============================================================================
|
|
6132
|
+
# IPSIE session_expiry enforcement
|
|
6133
|
+
# =============================================================================
|
|
6134
|
+
|
|
6135
|
+
|
|
6136
|
+
def test_is_session_ceiling_reached_none_never_expires():
|
|
6137
|
+
assert State.is_session_ceiling_reached(None) is False
|
|
6138
|
+
|
|
6139
|
+
|
|
6140
|
+
def test_is_session_ceiling_reached_future_and_past():
|
|
6141
|
+
now = int(time.time())
|
|
6142
|
+
# Comfortably in the future (beyond the leeway window) -> not reached.
|
|
6143
|
+
assert State.is_session_ceiling_reached(now + 3600) is False
|
|
6144
|
+
# In the past -> reached.
|
|
6145
|
+
assert State.is_session_ceiling_reached(now - 10) is True
|
|
6146
|
+
|
|
6147
|
+
|
|
6148
|
+
def test_is_session_ceiling_reached_applies_negative_leeway():
|
|
6149
|
+
now = int(time.time())
|
|
6150
|
+
# Ceiling is 10s away but leeway is 30s, so it's treated as already reached.
|
|
6151
|
+
assert State.is_session_ceiling_reached(now + 10) is True
|
|
6152
|
+
|
|
6153
|
+
|
|
6154
|
+
def test_is_session_ceiling_in_past_none_is_safe_default():
|
|
6155
|
+
# No ceiling asserted -> never treated as expired.
|
|
6156
|
+
assert State.is_session_ceiling_in_past(None, 1893456000) is False
|
|
6157
|
+
assert State.is_session_ceiling_in_past(None, None) is False
|
|
6158
|
+
|
|
6159
|
+
|
|
6160
|
+
def test_is_session_ceiling_in_past_past_ceiling_relative_to_iat():
|
|
6161
|
+
iat = 1893456000
|
|
6162
|
+
# Ceiling well before iat -> already lapsed at login.
|
|
6163
|
+
assert State.is_session_ceiling_in_past(iat - 3600, iat) is True
|
|
6164
|
+
|
|
6165
|
+
|
|
6166
|
+
def test_is_session_ceiling_in_past_future_ceiling_relative_to_iat():
|
|
6167
|
+
iat = 1893456000
|
|
6168
|
+
# Ceiling well after iat -> not lapsed.
|
|
6169
|
+
assert State.is_session_ceiling_in_past(iat + 3600, iat) is False
|
|
6170
|
+
|
|
6171
|
+
|
|
6172
|
+
def test_is_session_ceiling_in_past_falls_back_to_now_when_iat_absent():
|
|
6173
|
+
now = int(time.time())
|
|
6174
|
+
# No iat -> compare against wall-clock now; a past ceiling is lapsed.
|
|
6175
|
+
assert State.is_session_ceiling_in_past(now - 100, None) is True
|
|
6176
|
+
|
|
6177
|
+
|
|
6178
|
+
def test_is_session_ceiling_in_past_leeway_boundary():
|
|
6179
|
+
iat = 1893456000
|
|
6180
|
+
leeway = State.SESSION_EXPIRY_LEEWAY_SECONDS
|
|
6181
|
+
# Ceiling exactly at iat + leeway is treated as already lapsed...
|
|
6182
|
+
assert State.is_session_ceiling_in_past(iat + leeway, iat) is True
|
|
6183
|
+
# ...one second beyond the leeway window is not.
|
|
6184
|
+
assert State.is_session_ceiling_in_past(iat + leeway + 1, iat) is False
|
|
6185
|
+
|
|
6186
|
+
|
|
6187
|
+
def test_session_expired_error_message_is_generic():
|
|
6188
|
+
message = str(SessionExpiredError())
|
|
6189
|
+
# States the reason without leaking any timestamps or values.
|
|
6190
|
+
assert message == "The session has expired and the user must re-authenticate."
|
|
6191
|
+
assert not any(ch.isdigit() for ch in message)
|
|
6192
|
+
assert SessionExpiredError().code == AccessTokenErrorCode.SESSION_EXPIRED
|
|
6193
|
+
|
|
6194
|
+
|
|
6195
|
+
@pytest.mark.parametrize("value,expected", [
|
|
6196
|
+
(1900000000, 1900000000), # plausible seconds -> kept
|
|
6197
|
+
(1748566800000, None), # milliseconds -> rejected
|
|
6198
|
+
(10_000_000_000, None), # at the implausible-future bound -> rejected
|
|
6199
|
+
(0, None), # non-positive -> rejected
|
|
6200
|
+
(-5, None), # negative -> rejected
|
|
6201
|
+
(True, None), # bool is not a valid int here -> rejected
|
|
6202
|
+
("1748566800", None), # numeric string -> rejected
|
|
6203
|
+
("not-a-number", None), # garbage string -> rejected
|
|
6204
|
+
(1.5, None), # float -> rejected
|
|
6205
|
+
(None, None), # absent/null -> no ceiling
|
|
6206
|
+
])
|
|
6207
|
+
def test_user_claims_sanitizes_session_expiry(value, expected):
|
|
6208
|
+
assert UserClaims(sub="u", session_expiry=value).session_expiry == expected
|
|
6209
|
+
|
|
6210
|
+
|
|
6211
|
+
def test_user_claims_session_expiry_absent_is_none():
|
|
6212
|
+
assert UserClaims(sub="u").session_expiry is None
|
|
6213
|
+
|
|
6214
|
+
|
|
6215
|
+
def test_update_state_data_preserves_ceiling_across_refresh():
|
|
6216
|
+
now = int(time.time())
|
|
6217
|
+
ceiling = now + 3600
|
|
6218
|
+
existing_state = {
|
|
6219
|
+
"refresh_token": "refresh_xyz",
|
|
6220
|
+
"token_sets": [],
|
|
6221
|
+
"internal": {"sid": "some_sid", "created_at": now, "session_expires_at": ceiling},
|
|
6222
|
+
}
|
|
6223
|
+
# A refresh-token grant never carries session_expiry; the login ceiling stands.
|
|
6224
|
+
refresh_response = {"access_token": "new_token", "scope": "openid", "expires_in": 3600}
|
|
6225
|
+
|
|
6226
|
+
updated = State.update_state_data("default", existing_state, refresh_response)
|
|
6227
|
+
|
|
6228
|
+
assert updated["internal"]["session_expires_at"] == ceiling
|
|
6229
|
+
|
|
6230
|
+
|
|
6231
|
+
@pytest.mark.asyncio
|
|
6232
|
+
async def test_get_session_expired_by_ceiling_returns_none_and_deletes():
|
|
6233
|
+
now = int(time.time())
|
|
6234
|
+
mock_state_store = AsyncMock()
|
|
6235
|
+
mock_state_store.get.return_value = {
|
|
6236
|
+
"user": {"sub": "user123"},
|
|
6237
|
+
"id_token": "token123",
|
|
6238
|
+
"internal": {"sid": "some_sid", "created_at": now - 100, "session_expires_at": now - 10},
|
|
6239
|
+
}
|
|
6240
|
+
|
|
6241
|
+
client = ServerClient(
|
|
6242
|
+
domain="auth0.local",
|
|
6243
|
+
client_id="client_id",
|
|
6244
|
+
client_secret="client_secret",
|
|
6245
|
+
transaction_store=AsyncMock(),
|
|
6246
|
+
state_store=mock_state_store,
|
|
6247
|
+
secret="some-secret"
|
|
6248
|
+
)
|
|
6249
|
+
|
|
6250
|
+
session_data = await client.get_session()
|
|
6251
|
+
assert session_data is None
|
|
6252
|
+
mock_state_store.delete.assert_awaited_once()
|
|
6253
|
+
|
|
6254
|
+
|
|
6255
|
+
@pytest.mark.asyncio
|
|
6256
|
+
async def test_get_session_within_ceiling_ok():
|
|
6257
|
+
now = int(time.time())
|
|
6258
|
+
mock_state_store = AsyncMock()
|
|
6259
|
+
mock_state_store.get.return_value = {
|
|
6260
|
+
"user": {"sub": "user123"},
|
|
6261
|
+
"id_token": "token123",
|
|
6262
|
+
"internal": {"sid": "some_sid", "created_at": now, "session_expires_at": now + 3600},
|
|
6263
|
+
}
|
|
6264
|
+
|
|
6265
|
+
client = ServerClient(
|
|
6266
|
+
domain="auth0.local",
|
|
6267
|
+
client_id="client_id",
|
|
6268
|
+
client_secret="client_secret",
|
|
6269
|
+
transaction_store=AsyncMock(),
|
|
6270
|
+
state_store=mock_state_store,
|
|
6271
|
+
secret="some-secret"
|
|
6272
|
+
)
|
|
6273
|
+
|
|
6274
|
+
session_data = await client.get_session()
|
|
6275
|
+
assert session_data is not None
|
|
6276
|
+
assert session_data["user"] == {"sub": "user123"}
|
|
6277
|
+
mock_state_store.delete.assert_not_awaited()
|
|
6278
|
+
|
|
6279
|
+
|
|
6280
|
+
@pytest.mark.asyncio
|
|
6281
|
+
async def test_get_user_expired_by_ceiling_returns_none_and_deletes():
|
|
6282
|
+
now = int(time.time())
|
|
6283
|
+
mock_state_store = AsyncMock()
|
|
6284
|
+
mock_state_store.get.return_value = {
|
|
6285
|
+
"user": {"sub": "user123"},
|
|
6286
|
+
"internal": {"sid": "some_sid", "created_at": now - 100, "session_expires_at": now - 10},
|
|
6287
|
+
}
|
|
6288
|
+
|
|
6289
|
+
client = ServerClient(
|
|
6290
|
+
domain="auth0.local",
|
|
6291
|
+
client_id="client_id",
|
|
6292
|
+
client_secret="client_secret",
|
|
6293
|
+
transaction_store=AsyncMock(),
|
|
6294
|
+
state_store=mock_state_store,
|
|
6295
|
+
secret="some-secret"
|
|
6296
|
+
)
|
|
6297
|
+
|
|
6298
|
+
user = await client.get_user()
|
|
6299
|
+
assert user is None
|
|
6300
|
+
mock_state_store.delete.assert_awaited_once()
|
|
6301
|
+
|
|
6302
|
+
|
|
6303
|
+
@pytest.mark.asyncio
|
|
6304
|
+
async def test_get_user_no_ceiling_unaffected():
|
|
6305
|
+
mock_state_store = AsyncMock()
|
|
6306
|
+
mock_state_store.get.return_value = {
|
|
6307
|
+
"user": {"sub": "user123"},
|
|
6308
|
+
"internal": {"sid": "some_sid", "created_at": int(time.time())},
|
|
6309
|
+
}
|
|
6310
|
+
|
|
6311
|
+
client = ServerClient(
|
|
6312
|
+
domain="auth0.local",
|
|
6313
|
+
client_id="client_id",
|
|
6314
|
+
client_secret="client_secret",
|
|
6315
|
+
transaction_store=AsyncMock(),
|
|
6316
|
+
state_store=mock_state_store,
|
|
6317
|
+
secret="some-secret"
|
|
6318
|
+
)
|
|
6319
|
+
|
|
6320
|
+
user = await client.get_user()
|
|
6321
|
+
assert user == {"sub": "user123"}
|
|
6322
|
+
mock_state_store.delete.assert_not_awaited()
|
|
6323
|
+
|
|
6324
|
+
|
|
6325
|
+
@pytest.mark.asyncio
|
|
6326
|
+
async def test_get_access_token_expired_by_ceiling_raises_without_refresh(mocker):
|
|
6327
|
+
now = int(time.time())
|
|
6328
|
+
mock_state_store = AsyncMock()
|
|
6329
|
+
mock_state_store.get.return_value = {
|
|
6330
|
+
"refresh_token": "refresh_xyz",
|
|
6331
|
+
"token_sets": [
|
|
6332
|
+
{
|
|
6333
|
+
"audience": "default",
|
|
6334
|
+
"access_token": "cached_token",
|
|
6335
|
+
"expires_at": now + 500, # still valid, but ceiling overrides
|
|
6336
|
+
}
|
|
6337
|
+
],
|
|
6338
|
+
"internal": {"sid": "some_sid", "created_at": now - 100, "session_expires_at": now - 10},
|
|
6339
|
+
}
|
|
6340
|
+
|
|
6341
|
+
client = ServerClient(
|
|
6342
|
+
domain="auth0.local",
|
|
6343
|
+
client_id="client_id",
|
|
6344
|
+
client_secret="client_secret",
|
|
6345
|
+
transaction_store=AsyncMock(),
|
|
6346
|
+
state_store=mock_state_store,
|
|
6347
|
+
secret="some-secret"
|
|
6348
|
+
)
|
|
6349
|
+
|
|
6350
|
+
# If the refresh path is reached, that's a bug — make it explode.
|
|
6351
|
+
refresh_spy = mocker.patch.object(
|
|
6352
|
+
client, "get_token_by_refresh_token", new_callable=AsyncMock,
|
|
6353
|
+
side_effect=AssertionError("refresh must not be attempted after ceiling"),
|
|
6354
|
+
)
|
|
6355
|
+
|
|
6356
|
+
with pytest.raises(SessionExpiredError) as exc:
|
|
6357
|
+
await client.get_access_token()
|
|
6358
|
+
|
|
6359
|
+
assert exc.value.code == AccessTokenErrorCode.SESSION_EXPIRED
|
|
6360
|
+
refresh_spy.assert_not_awaited()
|
|
6361
|
+
mock_state_store.delete.assert_awaited_once()
|
|
6362
|
+
|
|
6363
|
+
|
|
6364
|
+
@pytest.mark.asyncio
|
|
6365
|
+
async def test_get_access_token_within_ceiling_serves_cached():
|
|
6366
|
+
now = int(time.time())
|
|
6367
|
+
mock_state_store = AsyncMock()
|
|
6368
|
+
mock_state_store.get.return_value = {
|
|
6369
|
+
"refresh_token": "refresh_xyz",
|
|
6370
|
+
"token_sets": [
|
|
6371
|
+
{
|
|
6372
|
+
"audience": "default",
|
|
6373
|
+
"access_token": "cached_token",
|
|
6374
|
+
"expires_at": now + 500,
|
|
6375
|
+
}
|
|
6376
|
+
],
|
|
6377
|
+
"internal": {"sid": "some_sid", "created_at": now, "session_expires_at": now + 3600},
|
|
6378
|
+
}
|
|
6379
|
+
|
|
6380
|
+
client = ServerClient(
|
|
6381
|
+
domain="auth0.local",
|
|
6382
|
+
client_id="client_id",
|
|
6383
|
+
client_secret="client_secret",
|
|
6384
|
+
transaction_store=AsyncMock(),
|
|
6385
|
+
state_store=mock_state_store,
|
|
6386
|
+
secret="some-secret"
|
|
6387
|
+
)
|
|
6388
|
+
|
|
6389
|
+
token = await client.get_access_token()
|
|
6390
|
+
assert token == "cached_token"
|
|
6391
|
+
mock_state_store.delete.assert_not_awaited()
|
|
6392
|
+
|
|
6393
|
+
|
|
6394
|
+
@pytest.mark.asyncio
|
|
6395
|
+
async def test_get_access_token_for_connection_not_gated_by_ceiling():
|
|
6396
|
+
# Token Vault connection tokens follow the upstream IdP's own expires_in,
|
|
6397
|
+
# so a passed session ceiling must NOT block or tear down the session here.
|
|
6398
|
+
now = int(time.time())
|
|
6399
|
+
mock_state_store = AsyncMock()
|
|
6400
|
+
mock_state_store.get.return_value = {
|
|
6401
|
+
"refresh_token": "refresh_xyz",
|
|
6402
|
+
"connection_token_sets": [
|
|
6403
|
+
{
|
|
6404
|
+
"connection": "google-oauth2",
|
|
6405
|
+
"login_hint": "user@example.com",
|
|
6406
|
+
"access_token": "cached_conn_token",
|
|
6407
|
+
"expires_at": now + 500,
|
|
6408
|
+
}
|
|
6409
|
+
],
|
|
6410
|
+
"internal": {"sid": "some_sid", "created_at": now - 100, "session_expires_at": now - 10},
|
|
6411
|
+
}
|
|
6412
|
+
|
|
6413
|
+
client = ServerClient(
|
|
6414
|
+
domain="auth0.local",
|
|
6415
|
+
client_id="client_id",
|
|
6416
|
+
client_secret="client_secret",
|
|
6417
|
+
transaction_store=AsyncMock(),
|
|
6418
|
+
state_store=mock_state_store,
|
|
6419
|
+
secret="some-secret"
|
|
6420
|
+
)
|
|
6421
|
+
|
|
6422
|
+
token = await client.get_access_token_for_connection({"connection": "google-oauth2"})
|
|
6423
|
+
assert token == "cached_conn_token"
|
|
6424
|
+
mock_state_store.delete.assert_not_awaited()
|
|
6425
|
+
|
|
6426
|
+
|
|
6427
|
+
@pytest.mark.asyncio
|
|
6428
|
+
async def test_complete_interactive_login_rejects_already_expired_ceiling(mocker):
|
|
6429
|
+
"""A session_expiry already in the past at login is rejected, not persisted."""
|
|
6430
|
+
iat = int(time.time())
|
|
6431
|
+
|
|
6432
|
+
mock_tx_store = AsyncMock()
|
|
6433
|
+
mock_tx_store.get.return_value = TransactionData(
|
|
6434
|
+
code_verifier="123",
|
|
6435
|
+
domain="tenant.auth0.com",
|
|
6436
|
+
)
|
|
6437
|
+
mock_state_store = AsyncMock()
|
|
6438
|
+
|
|
6439
|
+
client = ServerClient(
|
|
6440
|
+
domain="tenant.auth0.com",
|
|
6441
|
+
client_id="test_client",
|
|
6442
|
+
client_secret="test_secret",
|
|
6443
|
+
transaction_store=mock_tx_store,
|
|
6444
|
+
state_store=mock_state_store,
|
|
6445
|
+
secret="test_secret_key_32_chars_long!!",
|
|
6446
|
+
)
|
|
6447
|
+
|
|
6448
|
+
# Mock OIDC metadata
|
|
6449
|
+
mocker.patch.object(
|
|
6450
|
+
client,
|
|
6451
|
+
"_get_oidc_metadata_cached",
|
|
6452
|
+
return_value={"issuer": "https://tenant.auth0.com/", "token_endpoint": "https://tenant.auth0.com/token"}
|
|
6453
|
+
)
|
|
6454
|
+
|
|
6455
|
+
# Mock JWKS fetch
|
|
6456
|
+
mocker.patch.object(
|
|
6457
|
+
client,
|
|
6458
|
+
"_get_jwks_cached",
|
|
6459
|
+
return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]}
|
|
6460
|
+
)
|
|
6461
|
+
|
|
6462
|
+
# Mock OAuth fetch_token
|
|
6463
|
+
async_fetch_token = AsyncMock()
|
|
6464
|
+
async_fetch_token.return_value = {
|
|
6465
|
+
"access_token": "token123",
|
|
6466
|
+
"id_token": "id_token_jwt",
|
|
6467
|
+
"scope": "openid profile"
|
|
6468
|
+
}
|
|
6469
|
+
mocker.patch.object(client._oauth, "fetch_token", async_fetch_token)
|
|
6470
|
+
|
|
6471
|
+
# Mock jwt.get_unverified_header
|
|
6472
|
+
mocker.patch("jwt.get_unverified_header", return_value={"kid": "test-key"})
|
|
6473
|
+
|
|
6474
|
+
# Mock PyJWK.from_dict
|
|
6475
|
+
mock_signing_key = mocker.MagicMock()
|
|
6476
|
+
mock_signing_key.key = "mock_pem_key"
|
|
6477
|
+
mocker.patch("jwt.PyJWK.from_dict", return_value=mock_signing_key)
|
|
6478
|
+
|
|
6479
|
+
# Mock jwt.decode with a ceiling already in the past relative to iat
|
|
6480
|
+
mocker.patch("jwt.decode", return_value={
|
|
6481
|
+
"sub": "user123",
|
|
6482
|
+
"iss": "https://tenant.auth0.com/",
|
|
6483
|
+
"aud": "test_client",
|
|
6484
|
+
"iat": iat,
|
|
6485
|
+
"session_expiry": iat - 3600,
|
|
6486
|
+
})
|
|
6487
|
+
|
|
6488
|
+
with pytest.raises(SessionExpiredError) as exc:
|
|
6489
|
+
await client.complete_interactive_login("http://localhost/callback?code=abc&state=xyz")
|
|
6490
|
+
|
|
6491
|
+
assert exc.value.code == AccessTokenErrorCode.SESSION_EXPIRED
|
|
6492
|
+
# The already-expired session must never be persisted. The transaction is
|
|
6493
|
+
# cleaned up because its authorization code was already spent and cannot be
|
|
6494
|
+
# reused — a retry starts a fresh login with a new transaction.
|
|
6495
|
+
mock_state_store.set.assert_not_awaited()
|
|
6496
|
+
mock_tx_store.delete.assert_awaited_once()
|
|
6497
|
+
|
|
6498
|
+
|
|
6499
|
+
@pytest.mark.asyncio
|
|
6500
|
+
async def test_complete_interactive_login_future_ceiling_persists(mocker):
|
|
6501
|
+
"""A future session_expiry is stamped on the session and login succeeds."""
|
|
6502
|
+
iat = int(time.time())
|
|
6503
|
+
ceiling = iat + 3600
|
|
6504
|
+
|
|
6505
|
+
mock_tx_store = AsyncMock()
|
|
6506
|
+
mock_tx_store.get.return_value = TransactionData(
|
|
6507
|
+
code_verifier="123",
|
|
6508
|
+
domain="tenant.auth0.com",
|
|
6509
|
+
)
|
|
6510
|
+
mock_state_store = AsyncMock()
|
|
6511
|
+
|
|
6512
|
+
client = ServerClient(
|
|
6513
|
+
domain="tenant.auth0.com",
|
|
6514
|
+
client_id="test_client",
|
|
6515
|
+
client_secret="test_secret",
|
|
6516
|
+
transaction_store=mock_tx_store,
|
|
6517
|
+
state_store=mock_state_store,
|
|
6518
|
+
secret="test_secret_key_32_chars_long!!",
|
|
6519
|
+
)
|
|
6520
|
+
|
|
6521
|
+
# Mock OIDC metadata
|
|
6522
|
+
mocker.patch.object(
|
|
6523
|
+
client,
|
|
6524
|
+
"_get_oidc_metadata_cached",
|
|
6525
|
+
return_value={"issuer": "https://tenant.auth0.com/", "token_endpoint": "https://tenant.auth0.com/token"}
|
|
6526
|
+
)
|
|
6527
|
+
|
|
6528
|
+
# Mock JWKS fetch
|
|
6529
|
+
mocker.patch.object(
|
|
6530
|
+
client,
|
|
6531
|
+
"_get_jwks_cached",
|
|
6532
|
+
return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]}
|
|
6533
|
+
)
|
|
6534
|
+
|
|
6535
|
+
# Mock OAuth fetch_token
|
|
6536
|
+
async_fetch_token = AsyncMock()
|
|
6537
|
+
async_fetch_token.return_value = {
|
|
6538
|
+
"access_token": "token123",
|
|
6539
|
+
"id_token": "id_token_jwt",
|
|
6540
|
+
"scope": "openid profile"
|
|
6541
|
+
}
|
|
6542
|
+
mocker.patch.object(client._oauth, "fetch_token", async_fetch_token)
|
|
6543
|
+
|
|
6544
|
+
# Mock jwt.get_unverified_header
|
|
6545
|
+
mocker.patch("jwt.get_unverified_header", return_value={"kid": "test-key"})
|
|
6546
|
+
|
|
6547
|
+
# Mock PyJWK.from_dict
|
|
6548
|
+
mock_signing_key = mocker.MagicMock()
|
|
6549
|
+
mock_signing_key.key = "mock_pem_key"
|
|
6550
|
+
mocker.patch("jwt.PyJWK.from_dict", return_value=mock_signing_key)
|
|
6551
|
+
|
|
6552
|
+
# Mock jwt.decode with a ceiling comfortably in the future
|
|
6553
|
+
mocker.patch("jwt.decode", return_value={
|
|
6554
|
+
"sub": "user123",
|
|
6555
|
+
"iss": "https://tenant.auth0.com/",
|
|
6556
|
+
"aud": "test_client",
|
|
6557
|
+
"iat": iat,
|
|
6558
|
+
"session_expiry": ceiling,
|
|
6559
|
+
})
|
|
6560
|
+
|
|
6561
|
+
result = await client.complete_interactive_login("http://localhost/callback?code=abc&state=xyz")
|
|
6562
|
+
|
|
6563
|
+
assert "state_data" in result
|
|
6564
|
+
mock_state_store.set.assert_awaited_once()
|
|
6565
|
+
stored_state = mock_state_store.set.call_args.args[1]
|
|
6566
|
+
assert stored_state.internal.session_expires_at == ceiling
|
|
6567
|
+
|
|
6568
|
+
|
|
6569
|
+
@pytest.mark.asyncio
|
|
6570
|
+
async def test_complete_interactive_login_no_ceiling_persists_normally(mocker):
|
|
6571
|
+
"""No session_expiry claim -> login behaves exactly as before (no ceiling)."""
|
|
6572
|
+
iat = int(time.time())
|
|
6573
|
+
|
|
6574
|
+
mock_tx_store = AsyncMock()
|
|
6575
|
+
mock_tx_store.get.return_value = TransactionData(
|
|
6576
|
+
code_verifier="123",
|
|
6577
|
+
domain="tenant.auth0.com",
|
|
6578
|
+
)
|
|
6579
|
+
mock_state_store = AsyncMock()
|
|
6580
|
+
|
|
6581
|
+
client = ServerClient(
|
|
6582
|
+
domain="tenant.auth0.com",
|
|
6583
|
+
client_id="test_client",
|
|
6584
|
+
client_secret="test_secret",
|
|
6585
|
+
transaction_store=mock_tx_store,
|
|
6586
|
+
state_store=mock_state_store,
|
|
6587
|
+
secret="test_secret_key_32_chars_long!!",
|
|
6588
|
+
)
|
|
6589
|
+
|
|
6590
|
+
# Mock OIDC metadata
|
|
6591
|
+
mocker.patch.object(
|
|
6592
|
+
client,
|
|
6593
|
+
"_get_oidc_metadata_cached",
|
|
6594
|
+
return_value={"issuer": "https://tenant.auth0.com/", "token_endpoint": "https://tenant.auth0.com/token"}
|
|
6595
|
+
)
|
|
6596
|
+
|
|
6597
|
+
# Mock JWKS fetch
|
|
6598
|
+
mocker.patch.object(
|
|
6599
|
+
client,
|
|
6600
|
+
"_get_jwks_cached",
|
|
6601
|
+
return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]}
|
|
6602
|
+
)
|
|
6603
|
+
|
|
6604
|
+
# Mock OAuth fetch_token
|
|
6605
|
+
async_fetch_token = AsyncMock()
|
|
6606
|
+
async_fetch_token.return_value = {
|
|
6607
|
+
"access_token": "token123",
|
|
6608
|
+
"id_token": "id_token_jwt",
|
|
6609
|
+
"scope": "openid profile"
|
|
6610
|
+
}
|
|
6611
|
+
mocker.patch.object(client._oauth, "fetch_token", async_fetch_token)
|
|
6612
|
+
|
|
6613
|
+
# Mock jwt.get_unverified_header
|
|
6614
|
+
mocker.patch("jwt.get_unverified_header", return_value={"kid": "test-key"})
|
|
6615
|
+
|
|
6616
|
+
# Mock PyJWK.from_dict
|
|
6617
|
+
mock_signing_key = mocker.MagicMock()
|
|
6618
|
+
mock_signing_key.key = "mock_pem_key"
|
|
6619
|
+
mocker.patch("jwt.PyJWK.from_dict", return_value=mock_signing_key)
|
|
6620
|
+
|
|
6621
|
+
# Mock jwt.decode without a session_expiry claim
|
|
6622
|
+
mocker.patch("jwt.decode", return_value={
|
|
6623
|
+
"sub": "user123",
|
|
6624
|
+
"iss": "https://tenant.auth0.com/",
|
|
6625
|
+
"aud": "test_client",
|
|
6626
|
+
"iat": iat,
|
|
6627
|
+
})
|
|
6628
|
+
|
|
6629
|
+
result = await client.complete_interactive_login("http://localhost/callback?code=abc&state=xyz")
|
|
6630
|
+
|
|
6631
|
+
assert "state_data" in result
|
|
6632
|
+
mock_state_store.set.assert_awaited_once()
|
|
6633
|
+
stored_state = mock_state_store.set.call_args.args[1]
|
|
6634
|
+
assert stored_state.internal.session_expires_at is None
|
|
6635
|
+
|
|
6636
|
+
|
|
6637
|
+
@pytest.mark.asyncio
|
|
6638
|
+
async def test_complete_interactive_login_ignores_ceiling_from_userinfo(mocker):
|
|
6639
|
+
"""The ceiling is read only from the verified ID token. A session_expiry
|
|
6640
|
+
present in the unverified userinfo response must NOT be persisted."""
|
|
6641
|
+
iat = int(time.time())
|
|
6642
|
+
|
|
6643
|
+
mock_tx_store = AsyncMock()
|
|
6644
|
+
mock_tx_store.get.return_value = TransactionData(
|
|
6645
|
+
code_verifier="123",
|
|
6646
|
+
domain="tenant.auth0.com",
|
|
6647
|
+
)
|
|
6648
|
+
mock_state_store = AsyncMock()
|
|
6649
|
+
|
|
6650
|
+
client = ServerClient(
|
|
6651
|
+
domain="tenant.auth0.com",
|
|
6652
|
+
client_id="test_client",
|
|
6653
|
+
client_secret="test_secret",
|
|
6654
|
+
transaction_store=mock_tx_store,
|
|
6655
|
+
state_store=mock_state_store,
|
|
6656
|
+
secret="test_secret_key_32_chars_long!!",
|
|
6657
|
+
)
|
|
6658
|
+
|
|
6659
|
+
mocker.patch.object(
|
|
6660
|
+
client,
|
|
6661
|
+
"_get_oidc_metadata_cached",
|
|
6662
|
+
return_value={"issuer": "https://tenant.auth0.com/", "token_endpoint": "https://tenant.auth0.com/token"}
|
|
6663
|
+
)
|
|
6664
|
+
|
|
6665
|
+
# fetch_token returns a userinfo dict (no id_token), driving the userinfo
|
|
6666
|
+
# branch. Its session_expiry must be ignored, not stamped on the session.
|
|
6667
|
+
async_fetch_token = AsyncMock()
|
|
6668
|
+
async_fetch_token.return_value = {
|
|
6669
|
+
"access_token": "token123",
|
|
6670
|
+
"scope": "openid profile",
|
|
6671
|
+
"userinfo": {
|
|
6672
|
+
"sub": "user123",
|
|
6673
|
+
"iat": iat,
|
|
6674
|
+
"session_expiry": iat + 3600,
|
|
6675
|
+
},
|
|
6676
|
+
}
|
|
6677
|
+
mocker.patch.object(client._oauth, "fetch_token", async_fetch_token)
|
|
6678
|
+
|
|
6679
|
+
result = await client.complete_interactive_login("http://localhost/callback?code=abc&state=xyz")
|
|
6680
|
+
|
|
6681
|
+
assert "state_data" in result
|
|
6682
|
+
mock_state_store.set.assert_awaited_once()
|
|
6683
|
+
stored_state = mock_state_store.set.call_args.args[1]
|
|
6684
|
+
assert stored_state.internal.session_expires_at is None
|
|
6685
|
+
|
|
6686
|
+
|
|
6687
|
+
@pytest.mark.asyncio
|
|
6688
|
+
async def test_complete_interactive_login_malformed_ceiling_fails_open(mocker):
|
|
6689
|
+
"""A non-numeric session_expiry is treated as no ceiling, never a hard fail."""
|
|
6690
|
+
iat = int(time.time())
|
|
6691
|
+
|
|
6692
|
+
mock_tx_store = AsyncMock()
|
|
6693
|
+
mock_tx_store.get.return_value = TransactionData(
|
|
6694
|
+
code_verifier="123",
|
|
6695
|
+
domain="tenant.auth0.com",
|
|
6696
|
+
)
|
|
6697
|
+
mock_state_store = AsyncMock()
|
|
6698
|
+
|
|
6699
|
+
client = ServerClient(
|
|
6700
|
+
domain="tenant.auth0.com",
|
|
6701
|
+
client_id="test_client",
|
|
6702
|
+
client_secret="test_secret",
|
|
6703
|
+
transaction_store=mock_tx_store,
|
|
6704
|
+
state_store=mock_state_store,
|
|
6705
|
+
secret="test_secret_key_32_chars_long!!",
|
|
6706
|
+
)
|
|
6707
|
+
|
|
6708
|
+
mocker.patch.object(
|
|
6709
|
+
client,
|
|
6710
|
+
"_get_oidc_metadata_cached",
|
|
6711
|
+
return_value={"issuer": "https://tenant.auth0.com/", "token_endpoint": "https://tenant.auth0.com/token"}
|
|
6712
|
+
)
|
|
6713
|
+
mocker.patch.object(
|
|
6714
|
+
client,
|
|
6715
|
+
"_get_jwks_cached",
|
|
6716
|
+
return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]}
|
|
6717
|
+
)
|
|
6718
|
+
|
|
6719
|
+
async_fetch_token = AsyncMock()
|
|
6720
|
+
async_fetch_token.return_value = {
|
|
6721
|
+
"access_token": "token123",
|
|
6722
|
+
"id_token": "id_token_jwt",
|
|
6723
|
+
"scope": "openid profile"
|
|
6724
|
+
}
|
|
6725
|
+
mocker.patch.object(client._oauth, "fetch_token", async_fetch_token)
|
|
6726
|
+
mocker.patch("jwt.get_unverified_header", return_value={"kid": "test-key"})
|
|
6727
|
+
mock_signing_key = mocker.MagicMock()
|
|
6728
|
+
mock_signing_key.key = "mock_pem_key"
|
|
6729
|
+
mocker.patch("jwt.PyJWK.from_dict", return_value=mock_signing_key)
|
|
6730
|
+
|
|
6731
|
+
mocker.patch("jwt.decode", return_value={
|
|
6732
|
+
"sub": "user123",
|
|
6733
|
+
"iss": "https://tenant.auth0.com/",
|
|
6734
|
+
"aud": "test_client",
|
|
6735
|
+
"iat": iat,
|
|
6736
|
+
"session_expiry": "not-a-number",
|
|
6737
|
+
})
|
|
6738
|
+
|
|
6739
|
+
# Must not raise — garbage claim degrades to no ceiling.
|
|
6740
|
+
result = await client.complete_interactive_login("http://localhost/callback?code=abc&state=xyz")
|
|
6741
|
+
|
|
6742
|
+
assert "state_data" in result
|
|
6743
|
+
mock_state_store.set.assert_awaited_once()
|
|
6744
|
+
stored_state = mock_state_store.set.call_args.args[1]
|
|
6745
|
+
assert stored_state.internal.session_expires_at is None
|
|
6746
|
+
|
|
6747
|
+
|
|
6748
|
+
@pytest.mark.asyncio
|
|
6749
|
+
async def test_complete_interactive_login_milliseconds_ceiling_fails_open(mocker):
|
|
6750
|
+
"""A millisecond-scale session_expiry is rejected as implausible -> no ceiling."""
|
|
6751
|
+
iat = int(time.time())
|
|
6752
|
+
|
|
6753
|
+
mock_tx_store = AsyncMock()
|
|
6754
|
+
mock_tx_store.get.return_value = TransactionData(
|
|
6755
|
+
code_verifier="123",
|
|
6756
|
+
domain="tenant.auth0.com",
|
|
6757
|
+
)
|
|
6758
|
+
mock_state_store = AsyncMock()
|
|
6759
|
+
|
|
6760
|
+
client = ServerClient(
|
|
6761
|
+
domain="tenant.auth0.com",
|
|
6762
|
+
client_id="test_client",
|
|
6763
|
+
client_secret="test_secret",
|
|
6764
|
+
transaction_store=mock_tx_store,
|
|
6765
|
+
state_store=mock_state_store,
|
|
6766
|
+
secret="test_secret_key_32_chars_long!!",
|
|
6767
|
+
)
|
|
6768
|
+
|
|
6769
|
+
mocker.patch.object(
|
|
6770
|
+
client,
|
|
6771
|
+
"_get_oidc_metadata_cached",
|
|
6772
|
+
return_value={"issuer": "https://tenant.auth0.com/", "token_endpoint": "https://tenant.auth0.com/token"}
|
|
6773
|
+
)
|
|
6774
|
+
mocker.patch.object(
|
|
6775
|
+
client,
|
|
6776
|
+
"_get_jwks_cached",
|
|
6777
|
+
return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]}
|
|
6778
|
+
)
|
|
6779
|
+
|
|
6780
|
+
async_fetch_token = AsyncMock()
|
|
6781
|
+
async_fetch_token.return_value = {
|
|
6782
|
+
"access_token": "token123",
|
|
6783
|
+
"id_token": "id_token_jwt",
|
|
6784
|
+
"scope": "openid profile"
|
|
6785
|
+
}
|
|
6786
|
+
mocker.patch.object(client._oauth, "fetch_token", async_fetch_token)
|
|
6787
|
+
mocker.patch("jwt.get_unverified_header", return_value={"kid": "test-key"})
|
|
6788
|
+
mock_signing_key = mocker.MagicMock()
|
|
6789
|
+
mock_signing_key.key = "mock_pem_key"
|
|
6790
|
+
mocker.patch("jwt.PyJWK.from_dict", return_value=mock_signing_key)
|
|
6791
|
+
|
|
6792
|
+
mocker.patch("jwt.decode", return_value={
|
|
6793
|
+
"sub": "user123",
|
|
6794
|
+
"iss": "https://tenant.auth0.com/",
|
|
6795
|
+
"aud": "test_client",
|
|
6796
|
+
"iat": iat,
|
|
6797
|
+
"session_expiry": 1748566800000,
|
|
6798
|
+
})
|
|
6799
|
+
|
|
6800
|
+
# Must not raise — a ms value is implausible as Unix seconds, so no ceiling.
|
|
6801
|
+
result = await client.complete_interactive_login("http://localhost/callback?code=abc&state=xyz")
|
|
6802
|
+
|
|
6803
|
+
assert "state_data" in result
|
|
6804
|
+
mock_state_store.set.assert_awaited_once()
|
|
6805
|
+
stored_state = mock_state_store.set.call_args.args[1]
|
|
6806
|
+
assert stored_state.internal.session_expires_at is None
|
|
@@ -38,6 +38,10 @@ class PKCE:
|
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
class State:
|
|
41
|
+
# Clock-skew leeway (seconds): treat the session as expired slightly before
|
|
42
|
+
# the ceiling so the SDK never serves a session the platform has revoked.
|
|
43
|
+
SESSION_EXPIRY_LEEWAY_SECONDS = 30
|
|
44
|
+
|
|
41
45
|
@classmethod
|
|
42
46
|
def update_state_data(
|
|
43
47
|
cls,
|
|
@@ -92,12 +96,18 @@ class State:
|
|
|
92
96
|
else ts
|
|
93
97
|
for ts in token_sets
|
|
94
98
|
]
|
|
99
|
+
# A refresh-token grant does not carry session_expiry, so carry the
|
|
100
|
+
# existing internal block (including the ceiling pinned at login)
|
|
101
|
+
# forward unchanged rather than re-deriving it.
|
|
102
|
+
internal = dict(state_data_dict.get("internal") or {})
|
|
103
|
+
|
|
95
104
|
# Return updated state data
|
|
96
105
|
return {
|
|
97
106
|
**state_data_dict,
|
|
98
107
|
"id_token": token_endpoint_response.get("id_token"),
|
|
99
108
|
"refresh_token": token_endpoint_response.get("refresh_token") or state_data_dict.get("refresh_token"),
|
|
100
|
-
"token_sets": token_sets
|
|
109
|
+
"token_sets": token_sets,
|
|
110
|
+
"internal": internal
|
|
101
111
|
}
|
|
102
112
|
else:
|
|
103
113
|
# Create completely new state data
|
|
@@ -178,6 +188,35 @@ class State:
|
|
|
178
188
|
"connection_token_sets": connection_token_sets
|
|
179
189
|
}
|
|
180
190
|
|
|
191
|
+
@classmethod
|
|
192
|
+
def is_session_ceiling_reached(cls, session_expires_at: Optional[int]) -> bool:
|
|
193
|
+
"""
|
|
194
|
+
True when the session ceiling has been reached (applying negative
|
|
195
|
+
leeway for clock skew). None means no ceiling was asserted, so the
|
|
196
|
+
session is never expired on this basis.
|
|
197
|
+
"""
|
|
198
|
+
if session_expires_at is None:
|
|
199
|
+
return False
|
|
200
|
+
now = int(time.time())
|
|
201
|
+
return now >= (session_expires_at - cls.SESSION_EXPIRY_LEEWAY_SECONDS)
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def is_session_ceiling_in_past(
|
|
205
|
+
cls, session_expires_at: Optional[int], issued_at: Optional[int] = None
|
|
206
|
+
) -> bool:
|
|
207
|
+
"""
|
|
208
|
+
True when the session ceiling is already in the past at login.
|
|
209
|
+
|
|
210
|
+
Compares the ceiling against the ID token `iat`, or wall-clock now when
|
|
211
|
+
`iat` is absent, using the same leeway as is_session_ceiling_reached. A
|
|
212
|
+
None ceiling means none was asserted and is never treated as expired.
|
|
213
|
+
"""
|
|
214
|
+
if session_expires_at is None:
|
|
215
|
+
return False
|
|
216
|
+
reference = issued_at if issued_at is not None else int(time.time())
|
|
217
|
+
return session_expires_at <= (reference + cls.SESSION_EXPIRY_LEEWAY_SECONDS)
|
|
218
|
+
|
|
219
|
+
|
|
181
220
|
class URL:
|
|
182
221
|
@staticmethod
|
|
183
222
|
def build_url(base_url: str, params: dict[str, Any]) -> str:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/telemetry.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|