Skip to content

Commit 3ff30aa

Browse files
feat: enforce upstream IdP session_expiry ceiling (IPSIE SL1) (#120)
* PoC: enforce upstream IdP session_expiry ceiling for enterprise connections * 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. * refactor: read session_expiry/iat as raw claims and trust the platform boundary The signature-verified, Auth0-issued ID token has already been validated upstream (the platform refuses to emit a malformed session_expiry), so the SDK reads it like every other operational claim instead of running a bespoke validator. Removes State.extract_epoch_claim and reads session_expiry/iat with a plain .get() at both extraction sites; the None guards in the ceiling comparison functions still deliver the absent/null "no ceiling" safe default. Production-reachable inputs (absent/null, clean integer) are unchanged; only unreachable malformed values change behavior, now failing closed rather than being silently accepted. * fix: clean up spent transaction on login lockout and handle iat=0 Delete the transaction before raising SessionExpiredError at the login guard — the authorization code was already exchanged, so the transaction is spent and cannot be reused. Also treat iat=0 as a valid issued-at reference (use `is not None` rather than truthiness) in the ceiling lapsed check. * refactor: validate session_expiry in UserClaims and link Action docs Move the session_expiry plausibility check (type, range, ms-vs-seconds upper bound) into the UserClaims pydantic validator so both login extraction paths read an already-sanitized value. Point the example doc to official Auth0 docs for the Post-Login Action setup instead of embedding the snippet. * Fix formatting in server_client.py and ensure newline at end of test_server_client.py * fix: read session_expiry only from verified ID token and unify ceiling error Address PoC review findings carried into the implementation: - Stop reading session_expiry/iat from the unverified userinfo branch in complete_interactive_login; derive the ceiling only from the signature-and-issuer-verified ID token. Add a regression test that drives the userinfo branch and asserts no ceiling is persisted. - Raise the flow-agnostic SessionExpiredError at the get_access_token ceiling check so login and read-time enforcement use one error type. - Import field_validator in auth_types so the UserClaims sanitizer loads. - Correct the refresh-path comment in helpers to describe carrying the pinned ceiling forward rather than a non-existent overwrite guard.
1 parent f385b36 commit 3ff30aa

7 files changed

Lines changed: 866 additions & 4 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,12 @@ The SDK handles per-domain OIDC discovery, JWKS fetching, issuer validation, and
177177

178178
For more details and examples, see [examples/MultipleCustomDomains.md](examples/MultipleCustomDomains.md).
179179

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+
180186
## Feedback
181187

182188
### Contributing

examples/RetrievingData.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,65 @@ access_token = await server_client.get_access_token(store_options=store_options)
7070

7171
Read more above in [Configuring the Store](./ConfigureStore.md).
7272

73+
## Session Expiry from the Upstream IdP
74+
75+
For enterprise connections, the upstream identity provider can impose a ceiling on how long the user's session may live. This ceiling is delivered to the SDK as a `session_expiry` claim (an absolute Unix timestamp, **in seconds**) on the ID token. The SDK reads this value at login, stores it with the session, and enforces it on every subsequent read.
76+
77+
### Emitting the claim
78+
79+
The `session_expiry` claim is set by a Post-Login Action on your tenant, and **must** be an absolute Unix timestamp in **seconds**, not milliseconds. For the canonical Action setup, see the [Auth0 documentation](https://auth0.com/docs) (will be adding the link to the session_expiry Action guide once published).
80+
81+
> [!WARNING]
82+
> `session_expiry` is interpreted as **seconds** since the Unix epoch (per RFC 7519 `NumericDate`). A common mistake is emitting milliseconds (e.g. `getTime()` without `/ 1000`). The SDK rejects implausibly large values (anything at or above `10_000_000_000`, ≈ year 2286) as malformed and treats them as **no ceiling**, so a milliseconds value will silently disable enforcement rather than expiring the session ~55,000 years from now. Always divide by 1000.
83+
>
84+
> Because the claim is authored by your Action (untrusted input), the SDK **fails open** on any malformed value — a non-numeric, zero, negative, boolean, or millisecond value is treated as "no ceiling" and login proceeds normally. Only a clean, future, seconds timestamp is enforced.
85+
86+
Once the ceiling is reached, the read methods behave as follows:
87+
88+
- `get_user()` returns `None`, as if no session exists.
89+
- `get_session()` returns `None`, as if no session exists.
90+
- `get_access_token()` raises an `AccessTokenError` with code `session_expired`.
91+
92+
`get_access_token_for_connection()` (Token Vault) is **not** gated by the session ceiling — connection tokens follow the upstream IdP's own `expires_in`, so they remain retrievable from cache even after the session ceiling has passed.
93+
94+
```python
95+
from auth0_server_python.error import AccessTokenError, AccessTokenErrorCode
96+
97+
try:
98+
access_token = await server_client.get_access_token(store_options=store_options)
99+
except AccessTokenError as error:
100+
if error.code == AccessTokenErrorCode.SESSION_EXPIRED:
101+
# The upstream session ceiling has been reached; start a new login.
102+
...
103+
```
104+
105+
When the ceiling is reached, the SDK deletes the stored session before returning, so the next request starts clean.
106+
107+
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:
108+
109+
```python
110+
from auth0_server_python.error import SessionExpiredError
111+
112+
try:
113+
await server_client.complete_interactive_login(url, store_options=store_options)
114+
except SessionExpiredError:
115+
# The session was already past its ceiling on arrival; start a new login.
116+
...
117+
```
118+
119+
> [!NOTE]
120+
> **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.
121+
122+
The `session_expiry` value is also surfaced through the user claims, so you can read it without triggering enforcement:
123+
124+
```python
125+
user = await server_client.get_user(store_options=store_options)
126+
session_expires_at = (user or {}).get("session_expiry")
127+
```
128+
129+
> [!NOTE]
130+
> Enforcement applies a small negative leeway (30 seconds) to account for clock skew, so a session is treated as expired slightly before the exact `session_expiry` timestamp. The refresh-token grant preserves the original ceiling - refreshing an access token does not extend the upstream session.
131+
73132
## Multi-Resource Refresh Tokens (MRRT)
74133

75134
Multi-Resource Refresh Tokens allow using a single refresh token to obtain access tokens for multiple audiences, simplifying token management in applications that interact with multiple backend services.

src/auth0_server_python/auth_server/server_client.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
MissingTransactionError,
5757
OrganizationTokenValidationError,
5858
PollingApiError,
59+
SessionExpiredError,
5960
StartLinkUserError,
6061
)
6162
from auth0_server_python.telemetry import Telemetry
@@ -656,7 +657,12 @@ async def complete_interactive_login(
656657
# Use the userinfo field from the token_response for user claims
657658
user_info = token_response.get("userinfo")
658659
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
659664
id_token = token_response.get("id_token")
665+
660666
expected_org = transaction_data.organization
661667

662668
if not user_info and not id_token and expected_org:
@@ -698,6 +704,8 @@ async def complete_interactive_login(
698704
validate_org_claims(claims, expected_org)
699705

700706
user_claims = UserClaims.parse_obj(claims)
707+
session_expires_at = user_claims.session_expiry
708+
issued_at = claims.get("iat")
701709
except ValueError as e:
702710
raise ApiError("jwks_key_not_found", str(e))
703711
except jwt.InvalidSignatureError as e:
@@ -726,6 +734,11 @@ async def complete_interactive_login(
726734
)
727735

728736

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+
729742
# Build a token set using the token response data
730743
token_set = TokenSet(
731744
audience=transaction_data.audience or self.DEFAULT_AUDIENCE_STATE_KEY,
@@ -749,7 +762,8 @@ async def complete_interactive_login(
749762
domain=origin_domain,
750763
internal={
751764
"sid": sid,
752-
"created_at": int(time.time())
765+
"created_at": int(time.time()),
766+
"session_expires_at": session_expires_at
753767
}
754768
)
755769

@@ -775,6 +789,23 @@ async def complete_interactive_login(
775789
# Methods for retrieving user information, session data, and logout operations.
776790
# ============================================================================
777791

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+
778809
async def get_user(self, store_options: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]:
779810
"""
780811
Retrieves the user from the store, or None if no user found.
@@ -801,6 +832,10 @@ async def get_user(self, store_options: Optional[dict[str, Any]] = None) -> Opti
801832
if self._normalize_url(session_domain) != self._normalize_url(current_domain):
802833
return None
803834

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+
804839
return state_data.get("user")
805840
return None
806841

@@ -830,6 +865,10 @@ async def get_session(self, store_options: Optional[dict[str, Any]] = None) -> O
830865
if self._normalize_url(session_domain) != self._normalize_url(current_domain):
831866
return None
832867

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+
833872
session_data = {k: v for k, v in state_data.items()
834873
if k != "internal"}
835874
return session_data
@@ -1013,6 +1052,12 @@ async def get_access_token(
10131052

10141053
merged_scope = self._merge_scope_with_defaults(scope, audience)
10151054

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+
10161061
# Find matching token set
10171062
token_set = None
10181063
if state_data_dict and "token_sets" in state_data_dict:

src/auth0_server_python/auth_types/__init__.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55

66
from typing import Any, Literal, Optional, Union
77

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
912

1013

1114
class UserClaims(BaseModel):
@@ -23,10 +26,21 @@ class UserClaims(BaseModel):
2326
email_verified: Optional[bool] = None
2427
org_id: Optional[str] = None
2528
org_name: Optional[str] = None
29+
# IPSIE SL1 claim: upstream IdP-asserted RP session ceiling (Unix seconds).
30+
session_expiry: Optional[int] = None
2631

2732
class Config:
2833
extra = "allow" # Allow additional fields not defined in the model
2934

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+
3044

3145
class TokenSet(BaseModel):
3246
"""
@@ -55,6 +69,10 @@ class InternalStateData(BaseModel):
5569
"""
5670
sid: str
5771
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
5876

5977

6078
class SessionData(BaseModel):

src/auth0_server_python/error/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ class AccessTokenErrorCode:
198198
INCORRECT_AUDIENCE = "incorrect_audience"
199199
MISSING_SESSION_DOMAIN = "missing_session_domain"
200200
DOMAIN_MISMATCH = "domain_mismatch"
201+
SESSION_EXPIRED = "session_expired"
201202

202203

203204
class OrganizationTokenValidationError(Auth0Error):
@@ -222,6 +223,19 @@ class AccessTokenForConnectionErrorCode:
222223
DOMAIN_MISMATCH = "domain_mismatch"
223224

224225

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+
225239
class CustomTokenExchangeError(Auth0Error):
226240
"""
227241
Error raised during custom token exchange operations.

0 commit comments

Comments
 (0)