Skip to content

feat: Respect IdP provided session expiry and cap the SDK session #828

Merged
cschetan77 merged 16 commits into
masterfrom
feat/ipsie
Jun 30, 2026
Merged

feat: Respect IdP provided session expiry and cap the SDK session #828
cschetan77 merged 16 commits into
masterfrom
feat/ipsie

Conversation

@cschetan77

@cschetan77 cschetan77 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Description

When an upstream enterprise IdP (Okta, OIDC) emits a session_expiry claim in its ID token, Auth0 captures it and re-emits it in the ID token issued to the RP (when the connection has id_token_session_expiry_supported: true). Without SDK enforcement, Auth0 revokes the session server-side on the next /authorize round-trip, but the app-level session continues to look valid — defeating the security guarantee.

This PR makes the SDK enforce session_expiry as a hard ceiling on the local session lifetime.

The change is non-breaking — all enforcement is gated on the claim being present. Sessions without it (database/social logins, connection option disabled, or sessions created before this version) behave exactly as before.


What it does

At login (callback):

  • Extracts session_expiry from the ID token claims using strict positive-integer validation (fail-open: a missing or malformed claim means "no ceiling", never "already expired")
  • Runs a lockout guard — rejects with HTTP 400 if session_expiry was already in the past when the IdP issued the token (compared against claims.iat, not now, so a token that arrives late is not incorrectly rejected); a born-dead session is never persisted
  • Persists the ceiling as session.sessionExpiresAt (Unix seconds) in the session cookie

On every request (session read in appSession.js):

  • Asserts sessionExpiresAt has not been reached (with 30s negative leeway for clock skew) alongside existing iat/uat/exp checks
  • Gated on presence — sessions without sessionExpiresAt are unaffected

On token refresh:

  • Pre-checks the ceiling before calling /oauth/token — throws SessionExpiredError instead of letting Auth0 return a confusing invalid_grant
  • Preserves sessionExpiresAt across refreshes (the refresh grant does not re-assert session_expiry)

Cookie maxAge:

  • calculateExp() now accepts an optional sessionExpiresAt and caps exp = Math.min(exp, sessionExpiresAt) — so the browser cookie never outlives the IdP session ceiling

New public API

  • SessionExpiredError — exported error class (code: 'ERR_SESSION_EXPIRED', status: 401). Catch this in API routes to redirect for re-authentication rather than receiving a confusing invalid_grant.
  • Session.sessionExpiresAt?: number — new optional session field (Unix seconds)
  • IdTokenClaims.session_expiry?: number — new optional ID token claim type

Files changed

File Change
lib/errors.js New SessionExpiredError class
lib/utils/epoch.js Extracted epoch() utility (shared by appSession.js and context.js)
lib/utils/sessionExpiry.js New utility: SESSION_EXPIRY_LEEWAY, isPlausibleUnixSeconds, extractSessionExpiry, isSessionExpiryReached, isSessionExpiryInPast
lib/context.js Callback: extract + validate + persist sessionExpiresAt; refresh: pre-check + preserve ceiling
lib/appSession.js Session read: expiry assert; calculateExp: cap at sessionExpiresAt
index.js Export SessionExpiredError
index.d.ts Types for SessionExpiredError, Session.sessionExpiresAt, IdTokenClaims.session_expiry

Testing

Unit tests

extractSessionExpiry (test/sessionExpiry.tests.js)

  • Returns the value for a valid positive integer
  • Returns undefined when the claim is absent (undefined, null, missing key)
  • Returns undefined for a string value — fail-open
  • Returns undefined for a float value — fail-open
  • Returns undefined for zero — fail-open
  • Returns undefined for a negative value — fail-open
  • Returns undefined for NaN — fail-open
  • Returns undefined for a millisecond-magnitude value — fail-open

isSessionExpiryReached (test/sessionExpiry.tests.js)

  • Returns false when sessionExpiresAt is undefined (no ceiling)
  • Returns false when ceiling is well in the future
  • Returns true when ceiling is in the past
  • Returns true when now is within the 30s leeway window (clock skew guard)
  • Returns true exactly at the leeway boundary — inclusive
  • Returns false just outside the leeway boundary

isSessionExpiryInPast (test/sessionExpiry.tests.js)

  • Returns false when sessionExpiresAt is undefined (no ceiling)
  • Returns false when ceiling is well in the future relative to iat
  • Returns true when ceiling is in the past relative to iat
  • Returns true when ceiling is within the 30s leeway window relative to iat
  • Returns true exactly at the leeway boundary relative to iat — inclusive
  • Returns false just outside the leeway boundary relative to iat
  • Falls back to epoch() when issuedAt is absent
  • Falls back to epoch() when issuedAt is a millisecond-magnitude value

