Skip to content

PoC: enforce upstream IdP session_expiry ceiling#117

Open
kishore7snehil wants to merge 3 commits into
mainfrom
poc/session-expiry-enforcement
Open

PoC: enforce upstream IdP session_expiry ceiling#117
kishore7snehil wants to merge 3 commits into
mainfrom
poc/session-expiry-enforcement

Conversation

@kishore7snehil

Copy link
Copy Markdown
Contributor

Adds enforcement of the IPSIE session_expiry claim for enterprise connections.
When the connection is configured to honor an upstream IdP session ceiling, Auth0
includes a session_expiry claim (absolute Unix seconds) in the ID token. The SDK
reads it at login, stores it with the session, and enforces it on every session read.

Changes

Enforcement

  • Reads session_expiry from userinfo / verified ID-token claims at
    complete_interactive_login and stamps it onto the internal session state.
  • get_user() and get_session() return None once the ceiling is reached
    (silent — behaves like no session, so existing redirect-to-login fires).
  • get_access_token() raises AccessTokenError with code session_expired
    (loud — checked before serving cache or refreshing; refresh is never attempted).
  • Reaching the ceiling deletes the stored session before returning.
  • 30s negative leeway for clock skew; integer-seconds comparison.
  • Refresh-token grant preserves the original ceiling (not re-emitted on refresh).

Claims access

  • session_expiry is surfaced on UserClaims, so it can be read via
    get_user() without triggering enforcement.

New / changed types & errors

  • UserClaims.session_expiry: Optional[int]
  • InternalStateData.session_expires_at: Optional[int]
  • AccessTokenErrorCode.SESSION_EXPIRED = "session_expired"

@kishore7snehil kishore7snehil requested a review from a team as a code owner June 4, 2026 15:38
…laim 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.
…m 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.
user_claims = UserClaims.parse_obj(user_info)
# authlib populates `userinfo` from parsed ID token claims, so the
# IPSIE session_expiry claim may surface here.
session_expires_at = user_info.get("session_expiry")

@nandan-bhat nandan-bhat Jun 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to make sure we are reading session_expiry from a trusted source here. In the id_token branch below we verify the signature and run the normalized issuer check before we trust any claim. On this userinfo branch we read session_expiry and iat straight off the dict without either of those checks. Since session_expiry is the thing that caps the session, trusting it from an unverified source feels risky. The comment says authlib fills userinfo from the parsed ID token, so does authlib actually verify that token's signature for us in every flow we support ? If we are not sure, I would rather pull session_expiry from the verified claims path so both branches are equally safe.


# Refuse to persist a session whose ceiling is already in the past.
if State.is_session_ceiling_in_past(session_expires_at, issued_at):
raise SessionExpiredError()

@nandan-bhat nandan-bhat Jun 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we raise here we skip the transaction cleanup that happens later in the method, so the transaction record is left behind. The auth code has already been spent at fetch_token, so the user cannot reuse this transaction anyway, a retry will start a brand new one. So the old record just sits there until it expires. Is leaving it intentional, or should we delete the transaction before raising so we do not pile up dead records ? The test comment says we leave it so the user can retry, but a retry does not reuse it, so that reasoning does not quite hold.


# Once the session ceiling has passed, fail instead of serving or refreshing a token.
internal = (state_data_dict or {}).get("internal") or {}
if State.is_session_ceiling_reached(internal.get("session_expires_at")):

@nandan-bhat nandan-bhat Jun 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same ceiling check plus session delete that _is_session_expired_by_ceiling already does. The only difference is that here we raise instead of returning None. Could we have the helper do the check and delete, and just decide here whether to raise or return ? That way the leeway and delete behavior live in one place and cannot drift apart later.

# (it doesn't round-trip the upstream IdP), so the value from the
# refreshed ID token must NOT overwrite or erase the original
# ceiling — doing so would let the session outlive its bound.
internal = dict(state_data_dict.get("internal") or {})

@nandan-bhat nandan-bhat Jun 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just so I understand this line: the return below already spreads state_data_dict, which carries internal along with session_expires_at, and the refresh response has no path that writes into internal. So is this copy actually changing anything, or is it just a defensive copy ? The comment makes it sound like without this line the ceiling would get erased on refresh, but I do not think the old code ever touched internal. If it is defensive only, can we soften the comment so it does not imply a bug that was not there ?

if session_expires_at is None:
return False
reference = issued_at if issued_at else int(time.time())
return session_expires_at <= (reference + cls.SESSION_EXPIRY_LEEWAY_SECONDS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small thing: this uses if issued_at, so an iat of 0 would fall through to wall clock now. In practice iat is never 0, so this is not a real bug, but if issued_at is not None would read more clearly and match how we guard the ceiling itself.

# =============================================================================


def test_is_session_ceiling_reached_none_never_expires():

@nandan-bhat nandan-bhat Jun 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice coverage on the ceiling math and the read and login paths. Two gaps I noticed. First, nothing tests that a refresh keeps the original ceiling, which is the whole reason for the internal copy in helpers.py, so a regression there would slip through. Could we add a test that runs update_state_data with a refresh response and checks session_expires_at is still set ? Second, all the login tests go through the id_token branch. The userinfo branch that reads session_expiry is never exercised, and that is the branch I asked about on the security side. A test for it would help.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants