Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 25 additions & 0 deletions examples/migration-express-openid-connect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,31 @@ cookie name so the same-browser cookie is picked up across the migration.
Expected: `204`. Then confirm both the session key and its `logout:sid:<sid>`
index are gone from Redis. Reloading `/` shows logged-out.

## Scenario 3 — Aged session survives the absoluteDuration gap

Scenarios 1 and 2 both start from a fresh login, so they never exercise the state
where the migration logs a user out: a session already older than this SDK's default
`absoluteDuration` (3 days) but still valid under express-openid-connect (default 7
days). The migration stores keep the original creation time, so
`createdAt + absoluteDuration` can already be in the past. When it is, the store
treats the session as expired and refuses it on the **first read** — a migrated
cookie still carries express-openid-connect's own `Max-Age`, so the browser keeps
sending it, and the store (not the browser) is what enforces this SDK's cap. The
`after` app sets `absoluteDuration: 604800` to avoid this; this scenario proves that
guard is load-bearing.

1. Log in with the **legacy** app (either store) as in Scenario 1 or 2, then stop it.
2. Simulate an aged session by lowering the cap below the session's age: start the
`after` app with `SESSION_ABSOLUTE_DURATION=1` (1 second), then reload in the same
browser. Expected: you are logged out on that first reload for **both** stores —
the store computes `createdAt + absoluteDuration` in the past and returns no
session, so `getUser()` is empty and protected routes redirect to login. This is
the failure the fix prevents.
3. Restart the `after` app without the override (back to the default
`absoluteDuration: 604800`) and repeat with the original legacy cookie/session
still present. Expected: still logged in — the aged session is preserved because
the cap now exceeds its age.

### Notes

- `logout_token` is a signed JWT issued by Auth0; you cannot hand-craft one that
Expand Down
23 changes: 21 additions & 2 deletions examples/migration-express-openid-connect/after/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ const app = express();
const redisUrl = process.env.REDIS_URL;
const sessionStore = redisUrl ? await createRedisSessionStore(redisUrl) : undefined;

// Scenario 3 of the runbook lowers the absolute cap to simulate an aged session. Parse the override
// defensively: a missing or non-numeric value falls back to the express-openid-connect default (7
// days) rather than silently becoming NaN, which would drop every session.
const parsedAbsoluteDuration = Number(process.env.SESSION_ABSOLUTE_DURATION);
const absoluteDuration = Number.isFinite(parsedAbsoluteDuration) ? parsedAbsoluteDuration : 604800;

