feat: Respect IdP provided session expiry and cap the SDK session #828
Conversation
…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
|
|
||
| ## 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`. |
There was a problem hiding this comment.
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?
| const sessionExpiresAt = extractSessionExpiry(claims); | ||
| if (sessionExpiresAt) { | ||
| // Lockout guard: reject a session already past its ceiling at login. | ||
| if (isSessionExpiryReached(sessionExpiresAt)) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
yes, that makes sense.
Updated the lockout guard to compare against claims.iat instead of now.
- Added
isSessionExpiryInPast(sessionExpiresAt, issuedAt)tosessionExpiry.jsutility, 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 toisSessionExpiryReachedso the leeway logic isn't duplicated. Also extractedisPlausibleUnixSecondsas a shared private helper used by both extractSessionExpiry and isSessionExpiryInPast.
This aligns withauth0-server-js.
| throw createError( | ||
| 400, | ||
| 'The session_expiry claim is in the past at login time.', | ||
| { error: 'invalid_session_expiry' }, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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."
| /** | ||
| * 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`. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| // 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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
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_expirywas already in the past when the IdP issued the token (compared againstclaims.iat, notnow, so a token that arrives late is not incorrectly rejected); 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,isPlausibleUnixSeconds,extractSessionExpiry,isSessionExpiryReached,isSessionExpiryInPastlib/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-openundefinedfor a millisecond-magnitude value — 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 boundaryisSessionExpiryInPast(test/sessionExpiry.tests.js)falsewhensessionExpiresAtisundefined(no ceiling)falsewhen ceiling is well in the future relative toiattruewhen ceiling is in the past relative toiattruewhen ceiling is within the 30s leeway window relative toiattrueexactly at the leeway boundary relative toiat— inclusivefalsejust outside the leeway boundary relative toiatepoch()whenissuedAtis absentepoch()whenissuedAtis a millisecond-magnitude valueappSessionsession 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
Testing 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