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=
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({