Skip to content

Commit a73a9b3

Browse files
Merge branch 'poc/cte-stt-support' of https://github.com/auth0/auth0-server-python into poc/cte-stt-support
2 parents f9bdc59 + 6953209 commit a73a9b3

10 files changed

Lines changed: 871 additions & 5 deletions

File tree

.version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.0.0b11
1+
1.0.0b12

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Change Log
22

3+
## [1.0.0b12](https://github.com/auth0/auth0-server-python/tree/1.0.0b12) (2026-07-01)
4+
[Full Changelog](https://github.com/auth0/auth0-server-python/compare/1.0.0b11...1.0.0b12)
5+
6+
**Added**
7+
- feat: enforce upstream IdP session_expiry ceiling (IPSIE SL1) [\#120](https://github.com/auth0/auth0-server-python/pull/120) ([kishore7snehil](https://github.com/kishore7snehil))
8+
39
## [1.0.0b11](https://github.com/auth0/auth0-server-python/tree/1.0.0b11) (2026-06-25)
410
[Full Changelog](https://github.com/auth0/auth0-server-python/compare/1.0.0b10...1.0.0b11)
511

README.md

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

183183
For more details and examples, see [examples/MultipleCustomDomains.md](examples/MultipleCustomDomains.md).
184184

185+
### 6. Session Expiry from the Upstream IdP
186+
187+
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.
188+
189+
For more details and examples, see [examples/RetrievingData.md](examples/RetrievingData.md#session-expiry-from-the-upstream-idp).
190+
185191
## Feedback
186192

187193
### 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.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "auth0-server-python"
3-
version = "1.0.0b11"
3+
version = "1.0.0b12"
44
description = "Auth0 server-side Python SDK"
55
readme = "README.md"
66
authors = ["Auth0 <support@okta.com>"]

src/auth0_server_python/auth_server/server_client.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
MissingTransactionError,
5858
OrganizationTokenValidationError,
5959
PollingApiError,
60+
SessionExpiredError,
6061
StartLinkUserError,
6162
)
6263
from auth0_server_python.telemetry import Telemetry
@@ -663,7 +664,12 @@ async def complete_interactive_login(
663664
# Use the userinfo field from the token_response for user claims
664665
user_info = token_response.get("userinfo")
665666
user_claims = None
667+
# IPSIE session_expiry ceiling, read from the verified ID token claims.
668+
session_expires_at = None
669+
# ID token `iat`, used to detect a ceiling that is already past at login.
670+
issued_at = None
666671
id_token = token_response.get("id_token")
672+
667673
expected_org = transaction_data.organization
668674

669675
if not user_info and not id_token and expected_org:
@@ -705,6 +711,8 @@ async def complete_interactive_login(
705711
validate_org_claims(claims, expected_org)
706712

707713
user_claims = UserClaims.parse_obj(claims)
714+
session_expires_at = user_claims.session_expiry
715+
issued_at = claims.get("iat")
708716
except ValueError as e:
709717
raise ApiError("jwks_key_not_found", str(e))
710718
except jwt.InvalidSignatureError as e:
@@ -733,6 +741,11 @@ async def complete_interactive_login(
733741
)
734742

735743

744+
# Refuse to persist a session whose ceiling is already in the past.
745+
if State.is_session_ceiling_in_past(session_expires_at, issued_at):
746+
await self._transaction_store.delete(transaction_identifier, options=store_options)
747+
raise SessionExpiredError()
748+
736749
# Build a token set using the token response data
737750
token_set = TokenSet(
738751
audience=transaction_data.audience or self.DEFAULT_AUDIENCE_STATE_KEY,
@@ -756,7 +769,8 @@ async def complete_interactive_login(
756769
domain=origin_domain,
757770
internal={
758771
"sid": sid,
759-
"created_at": int(time.time())
772+
"created_at": int(time.time()),
773+
"session_expires_at": session_expires_at
760774
}
761775
)
762776

@@ -782,6 +796,23 @@ async def complete_interactive_login(
782796
# Methods for retrieving user information, session data, and logout operations.
783797
# ============================================================================
784798

799+
async def _is_session_expired_by_ceiling(
800+
self, state_data_dict: dict, store_options: Optional[dict[str, Any]] = None
801+
) -> bool:
802+
"""
803+
Enforce the IPSIE session_expiry ceiling on a session read.
804+
805+
Returns True (and deletes the stored session) when the upstream
806+
IdP-asserted ceiling has been reached. Sessions without a
807+
session_expires_at value are never expired on this basis.
808+
"""
809+
internal = state_data_dict.get("internal") or {}
810+
session_expires_at = internal.get("session_expires_at")
811+
if State.is_session_ceiling_reached(session_expires_at):
812+
await self._state_store.delete(self._state_identifier, options=store_options)
813+
return True
814+
return False
815+
785816
async def get_user(self, store_options: Optional[dict[str, Any]] = None) -> Optional[dict[str, Any]]:
786817
"""
787818
Retrieves the user from the store, or None if no user found.
@@ -808,6 +839,10 @@ async def get_user(self, store_options: Optional[dict[str, Any]] = None) -> Opti
808839
if self._normalize_url(session_domain) != self._normalize_url(current_domain):
809840
return None
810841

842+
# IPSIE: force re-auth once the upstream IdP session ceiling passes.
843+
if await self._is_session_expired_by_ceiling(state_data, store_options):
844+
return None
845+
811846
return state_data.get("user")
812847
return None
813848

@@ -837,6 +872,10 @@ async def get_session(self, store_options: Optional[dict[str, Any]] = None) -> O
837872
if self._normalize_url(session_domain) != self._normalize_url(current_domain):
838873
return None
839874

875+
# IPSIE: force re-auth once the upstream IdP session ceiling passes.
876+
if await self._is_session_expired_by_ceiling(state_data, store_options):
877+
return None
878+
840879
session_data = {k: v for k, v in state_data.items()
841880
if k != "internal"}
842881
return session_data
@@ -1020,6 +1059,12 @@ async def get_access_token(
10201059

10211060
merged_scope = self._merge_scope_with_defaults(scope, audience)
10221061

1062+
# Once the session ceiling has passed, fail instead of serving or refreshing a token.
1063+
internal = (state_data_dict or {}).get("internal") or {}
1064+
if State.is_session_ceiling_reached(internal.get("session_expires_at")):
1065+
await self._state_store.delete(self._state_identifier, options=store_options)
1066+
raise SessionExpiredError()
1067+
10231068
# Find matching token set
10241069
token_set = None
10251070
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)