Skip to content

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

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

feat(auth0-express): migration stores for express-openid-connect migration#7
frederikprijck wants to merge 5 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 from e620ec7 to b54d666 Compare July 9, 2026 10:57
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.
@frederikprijck frederikprijck force-pushed the feat/legacy-migration-v2 branch from b54d666 to a9b7e1f Compare July 9, 2026 11:10
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.
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