feat: Respect IdP provided session expiry and cap the SDK session #828
feat: Respect IdP provided session expiry and cap the SDK session #828cschetan77 wants to merge 13 commits into
Conversation
| */ | ||
| function extractSessionExpiry(claims) { | ||
| const value = claims?.session_expiry; | ||
| return typeof value === 'number' && Number.isInteger(value) && value > 0 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| const sessionExpiresAt = extractSessionExpiry(claims); | ||
| if (sessionExpiresAt) { | ||
| // Lockout guard: reject a session already past its ceiling at login. | ||
| if (sessionExpiresAt <= epoch()) { |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
Updated: The lockout guard now routes through isSessionExpiryReached instead of comparing against epoch() directly.
| // 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)) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| @@ -1,3 +0,0 @@ | |||
| registry=https://registry.npmjs.org/ | |||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
This was accidental, have restored the file.
Though was planning to remove registry url from it, but will do that in a separate PR.
…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
| @@ -0,0 +1,11 @@ | |||
| class SessionExpiredError extends Error { | |||
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 aSessionExpiredErrorcatch example, and how to readsessionExpiresAt.FAQ.md: new entry for the most likely confusing symptom, a previously authenticated user being unexpectedly redirected to login, pointing to theEXAMPLES.mdsection for full details.README.md: added a link toEXAMPLES.mdin the Documentation section, which was missing for quick navigation.
Description
When an upstream enterprise IdP (Okta, OIDC) emits a
session_expiryclaim in its ID token, Auth0 captures it and re-emits it in the ID token issued to the RP (when the connection hasid_token_session_expiry_supported: true). Without SDK enforcement, Auth0 revokes the session server-side on the next/authorizeround-trip, but the app-level session continues to look valid — defeating the security guarantee.This PR makes the SDK enforce
session_expiryas 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):
session_expiryfrom the ID token claims using strict positive-integer validation (fail-open: a missing or malformed claim means "no ceiling", never "already expired")session_expiryis already in the past at login time, so a born-dead session is never persistedsession.sessionExpiresAt(Unix seconds) in the session cookieOn every request (session read in
appSession.js):sessionExpiresAthas not been reached (with 30s negative leeway for clock skew) alongside existingiat/uat/expcheckssessionExpiresAtare unaffectedOn token refresh:
/oauth/token— throwsSessionExpiredErrorinstead of letting Auth0 return a confusinginvalid_grantsessionExpiresAtacross refreshes (the refresh grant does not re-assertsession_expiry)Cookie
maxAge:calculateExp()now accepts an optionalsessionExpiresAtand capsexp = Math.min(exp, sessionExpiresAt)— so the browser cookie never outlives the IdP session ceilingNew 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 confusinginvalid_grant.Session.sessionExpiresAt?: number— new optional session field (Unix seconds)IdTokenClaims.session_expiry?: number— new optional ID token claim typeFiles changed
lib/errors.jsSessionExpiredErrorclasslib/utils/epoch.jsepoch()utility (shared byappSession.jsandcontext.js)lib/utils/sessionExpiry.jsSESSION_EXPIRY_LEEWAY,extractSessionExpiry,isSessionExpiryReachedlib/context.jssessionExpiresAt; refresh: pre-check + preserve ceilinglib/appSession.jscalculateExp: cap atsessionExpiresAtindex.jsSessionExpiredErrorindex.d.tsSessionExpiredError,Session.sessionExpiresAt,IdTokenClaims.session_expiryTesting
Unit tests
extractSessionExpiry(test/sessionExpiry.tests.js)undefinedwhen the claim is absent (undefined,null, missing key)undefinedfor a string value — fail-openundefinedfor a float value — fail-openundefinedfor zero — fail-openundefinedfor a negative value — fail-openundefinedforNaN— fail-openisSessionExpiryReached(test/sessionExpiry.tests.js)falsewhensessionExpiresAtisundefined(no ceiling)falsewhen ceiling is well in the futuretruewhen ceiling is in the pasttruewhennowis within the 30s leeway window (clock skew guard)trueexactly at the leeway boundary — inclusivefalsejust outside the leeway boundaryappSessionsession read (test/appSession.tests.js)sessionExpiresAtceiling is reachedsessionExpiresAtare unaffected — non-breakingmaxAgeis capped atsessionExpiresAtwhen it is sooner thanabsoluteDurationCallback + refresh (
test/callback.tests.js)sessionExpiresAtis persisted in the session whensession_expiryclaim is presentsessionExpiresAtis absent whensession_expiryclaim is missing — non-breakingsession_expiryis already in the past (lockout guard)session_expiryshapes (string, float, zero, negative) are ignored — fail-openSessionExpiredError(401) is thrown onaccessToken.refresh()when ceiling has passed —/oauth/tokenis never calledsessionExpiresAtis 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.session_expiryin ID token —sessionExpiresAtpersisted in sessionsessionExpiresAtpreserved after refresh — ceiling carried forward across token refreshSessionExpiredError(401,ERR_SESSION_EXPIRED) thrown;/oauth/tokennot called