appSession session read (test/appSession.tests.js)

  • Session expires when sessionExpiresAt ceiling is reached
  • Session remains valid when ceiling is in the future
  • Sessions without sessionExpiresAt are unaffected — non-breaking
  • Cookie maxAge is capped at sessionExpiresAt when it is sooner than absoluteDuration

Callback + refresh (test/callback.tests.js)

  • sessionExpiresAt is persisted in the session when session_expiry claim is present
  • sessionExpiresAt is absent when session_expiry claim is missing — non-breaking
  • Login rejected with 400 when session_expiry is already in the past (lockout guard)
  • Invalid session_expiry shapes (string, float, zero, negative) are ignored — fail-open
  • SessionExpiredError (401) is thrown on accessToken.refresh() when ceiling has passed — /oauth/token is never called
  • sessionExpiresAt is preserved in the session after a successful refresh (carry-forward)

Manual tests

Testing against a live Auth0 tenant with a Post-Login Action deployed to emit session_expiry.

  • Login with session_expiry in ID token — sessionExpiresAt persisted in session
  • Session read before expiry — session valid and accessible
  • Token refresh before expiry — new access token returned successfully
  • sessionExpiresAt preserved after refresh — ceiling carried forward across token refresh
  • Session read after expiry — session cleared, redirected to Auth0 for re-authentication
  • Token refresh after expiry — SessionExpiredError (401, ERR_SESSION_EXPIRED) thrown; /oauth/token not called

@cschetan77 cschetan77 requested a review from a team as a code owner June 11, 2026 05:26
Comment thread lib/context.js Fixed
Comment thread lib/utils/sessionExpiry.js Outdated
Comment thread lib/context.js Outdated
Comment thread lib/context.js Outdated
Comment thread lib/errors.js
Comment thread .npmrc
Comment thread test/sessionExpiry.tests.js
Comment thread test/callback.tests.js
Comment thread lib/errors.js
…lities

- Extract epoch() to lib/utils/epoch.js (single source of truth)
- Extract SESSION_EXPIRY_LEEWAY, extractSessionExpiry, isSessionExpiryReached
  to lib/utils/sessionExpiry.js; used by both appSession.js and context.js
- Fix session_expiry validation: strict positive-integer check via
  extractSessionExpiry (fail-open for malformed/missing claims)
- Fix lockout guard: compare against epoch() not claims.iat
- Cap cookie maxAge at sessionExpiresAt in calculateExp so the browser
  cookie never outlives the IdP-asserted session ceiling
Comment thread EXAMPLES.md
Comment thread EXAMPLES.md
Comment thread EXAMPLES.md Outdated

## 14. Session expiry from upstream IdP (IPSIE `session_expiry`)

When an upstream IdP supports the IPSIE SL1 spec, it can include a `session_expiry` claim in the ID token — an absolute Unix timestamp (seconds) marking the latest moment the IdP considers the session valid. Auth0, for example, emits this claim on enterprise connections configured with `id_token_session_expiry_supported: true`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

server-python handles this with a short "Emitting the claim" note plus a placeholder link to the canonical Auth0 Action guide: RetrievingData.md ("For the canonical Action setup, see the Auth0 documentation (will be adding the link to the session_expiry Action guide once published)"). Could we add the same here - a line on the Action emitting the claim, with a placeholder for the Auth0 docs link to fill in once that guide is published?

kishore7snehil
kishore7snehil previously approved these changes Jun 29, 2026

@kishore7snehil kishore7snehil left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM!

