Repository: auth0/nextjs-auth0 (ts)

Issue:
Support federated params for logout
### Checklist

- [X] I have looked into the [README](https://github.com/auth0/nextjs-auth0#readme) and have not found a suitable solution or answer.
- [X] I have looked into the [examples](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.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/c/sdks/5) 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).

### Describe the problem you'd like to have solved

Please add support for https://auth0.com/docs/authenticate/login/logout/log-users-out-of-idps

### Describe the ideal solution

Add federated as part of the query params in https://github.com/auth0/nextjs-auth0/blob/main/src/auth0-session/client.ts#L121

### Alternatives and current workarounds

_No response_

### Additional context

_No response_

Pull request description:
Add logout options
### 📋 Changes

For example, to pass the federated logout param per https://auth0.com/docs/authenticate/login/logout/log-users-out-of-idps

```js
handleLogout(req, res, { logoutParams: { federated: '' } });
```

### 📎 References

fixes #842 


Patch:
diff --git a/src/auth0-session/client.ts b/src/auth0-session/client.ts
index 2353842a..021edf78 100644
--- a/src/auth0-session/client.ts
+++ b/src/auth0-session/client.ts
@@ -4,6 +4,7 @@ import urlJoin from 'url-join';
 import createDebug from './utils/debug';
 import { DiscoveryError } from './utils/errors';
 import { Config } from './config';
+import { ParsedUrlQueryInput } from 'querystring';
 
 const debug = createDebug('client');
 
