Repository: auth0/nextjs-auth0 (ts)

Issue:
TransientStore cookie name conflicts when using multiple Auth0 instances
### Checklist

- [X] The issue can be reproduced in the [nextjs-auth0 sample app](https://github.com/auth0-samples/auth0-nextjs-samples/tree/main/Sample-01) (or N/A).
- [X] I have looked into the [Readme](https://github.com/auth0/nextjs-auth0#readme), [Examples](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md), and [FAQ](https://github.com/auth0/nextjs-auth0/blob/main/FAQ.md) and have not found a suitable solution or answer.
- [X] I have looked into the [API documentation](https://auth0.github.io/nextjs-auth0/) and have not found a suitable solution or answer.
- [X] I have searched the [issues](https://github.com/auth0/nextjs-auth0/issues) and have not found a suitable solution or answer.
- [X] I have searched the [Auth0 Community](https://community.auth0.com) forums and have not found a suitable solution or answer.
- [X] I agree to the terms within the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md).

### Description

Preface: I'm using Next 13 and the v3 beta of this library, not sure if this issue exists in older versions.

In my app I have multiple instances of nextjs-auth0, since I need tokens for multiple audiences. In my UI I am trying to fetch these tokens simultaneously using fetch requests (with `prompt=none` so there's no login prompt). This almost works, but the issue I'm running into is that the cookies that TransientStore sets (`nonce`, `code_verifier`, `state`) are overwritten when I make multiple requests at once. So when the callback is hit, those cookies are no longer the same value.

There doesn't seem to be any way to override the TransientStore instance, or change the names of the cookies it sets. The only way I've found to work around this is by wrapping the login and callback handlers, and re-writing the request/response cookies before and after the handler. This isn't a great solution, it would be much better if I could just customize the TransientStore to add a prefix to cookies, but for now my solution at least works.

### Reproduction

1. Create multiple auth0 instances by making calls to `initAuth0`. Set the login auth params to `prompt: "none"`. (make sure you are already logged in so it works without prompt).
2. In the UI, send multiple login requests in parallel, handled by the different instances.
3. Observe that all but one of the requests will fail, due to the `code_verifier` cookie having an incorrect value.

Here's an example of my code. Note that I'm using a secondary `refresh` route for the promt-less logins: 

```js
function initInstance(name, audience) {
  const instance = initAuth0({
    routes: {
      callback: "/api/auth/callback?service=" + name,
    },
    authorizationParams: {
      audience,
    },
    session: { name: name + "Session", storeIDToken: name === "default" },
  });

  const handler = instance.handleAuth({
    onError(req, error) {
      console.error(error);
    },
    refresh: instance.handleLogin({
      authorizationParams: { prompt: "none" },
    }),
  });

  return { instance, handler };
}

export const authInstances = {
  default: initInstance("default", process.env.AUTH0_AUDIENCE),
  serviceA: initInstance("serviceA", process.env.AUTH0_AUDIENCE_SERVICE_A),
  serviceB: initInstance("serviceB", process.env.AUTH0_AUDIENCE_SERVICE_B),
};

// Next.js route handler
export const GET = (req, ctx) => {
  const { searchParams } = new URL(req.url);
  let service = searchParams.get("service");

  if (!service || !Object.hasOwnProperty.call(authInstances, service)) {
    service = "default";
  }

  return authInstances[service].handler(req, ctx);
};

// UI Component
export function UserAuth() {
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  useEffect(() => {
    Promise.all([
      fetch("/api/auth/refresh?service=serviceA", { mode: "no-cors" }),
      fetch("/api/auth/refresh?service=serviceB", { mode: "no-cors" }),
    ]).then(() => setIsAuthenticated(true));
  }, []);

  return !isAuthenticated ? null : "app content";
}
```

### Additional context

Here's the current solution I've come up with. I wrap the login and callback handlers with this function. Like I said, it's not ideal, but it does work:

```ts
function wrapHandler<T extends HandleLogin | HandleCallback, Opts extends Record<string, unknown>>(
  name: string,
  handler: T
): T {
  return ((reqOrOptions: NextRequest | Opts, ctx: AppRouteHandlerFnContext, opts?: Opts) => {
    const wrappedHandler = async (req: NextRequest, ctx: AppRouteHandlerFnContext, opts?: Opts) => {
      const cookieNames = ["nonce", "code_verifier", "state"];

      for (const cookieName of cookieNames.map((cookieName) => name + "." + cookieName)) {
        if (req.cookies.has(cookieName)) {
          const cookie = req.cookies.get(cookieName) as RequestCookie;
          req.cookies.delete(cookieName);
          cookie.name = cookieName.replace(name + ".", "");
          req.cookies.set(cookie);
        }
      }

      const response: NextResponse = await (handler as any)(req, ctx, opts);

      for (const cookieName of cookieNames) {
        const value = response.cookies.get(cookieName);
        if (value) {
          response.cookies.delete(value);
          value.name = name + "." + cookieName;
          response.cookies.set(value);
        }
      }

      return response;
    };

    if (reqOrOptions instanceof Request) {
      return wrappedHandler(reqOrOptions, ctx, opts);
    } else {
      return (req: NextRequest, ctx: AppRouteHandlerFnContext) =>
        wrappedHandler(req, ctx, reqOrOptions);
    }
  }) as T;
}
```

### nextjs-auth0 version

3.0.0-beta.3

### Next.js version

13.4.10

### Node.js version

20.4.0

Pull request description:
Add option to override transaction cookie name and config
### 📋 Changes

Add options to customize the transaction cookie's name and config. This can be useful for a number or reasons:

- You may want to make concurrent logins for different instances, so you would need to customise the cookie name (see #1297 )
- You may want to use `SameSite=Strict` for the session cookie, in which case you would need to set the transaction cookie to `Lax` or `None` to make the code flow work. (see https://github.com/auth0/express-openid-connect/issues/322)
- You may want to limit the session cookie to a specific host (or path), but accept logins from the naked domain (or root path) (see https://github.com/auth0/nextjs-auth0/issues/1337#issuecomment-1662767118)

### 📎 References

fixes #1297


Patch:
diff --git a/src/auth0-session/config.ts b/src/auth0-session/config.ts
index 1963d612..9334bad7 100644
--- a/src/auth0-session/config.ts
+++ b/src/auth0-session/config.ts
@@ -188,6 +188,14 @@ export interface Config {
    * You can also use the `AUTH0_CLIENT_ASSERTION_SIGNING_ALG` environment variable.
    */
   clientAssertionSigningAlg?: string;
+
+  /**
+   * By default, the transaction cookie takes the same settings as the
+   * session cookie. But you may want to configure the session cookie to be more
+   * secure in a way that would break the OAuth flow's usage of the transaction
+   * cookie (Setting SameSite=Strict for example).
+   */
+  transactionCookie: Omit<CookieConfig, 'transient' | 'httpOnly'> & { name: string };
 }
 
 /**
diff --git a/src/auth0-session/get-config.ts b/src/auth0-session/get-config.ts
index 16e9f19b..1adc9e79 100644
--- a/src/auth0-session/get-config.ts
+++ b/src/auth0-session/get-config.ts
@@ -166,7 +166,16 @@ const paramsSchema = Joi.object({
     }),
   clientAssertionSigningAlg: Joi.string()
     .optional()
-    .valid('RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES256K', 'ES384', 'ES512', 'EdDSA')
+    .valid('RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES256K', 'ES384', 'ES512', 'EdDSA'),
+  transactionCookie: Joi.object({
+    name: Joi.string().default('auth_verification'),
+    domain: Joi.string().default(Joi.ref('/session.cookie.domain')),
+    secure: Joi.boolean().default(Joi.ref('/session.cookie.secure')),
+    sameSite: Joi.string().valid('lax', 'strict', 'none').default(Joi.ref('/session.cookie.sameSite')),
+    path: Joi.string().uri({ relativeOnly: true }).default(Joi.ref('/session.cookie.transient'))
+  })
+    .default()
+    .unknown(false)
 });
 
 export type DeepPartial<T> = {
diff --git a/src/auth0-session/handlers/callback.ts b/src/auth0-session/handlers/callback.ts
index efa4c85b..6a094593 100644
--- a/src/auth0-session/handlers/callback.ts
+++ b/src/auth0-session/handlers/callback.ts
@@ -36,7 +36,7 @@ export default function callbackHandlerFactory(
     let tokenResponse;
 
     let authVerification: AuthVerification;
-    const cookie = await transientCookieHandler.read('auth_verification', req, res);
+    const cookie = await transientCookieHandler.read(config.transactionCookie.name, req, res);
 
     if (!cookie) {
       throw new MissingStateCookieError();
diff --git a/src/auth0-session/handlers/login.ts b/src/auth0-session/handlers/login.ts
index c8ea37ac..bda12462 100644
--- a/src/auth0-session/handlers/login.ts
+++ b/src/auth0-session/handlers/login.ts
@@ -86,8 +86,8 @@ export default function loginHandlerFactory(
       authVerification.response_type = responseType;
     }
 
-    await transientHandler.save('auth_verification', req, res, {
-      sameSite: authParams.response_mode === 'form_post' ? 'none' : config.session.cookie.sameSite,
+    await transientHandler.save(config.transactionCookie.name, req, res, {
+      sameSite: authParams.response_mode === 'form_post' ? 'none' : config.transactionCookie.sameSite,
       value: JSON.stringify(authVerification)
     });
 
diff --git a/src/auth0-session/transient-store.ts b/src/auth0-session/transient-store.ts
index 0a70a327..ee89457b 100644
--- a/src/auth0-session/transient-store.ts
+++ b/src/auth0-session/transient-store.ts
@@ -41,7 +41,7 @@ export default class TransientStore {
     { sameSite = 'none', value }: StoreOptions
   ): Promise<string> {
     const isSameSiteNone = sameSite === 'none';
-    const { domain, path, secure } = this.config.session.cookie;
+    const { domain, path, secure } = this.config.transactionCookie;
     const basicAttr = {
       httpOnly: true,
       secure,
@@ -81,7 +81,7 @@ export default class TransientStore {
   async read(key: string, req: Auth0Request, res: Auth0Response): Promise<string | undefined> {
     const cookies = req.getCookies();
     const cookie = cookies[key];
-    const cookieConfig = this.config.session.cookie;
+    const cookieConfig = this.config.transactionCookie;
 
     const verifyingKeys = await this.getKeys();
     let value = await getCookieValue(key, cookie, verifyingKeys);
diff --git a/src/config.ts b/src/config.ts
index 4dd659b6..7d0eaf64 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -192,6 +192,21 @@ export interface BaseConfig {
    *  You can also use the `AUTH0_CLIENT_ASSERTION_SIGNING_ALG` environment variable.
    */
   clientAssertionSigningAlg?: string;
+
+  /**
+   * By default, the transaction cookie takes the same settings as the
+   * session cookie. But you may want to configure the session cookie to be more
+   * secure in a way that would break the OAuth flow's usage of the transaction
+   * cookie (Setting SameSite=Strict for example).
+   *
+   * You can also use:
+   * `AUTH0_TRANSACTION_COOKIE_NAME`
+   * `AUTH0_TRANSACTION_COOKIE_DOMAIN`
+   * `AUTH0_TRANSACTION_COOKIE_PATH`
+   * `AUTH0_TRANSACTION_COOKIE_SAME_SITE`
+   * `AUTH0_TRANSACTION_COOKIE_SECURE`
+   */
+  transactionCookie: Omit<CookieConfig, 'transient' | 'httpOnly'> & { name: string };
 }
 
 /**
@@ -417,6 +432,11 @@ export interface NextConfig extends Pick<BaseConfig, 'identityClaimFilter'> {
  * - `AUTH0_COOKIE_SAME_SITE`: See {@link CookieConfig.sameSite}.
  * - `AUTH0_CLIENT_ASSERTION_SIGNING_KEY`: See {@link BaseConfig.clientAssertionSigningKey}
  * - `AUTH0_CLIENT_ASSERTION_SIGNING_ALG`: See {@link BaseConfig.clientAssertionSigningAlg}
+ * - `AUTH0_TRANSACTION_COOKIE_NAME` See {@link BaseConfig.transactionCookie}
+ * - `AUTH0_TRANSACTION_COOKIE_DOMAIN` See {@link BaseConfig.transactionCookie}
+ * - `AUTH0_TRANSACTION_COOKIE_PATH` See {@link BaseConfig.transactionCookie}
+ * - `AUTH0_TRANSACTION_COOKIE_SAME_SITE` See {@link BaseConfig.transactionCookie}
+ * - `AUTH0_TRANSACTION_COOKIE_SECURE` See {@link BaseConfig.transactionCookie}
  *
  * ### 2. Create your own instance using {@link InitAuth0}
  *
@@ -519,6 +539,11 @@ export const getConfig = (params: ConfigParameters = {}): { baseConfig: BaseConf
   const AUTH0_COOKIE_SAME_SITE = process.env.AUTH0_COOKIE_SAME_SITE;
   const AUTH0_CLIENT_ASSERTION_SIGNING_KEY = process.env.AUTH0_CLIENT_ASSERTION_SIGNING_KEY;
   const AUTH0_CLIENT_ASSERTION_SIGNING_ALG = process.env.AUTH0_CLIENT_ASSERTION_SIGNING_ALG;
+  const AUTH0_TRANSACTION_COOKIE_NAME = process.env.AUTH0_TRANSACTION_COOKIE_NAME;
+  const AUTH0_TRANSACTION_COOKIE_DOMAIN = process.env.AUTH0_TRANSACTION_COOKIE_DOMAIN;
+  const AUTH0_TRANSACTION_COOKIE_PATH = process.env.AUTH0_TRANSACTION_COOKIE_PATH;
+  const AUTH0_TRANSACTION_COOKIE_SAME_SITE = process.env.AUTH0_TRANSACTION_COOKIE_SAME_SITE;
+  const AUTH0_TRANSACTION_COOKIE_SECURE = process.env.AUTH0_TRANSACTION_COOKIE_SECURE;
 
   const baseURL =
     AUTH0_BASE_URL && !/^https?:\/\//.test(AUTH0_BASE_URL as string) ? `https://${AUTH0_BASE_URL}` : AUTH0_BASE_URL;
@@ -575,7 +600,15 @@ export const getConfig = (params: ConfigParameters = {}): { baseConfig: BaseConf
       postLogoutRedirect: baseParams.routes?.postLogoutRedirect || AUTH0_POST_LOGOUT_REDIRECT
     },
     clientAssertionSigningKey: AUTH0_CLIENT_ASSERTION_SIGNING_KEY,
-    clientAssertionSigningAlg: AUTH0_CLIENT_ASSERTION_SIGNING_ALG
+    clientAssertionSigningAlg: AUTH0_CLIENT_ASSERTION_SIGNING_ALG,
+    transactionCookie: {
+      name: AUTH0_TRANSACTION_COOKIE_NAME,
+      domain: AUTH0_TRANSACTION_COOKIE_DOMAIN,
+      path: AUTH0_TRANSACTION_COOKIE_PATH || '/',
+      secure: bool(AUTH0_TRANSACTION_COOKIE_SECURE),
+      sameSite: AUTH0_TRANSACTION_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none' | undefined,
+      ...baseParams.transactionCookie
+    }
   });
 
   const nextConfig: NextConfig = {


Test patch:
diff --git a/tests/auth0-session/handlers/callback.test.ts b/tests/auth0-session/handlers/callback.test.ts
index 26dcc302..332eaeeb 100644
--- a/tests/auth0-session/handlers/callback.test.ts
+++ b/tests/auth0-session/handlers/callback.test.ts
@@ -636,4 +636,62 @@ describe('callback', () => {
       'Discovery requests failing for https://op2.example.com, expected 200 OK, got: 500 Internal Server Error'
     );
   });
+
+  it('should use custom transaction cookie name', async () => {
+    const idToken = await makeIdToken({
+      c_hash: '77QmUPtjPfzWtF2AnpK9RQ'
+    });
+
+    const baseURL = await setup({
+      ...defaultConfig,
+      clientSecret: '__test_client_secret__',
+      authorizationParams: {
+        response_type: 'code id_token',
+        audience: 'https://api.example.com/',
+        scope: 'openid profile email read:reports offline_access'
+      },
+      transactionCookie: { name: 'foo_bar' }
+    });
+
+    let credentials = '';
+    let body = '';
+    nock('https://op.example.com')
+      .post('/oauth/token')
+      .reply(200, function (_uri, requestBody) {
+        credentials = this.req.headers.authorization.replace('Basic ', '');
+        body = requestBody as string;
+        return {
+          access_token: '__test_access_token__',
+          refresh_token: '__test_refresh_token__',
+          id_token: idToken,
+          token_type: 'Bearer',
+          expires_in: 86400
+        };
+      });
+
+    const cookieJar = await toSignedCookieJar(
+      {
+        foo_bar: JSON.stringify({
+          state: expectedDefaultState,
+          nonce: '__test_nonce__'
+        })
+      },
+      baseURL
+    );
+
+    const code = 'jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y';
+    await post(baseURL, '/callback', {
+      body: {
+        state: expectedDefaultState,
+        id_token: idToken,
+        code
+      },
+      cookieJar
+    });
+
+    expect(Buffer.from(credentials, 'base64').toString()).toEqual('__test_client_id__:__test_client_secret__');
+    expect(body).toEqual(
+      `grant_type=authorization_code&code=${code}&redirect_uri=${encodeURIComponent(baseURL)}%2Fcallback`
+    );
+  });
 });
diff --git a/tests/auth0-session/handlers/login.test.ts b/tests/auth0-session/handlers/login.test.ts
index 8aeda3c9..6b01a47d 100644
--- a/tests/auth0-session/handlers/login.test.ts
+++ b/tests/auth0-session/handlers/login.test.ts
@@ -260,4 +260,29 @@ describe('login', () => {
     expect(cookie?.sameSite).toEqual('none');
     expect(cookie?.secure).toBeTruthy();
   });
+
+  it('transient cookie should honor transaction cookie config in code flow', async () => {
+    const baseURL = await setup(
+      {
+        ...defaultConfig,
+        clientSecret: '__test_client_secret__',
+        authorizationParams: {
+          response_type: 'code'
+        },
+        transactionCookie: {
+          name: 'foo_bar',
+          sameSite: 'none'
+        }
+      },
+      { https: true }
+    );
+    const cookieJar = new CookieJar();
+
+    const { res } = await get(baseURL, '/login', { fullResponse: true, cookieJar });
+    expect(res.statusCode).toEqual(302);
+
+    const cookie = getCookie('foo_bar', cookieJar, baseURL);
+    expect(cookie?.sameSite).toEqual('none');
+    expect(cookie?.secure).toBeTruthy();
+  });
 });
diff --git a/tests/config.test.ts b/tests/config.test.ts
index 041cdac3..4022bde6 100644
--- a/tests/config.test.ts
+++ b/tests/config.test.ts
@@ -80,7 +80,14 @@ describe('config params', () => {
         'at_hash',
         'c_hash'
       ],
-      clientAuthMethod: 'client_secret_basic'
+      clientAuthMethod: 'client_secret_basic',
+      transactionCookie: {
+        name: 'auth_verification',
+        domain: undefined,
+        path: '/',
+        sameSite: 'lax',
+        secure: true
+      }
     });
     expect(nextConfig).toStrictEqual({
       identityClaimFilter: [