Skip to content

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

Open
cschetan77 wants to merge 13 commits into
masterfrom
feat/ipsie
Open

feat: Respect IdP provided session expiry and cap the SDK session #828
cschetan77 wants to merge 13 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 is already in the past at login time, so 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, extractSessionExpiry, isSessionExpiryReached
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

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

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

Tested 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
*/
function extractSessionExpiry(claims) {
const value = claims?.session_expiry;
return typeof value === 'number' && Number.isInteger(value) && value > 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Since this claim is set by a customer-side action rather than the platform, it's effectively untrusted input- and the easiest mistake someone can make is handing it milliseconds instead of seconds (forgetting the /1000). A 13-digit millisecond value sails through this positive-integer check, lands tens of thousands of years in the future, and the ceiling then never triggers, so enforcement is silently off while everything looks fine.

Could we add an upper-bound sanity check here and treat anything implausibly large as "no ceiling" (fail-open)? Something like rejecting values >= 10_000_000_000 , that's around the year 2286 in seconds, so no real timestamp is affected for a couple of centuries, but every millisecond slip-up gets caught.

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 and the value 10_000_000_000 seems the best choice.
Added an upper bound of 10_000_000_000 (year 2286) to extractSessionExpiry, any value at or above that, is treated as "no ceiling" (fail-open), which catches every accidental millisecond slip-up while leaving all realistic seconds timestamps unaffected for the next couple of centuries.
Also added a test asserting a millisecond-magnitude value is ignored.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The login guard here compares against epoch() with no leeway, but the read-time and refresh checks go through the helper that applies the 30s skew leeway. The effect is that a ceiling landing 1–30s in the future passes login and gets persisted, then the very next session read immediately rejects it ,so the user "logs in" and is bounced on the next page load, which is the born-dead session this guard is meant to avoid.

Routing this through the same helper the reads use would keep both ends in agreement (and a quick boundary test would lock it in).

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.

Updated: The lockout guard now routes through isSessionExpiryReached instead of comparing against epoch() directly.

Comment thread lib/context.js Outdated
// Auth0 has already revoked the session server-side at this point, so a
// refresh attempt would fail with invalid_grant anyway. Surfacing a clean
// SessionExpiredError is better than a confusing token-endpoint error.
if (isSessionExpiryReached(session.sessionExpiresAt)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

session is read and de-referenced right at the top, so if refresh() is ever reached without a live session this throws a TypeError before the usual token checks can surface a clearer error.

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.

Changed to session?.sessionExpiresAt, if session is null or undefined, isSessionExpiryReached(undefined) returns false, and the code falls through to fail naturally at the token endpoint call rather than throwing a misleading SessionExpiredError.

Comment thread lib/errors.js
Comment thread .npmrc
@@ -1,3 +0,0 @@
registry=https://registry.npmjs.org/

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 looks unrelated to the feature — deleting .npmrc drops save-exact=true (plus the registry/loglevel pins), which means new dependencies start getting saved as caret ranges instead of exact versions and installs become less reproducible. Was this intentional? If not, probably worth restoring; if it is, a line in the description 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 accidental, have restored the file.
Though was planning to remove registry url from it, but will do that in a separate PR.

Comment thread test/sessionExpiry.tests.js
Comment thread test/callback.tests.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 lib/errors.js
@@ -0,0 +1,11 @@
class SessionExpiredError extends Error {

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 adds SessionExpiredError as a public export and a new sessionExpiresAt field on the session, both well-described in the typings but there's no prose documentation for the feature anywhere in the repo. EXAMPLES.md, README.md, and FAQ.md don't mention session_expiry, and the diff doesn't touch any markdown.

You can refer to this - https://github.com/auth0/auth0-auth-js/pull/191/changes#diff-15a5f387b45be22cd46b0b8293094d414345ca978c918743c43e0a9537964d65

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.

Added documentation across three files:

  • EXAMPLES.md: "Session expiry from upstream IdP (IPSIE session_expiry)" covering what it's about and what's the SDK does automatically (lockout guard, session enforcement, refresh pre-check, cookie cap), behavior on expiry with a SessionExpiredError catch example, and how to read sessionExpiresAt.
  • FAQ.md: new entry for the most likely confusing symptom, a previously authenticated user being unexpectedly redirected to login, pointing to the EXAMPLES.md section for full details.
  • README.md: added a link to EXAMPLES.md in the Documentation section, which was missing for quick navigation.

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.

3 participants