// Auth0 sends the backchannel logout token as application/x-www-form-urlencoded.
// Express 5 does not parse request bodies by default, so mount a parser before the
// Auth0 router or `POST /auth/backchannel-logout` would see an undefined `req.body`.
Expand All @@ -27,8 +33,21 @@ app.use(
// derive from AUTH0_AUDIENCE here — or the carried-over token is not found.
legacyAudience: process.env.AUTH0_AUDIENCE,
},
// Match express-openid-connect's default cookie name so the same-browser cookie is picked up.
sessionConfiguration: { cookie: { name: 'appSession' } },
sessionConfiguration: {
// Match express-openid-connect's default cookie name so the same-browser cookie is picked up.
cookie: { name: 'appSession' },
// A migrated session keeps its original creation time (the express-openid-connect `iat`), and
// this SDK expires a session at `createdAt + absoluteDuration`. This SDK defaults that to 3
// days but express-openid-connect defaults it to 7 — so without raising absoluteDuration a
// legacy session older than 3 days would be logged out on first read despite still being valid
// under the old SDK. Match the old deployment's durations so no in-flight session is cut short.
// Default matches express-openid-connect (7 days); the SESSION_ABSOLUTE_DURATION override
// (parsed defensively above) lets the runbook simulate an aged session without editing code.
absoluteDuration,
// Matches this SDK's own default (1 day); only needs changing if you customized
// express-openid-connect's `rollingDuration`. Kept here for symmetry with absoluteDuration.
inactivityDuration: 86400, // 1 day (express-openid-connect default rollingDuration)
},
// Only set for the stateful scenario; undefined => cookie (stateless) store.
sessionStore,
})
Expand Down
22 changes: 19 additions & 3 deletions packages/auth0-express/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,9 +565,23 @@ app.use(createAuth0({
legacyScope: 'openid profile email offline_access',
},

// express-openid-connect's default cookie name is `appSession`. Match it so the existing
// cookie is picked up; otherwise the SDK looks for its own default (`__a0_session`).
sessionConfiguration: { cookie: { name: 'appSession' } },
sessionConfiguration: {
// express-openid-connect's default cookie name is `appSession`. Match it so the existing
// cookie is picked up; otherwise the SDK looks for its own default (`__a0_session`).
cookie: { name: 'appSession' },

// A migrated session keeps its ORIGINAL creation time (its express-openid-connect `iat`),
// and this SDK expires a session at `createdAt + absoluteDuration`. This SDK defaults
// `absoluteDuration` to 3 days, but express-openid-connect defaults it to 7 days — so with the
// default a legacy session already older than 3 days would be logged out on first read even
// though it was still valid under express-openid-connect. Set `absoluteDuration` (and
// `inactivityDuration` if you customized express-openid-connect's `rollingDuration`) to at
// least what the old deployment used, so no in-flight session is cut short by the switch.
absoluteDuration: 604800, // 7 days — match (or exceed) express-openid-connect's default
// Already this SDK's default (1 day); only change it if you customized express-openid-connect's
// `rollingDuration`. Shown here for symmetry with absoluteDuration.
inactivityDuration: 86400, // 1 day — match (or exceed) express-openid-connect's rollingDuration
},
}));
```

Expand All @@ -589,6 +603,8 @@ app.use(createAuth0({

> **Note:** `legacyAudience` and `legacyScope` only apply to a legacy session's single access token, which is migrated into one token set. Match `legacyAudience` to your requested `audience` or the carried-over token will not be found.

> **Note:** A migrated session keeps its original creation time, and this SDK expires a session at `createdAt + absoluteDuration`. This SDK defaults `absoluteDuration` to 3 days while express-openid-connect defaults it to 7 — so set `sessionConfiguration.absoluteDuration` (and `inactivityDuration` if you customized express-openid-connect's `rollingDuration`) to at least the old deployment's value, or in-flight sessions older than the default are treated as expired and rejected on the next request. The migration store enforces this cap on read (a migrated cookie still carries express-openid-connect's own `Max-Age`, so the browser keeps sending it past this SDK's cap; the store refuses it rather than honoring it until the next write). See the `sessionConfiguration` block in the example above. If you leave `absoluteDuration` unset in migration mode, the store logs a one-time `console.warn` at startup so this potential misconfiguration surfaces before it shows up as user "why was I logged out?" reports. Conversely, setting `absoluteDuration` **higher** than your old deployment used extends carried-over sessions beyond what express-openid-connect would have allowed (and the inactivity window restarts from the migration), so choose a value that matches your intended session policy, not just the largest one that avoids logouts.

---

## Custom Login Parameters
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,11 @@ describe('MigrationStatefulStateStore', () => {
);

const now = Math.floor(Date.now() / 1000);
// A recent iat (within the default absoluteDuration) so the store returns the session; this
// test pins the iat -> createdAt mapping, not the cap enforcement covered elsewhere.
const iat = now - 3600;
const legacyPayload = {
header: { iat: 1700000000, uat: 1700000001, exp: now + 3600 },
header: { iat, uat: iat + 1, exp: now + 3600 },
data: {
id_token: sampleIdToken,
access_token: 'test-access-token',
Expand All @@ -372,7 +375,120 @@ describe('MigrationStatefulStateStore', () => {

const result = await store.get('__a0_session', {});

expect(result!.internal.createdAt).toBe(1700000000);
expect(result!.internal.createdAt).toBe(iat);
});
});

describe('get - absoluteDuration and the migrated session age', () => {
// A migrated session keeps its original express-openid-connect `iat` as `createdAt`, and the
// base store expires it at `createdAt + absoluteDuration`. express-openid-connect defaults
// absoluteDuration to 7 days, this SDK to 3. If the app does not raise absoluteDuration to at
// least the old value, a legacy session already older than 3 days is transformed successfully
// but the write-back emits a maxAge<=0 cookie the browser immediately drops — a silent logout.
const FOUR_DAYS = 4 * 24 * 60 * 60;

const agedLegacyPayload = () => {
const now = Math.floor(Date.now() / 1000);
return {
// Issued 4 days ago, but still valid under express-openid-connect (exp in the future).
header: { iat: now - FOUR_DAYS, uat: now - 100, exp: now + 3600 },
data: { id_token: sampleIdToken, access_token: 'aged-token', expires_at: now + 3600 },
cookie: { expires: now + 3600, maxAge: 3600 },
};
};

it('returns no session for a >3-day-old legacy session under the default (3-day) absoluteDuration', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const handler = createCookieHandler();
const store = new MigrationStatefulStateStore({ secret, store: mockStore }, handler);

await mockStore.set('sess:aged-default', agedLegacyPayload());
(handler.getCookie as ReturnType<typeof vi.fn>).mockReturnValue('sess:aged-default');

const result = await store.get('__a0_session', {});

// The envelope decrypts (it was valid under express-openid-connect), but it is already past
// this SDK's absoluteDuration, so the store enforces the cap on read and returns no session.
expect(result).toBeUndefined();

warn.mockRestore();
});

it('keeps a >3-day-old legacy session alive when absoluteDuration matches express-openid-connect', async () => {
const handler = createCookieHandler();
const store = new MigrationStatefulStateStore(
{ secret, store: mockStore, sessionConfiguration: { absoluteDuration: 604800 } },
handler
);

await mockStore.set('sess:aged-configured', agedLegacyPayload());
(handler.getCookie as ReturnType<typeof vi.fn>).mockReturnValue('sess:aged-configured');

const result = await store.get('__a0_session', {});

expect(result).toBeDefined();
expect(handler.setCookie).toHaveBeenCalled();
const emittedMaxAge = (handler.setCookie as ReturnType<typeof vi.fn>).mock.calls[0]![2]!.maxAge;
// Raising the absolute cap to 7 days keeps `createdAt + absoluteDuration` in the future, so
// the rolling inactivity window (1 day) governs and the cookie survives instead of expiring.
expect(emittedMaxAge).toBeGreaterThan(0);
});

it('read-rejection and write-drop agree on the same aged createdAt', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const handler = createCookieHandler();
const store = new MigrationStatefulStateStore({ secret, store: mockStore }, handler);

await mockStore.set('sess:aged-agree', agedLegacyPayload());
(handler.getCookie as ReturnType<typeof vi.fn>).mockReturnValue('sess:aged-agree');

// Read rejects the aged session (no write-back, since transform returns undefined)...
const result = await store.get('__a0_session', {});
expect(result).toBeUndefined();
expect(handler.setCookie).not.toHaveBeenCalled();

// ...and a write of state carrying that same aged createdAt would emit a Max-Age<=0 cookie the
// browser drops. Both paths are driven by calculateMaxAge(createdAt), so there is no state the
// write path keeps alive while the read path logs out.
const now = Math.floor(Date.now() / 1000);
const agedStateData = {
user: { sub: 'auth0|123456' },
idToken: undefined,
refreshToken: undefined,
tokenSets: [],
internal: { sid: 'aged-sid', createdAt: now - FOUR_DAYS },
};

await store.set('__a0_session', agedStateData, false, {});
expect(handler.setCookie).toHaveBeenCalled();
const emittedMaxAge = (handler.setCookie as ReturnType<typeof vi.fn>).mock.calls[0]![2]!.maxAge;
expect(emittedMaxAge).toBe(0);

warn.mockRestore();
});

it('warns once at construction when absoluteDuration is left unset', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

new MigrationStatefulStateStore({ secret, store: mockStore }, createCookieHandler());

expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('without sessionConfiguration.absoluteDuration'));

warn.mockRestore();
});

it('does not warn at construction when absoluteDuration is set explicitly', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

new MigrationStatefulStateStore(
{ secret, store: mockStore, sessionConfiguration: { absoluteDuration: 604800 } },
createCookieHandler()
);

expect(warn).not.toHaveBeenCalled();

warn.mockRestore();
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
type SessionStore,
} from '@auth0/auth0-server-js';
import type { CookieHandler, StateData } from '@auth0/auth0-server-js';
import { LegacySessionTransformer } from './legacy-session-transformer.js';
import { LegacySessionTransformer, warnIfAbsoluteDurationUnset } from './legacy-session-transformer.js';
import type { ExpressOpenidConnectStorePayload } from './legacy-session-transformer.js';
import { deriveHkdfKey } from './express-oidc-hkdf.js';

Expand Down Expand Up @@ -134,6 +134,8 @@ export class MigrationStatefulStateStore<TStoreOptions> extends StatefulStateSto
const legacyAudience = options.legacyAudience ?? 'default';
const legacyScope = options.legacyScope ?? 'openid profile email offline_access';
this.#transformer = new LegacySessionTransformer(legacyAudience, legacyScope);

warnIfAbsoluteDurationUnset(options.sessionConfiguration);
}

/**
Expand Down Expand Up @@ -262,6 +264,13 @@ export class MigrationStatefulStateStore<TStoreOptions> extends StatefulStateSto
if (payload.header.exp <= Math.floor(Date.now() / 1000)) {
return undefined;
}
// Enforce this SDK's absoluteDuration on read. The legacy envelope's own exp reflects the OLD
// deployment's window, so a session past `createdAt + absoluteDuration` (calculateMaxAge <= 0)
// must be treated as expired here rather than returned for this one request before the
// write-back drops the cookie.
if (this.calculateMaxAge(payload.header.iat) <= 0) {
return undefined;
}
const sessionData = this.#transformer.transformLegacySession(payload.data);
sessionData.internal.createdAt = payload.header.iat;
return sessionData;
Expand Down
Loading