Skip to content

feat(auth0-express): migration stores for express-openid-connect migration - #7

Open
frederikprijck wants to merge 11 commits into
mainfrom
feat/legacy-migration-v2
Open

feat(auth0-express): migration stores for express-openid-connect migration#7
frederikprijck wants to merge 11 commits into
mainfrom
feat/legacy-migration-v2

Conversation

@frederikprijck

@frederikprijck frederikprijck commented Jun 17, 2026

Copy link
Copy Markdown
Member

Summary

Adds zero-downtime session migration from express-openid-connect to @auth0/auth0-express. When enabled via a legacyCompatibility option on createAuth0, the SDK transparently reads existing express-openid-connect sessions and upgrades them to the auth0-server-js format on the next write, so users are not forced to re-authenticate.

  • Stateless (cookie): decrypts existing express-openid-connect JWE cookies (dir / A256GCM, HKDF 'JWE CEK'), enforces the header-level exp, and converts them to StateData. The next write re-encrypts in the modern format.
  • Stateful (server-side store): reads express-openid-connect session envelopes ({ header, data, cookie }) from Redis/Mongo/etc., resolving a plain or JWS-signed store-key cookie, and upgrades the session in place on the next write. Backchannel logout works for migrated sessions once re-written.
  • Exposed through the legacyCompatibility config; the stores live behind the @auth0/auth0-express/migration subpath (not the main entrypoint).

Key details

  • Cookie-shape routing (stateful): a 5-segment compact JWE is delegated to the base decrypt; only plain / one-dot (JWS) values go to legacy resolution — so a modern ciphertext is never used as a raw store key.
  • requireSignedLegacyCookie: mirrors express-openid-connect's requireSignedSessionStoreCookie. When true, an unsigned or bad-signature store cookie resolves to no session instead of the raw value. Default false, matching eoc. Stateful-only.
  • Expiry: rejects a legacy cookie whose header exp is missing/non-numeric or in the past, mirroring eoc's assert(exp > epoch()).
  • legacyAudience / legacyScope: stamped onto the single migrated token set. legacyAudience must equal the audience your app requests, since getAccessToken looks tokens up by audience.
  • Key rotation: legacySecret accepts a string array; secrets are tried in order.
  • Catch scope: legacy decryption only swallows JWEDecryptionFailed / JWEInvalid; programming errors propagate.
  • HKDF utility: shared deriveHkdfKey(secret, info) in express-oidc-hkdf.ts, used by both stores.

How to test

Automated

npm install && npm run build
npm test --workspace packages/auth0-express

Unit tests cover tamper rejection, expiry (missing / non-numeric / past exp), cookie-shape routing, in-place upgrade on write, secret rotation, requireSignedLegacyCookie, and — importantly — fixtures produced by the real express-openid-connect library (signing a stateful cookie with eoc's own signCookie, and encrypting a stateless cookie with eoc's encryption() CEK derivation) to catch any drift in HKDF salt/info or the JWS header.

Manual (end-to-end migration)

Full runbook: examples/migration-express-openid-connect/README.md. In short:

Prerequisitesnpm install && npm run build; a test Auth0 Regular Web App with callback URLs http://localhost:3000/callback (before) and http://localhost:3000/auth/callback (after); copy each app's .env.example to .env using the same session secret in both.

The example apps render a Session facts panel (user sub, token audience/scope/expiry, refresh + id token present) rather than the access token itself — an access token is a bearer secret and does not belong in a page. You verify the migration by comparing those facts before vs after (same sub, same audience/scope, refresh token still present).

Scenario 1 — Stateless (cookie):

  1. npm start --workspace examples/migration-express-openid-connect/before — log in at http://localhost:3000, confirm the appSession cookie and note the Session facts panel.
  2. Stop it, then npm start --workspace examples/migration-express-openid-connect/after.
  3. Reload in the same browser → still logged in (legacy cookie decrypted, transformed, re-encrypted modern), and the Session facts match the legacy app's. /private accessible without re-login.
  4. Optional strongest proof: with AUTH0_SECOND_AUDIENCE set, hit /refresh-token — it exchanges the carried-over refresh token for a fresh token set (succeeding proves the migrated refresh token is intact) and reports the new set's metadata, not the token.

Scenario 2 — Stateful (Redis) + backchannel logout:

  1. docker compose -f examples/migration-express-openid-connect/shared/docker-compose.yml up -d; set REDIS_URL=redis://localhost:6379 in both .envs.
  2. Start before/, log in, confirm a session key in Redis. Stop it, start after/, reload → still logged in.
  3. Log in again (or any session write) so a modern StateData + logout:sid:<sid> index is written.
  4. POST /auth/backchannel-logout with a real logout_token from your tenant → 204; confirm the session key and its sid index are removed from Redis.

Notes

  • A purely migrated session that is never re-written has no sid index yet, so it is not deletable by deleteByLogoutToken until the first write — expected and documented in the design.
  • History was cleaned up to three linear, signed commits on top of main.

@frederikprijck frederikprijck changed the title feat: migration stores for zero-downtime express-openid-connect migration feat: migration stores for express-openid-connect migration Jun 17, 2026
@frederikprijck frederikprijck changed the title feat: migration stores for express-openid-connect migration feat(auth0-express): migration stores for express-openid-connect migration Jun 17, 2026
Comment thread packages/auth0-express/src/store/legacy-session-transformer.ts
@frederikprijck
frederikprijck force-pushed the feat/legacy-migration-v2 branch 2 times, most recently from b54d666 to a9b7e1f Compare July 9, 2026 11:10
Add zero-downtime session migration from express-openid-connect via a
`legacyCompatibility` option on createAuth0. When enabled, the SDK reads
existing express-openid-connect sessions (stateless cookie or stateful
server-side store) and upgrades them to the auth0-server-js format on the
next write, so users are not forced to re-authenticate.

