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.
Files changed (23) hide show
  1. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/PKG-INFO +7 -1
  2. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/README.md +6 -0
  3. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/pyproject.toml +1 -1
  4. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_server/server_client.py +46 -1
  5. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_types/__init__.py +19 -1
  6. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/error/__init__.py +14 -0
  7. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/tests/test_server_client.py +682 -1
  8. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/utils/helpers.py +40 -1
  9. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/LICENSE +0 -0
  10. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_schemes/__init__.py +0 -0
  11. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_schemes/bearer_auth.py +0 -0
  12. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_server/__init__.py +0 -0
  13. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_server/mfa_client.py +0 -0
  14. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/auth_server/my_account_client.py +0 -0
  15. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/encryption/__init__.py +0 -0
  16. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/encryption/encrypt.py +0 -0
  17. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/store/__init__.py +0 -0
  18. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/store/abstract.py +0 -0
  19. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/telemetry.py +0 -0
  20. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/tests/test_mfa_client.py +0 -0
  21. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/tests/test_my_account_client.py +0 -0
  22. {auth0_server_python-1.0.0b11 → auth0_server_python-1.0.0b12}/src/auth0_server_python/tests/test_telemetry.py +0 -0
  23. {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.0b11
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
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "auth0-server-python"
3
- version = "1.0.0b11"
3
+ version = "1.0.0b12"
4
4
  description = "Auth0 server-side Python SDK"
5
5
  readme = "README.md"
6
6
  authors = ["Auth0 <support@okta.com>"]
@@ -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
- # ORGANIZATIONS SUPPORT TESTS
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: