Skip to content

Commit ec4c45b

Browse files
feat: reject already-expired sessions at login and generalize epoch claim extraction
Add a login-time lockout guard to complete_interactive_login: when the upstream IdP asserts a session_expiry ceiling already in the past at login (compared against the ID token iat with the same 30s leeway as read-time enforcement), raise the new flow-agnostic SessionExpiredError instead of persisting an already-expired session. A missing claim stays a no-op, preserving existing behavior. Generalize extract_session_expiry into extract_epoch_claim(claims, name), reused for both session_expiry and iat, and rename the ceiling predicates to is_session_ceiling_reached (read-time) and is_session_ceiling_in_past (login). Document the login rejection in the README, RetrievingData guide, and the ipsie-webapp example.
1 parent aaad5b1 commit ec4c45b

6 files changed

Lines changed: 371 additions & 70 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ For more details and examples, see [examples/MultipleCustomDomains.md](examples/
175175

176176
### 6. Session Expiry from the Upstream IdP
177177

178-
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`.
178+
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.
179179

180180
For more details and examples, see [examples/RetrievingData.md](examples/RetrievingData.md#session-expiry-from-the-upstream-idp).
181181

examples/RetrievingData.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,21 @@ except AccessTokenError as error:
9393

9494
When the ceiling is reached, the SDK deletes the stored session before returning, so the next request starts clean.
9595

96+
If the upstream IdP asserts a ceiling that is already in the past at login time, `complete_interactive_login()` raises a `SessionExpiredError` rather than persisting an already-expired session:
97+
98+
```python
99+
from auth0_server_python.error import SessionExpiredError
100+
101+
try:
102+
await server_client.complete_interactive_login(url, store_options=store_options)
103+
except SessionExpiredError:
104+
# The session was already past its ceiling on arrival; start a new login.
105+
...
106+
```
107+
108+
> [!NOTE]
109+
> **Upgrading:** with this feature enabled, `get_user()` and `get_session()` can return `None` for a user who was previously logged in, once the upstream ceiling passes. Applications that assumed these always return a value after login should add a null check and route the user back through login.
110+
96111
The `session_expiry` value is also surfaced through the user claims, so you can read it without triggering enforcement:
97112

98113
```python

src/auth0_server_python/auth_server/server_client.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
MissingRequiredArgumentError,
5656
MissingTransactionError,
5757
PollingApiError,
58+
SessionExpiredError,
5859
StartLinkUserError,
5960
)
6061
from auth0_server_python.telemetry import Telemetry
@@ -640,12 +641,15 @@ async def complete_interactive_login(
640641
id_token = token_response.get("id_token")
641642
# IPSIE session_expiry ceiling, read from the verified ID token claims.
642643
session_expires_at = None
644+
# ID token `iat`, used to detect a ceiling that is already past at login.
645+
issued_at = None
643646

644647
if user_info:
645648
user_claims = UserClaims.parse_obj(user_info)
646649
# authlib populates `userinfo` from parsed ID token claims, so the
647650
# IPSIE session_expiry claim may surface here.
648-
session_expires_at = State.extract_session_expiry(user_info)
651+
session_expires_at = State.extract_epoch_claim(user_info, "session_expiry")
652+
issued_at = State.extract_epoch_claim(user_info, "iat")
649653
elif id_token:
650654
# Fetch JWKS for signature verification
651655
jwks = await self._get_jwks_cached(origin_domain, metadata)
@@ -663,7 +667,8 @@ async def complete_interactive_login(
663667

664668
user_claims = UserClaims.parse_obj(claims)
665669
# IPSIE session_expiry ceiling from the verified ID token.
666-
session_expires_at = State.extract_session_expiry(claims)
670+
session_expires_at = State.extract_epoch_claim(claims, "session_expiry")
671+
issued_at = State.extract_epoch_claim(claims, "iat")
667672
except ValueError as e:
668673
raise ApiError("jwks_key_not_found", str(e))
669674
except jwt.InvalidSignatureError as e:
@@ -692,6 +697,10 @@ async def complete_interactive_login(
692697
)
693698

694699

700+
# Refuse to persist a session whose ceiling is already in the past.
701+
if State.is_session_ceiling_in_past(session_expires_at, issued_at):
702+
raise SessionExpiredError()
703+
695704
# Build a token set using the token response data
696705
token_set = TokenSet(
697706
audience=transaction_data.audience or self.DEFAULT_AUDIENCE_STATE_KEY,
@@ -754,7 +763,7 @@ async def _is_session_expired_by_ceiling(
754763
"""
755764
internal = state_data_dict.get("internal") or {}
756765
session_expires_at = internal.get("session_expires_at")
757-
if State.is_session_expiry_reached(session_expires_at):
766+
if State.is_session_ceiling_reached(session_expires_at):
758767
await self._state_store.delete(self._state_identifier, options=store_options)
759768
return True
760769
return False
@@ -1005,17 +1014,13 @@ async def get_access_token(
10051014

10061015
merged_scope = self._merge_scope_with_defaults(scope, audience)
10071016

1008-
# IPSIE: once the upstream IdP session ceiling has passed, the session
1009-
# is expired. Surface "session expired" and do NOT serve a cached token
1010-
# or attempt a refresh-token exchange (which would race the platform's
1011-
# session revocation).
1017+
# Once the session ceiling has passed, fail instead of serving or refreshing a token.
10121018
internal = (state_data_dict or {}).get("internal") or {}
1013-
if State.is_session_expiry_reached(internal.get("session_expires_at")):
1019+
if State.is_session_ceiling_reached(internal.get("session_expires_at")):
10141020
await self._state_store.delete(self._state_identifier, options=store_options)
10151021
raise AccessTokenError(
10161022
AccessTokenErrorCode.SESSION_EXPIRED,
1017-
"The session has expired because the upstream identity provider's "
1018-
"session_expiry was reached. The user needs to re-authenticate."
1023+
"The session has expired and the user must re-authenticate."
10191024
)
10201025

10211026
# Find matching token set

src/auth0_server_python/error/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,19 @@ class AccessTokenForConnectionErrorCode:
211211
DOMAIN_MISMATCH = "domain_mismatch"
212212

213213

214+
class SessionExpiredError(Auth0Error):
215+
"""
216+
Error raised when a session is rejected at login because its
217+
session_expiry ceiling is already in the past.
218+
"""
219+
code = AccessTokenErrorCode.SESSION_EXPIRED
220+
221+
def __init__(self, message: Optional[str] = None, cause=None):
222+
super().__init__(message or "The session has expired and the user must re-authenticate.")
223+
self.name = "SessionExpiredError"
224+
self.cause = cause
225+
226+
214227
class CustomTokenExchangeError(Auth0Error):
215228
"""
216229
Error raised during custom token exchange operations.

0 commit comments

Comments
 (0)