diff --git a/FAQ.md b/FAQ.md index 32d71a5a..1fe6d189 100644 --- a/FAQ.md +++ b/FAQ.md @@ -12,6 +12,95 @@ This should not be an issue in production, because your application will be runn To resolve this, you should [run your local development server over https](https://auth0.com/docs/libraries/secure-local-development). +## I'm getting `"auth_verification" cookie not found` — login fails intermittently or only on certain browsers + +This error means the SDK's transaction cookie was absent when the OAuth callback was processed. The transaction cookie (`auth_verification` by default) is a short-lived, one-time-use cookie that the SDK sets at the start of every login flow and consumes at the callback. When it is missing, the SDK cannot verify the `state` and `nonce` values agreed upon at login. + +There are four distinct root causes that produce this error. Identify which one applies to your setup: + +### 1. The callback URL was opened directly without going through `/login` first + +If a user (or a test script) navigates directly to `/callback?code=...&state=...` without first hitting the login route, the transaction cookie was never set. + +**Fix:** Always initiate login through the SDK's `/login` route. Do not construct or bookmark callback URLs manually. + +--- + +### 2. The app is running over HTTP, not HTTPS, and the browser dropped the cookie + +The transaction cookie is set with `SameSite=None; Secure` because the OAuth callback is a cross-site redirect from your Identity Provider back to your app. Browsers require the `Secure` flag to honour `SameSite=None`, and `Secure` cookies are only sent over HTTPS. On an HTTP origin the browser silently discards the cookie before it is ever stored — so by the time the callback fires, there is nothing to read. + +This could be the most common cause in **local development**. + +**Fix:** Run your local server over HTTPS. See [Secure Local Development](https://auth0.com/docs/libraries/secure-local-development) for a step-by-step guide using a trusted local certificate. + +Alternatively, for local development only, you can switch to `response_type: 'code'` with `response_mode: 'query'`, which allows the SDK to use `SameSite=Lax` instead of `SameSite=None`. + +--- + +### 3. `legacySameSiteCookie` is set to `false` and the user's browser mishandles `SameSite=None` + +By default, the SDK sets two cookies on every login: the primary `auth_verification` cookie (`SameSite=None; Secure`) for modern browsers, and a fallback `_auth_verification` cookie (no `SameSite` attribute) for older browsers that incorrectly treat `SameSite=None` as `SameSite=Strict` — notably Safari 12 and Chrome versions before 80. + +When you opt out of this fallback by setting `legacySameSiteCookie: false`, users on those older browsers will have their primary cookie blocked on the cross-site return from the Identity Provider, with no fallback to recover from. + +**Fix:** Leave `legacySameSiteCookie` at its default (`true`) unless you are certain your entire user base is on browsers that correctly implement `SameSite=None`. + +--- + +### 4. Multiple apps on the same domain are overwriting each other's transaction cookie + +This is the most subtle cause and the one most likely to appear intermittently in production. When two or more applications that use this SDK are hosted on the same domain with different paths (e.g. `example.com/app1` and `example.com/app2`), they share the same cookie namespace by default. If a user starts a login flow on App 1 and then starts one on App 2 before completing App 1's callback, App 2's `Set-Cookie: auth_verification=...` overwrites App 1's cookie in the browser. When App 1's callback eventually fires it finds the wrong cookie — or no cookie at all if App 2's callback already consumed it. + +**Example of the collision:** + +``` +1. User starts login on App 1 + → Browser stores: auth_verification=; Path=/ + +2. Before completing App 1's login, user starts login on App 2 + → Browser stores: auth_verification=; Path=/ + → This overwrites App 1's cookie + +3. App 1's IdP redirects to /callback?state= + → Browser sends: auth_verification= ← wrong cookie + → SDK finds state mismatch, or cookie was already consumed → error +``` + +**Fix:** Give each application a unique cookie name and a scoped cookie path so their cookies do not collide. + +```js +// App 1 — mounted at /app1 +app.use( + '/app1', + auth({ + baseURL: 'https://example.com/app1', + session: { + name: 'app1Session', + cookie: { path: '/app1' }, + }, + transactionCookie: { name: 'app1_auth_verification' }, + }), +); + +// App 2 — mounted at /app2 +app.use( + '/app2', + auth({ + baseURL: 'https://example.com/app2', + session: { + name: 'app2Session', + cookie: { path: '/app2' }, + }, + transactionCookie: { name: 'app2_auth_verification' }, + }), +); +``` + +Setting both `session.cookie.path` and `transactionCookie.name` is recommended. The path scopes the cookies so the browser only sends each app's cookie to requests under its own path, and the unique name ensures that even if the paths overlap, the cookies cannot silently overwrite each other. + +--- + ## Login calls are failing with 'RequestError: The "listener" argument must be of type function. Received an instance of Object' This module depends indirectly on a newer version of the `agent-base` module. If an unrelated module depends on a version of the `agent-base` older than 5.0, that older dependency is monkeypatching the global `http.request` object, causing this module to fail. You can check if you have this problem by running this check: diff --git a/README.md b/README.md index 0e0615f4..5f1e592d 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ app.use( clientID: 'YOUR_CLIENT_ID', secret: 'LONG_RANDOM_STRING', idpLogout: true, - }) + }), ); ``` @@ -96,6 +96,40 @@ For other comprehensive examples such as route-specific authentication, custom a See the [examples](https://github.com/auth0/express-openid-connect/blob/master/EXAMPLES.md) for route-specific authentication, custom application session handling, requesting and using access tokens for external APIs, and more. +### Deploying Multiple Apps on the Same Domain + +If you host more than one application that uses this SDK under the same domain (e.g. `example.com/app1` and `example.com/app2`), each app must be given a unique session cookie name, transaction cookie name, and cookie path. Without this, the apps share the same cookie namespace and can silently overwrite each other's login state, causing intermittent `"auth_verification" cookie not found` errors. + +```js +// App 1 — mounted at /app1 +app.use( + '/app1', + auth({ + baseURL: 'https://example.com/app1', + session: { + name: 'app1Session', + cookie: { path: '/app1' }, + }, + transactionCookie: { name: 'app1_auth_verification' }, + }), +); + +// App 2 — mounted at /app2 +app.use( + '/app2', + auth({ + baseURL: 'https://example.com/app2', + session: { + name: 'app2Session', + cookie: { path: '/app2' }, + }, + transactionCookie: { name: 'app2_auth_verification' }, + }), +); +``` + +See the [FAQ](https://github.com/auth0/express-openid-connect/blob/master/FAQ.md#im-getting-auth_verification-cookie-not-found--login-fails-intermittently-or-only-on-certain-browsers) for a full explanation of the cookie collision mechanism and other causes of the same error. + ### Use of Custom Session Stores and `genid` If you create your own session id when using [Custom Session Stores](https://github.com/auth0/express-openid-connect/blob/master/EXAMPLES.md#9-use-a-custom-session-store) by overriding the `genid` configuration, you must use a suitable cryptographically strong random value of sufficient size to prevent collisions and reduce the ability to hijack a session by guessing the session ID. @@ -147,4 +181,4 @@ Please do not report security vulnerabilities on the public GitHub issue tracker

This project is licensed under the MIT license. See the LICENSE file for more info. -

\ No newline at end of file +

diff --git a/index.d.ts b/index.d.ts index 499954ab..1428579c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -924,7 +924,10 @@ interface CookieConfigParams { /** * Path for the cookie. - * Passed to the [Response cookie](https://expressjs.com/en/api.html#res.cookie) as `path` + * Passed to the [Response cookie](https://expressjs.com/en/api.html#res.cookie) as `path`. + * + * To prevent cookie collision when multiple apps are hosted on the same domain + * (e.g., `example.com/app1` and `example.com/app2`), set this to your app's base path. */ path?: string; diff --git a/lib/context.js b/lib/context.js index 3530d776..36e1197a 100644 --- a/lib/context.js +++ b/lib/context.js @@ -481,7 +481,41 @@ class ResponseContext { res, ); - const checks = authVerification ? JSON.parse(authVerification) : {}; + if (!authVerification) { + if (req.oidc.isAuthenticated()) { + // User already has a valid session — this is a stale/replayed callback + // (e.g., browser back button navigated back to a consumed /callback URL). + debug( + 'stale callback detected, user already authenticated, redirecting to baseURL', + ); + return res.redirect(config.baseURL); + } else { + /* + * The transaction cookie is missing for an unauthenticated user. Possible causes: + * 1. A request was made directly to the callback URL without going through the + * login route first — no cookie was ever set. + * 2. The browser dropped the SameSite=None cookie during the IdP redirect + * (common in Safari/ITP, privacy mode, or browsers that reject SameSite=None + * without a Secure flag on non-HTTPS origins). + * 3. legacySameSiteCookie is false and the browser does not support SameSite=None, + * so neither the primary nor the fallback cookie was sent back. + * 4. Multiple apps share the same transactionCookie.name and cookie path on the + * same domain — a second app's login flow overwrote this app's cookie before + * the callback fired. Set a unique transactionCookie.name and session.cookie.path + * per app to isolate them. + */ + throw new Error( + `"${config.transactionCookie.name}" cookie not found. ` + + `Ensure the login flow is initiated through the SDK's login route before ` + + `the callback is processed. If using SameSite=None cookies, verify the ` + + `origin is served over HTTPS and the Secure flag is set. For multi-app ` + + `deployments on the same domain, configure a unique transactionCookie.name ` + + `and session.cookie.path per application.`, + ); + } + } + + const checks = JSON.parse(authVerification); req.openidState = decodeState(checks.state); diff --git a/test/callback.tests.js b/test/callback.tests.js index d560c901..c7c1cdde 100644 --- a/test/callback.tests.js +++ b/test/callback.tests.js @@ -154,21 +154,27 @@ describe('callback response_mode: form_post', () => { assert.equal(err.message, 'state missing from the response'); }); - it('should error when the state is missing', async () => { - const { - response: { - statusCode, - body: { err }, - }, - } = await setup({ - cookies: {}, - body: { + it('should error with descriptive message when the state cookie is missing and user is not authenticated', async () => { + const jar = request.jar(); + const authOpts = { ...defaultConfig }; + const router = auth(authOpts); + server = await createServer(router); + + const response = await request.post('/callback', { + baseUrl, + jar, + json: { state: '__test_state__', id_token: '__invalid_token__', }, + followRedirect: false, }); - assert.equal(statusCode, 400); - assert.equal(err.message, 'checks.state argument is missing'); + + assert.equal(response.statusCode, 400); + assert.match( + response.body.err.message, + /"auth_verification" cookie not found/, + ); }); it("should error when state doesn't match", async () => { @@ -274,7 +280,7 @@ describe('callback response_mode: form_post', () => { assert.match(err.message, /nonce mismatch/i); }); - it('should error when legacy samesite fallback is off', async () => { + it('should error when legacy samesite fallback is off and main cookie missing', async () => { const { response: { statusCode, @@ -296,7 +302,7 @@ describe('callback response_mode: form_post', () => { }, }); assert.equal(statusCode, 400); - assert.equal(err.message, 'checks.state argument is missing'); + assert.match(err.message, /"auth_verification" cookie not found/); }); it('should include oauth error properties in error', async () => { @@ -308,8 +314,12 @@ describe('callback response_mode: form_post', () => { }, }, } = await setup({ - cookies: {}, + cookies: generateCookies({ + nonce: '__test_nonce__', + state: '__test_state__', + }), body: { + state: '__test_state__', error: 'foo', error_description: 'bar', }, @@ -1325,4 +1335,63 @@ describe('callback response_mode: form_post', () => { }); assert.equal(headers.foo, 'bar'); }); + + it('should redirect to baseURL on stale callback when user is already authenticated (back button scenario)', async () => { + const jar = request.jar(); + const authOpts = { ...defaultConfig }; + const router = auth(authOpts); + server = await createServer(router); + + // First, establish a session by setting up an existing authenticated session + await request.post('/session', { + baseUrl, + jar, + json: { + id_token: makeIdToken(), + access_token: '__test_access_token__', + token_type: 'Bearer', + expires_at: Math.floor(Date.now() / 1000) + 86400, + }, + }); + + // Now simulate a stale callback (back button) - no auth_verification cookie, but user is authenticated + const response = await request.post('/callback', { + baseUrl, + jar, + json: { + state: expectedDefaultState, + code: '__test_code__', + }, + followRedirect: false, + }); + + // Should redirect to baseURL instead of throwing an error + assert.equal(response.statusCode, 302); + assert.equal(response.headers.location, 'http://example.org'); + }); + + it('should error with descriptive message on callback when user is not authenticated and cookie is missing (dropped cookie scenario)', async () => { + const jar = request.jar(); + const authOpts = { ...defaultConfig, authRequired: false }; + const router = auth(authOpts); + server = await createServer(router); + + // Simulate a callback with no auth_verification cookie and user not authenticated + // (e.g., SameSite=None cookie dropped by browser, or direct navigation to /callback) + const response = await request.post('/callback', { + baseUrl, + jar, + json: { + state: expectedDefaultState, + code: '__test_code__', + }, + followRedirect: false, + }); + + assert.equal(response.statusCode, 400); + assert.match( + response.body.err.message, + /"auth_verification" cookie not found/, + ); + }); }); diff --git a/test/config.tests.js b/test/config.tests.js index bfaf2cd9..8ad469ab 100644 --- a/test/config.tests.js +++ b/test/config.tests.js @@ -299,6 +299,15 @@ describe('get config', () => { }); }); + it('should allow setting custom cookie path to prevent collision on same domain', function () { + const config = getConfig({ + ...defaultConfig, + baseURL: 'https://example.com/app1', + session: { cookie: { path: '/app1' } }, + }); + assert.equal(config.session.cookie.path, '/app1'); + }); + it('should fail when the baseURL is http and cookie is secure', function () { assert.throws(() => { getConfig({