Comment thread lib/context.js Outdated
const sessionExpiresAt = extractSessionExpiry(claims);
if (sessionExpiresAt) {
// Lockout guard: reject a session already past its ceiling at login.
if (isSessionExpiryReached(sessionExpiresAt)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This lockout compares session_expiry against now, but at login I think it should compare against the token's iat instead.

The point of this guard is to reject a session that is born expired, where session_expiry <= iat. Using now means a token whose ceiling is, say, 20s in the future passes here at login, but then fails the very next session read and the user gets bounced immediately. Comparing against iat avoids that confusing "logged in then instantly logged out" case.

Related point: isSessionExpiryReached is now doing double duty. The read path uses it for "has the ceiling passed now ?" and the login path reuses it for "was this born expired ?". Those are two different questions. auth0-server-js keeps them separate with a dedicated iat based helper (isSessionExpiryInPast(sessionExpiresAt, claims.iat)). Could we align with that here ? Keeping the two SDKs consistent would help.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yes, that makes sense.
Updated the lockout guard to compare against claims.iat instead of now.

  • Added isSessionExpiryInPast(sessionExpiresAt, issuedAt) to sessionExpiry.js utility, it resolves the reference time from iat (with a plausibility guard that falls back to epoch() if iat is absent or a millisecond-magnitude value), then delegates to isSessionExpiryReached so the leeway logic isn't duplicated. Also extracted isPlausibleUnixSeconds as a shared private helper used by both extractSessionExpiry and isSessionExpiryInPast.
    This aligns with auth0-server-js.

Comment thread lib/context.js
throw createError(
400,
'The session_expiry claim is in the past at login time.',
{ error: 'invalid_session_expiry' },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The login lockout throws a generic createError(400, ...) with error: 'invalid_session_expiry', but the refresh path throws the new SessionExpiredError (401). Same underlying reason, "session is past its ceiling", surfaced as two different error types and status codes.

A consumer catching SessionExpiredError won't catch this login-time case. Could we throw SessionExpiredError here too, so there's just one thing to catch ? If the 400 is intentional (born-dead at login being a bad request rather than unauthorized), a short comment explaining the choice would help.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This was an intentional decision, discussion_r3451378840.

  • SessionExpiredError (401) is designed for the refresh path: the user had a valid, established session that has since expired. It's meant to be caught by app error handlers to trigger re-authentication.
  • At the callback, the user has no established session yet, the IdP issued a token with a session_expiry already in the past. That's an invalid token, not an expired session. A 400 correctly signals "bad auth response."

Comment thread index.d.ts Outdated
/**
* Unix timestamp (seconds since epoch) indicating the hard ceiling on the RP
* session lifetime, as asserted by the upstream enterprise IdP.
* Present only when the connection has `id_token_session_expiry_supported: true`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This SDK is a generic OIDC library, not Auth0-specific, but the docs and types here read as Auth0-only. id_token_session_expiry_supported is an Auth0 connection flag, and session_expiry is actually an IPSIE / OIDC profile claim that any conforming provider can emit.

Could we frame this generically, something like "when the OpenID Provider emits a session_expiry claim (per IPSIE SL1 §3.2.2)", and mention the Auth0 connection flag only as one example of how a provider enables it ? Otherwise a non-Auth0 user might assume this feature doesn't apply to them. Same applies to the "upstream enterprise IdP" wording just above and the res.oidc.login() example in the SessionExpiredError docblock.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yes, that's appropriate.
Updated both docstrings:

  • IdTokenClaims.session_expiry: now reads "asserted by the upstream IdP via the IPSIE SL1 protocol", with id_token_session_expiry_supported mentioned as an Auth0-specific example.
  • Session.sessionExpiresAt: same wording, generic framing first with Auth0 as the example.

SessionExpiredError jsdoc was already generic so no change needed there.

Comment thread lib/appSession.js
// uat: session last-updated-at time (Unix seconds)
// sessionExpiresAt: optional IdP-asserted ceiling (Unix seconds) from IPSIE session_expiry claim;
// when present, caps the result so the cookie never outlives the IdP session.
function calculateExp(iat, uat, sessionExpiresAt) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The cookie maxAge cap looks right, exp = Math.min(exp, sessionExpiresAt). Do we have a unit test that asserts the cap actually kicks in when sessionExpiresAt is sooner than the rolling / absolute duration ? I saw the read-enforcement and callback tests, just want to make sure the cookie-cap path has an explicit assertion so it doesn't silently regress.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The test was already there but only asserted isBelow(maxAgeSeconds, 3 * 60 * 60), proving the cap fired but not that it landed specifically at sessionExpiresAt. Tightened it to:

  const expSeconds = Math.floor(expDate.getTime() / 1000);
  assert.approximately(expSeconds, sessionExpiresAt, 5);

This confirms the cookie expiry is pinned to sessionExpiresAt

- Extract isPlausibleUnixSeconds helper in sessionExpiry.js, shared by
  extractSessionExpiry and the new isSessionExpiryInPast
- Add isSessionExpiryInPast: compares ceiling against claims.iat (not now),
  delegating to isSessionExpiryReached to avoid duplicating leeway logic
- Use isSessionExpiryInPast in the login lockout guard in context.js
- Tighten cookie maxAge cap test to assert.approximately against sessionExpiresAt
- Reframe index.d.ts docstrings as generic IPSIE SL1 with Auth0 as example
- Fix flaky leeway boundary test by passing explicit iat to makeIdToken
@cschetan77 cschetan77 merged commit 5cd916c into master Jun 30, 2026
11 checks passed
@cschetan77 cschetan77 deleted the feat/ipsie branch June 30, 2026 09:08
@cschetan77 cschetan77 mentioned this pull request Jun 30, 2026
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.

4 participants