Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
68 changes: 68 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,70 @@ 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.

### Emitting the claim

How the claim is included in the ID token depends on your authorization server. For example on Auth0, it is emitted on `okta` and `oidc` enterprise connections configured with `id_token_session_expiry_supported: true`, computing it as the earliest of the tenant's absolute session lifetime, the upstream IdP's own session expiry, and any value set via `api.session.setExpiresAt` in a Post-Login Action. For the canonical Action setup, see the [Auth0 documentation](#) _(link to be added once the session_expiry Action guide is published)_.

> [!WARNING]
> `session_expiry` must be a Unix timestamp in **seconds**. The SDK rejects implausibly large values (anything at or above `10,000,000,000`, ≈ year 2286) as malformed and treats them as "no ceiling", so a milliseconds value will silently disable enforcement rather than expiring the session ~55,000 years from now. Any other malformed value — non-integer, float, zero, or negative — also fails open. If your authorization server computes this value from a millisecond timestamp, ensure it divides by 1000 before including it in the ID token. For example, in an Auth0 Post-Login Action, make sure to convert the timestamp to seconds before setting the claim.

### 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
51 changes: 49 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,17 @@ 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 IdP via the IPSIE SL1 protocol.
* Present when the IdP signals support for session expiry enforcement
* (e.g. Auth0 enterprise connections with `id_token_session_expiry_supported: true`).
*/
session_expiry?: number;
};

/**
* Session object
Expand All @@ -52,6 +60,14 @@ 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 when the upstream IdP signals
* support for session expiry enforcement (e.g. Auth0 enterprise connections
* with `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 +1179,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
50 changes: 46 additions & 4 deletions lib/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ const {
regenerateSessionStoreId,
replaceSession,
} = require('../lib/appSession');
const { SessionExpiredError } = require('./errors');
const { epoch } = require('./utils/epoch');
const {
extractSessionExpiry,
isSessionExpiryReached,
isSessionExpiryInPast,
} = 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 +150,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 +174,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 +185,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 +759,33 @@ 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 whose ceiling was already in the past when the IdP
// issued the token. We compare against claims.iat so a token that arrives late does
// not get incorrectly rejected.
if (isSessionExpiryInPast(sessionExpiresAt, claims.iat)) {
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