@@ -107,11 +108,18 @@ export default function get(config: Config, { name, version }: Telemetry): Clien
       if (config.auth0Logout || (url.parse(issuer.metadata.issuer).hostname as string).match('\\.auth0\\.com$')) {
         Object.defineProperty(client, 'endSessionUrl', {
           value(params: EndSessionParameters) {
-            const parsedUrl = url.parse(urlJoin(issuer.metadata.issuer, '/v2/logout'));
-            (parsedUrl as UrlObject).query = {
-              returnTo: params.post_logout_redirect_uri,
+            const { id_token_hint, post_logout_redirect_uri, ...extraParams } = params;
+            const parsedUrl: UrlObject = url.parse(urlJoin(issuer.metadata.issuer, '/v2/logout'));
+            parsedUrl.query = {
+              ...extraParams,
+              returnTo: post_logout_redirect_uri,
               client_id: config.clientID
             };
+            Object.entries(parsedUrl.query).forEach(([key, value]) => {
+              if (value === null || value === undefined) {
+                delete (parsedUrl.query as ParsedUrlQueryInput)[key];
+              }
+            });
             return url.format(parsedUrl);
           }
         });
diff --git a/src/auth0-session/config.ts b/src/auth0-session/config.ts
index 51f3413c..37164815 100644
--- a/src/auth0-session/config.ts
+++ b/src/auth0-session/config.ts
@@ -287,4 +287,15 @@ export interface LogoutOptions {
    * default in {@link Config.routes.postLogoutRedirect routes.postLogoutRedirect}.
    */
   returnTo?: string;
+
+  /**
+   * Additional custom parameters to pass to the logout endpoint.
+   *
+   * @example pass the federated logout param per https://auth0.com/docs/authenticate/login/logout/log-users-out-of-idps
+   *
+   * ```js
+   * handleLogout(req, res, { logoutParams: { federated: '' } });
+   * ```
+   */
+  logoutParams?: { [key: string]: any };
 }
diff --git a/src/auth0-session/handlers/logout.ts b/src/auth0-session/handlers/logout.ts
index 9a8fda4c..7154e431 100644
--- a/src/auth0-session/handlers/logout.ts
+++ b/src/auth0-session/handlers/logout.ts
@@ -48,6 +48,7 @@ export default function logoutHandlerFactory(
 
     const client = await getClient();
     returnURL = client.endSessionUrl({
+      ...options.logoutParams,
       post_logout_redirect_uri: returnURL,
       id_token_hint: idToken
     });
diff --git a/src/handlers/logout.ts b/src/handlers/logout.ts
index fc51b490..309d905f 100644
--- a/src/handlers/logout.ts
+++ b/src/handlers/logout.ts
@@ -17,6 +17,11 @@ export interface LogoutOptions {
    * in {@link BaseConfig.routes.postLogoutRedirect routes.postLogoutRedirect}.
    */
   returnTo?: string;
+
+  /**
+   * Additional custom parameters to pass to the logout endpoint.
+   */
+  logoutParams?: { [key: string]: any };
 }
 
 /**


Test patch:
diff --git a/tests/auth0-session/handlers/logout.test.ts b/tests/auth0-session/handlers/logout.test.ts
index 12ec82cb..d55394d8 100644
--- a/tests/auth0-session/handlers/logout.test.ts
+++ b/tests/auth0-session/handlers/logout.test.ts
@@ -1,9 +1,11 @@
 import { parse } from 'url';
+import nock from 'nock';
 import { CookieJar } from 'tough-cookie';
 import { SessionResponse, setup, teardown } from '../fixtures/server';
 import { toSignedCookieJar, defaultConfig, get, post, fromCookieJar } from '../fixtures/helpers';
 import { makeIdToken } from '../fixtures/cert';
 import { encodeState } from '../../../src/auth0-session/utils/encoding';
+import wellKnown from '../fixtures/well-known.json';
 
 const login = async (baseURL: string): Promise<CookieJar> => {
   const nonce = '__test_nonce__';
@@ -180,4 +182,93 @@ describe('logout route', () => {
     expect(cookies).toHaveProperty('foo');
     expect(cookies).not.toHaveProperty('appSession');
   });
+
+  it('should pass logout params to idp', async () => {
+    const baseURL = await setup(
+      { ...defaultConfig, idpLogout: true },
+      { logoutOptions: { logoutParams: { foo: 'bar' } } }
+    );
+    const cookieJar = await login(baseURL);
+
+    const session: SessionResponse = await get(baseURL, '/session', { cookieJar });
+    expect(session.id_token).toBeTruthy();
+
+    const { res } = await get(baseURL, '/logout', { cookieJar, fullResponse: true });
+
+    await expect(get(baseURL, '/session', { cookieJar })).rejects.toThrowError('Unauthorized');
+
+    expect(res.statusCode).toEqual(302);
+    const redirect = parse(res.headers.location, true);
+    expect(redirect).toMatchObject({
+      hostname: 'op.example.com',
+      pathname: '/session/end',
+      protocol: 'https:',
+      query: {
+        post_logout_redirect_uri: baseURL,
+        id_token_hint: session.id_token,
+        foo: 'bar'
+      }
+    });
+  });
+
+  it('should pass logout params to auth0', async () => {
+    const baseURL = await setup(
+      { ...defaultConfig, issuerBaseURL: 'https://op.auth0.com', idpLogout: true, auth0Logout: true },
+      { logoutOptions: { logoutParams: { foo: 'bar' } } }
+    );
+    const { end_session_endpoint, ...a0WellKnown } = wellKnown;
+    nock('https://op.auth0.com').get('/.well-known/openid-configuration').reply(200, a0WellKnown);
+    const cookieJar = await login(baseURL);
+
+    const session: SessionResponse = await get(baseURL, '/session', { cookieJar });
+    expect(session.id_token).toBeTruthy();
+
+    const { res } = await get(baseURL, '/logout', { cookieJar, fullResponse: true });
+
+    await expect(get(baseURL, '/session', { cookieJar })).rejects.toThrowError('Unauthorized');
+
+    expect(res.statusCode).toEqual(302);
+    const redirect = parse(res.headers.location, true);
+    expect(redirect).toMatchObject({
+      hostname: 'op.example.com',
+      pathname: '/v2/logout',
+      protocol: 'https:',
+      query: {
+        client_id: defaultConfig.clientID,
+        foo: 'bar'
+      }
+    });
+  });
+
+  it('should ignore null logout params', async () => {
+    const baseURL = await setup(
+      { ...defaultConfig, issuerBaseURL: 'https://op.auth0.com', idpLogout: true, auth0Logout: true },
+      { logoutOptions: { logoutParams: { foo: 'bar', baz: null, qux: undefined, federated: '' } } }
+    );
+    const { end_session_endpoint, ...a0WellKnown } = wellKnown;
+    nock('https://op.auth0.com').get('/.well-known/openid-configuration').reply(200, a0WellKnown);
+    const cookieJar = await login(baseURL);
+
+    const session: SessionResponse = await get(baseURL, '/session', { cookieJar });
+    expect(session.id_token).toBeTruthy();
+
+    const { res } = await get(baseURL, '/logout', { cookieJar, fullResponse: true });
+
+    await expect(get(baseURL, '/session', { cookieJar })).rejects.toThrowError('Unauthorized');
+
+    expect(res.statusCode).toEqual(302);
+    const redirect = parse(res.headers.location, true);
+    expect(redirect).toMatchObject({
+      hostname: 'op.example.com',
+      pathname: '/v2/logout',
+      protocol: 'https:',
+      query: {
+        client_id: defaultConfig.clientID,
+        foo: 'bar',
+        federated: ''
+      }
+    });
+    expect(redirect.query).not.toHaveProperty('baz');
+    expect(redirect.query).not.toHaveProperty('qux');
+  });
 });
diff --git a/tests/handlers/logout.test.ts b/tests/handlers/logout.test.ts
index 3d197d89..c8e30dd5 100644
--- a/tests/handlers/logout.test.ts
+++ b/tests/handlers/logout.test.ts
@@ -30,6 +30,30 @@ describe('logout handler', () => {
     });
   });
 
+  test('should pass logout params to the identity provider', async () => {
+    const baseUrl = await setup(withoutApi, { logoutOptions: { logoutParams: { foo: 'bar' } } });
+    const cookieJar = await login(baseUrl);
+
+    const {
+      res: { statusCode, headers }
+    } = await get(baseUrl, '/api/auth/logout', {
+      cookieJar,
+      fullResponse: true
+    });
+
+    expect(statusCode).toBe(302);
+    expect(parseUrl(headers['location'], true)).toMatchObject({
+      protocol: 'https:',
+      host: 'acme.auth0.local',
+      query: {
+        returnTo: 'http://www.acme.com',
+        client_id: '__test_client_id__',
+        foo: 'bar'
+      },
+      pathname: '/v2/logout'
+    });
+  });
+
   test('should return to the custom path', async () => {
     const customReturnTo = 'https://www.foo.bar';
     const baseUrl = await setup(withoutApi, {