feat(auth0-express): migration stores for express-openid-connect migration - #7
Open
frederikprijck wants to merge 11 commits into
Open
feat(auth0-express): migration stores for express-openid-connect migration#7frederikprijck wants to merge 11 commits into
frederikprijck wants to merge 11 commits into
Conversation
nandan-bhat
reviewed
Jun 19, 2026
frederikprijck
force-pushed
the
feat/legacy-migration-v2
branch
2 times, most recently
from
July 9, 2026 11:10
b54d666 to
a9b7e1f
Compare
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
force-pushed
the
feat/legacy-migration-v2
branch
from
August 3, 2026 07:35
5fb8bc4 to
1d2755f
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds zero-downtime session migration from
express-openid-connectto@auth0/auth0-express. When enabled via alegacyCompatibilityoption oncreateAuth0, the SDK transparently reads existingexpress-openid-connectsessions and upgrades them to theauth0-server-jsformat on the next write, so users are not forced to re-authenticate.express-openid-connectJWE cookies (dir/A256GCM, HKDF'JWE CEK'), enforces the header-levelexp, and converts them toStateData. The next write re-encrypts in the modern format.express-openid-connectsession 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.legacyCompatibilityconfig; the stores live behind the@auth0/auth0-express/migrationsubpath (not the main entrypoint).Key details
requireSignedLegacyCookie: mirrorsexpress-openid-connect'srequireSignedSessionStoreCookie. Whentrue, an unsigned or bad-signature store cookie resolves to no session instead of the raw value. Defaultfalse, matching eoc. Stateful-only.expis missing/non-numeric or in the past, mirroring eoc'sassert(exp > epoch()).legacyAudience/legacyScope: stamped onto the single migrated token set.legacyAudiencemust equal theaudienceyour app requests, sincegetAccessTokenlooks tokens up by audience.legacySecretaccepts a string array; secrets are tried in order.JWEDecryptionFailed/JWEInvalid; programming errors propagate.deriveHkdfKey(secret, info)inexpress-oidc-hkdf.ts, used by both stores.How to test
Automated
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 realexpress-openid-connectlibrary (signing a stateful cookie with eoc's ownsignCookie, and encrypting a stateless cookie with eoc'sencryption()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:Prerequisites —
npm install && npm run build; a test Auth0 Regular Web App with callback URLshttp://localhost:3000/callback(before) andhttp://localhost:3000/auth/callback(after); copy each app's.env.exampleto.envusing 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 (samesub, same audience/scope, refresh token still present).Scenario 1 — Stateless (cookie):
npm start --workspace examples/migration-express-openid-connect/before— log in athttp://localhost:3000, confirm theappSessioncookie and note the Session facts panel.npm start --workspace examples/migration-express-openid-connect/after./privateaccessible without re-login.AUTH0_SECOND_AUDIENCEset, 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:
docker compose -f examples/migration-express-openid-connect/shared/docker-compose.yml up -d; setREDIS_URL=redis://localhost:6379in both.envs.before/, log in, confirm a session key in Redis. Stop it, startafter/, reload → still logged in.StateData+logout:sid:<sid>index is written.POST /auth/backchannel-logoutwith a reallogout_tokenfrom your tenant →204; confirm the session key and itssidindex are removed from Redis.Notes
sidindex yet, so it is not deletable bydeleteByLogoutTokenuntil the first write — expected and documented in the design.main.