- MigrationStatelessStateStore: decrypts legacy A256GCM (HKDF, "JWE CEK")
  cookies, enforces the header-level exp, and transforms them into StateData.
- MigrationStatefulStateStore: resolves a legacy plain or JWS-signed store
  key (routing modern JWE cookies to the base decrypt by shape), upgrades the
  session in place on write, and supports requireSignedLegacyCookie to mirror
  express-openid-connect's requireSignedSessionStoreCookie.
- Shared HKDF derivation and legacy-session transformer; `legacyAudience`
  must match the requested audience for carried-over access tokens.
- Wire it through getStateStore and expose the option via the /migration
  subpath export.

Includes unit tests covering tamper rejection, expiry (missing/non-numeric
exp), JWS verification against real express-openid-connect fixtures, and
key/secret rotation.
Add examples/migration-express-openid-connect with before/ (an
express-openid-connect app), after/ (the same app on @auth0/auth0-express
with legacyCompatibility enabled), and shared/ Redis infra. Demonstrates
zero-downtime migration for stateless and stateful sessions, access-token
carryover by audience, and backchannel logout, with a verification runbook.
Register the example folders in the workspace globs.
Add a "Zero-Downtime Session Migration" section to MIGRATION.md covering the
legacyCompatibility option (enabled/legacySecret/legacyAudience/legacyScope/
requireSignedLegacyCookie), stateless vs. stateful behavior, and the
legacyAudience-must-match-audience rule. Also correct stale option references
(sessionConfiguration, inactivityDuration in seconds, customFetch) and repoint
the README license link.
The migration example apps rendered the raw access token into an HTML
page and the runbook told reviewers to eyeball it. An access token is a
bearer secret and does not belong in a page, and a long opaque string is
a weak signal that anything migrated.

Show non-secret session facts instead: user sub, per-token-set audience /
scope / expiry, and whether a refresh + id token are present. The before/
and after/ apps now render the same facts so the migration is verified by
comparing them side by side (same sub, same audience/scope, refresh token
still present). Reframe /refresh-token as the strongest proof — exchanging
the carried-over refresh token succeeding demonstrates the migrated token
set is intact — and report the new set's metadata, never the token value.
Both migration example apps spelled out connection config that the
frameworks already read from the environment: express-openid-connect
reads ISSUER_BASE_URL / BASE_URL / CLIENT_ID / CLIENT_SECRET / SECRET,
and createAuth0 reads AUTH0_* / APP_BASE_URL. Drop those explicit lines
so each config block shows only what is specific to the migration
(legacyCompatibility, the appSession cookie name, the session store, and
eoc's authRequired: false / authorizationParams).

Replace the hand-rolled requireSession middleware in the after/ app with
the SDK's exported requiresAuth() — the direct counterpart to eoc's
requiresAuth() — which also returns 401 for JSON clients instead of only
redirecting.
Align the legacy session expiry check with express-openid-connect's
appSession assertion (`exp > epoch()`, i.e. invalid when `exp <= now`)
so a session is not accepted for one extra second past its header exp.
Applies to both stateful and stateless migration stores.
… secret rotation in migration stores

The stateless migration store fell into the express-openid-connect legacy
path for any cookie the modern decrypt could not read. A modern
auth0-server-js cookie (A256CBC-HS512) run through the A256GCM-only legacy
path throws JOSEAlgNotAllowed, which propagated out of decrypt() and
surfaced as a 500 from getUser()/getSession() — where the base store simply
returns undefined ("logged out"). Broaden the swallowed legacy-decrypt
errors to any JOSEError so an undecryptable/expired modern cookie fails soft
to undefined, while genuine programming errors (e.g. TypeError) still
propagate.

Also widen the migration stores' `secret` option to `string | string[]` so
they accept a rotated session secret (the base stores already try each
secret in order on decrypt), and normalize the legacy-secret fallback to a
flat string[] when it derives from an array session secret.
@frederikprijck
frederikprijck force-pushed the feat/legacy-migration-v2 branch from 5fb8bc4 to 1d2755f Compare August 3, 2026 07:35
The presence of the legacyCompatibility object already signals opt-in;
a separate enabled boolean only allowed the inconsistent state of a
defined config with enabled: false.
…t next write

MigrationStatefulStateStore.get() now writes the transformed StateData
back to the same store key immediately after detecting a legacy
express-openid-connect envelope, instead of waiting for the caller's
next write. Custom stores commonly build their sid index inside set(),
so this is what lets deleteByLogoutToken (backchannel logout) resolve a
migrated session right away rather than only after some unrelated later
write. The write reuses the existing public set() API, is a no-op on a
subsequent get() once the session is modern, and costs one extra store
write on a session's first read after migration.
The migration stateless store imports `jose` at runtime for the
express-openid-connect A256GCM cookie decryption, but `jose` was never
declared in this package's dependencies. It resolved only transitively,
via `@auth0/auth0-server-js`, which is not a contract we control: a
future server-js release that drops or replaces `jose` would break
`@auth0/auth0-express` at runtime for consumers.

Declared at `^6.0.8` to match the range server-js itself declares, so
npm keeps deduping to a single copy rather than nesting a second one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants