Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,5 +107,5 @@ Please do not report security vulnerabilities on the public GitHub issue tracker
Auth0 is an easy to implement, adaptable authentication and authorization platform. To learn more checkout <a href="https://auth0.com/why-auth0">Why Auth0?</a>
</p>
<p align="center">
This project is licensed under the Apache License 2.0. See the <a href="https://github.com/auth0/auth0-express/blob/main/LICENSE"> LICENSE</a> file for more info.
This project is licensed under the Apache License 2.0. See the <a href="https://github.com/auth0/auth0-express/blob/main/packages/auth0-express/LICENSE"> LICENSE</a> file for more info.
</p>
84 changes: 84 additions & 0 deletions examples/migration-express-openid-connect/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Migrating from `express-openid-connect` to `@auth0/auth0-express`

This example verifies that `@auth0/auth0-express` picks up an existing
`express-openid-connect` session without forcing re-authentication, for both
stateless (cookie) and stateful (Redis) sessions, plus backchannel logout.

## Layout

- `before/` — the legacy app, built with
[`express-openid-connect`](https://github.com/auth0/express-openid-connect).
- `after/` — the new app, built with `@auth0/auth0-express` and
`legacyCompatibility` enabled so it reads sessions created by `before/`.
- `shared/` — the Redis `docker-compose.yml` used by the stateful scenario.

Start either app from the repo root with `npm start --workspace <path>`, passing
the folder path shown in the steps below.

Both apps run on `http://localhost:3000` (one at a time) and use the `appSession`
cookie name so the same-browser cookie is picked up across the migration.

## Prerequisites

1. From the repo root: `npm install && npm run build`.
2. A test Auth0 tenant + Regular Web App. In the app settings register:
- Allowed Callback URLs: `http://localhost:3000/callback` (express-openid-connect)
and `http://localhost:3000/auth/callback` (auth0-express).
- Allowed Logout URLs: `http://localhost:3000`.
- For the backchannel logout test: set the app's **Back-Channel Logout URI** to
`http://localhost:3000/auth/backchannel-logout` and enable Back-Channel Logout.
3. Copy each app's `.env.example` to `.env` and fill in tenant values. Use the
**same** session secret in both (`SECRET` in `before/`, `AUTH0_SESSION_SECRET`
in `after/`).

## Scenario 1 — Stateless (cookie) migration

1. Start the legacy app (no `REDIS_URL` in its `.env`):
`npm start --workspace examples/migration-express-openid-connect/before`
2. Open `http://localhost:3000`, click Login, complete auth. Confirm home shows
your user and an `appSession` cookie exists (DevTools → Application → Cookies).
Note the **Session facts** panel: your `sub`, the token audience/scope, and that
a refresh + id token are present. (The apps intentionally never print the access
token itself — it is a bearer secret.)
3. Stop the legacy app (Ctrl-C).
4. Start the new app (no `REDIS_URL` in its `.env`):
`npm start --workspace examples/migration-express-openid-connect/after`
5. Reload `http://localhost:3000` in the **same browser**. Expected: still logged
in — the legacy cookie was decrypted, transformed, and re-encrypted in modern
format. Confirm the **Session facts** match the legacy app's (same `sub`, same
audience/scope, refresh token still present) — this is what proves the session
carried over. Confirm `/private` is accessible without a new login.
6. (Optional, strongest proof) With `AUTH0_SECOND_AUDIENCE` set to a second API
in your tenant, open `/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 token set's audience/scope/expiry (never the token).

## Scenario 2 — Stateful (Redis) migration + backchannel logout

1. Start Redis:
`docker compose -f examples/migration-express-openid-connect/shared/docker-compose.yml up -d`.
2. Set `REDIS_URL=redis://localhost:6379` in BOTH apps' `.env`.
3. Start the legacy app:
`npm start --workspace examples/migration-express-openid-connect/before`.
Log in. Confirm a session key exists in Redis:
`docker compose -f examples/migration-express-openid-connect/shared/docker-compose.yml exec redis redis-cli keys '*'`
4. Stop the legacy app. Start the new app:
`npm start --workspace examples/migration-express-openid-connect/after`.
5. Reload `http://localhost:3000`. Expected: still logged in (migration store read
the eoc envelope from Redis, transformed it, and immediately wrote the modern
`StateData` plus a `logout:sid:<sid>` index back to the same key — no further
action needed). Confirm the index key exists:
`... redis-cli keys 'logout:sid:*'`
6. Trigger backchannel logout. In production Auth0 posts this automatically on
logout elsewhere; to test locally, POST a real `logout_token` obtained from your
tenant:
`curl -i -X POST http://localhost:3000/auth/backchannel-logout -H 'Content-Type: application/x-www-form-urlencoded' --data-urlencode "logout_token=<JWT>"`
Expected: `204`. Then confirm both the session key and its `logout:sid:<sid>`
index are gone from Redis. Reloading `/` shows logged-out.

### Notes

- `logout_token` is a signed JWT issued by Auth0; you cannot hand-craft one that
passes `verifyLogoutToken`. Obtain it from a real logout event (tenant logs /
a second app), or observe the automatic POST when logging out from another app
in the same session.
20 changes: 20 additions & 0 deletions examples/migration-express-openid-connect/after/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# @auth0/auth0-express config
AUTH0_DOMAIN=YOUR_TENANT.auth0.com
AUTH0_CLIENT_ID=
AUTH0_CLIENT_SECRET=
APP_BASE_URL=http://localhost:3000

# API audience — MUST be identical to the express-openid-connect app's AUDIENCE so the
# carried-over access token is found by getAccessToken.
AUTH0_AUDIENCE=https://YOUR_API_IDENTIFIER

# Optional: a DIFFERENT API identifier, used by GET /refresh-token to force a session write
# (and observe the appSession cookie migrate to the modern format).
# AUTH0_SECOND_AUDIENCE=https://YOUR_OTHER_API_IDENTIFIER

# Shared session secret — MUST be identical to the express-openid-connect app's SECRET.
AUTH0_SESSION_SECRET=a-long-at-least-32-character-random-string-change-me

# Leave unset for the stateless (cookie) scenario.
# Set for the stateful (Redis) scenario:
# REDIS_URL=redis://localhost:6379
1 change: 1 addition & 0 deletions examples/migration-express-openid-connect/after/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
registry=https://registry.npmjs.org
13 changes: 13 additions & 0 deletions examples/migration-express-openid-connect/after/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# migration-after (new app)

The "after" app for migration verification, built with `@auth0/auth0-express` and
`legacyCompatibility` enabled so it reads sessions created by the `before/`
(`express-openid-connect`) app.

Runs on `http://localhost:3000`. Session cookie name is forced to `appSession` to
match the legacy app. Stateless (cookie) by default; set `REDIS_URL` for the
stateful (Redis) scenario. Backchannel logout is mounted at
`/auth/backchannel-logout`.

See [`../README.md`](../README.md) for the full end-to-end runbook. Copy
`.env.example` to `.env` and fill in tenant values first.
20 changes: 20 additions & 0 deletions examples/migration-express-openid-connect/after/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "migration-after",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/index.ts --project tsconfig.json"
},
"devDependencies": {
"@types/express": "^5.0.6",
"tsx": "^4.21.0",
"typescript": "~5.9.3"
},
"dependencies": {
"@auth0/auth0-express": "*",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"redis": "^4.7.0"
}
}
118 changes: 118 additions & 0 deletions examples/migration-express-openid-connect/after/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import express, { Request, Response } from 'express';
import { createAuth0, requiresAuth } from '@auth0/auth0-express';
import 'dotenv/config';
import { createRedisSessionStore } from './redis-store.js';

const app = express();

const redisUrl = process.env.REDIS_URL;
const sessionStore = redisUrl ? await createRedisSessionStore(redisUrl) : undefined;

// 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`.
app.use(express.urlencoded({ extended: false }));

app.use(
createAuth0({
// domain, clientId, clientSecret, appBaseUrl, sessionSecret and audience are read from the
// AUTH0_* / APP_BASE_URL env vars by createAuth0, so we only spell out what is specific to
// this migration example.
// Read sessions written by express-openid-connect.
legacyCompatibility: {
legacySecret: process.env.AUTH0_SESSION_SECRET as string,
legacyScope: 'openid profile email offline_access',
// The migration transformer stamps the legacy access token with this audience. getAccessToken
// looks up the token set by audience, so this must equal the audience the SDK requests — both
// 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' } },
// Only set for the stateful scenario; undefined => cookie (stateless) store.
sessionStore,
})
);

// We never render the access token itself — it is a bearer secret and does not belong in a
// page. To confirm the session carried over from express-openid-connect we show non-secret
// facts about it: the user identity, and each token set's audience / scope / expiry plus
// whether a refresh + id token are present. Matching these against the legacy app's `/` page
// (same `sub`, same audience/scope, refresh token still present) proves the migration worked.
function renderSessionFacts(user: Record<string, unknown>, session: { tokenSets?: Array<{ audience: string; scope?: string; expiresAt: number }>; refreshToken?: string; idToken?: string }) {
const tokenSets = session.tokenSets ?? [];
const rows = tokenSets
.map(
(t) =>
`<tr><td>${t.audience}</td><td>${t.scope ?? '(none)'}</td>` +
`<td>${new Date(t.expiresAt * 1000).toISOString()}</td></tr>`
)
.join('');
return (
`<h1>auth0-express</h1><p>Logged in as ${user.name ?? user.sub}</p>` +
`<h2>Session facts</h2>` +
`<ul>` +
`<li>Refresh token present: <b>${session.refreshToken ? 'yes' : 'no'}</b></li>` +
`<li>ID token present: <b>${session.idToken ? 'yes' : 'no'}</b></li>` +
`<li>Token sets: <b>${tokenSets.length}</b></li>` +
`</ul>` +
(tokenSets.length
? `<table border="1" cellpadding="4"><thead><tr><th>Audience</th><th>Scope</th><th>Expires (UTC)</th></tr></thead><tbody>${rows}</tbody></table>`
: `<p>(no token sets)</p>`) +
`<h2>User</h2><pre>${JSON.stringify(user, null, 2)}</pre>` +
`<p><a href="/auth/logout">Logout</a></p>`
);
}

app.get('/', async (req: Request, res: Response) => {
const user = await req.auth0.client.getUser();
if (!user) {
return res.send(`<h1>auth0-express</h1><p>Not logged in</p><a href="/auth/login">Login</a>`);
}

const session = await req.auth0.client.getSession();
res.send(renderSessionFacts(user as Record<string, unknown>, session ?? {}));
});

app.get('/private', requiresAuth(), async (req: Request, res: Response) => {
const user = await req.auth0.client.getUser();
res.send(`<h1>Private</h1><pre>${JSON.stringify(user, null, 2)}</pre>`);
});

// Forces a session write to observe the cookie migrating to the modern format, and proves the
// carried-over refresh token still works. Requesting a token for a *different* audience misses
// the cached token set, so the SDK exchanges the carried-over refresh token and then calls
// stateStore.set() — which re-encrypts (migrates) the appSession cookie. A successful exchange
// is the strongest signal that the token set migrated intact. We report the new token's
// metadata (audience / scope / expiry) rather than the token itself. Reload / afterwards to
// confirm the original session survived the rewrite. Requires AUTH0_SECOND_AUDIENCE to be a
// second API registered in the tenant that this client is authorized for.
app.get('/refresh-token', requiresAuth(), async (req: Request, res: Response) => {
const secondAudience = process.env.AUTH0_SECOND_AUDIENCE;
if (!secondAudience) {
return res.status(400).send('Set AUTH0_SECOND_AUDIENCE to a different API identifier to run this.');
}
try {
const result = await req.auth0.client.getAccessToken({ audience: secondAudience });
res.send(
`<h1>Refresh succeeded — session written</h1>` +
`<p>Requested a token for a second audience, forcing the SDK to exchange the ` +
`carried-over refresh token and call stateStore.set() — the appSession cookie has now ` +
`been re-encrypted in the modern format. The exchange succeeding proves the migrated ` +
`refresh token is valid.</p>` +
`<h2>New token set</h2>` +
`<ul>` +
`<li>Audience: <b>${result.audience}</b></li>` +
`<li>Scope: <b>${result.scope ?? '(none)'}</b></li>` +
`<li>Expires (UTC): <b>${new Date(result.expiresAt * 1000).toISOString()}</b></li>` +
`</ul>` +
`<a href="/">Back to home (confirm original session + audience token still there)</a>`
);
} catch (e) {
res.status(400).send(`getAccessToken for '${secondAudience}' failed: ${(e as Error).message}`);
}
});

app.listen(3000, () => {
console.log(`auth0-express app on http://localhost:3000 (store: ${sessionStore ? 'redis' : 'cookie'})`);
});
64 changes: 64 additions & 0 deletions examples/migration-express-openid-connect/after/src/redis-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { createClient } from 'redis';
import type { StateData, SessionStore, LogoutTokenClaims } from '@auth0/auth0-server-js';

/**
* Redis-backed SessionStore for @auth0/auth0-express.
*
* Keys sessions by the raw session ID (same namespace the express-openid-connect example
* writes to), so migrated sessions are readable by MigrationStatefulStateStore. The value is
* either an express-openid-connect envelope { header, data, cookie } (legacy, written by the
* old app) or a StateData object (modern, written here). MigrationStatefulStateStore detects
* and transforms the legacy shape on read, so this adapter can store/return values verbatim.
*
* On set() we also maintain a `logout:sid:<sid> -> <sessionId>` index so deleteByLogoutToken
* (which only receives { sub, sid }) can resolve to the session key. MigrationStatefulStateStore
* writes the transformed StateData back on the first get() of a legacy session (not just on the
* caller's next write), so this index exists as soon as a migrated session is read, not only
* after some later action re-writes it.
*/
export async function createRedisSessionStore(url: string): Promise<SessionStore<unknown>> {
const client = createClient({ url });
client.on('error', (err) => console.error('Redis error', err));
await client.connect();

const sidKey = (sid: string) => `logout:sid:${sid}`;

return {
async get(id: string): Promise<StateData | undefined> {
const raw = await client.get(id);
return raw ? (JSON.parse(raw) as StateData) : undefined;
},

async set(id: string, stateData: StateData): Promise<void> {
await client.set(id, JSON.stringify(stateData));
const sid = stateData.internal?.sid;
if (sid) {
await client.set(sidKey(sid), id);
}
},

async delete(id: string): Promise<void> {
const raw = await client.get(id);
if (raw) {
try {
const data = JSON.parse(raw) as StateData;
const sid = data.internal?.sid;
if (sid) await client.del(sidKey(sid));
} catch {
// ignore malformed payloads
}
}
await client.del(id);
},

async deleteByLogoutToken(claims: LogoutTokenClaims): Promise<void> {
const sid = claims.sid;
if (!sid) return;
const sessionId = await client.get(sidKey(sid));
if (sessionId) {
await client.del(sessionId);
await client.del(sidKey(sid));
}
},
};
}
22 changes: 22 additions & 0 deletions examples/migration-express-openid-connect/after/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"incremental": false,
"isolatedModules": true,
"lib": ["es2022", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleDetection": "force",
"moduleResolution": "NodeNext",
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022",
"outDir": "dist",
"rootDir": "src"
},
"ts-node": { "esm": true, "compilerOptions": { "module": "nodenext" } }
}
16 changes: 16 additions & 0 deletions examples/migration-express-openid-connect/before/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# express-openid-connect config
ISSUER_BASE_URL=https://YOUR_TENANT.auth0.com
CLIENT_ID=
CLIENT_SECRET=
BASE_URL=http://localhost:3000

# API audience — makes Auth0 issue an access token for this API. MUST be identical to the
# auth0-express app's AUDIENCE so the carried-over access token is found after migration.
AUDIENCE=https://YOUR_API_IDENTIFIER

# Shared session secret — MUST be identical to the auth0-express app.
SECRET=a-long-at-least-32-character-random-string-change-me

# Leave unset for the stateless (cookie) scenario.
# Set for the stateful (Redis) scenario:
# REDIS_URL=redis://localhost:6379
1 change: 1 addition & 0 deletions examples/migration-express-openid-connect/before/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
registry=https://registry.npmjs.org
10 changes: 10 additions & 0 deletions examples/migration-express-openid-connect/before/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# migration-before (legacy app)

The "before" app for migration verification, built with
[`express-openid-connect`](https://github.com/auth0/express-openid-connect).

Runs on `http://localhost:3000`. Default session cookie name `appSession`.
Stateless (cookie) by default; set `REDIS_URL` to store sessions in Redis.

See [`../README.md`](../README.md) for the full end-to-end runbook. Copy
`.env.example` to `.env` and fill in tenant values first.
Loading
Loading