Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f0cd595
initial ipsie draft
cschetan77 Jun 11, 2026
7671118
feat(ipsie): harden session_expiry enforcement and extract shared uti…
cschetan77 Jun 17, 2026
6e9a51a
fix: remove unused SESSION_EXPIRY_LEEWAY import in context.js
cschetan77 Jun 17, 2026
aa0a68c
test(ipsie): add unit and integration tests for session_expiry enforc…
cschetan77 Jun 17, 2026
930c5c8
fix: put a upper bound on session_expirty claim for accidental millis…
cschetan77 Jun 22, 2026
fdf2d3c
fix: use isSessionExpired utility for lockout guard as well
cschetan77 Jun 22, 2026
64c465f
fix: put a session existence check before checking for session expiry…
cschetan77 Jun 22, 2026
6ada842
restore .npmrx
cschetan77 Jun 22, 2026
9e76dd6
add: test for upper sanity check on session expiry for large values
cschetan77 Jun 22, 2026
20c24ae
add fake timer test to for ceiling pass and clear the session
cschetan77 Jun 22, 2026
aee902f
fix: replace numeric separator with plain literal for ES2020 compat
cschetan77 Jun 22, 2026
971ca7e
docs: add session_expiry feature docs, FAQ entry and README link
cschetan77 Jun 23, 2026
7b28673
docs: improve punctuation in session_expiry examples section
cschetan77 Jun 23, 2026
09c750a
docs: add emitting the claim section and milliseconds warning to sess…
cschetan77 Jun 26, 2026
882b52f
docs: use GitHub warning alert syntax and refine emitting the claim w…
cschetan77 Jun 26, 2026
3e2f615
fix: address nandan-bhat review comments on session_expiry (PR #828)
cschetan77 Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
11. [Back-Channel Logout](#11-back-channel-logout)
12. [Custom Token Exchange](#12-custom-token-exchange)
13. [Use a proxy for OIDC requests](#13-use-a-proxy-for-oidc-requests)
14. [Session expiry from upstream IdP (IPSIE `session_expiry`)](#14-session-expiry-from-upstream-idp-ipsie-session_expiry)

## 1. Basic setup

Expand Down Expand Up @@ -432,3 +433,63 @@ app.use(
```

The SDK wraps your `customFetch` function to add required headers (User-Agent, Auth0-Client telemetry) before making requests.

## 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?


### What the SDK does automatically

No configuration or code change is required. When the claim is present, the SDK handles everything automatically:

- Persists the ceiling as `sessionExpiresAt` (Unix seconds) on the session.
Comment thread
kishore7snehil marked this conversation as resolved.
- Rejects login with HTTP 400 if the ceiling is already in the past at callback time, so a born-dead session is never persisted.
- Treats the session as expired once `sessionExpiresAt` is reached on every request, with a 30-second leeway for clock skew.
- Throws `SessionExpiredError` on `accessToken.refresh()` instead of making a token endpoint call that would fail anyway.
- Caps the session cookie lifetime at the ceiling as a defense-in-depth backstop.

This is layered **on top of** your existing idle and absolute session timeouts — the session ends at whichever limit is reached first.
Comment thread
kishore7snehil marked this conversation as resolved.

### Behavior on expiry

- **Session reads:** `req.appSession` is cleared and `req.oidc.isAuthenticated()` returns `false`. Your existing redirect-to-login path runs unchanged.
- **Token refresh:** `req.oidc.accessToken.refresh()` throws `SessionExpiredError` (`error.code === 'ERR_SESSION_EXPIRED'`, `error.status === 401`). Catch it to redirect the user to log in again.

```js
const { SessionExpiredError } = require('express-openid-connect');

app.get('/resource', async (req, res, next) => {
try {
let { token_type, access_token, isExpired, refresh } = req.oidc.accessToken;
if (isExpired()) {
({ access_token } = await refresh());
}
// use access_token
} catch (err) {
if (err instanceof SessionExpiredError) {
return res.redirect('/');
}
next(err);
}
});
```

### Reading the value (optional)

To show a "your session ends soon" prompt, read `sessionExpiresAt` off the session:

```js
app.get('/status', (req, res) => {
const { sessionExpiresAt } = req.appSession || {};
if (sessionExpiresAt) {
const remainingSeconds = sessionExpiresAt - Math.floor(Date.now() / 1000);
res.json({ remainingSeconds });
} else {
res.json({});
}
});
```

### Upgrading existing apps

Once your IdP starts emitting `session_expiry`, `req.appSession` can be `null` for a previously logged-in user once the ceiling is reached. If your code assumed the session always exists after login, add a null check. Sessions created before the upgrade (or through connections without the claim) have no `sessionExpiresAt` and behave exactly as before.
12 changes: 12 additions & 0 deletions FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,18 @@ Setting both `session.cookie.path` and `transactionCookie.name` is recommended.

---

## A previously authenticated user is being redirected to login unexpectedly

If a user who was logged in suddenly gets redirected to the login page with no visible error, and `req.appSession` is `null` on a route they previously accessed fine, the session may have been cleared because the upstream IdP's `session_expiry` ceiling was reached.

When an upstream IdP includes a `session_expiry` claim in the ID token (IPSIE SL1), the SDK enforces it as a hard ceiling on the local session lifetime. Once the ceiling is reached, the session is cleared on the next request and the user is treated as unauthenticated — exactly like an idle or absolute timeout expiry.

To confirm this is the cause, check whether `req.appSession.sessionExpiresAt` was set after login. If it was, compare it against the current time.

See [Session expiry from upstream IdP](./EXAMPLES.md#14-session-expiry-from-upstream-idp-ipsie-session_expiry) in EXAMPLES.md for full details.

---

## Login calls are failing with 'RequestError: The "listener" argument must be of type function. Received an instance of Object'

This module depends indirectly on a newer version of the `agent-base` module. If an unrelated module depends on a version of the `agent-base` older than 5.0, that older dependency is monkeypatching the global `http.request` object, causing this module to fail. You can check if you have this problem by running this check:
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

- [Quickstart](https://auth0.com/docs/quickstart/webapp/express) - our guide for quickly adding Auth0 to your Express app.
- [Sample](https://github.com/auth0-samples/auth0-express-webapp-sample/tree/master/01-Login) - an Express app integrated with Auth0.
- [Examples](https://github.com/auth0/express-openid-connect/blob/master/EXAMPLES.md) - code examples for common use cases.
- [FAQs](https://github.com/auth0/express-openid-connect/blob/master/FAQ.md) - Frequently asked questions about express-openid-connect.
- [Docs Site](https://auth0.com/docs) - explore our docs site and learn more about Auth0.

Expand Down
49 changes: 47 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,16 @@ interface AuthorizationParameters {
}

/**
* ID Token claims
* ID Token claims, extended with the IPSIE SL1 session_expiry claim.
*/
type IdTokenClaims = IDToken;
type IdTokenClaims = IDToken & {
/**
* 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.

*/
session_expiry?: number;
};

/**
* Session object
Expand All @@ -52,6 +59,13 @@ interface Session {
refresh_token: string;
token_type: string;
expires_at: string;
/**
* Unix timestamp (seconds) of the IdP-asserted session ceiling from the
* IPSIE SL1 `session_expiry` claim. Present only when the upstream connection
* has `id_token_session_expiry_supported: true`. The SDK enforces this as a
* hard expiry on every session read and before any token refresh.
*/
sessionExpiresAt?: number;
[key: string]: any;
}

Expand Down Expand Up @@ -1163,3 +1177,34 @@ export function claimCheck(
* ```
*/
export function attemptSilentLogin(): RequestHandler;

/**
* Error thrown by `accessToken.refresh()` when the IdP-asserted session ceiling
* (`session_expiry` claim) has passed. Catch this in API routes to redirect the
* user to re-authenticate, rather than receiving a confusing `invalid_grant` from
* the token endpoint.
*
* ```js
* const { SessionExpiredError } = require('express-openid-connect');
*
* app.get('/api/data', async (req, res, next) => {
* try {
* if (req.oidc.accessToken.isExpired()) {
* await req.oidc.accessToken.refresh();
* }
* } catch (err) {
* if (err instanceof SessionExpiredError) {
* return res.oidc.login();
* }
* return next(err);
* }
* });
* ```
*/
export class SessionExpiredError extends Error {
readonly name: 'SessionExpiredError';
readonly code: 'ERR_SESSION_EXPIRED';
readonly status: 401;
readonly statusCode: 401;
constructor(message?: string);
}
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
const auth = require('./middleware/auth');
const requiresAuth = require('./middleware/requiresAuth');
const attemptSilentLogin = require('./middleware/attemptSilentLogin');
const { SessionExpiredError } = require('./lib/errors');

module.exports = {
auth,
...requiresAuth,
attemptSilentLogin,
SessionExpiredError,
};
51 changes: 42 additions & 9 deletions lib/appSession.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ const COOKIES = require('./cookies');
const { getKeyStore, verifyCookie, signCookie } = require('./crypto');
const debug = require('./debug')('appSession');

const epoch = () => (Date.now() / 1000) | 0;
const { epoch } = require('./utils/epoch');
const { isSessionExpiryReached } = require('./utils/sessionExpiry');
const MAX_COOKIE_SIZE = 4096;

const REASSIGN = Symbol('reassign');
Expand Down Expand Up @@ -101,22 +102,36 @@ module.exports = (config) => {
throw lastError;
}

function calculateExp(iat, uat) {
// Calculates the session expiry Unix timestamp (seconds).
// iat: session issued-at time (Unix seconds)
// 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

let exp;
if (!rollingEnabled) {
return iat + absoluteDuration;
exp = iat + absoluteDuration;
} else if (!absoluteDuration) {
exp = uat + rollingDuration;
} else {
exp = Math.min(uat + rollingDuration, iat + absoluteDuration);
}

if (!absoluteDuration) {
return uat + rollingDuration;
if (sessionExpiresAt) {
exp = Math.min(exp, sessionExpiresAt);
}

return Math.min(uat + rollingDuration, iat + absoluteDuration);
return exp;
}

async function setCookie(
req,
res,
{ uat = epoch(), iat = uat, exp = calculateExp(iat, uat) },
{
uat = epoch(),
iat = uat,
exp = calculateExp(iat, uat, req[sessionName]?.sessionExpiresAt),
},
) {
if (res.headersSent) {
// If headers were already flushed before res.end() the
Expand Down Expand Up @@ -229,7 +244,11 @@ module.exports = (config) => {
id,
req,
res,
{ uat = epoch(), iat = uat, exp = calculateExp(iat, uat) },
{
uat = epoch(),
iat = uat,
exp = calculateExp(iat, uat, req[sessionName]?.sessionExpiresAt),
},
) {
const hasPrevSession = !!req[COOKIES][sessionName];
const replacingPrevSession = !!req[REGENERATED_SESSION_ID];
Expand Down Expand Up @@ -269,7 +288,11 @@ module.exports = (config) => {
id,
req,
res,
{ uat = epoch(), iat = uat, exp = calculateExp(iat, uat) },
{
uat = epoch(),
iat = uat,
exp = calculateExp(iat, uat, req[sessionName]?.sessionExpiresAt),
},
) {
if (res.headersSent) {
// If headers were already flushed before res.end() the
Expand Down Expand Up @@ -395,6 +418,16 @@ module.exports = (config) => {
);
}

// check that the existing session hasn't passed the IdP-asserted session ceiling
// (IPSIE SL1 session_expiry claim). Only enforced when the claim was present at login.
// A 30s leeway guards against clock skew between the SDK and the authorization server.
if (data.sessionExpiresAt) {
assert(
!isSessionExpiryReached(data.sessionExpiresAt),
'it is expired based on the session_expiry claim from the upstream IdP',
);
}

attachSessionObject(req, sessionName, data);
}
}
Expand Down
47 changes: 43 additions & 4 deletions lib/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ const {
regenerateSessionStoreId,
replaceSession,
} = require('../lib/appSession');
const { SessionExpiredError } = require('./errors');
const { epoch } = require('./utils/epoch');
const {
extractSessionExpiry,
isSessionExpiryReached,
} = require('./utils/sessionExpiry');

// Caches one RemoteJWKSet instance per auth() configuration for backchannel logout token verification.
const jwksClientCache = new Map();
Expand Down Expand Up @@ -143,6 +149,16 @@ function normalizeTokenType(tokenType) {

async function refresh({ tokenEndpointParams } = {}) {
let { config, req } = weakRef(this);
const session = req[config.session.name];

// Pre-check: do not call /oauth/token if the IdP session ceiling has passed.
// The authorization server 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)) {
throw new SessionExpiredError();
}

const { configuration } = await getClient(config);
const oldTokenSet = tokenSet.call(this);

Expand All @@ -157,8 +173,9 @@ async function refresh({ tokenEndpointParams } = {}) {
parameters,
);

// Update the session
const session = req[config.session.name];
// Update the session, preserving sessionExpiresAt — the refresh-token grant
// does not return a session_expiry claim, so we must not overwrite the ceiling
// that was set at login.
Object.assign(session, {
access_token: newTokenSet.access_token,
// If no new ID token assume the current ID token is valid.
Expand All @@ -167,8 +184,13 @@ async function refresh({ tokenEndpointParams } = {}) {
refresh_token: newTokenSet.refresh_token || oldTokenSet.refresh_token,
token_type: normalizeTokenType(newTokenSet.token_type),
expires_at: newTokenSet.expires_in
? Math.floor(Date.now() / 1000) + newTokenSet.expires_in
? epoch() + newTokenSet.expires_in
: undefined,
// Preserve the IdP session ceiling across refreshes — do not re-derive
// from the refresh response (it won't contain session_expiry).
...(session.sessionExpiresAt !== undefined && {
sessionExpiresAt: session.sessionExpiresAt,
}),
});

// Delete the old token set
Expand Down Expand Up @@ -736,14 +758,31 @@ class ResponseContext {
refresh_token: tokenResponse.refresh_token,
token_type: normalizeTokenType(tokenResponse.token_type),
expires_at: tokenResponse.expires_in
? Math.floor(Date.now() / 1000) + tokenResponse.expires_in
? epoch() + tokenResponse.expires_in
: undefined,
};

// Must store the `sid` separately as the ID Token gets overridden by
// ID Token from the Refresh Grant which may not contain a sid (In Auth0 currently).
session.sid = claims?.sid;

// Persist the session_expiry claim from the ID token as sessionExpiresAt.
// This is the IdP-asserted ceiling on the RP session lifetime (IPSIE SL1).
// Only set when the claim is present — absence means the connection does not
// have id_token_session_expiry_supported enabled, and existing behavior applies.
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.

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."

);
}
session.sessionExpiresAt = sessionExpiresAt;
}

// Check if user was previously authenticated BEFORE we modify the session
const wasAuthenticated = req.oidc.isAuthenticated();
const previousSub = wasAuthenticated ? req.oidc.user.sub : null;
Expand Down
11 changes: 11 additions & 0 deletions lib/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class SessionExpiredError extends Error {
Comment thread
kishore7snehil marked this conversation as resolved.
constructor(message = 'The upstream IdP session has expired.') {
super(message);
this.name = 'SessionExpiredError';
this.code = 'ERR_SESSION_EXPIRED';
Comment thread
kishore7snehil marked this conversation as resolved.
this.status = 401;
this.statusCode = 401;
}
}

module.exports = { SessionExpiredError };
3 changes: 3 additions & 0 deletions lib/utils/epoch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const epoch = () => (Date.now() / 1000) | 0;

module.exports = { epoch };
Loading
Loading