Repository: auth0/nextjs-auth0
Pull request #1105 (2023-03-10 15:15:03)

[SDK-3887] Always honor auth0Logout config for v1
Same as https://github.com/auth0/nextjs-auth0/pull/1104 for v1 (note the base branch)


Patch:
diff --git a/src/auth0-session/client.ts b/src/auth0-session/client.ts
index 7658503d..0b8db835 100644
--- a/src/auth0-session/client.ts
+++ b/src/auth0-session/client.ts
@@ -113,8 +113,12 @@ export default function get(config: Config, { name, version }: Telemetry): Clien
     applyHttpOptionsCustom(client);
     client[custom.clock_tolerance] = config.clockTolerance;
 
-    if (config.idpLogout && !issuer.end_session_endpoint) {
-      if (config.auth0Logout || (url.parse(issuer.metadata.issuer).hostname as string).match('\\.auth0\\.com$')) {
+    if (config.idpLogout) {
+      if (
+        config.auth0Logout ||
+        ((url.parse(issuer.metadata.issuer).hostname as string).match('\\.auth0\\.com$') &&
+          config.auth0Logout !== false)
+      ) {
         Object.defineProperty(client, 'endSessionUrl', {
           value(params: EndSessionParameters) {
             const parsedUrl = url.parse(urlJoin(issuer.metadata.issuer, '/v2/logout'));
@@ -125,7 +129,7 @@ export default function get(config: Config, { name, version }: Telemetry): Clien
             return url.format(parsedUrl);
           }
         });
-      } else {
+      } else if (!issuer.end_session_endpoint) {
         debug('the issuer does not support RP-Initiated Logout');
       }
     }
diff --git a/src/auth0-session/config.ts b/src/auth0-session/config.ts
index a6d49c10..cbe5d858 100644
--- a/src/auth0-session/config.ts
+++ b/src/auth0-session/config.ts
@@ -20,7 +20,7 @@ export interface Config {
   /**
    * Boolean value to enable Auth0's logout feature.
    */
-  auth0Logout: boolean;
+  auth0Logout?: boolean;
 
   /**
    *  URL parameters used when redirecting users to the authorization server to log in.
diff --git a/src/auth0-session/get-config.ts b/src/auth0-session/get-config.ts
index 691f030b..0be195da 100644
--- a/src/auth0-session/get-config.ts
+++ b/src/auth0-session/get-config.ts
@@ -61,7 +61,7 @@ const paramsSchema = Joi.object({
   })
     .default()
     .unknown(false),
-  auth0Logout: Joi.boolean().optional().default(false),
+  auth0Logout: Joi.boolean().optional(),
   authorizationParams: Joi.object({
     response_type: Joi.string().optional().valid('id_token', 'code id_token', 'code').default('id_token'),
     scope: Joi.string()
diff --git a/src/config.ts b/src/config.ts
index ed7f14cd..0a00c3dd 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -23,7 +23,7 @@ export interface BaseConfig {
    * Boolean value to enable Auth0's proprietary logout feature.
    * Since this SDK is for Auth0, it's set to `true`by default.
    */
-  auth0Logout: boolean;
+  auth0Logout?: boolean;
 
   /**
    *  URL parameters used when redirecting users to the authorization server to log in.


Test patch:
diff --git a/tests/auth0-session/client.test.ts b/tests/auth0-session/client.test.ts
index e5b73c4a..e66fe74f 100644
--- a/tests/auth0-session/client.test.ts
+++ b/tests/auth0-session/client.test.ts
@@ -100,6 +100,66 @@ describe('clientFactory', function () {
     expect(client.id_token_signed_response_alg).toEqual('RS256');
   });
 
+  it('should use discovered logout endpoint by default', async function () {
+    const client = await getClient({ ...defaultConfig, idpLogout: true });
+    expect(client.endSessionUrl({})).toEqual('https://op.example.com/session/end');
+  });
+
+  it('should use auth0 logout endpoint if configured', async function () {
+    const client = await getClient({ ...defaultConfig, idpLogout: true, auth0Logout: true });
+    expect(client.endSessionUrl({})).toEqual('https://op.example.com/v2/logout?returnTo=&client_id=__test_client_id__');
+  });
+
+  it('should use auth0 logout endpoint if domain is auth0.com', async function () {
+    nock('https://foo.auth0.com')
+      .get('/.well-known/openid-configuration')
+      .reply(200, { ...wellKnown, issuer: 'https://foo.auth0.com/' });
+    const client = await getClient({ ...defaultConfig, idpLogout: true, issuerBaseURL: 'https://foo.auth0.com' });
+    expect(client.endSessionUrl({})).toEqual('https://foo.auth0.com/v2/logout?returnTo=&client_id=__test_client_id__');
+  });
+
+  it('should use auth0 logout endpoint if domain is auth0.com and configured', async function () {
+    nock('https://foo.auth0.com')
+      .get('/.well-known/openid-configuration')
+      .reply(200, { ...wellKnown, issuer: 'https://foo.auth0.com/' });
+    const client = await getClient({
+      ...defaultConfig,
+      issuerBaseURL: 'https://foo.auth0.com',
+      idpLogout: true,
+      auth0Logout: true
+    });
+    expect(client.endSessionUrl({})).toEqual('https://foo.auth0.com/v2/logout?returnTo=&client_id=__test_client_id__');
+  });
+
+  it('should use discovered logout endpoint if domain is auth0.com but configured with auth0logout false', async function () {
+    nock('https://foo.auth0.com')
+      .get('/.well-known/openid-configuration')
+      .reply(200, {
+        ...wellKnown,
+        issuer: 'https://foo.auth0.com/',
+        end_session_endpoint: 'https://foo.auth0.com/oidc/logout'
+      });
+    const client = await getClient({
+      ...defaultConfig,
+      issuerBaseURL: 'https://foo.auth0.com',
+      idpLogout: true,
+      auth0Logout: false
+    });
+    expect(client.endSessionUrl({})).toEqual('https://foo.auth0.com/oidc/logout');
+  });
+
+  it('should create client with no end_session_endpoint', async function () {
+    nock('https://op2.example.com')
+      .get('/.well-known/openid-configuration')
+      .reply(200, {
+        ...wellKnown,
+        issuer: 'https://op2.example.com',
+        end_session_endpoint: undefined
+      });
+    const client = await getClient({ ...defaultConfig, issuerBaseURL: 'https://op2.example.com' });
+    expect(() => client.endSessionUrl({})).toThrowError();
+  });
+
   it('should create custom logout for auth0', async function () {
     nock('https://test.eu.auth0.com')
       .get('/.well-known/openid-configuration')
diff --git a/tests/auth0-session/config.test.ts b/tests/auth0-session/config.test.ts
index 710dc57f..c02caebd 100644
--- a/tests/auth0-session/config.test.ts
+++ b/tests/auth0-session/config.test.ts
@@ -64,12 +64,10 @@ describe('Config', () => {
     });
   });
 
-  it('auth0Logout and idpLogout should default to false', () => {
+  it('auth0Logout should default to undefined and idpLogout should default to false', () => {
     const config = getConfig(defaultConfig);
-    expect(config).toMatchObject({
-      auth0Logout: false,
-      idpLogout: false
-    });
+    expect(config.idpLogout).toBe(false);
+    expect(config.auth0Logout).toBeUndefined();
   });
 
   it('should not set auth0Logout to true when idpLogout is true', () => {
@@ -77,10 +75,8 @@ describe('Config', () => {
       ...defaultConfig,
       idpLogout: true
     });
-    expect(config).toMatchObject({
-      auth0Logout: false,
-      idpLogout: true
-    });
+    expect(config.idpLogout).toBe(true);
+    expect(config.auth0Logout).toBeUndefined();
   });
 
   it('should set default route paths', () => {
@@ -229,7 +225,7 @@ describe('Config', () => {
         ...defaultConfig,
         session: {
           rolling: true,
-          rollingDuration: (false as unknown) as undefined // testing invalid configuration
+          rollingDuration: false as unknown as undefined // testing invalid configuration
         }
       })
     ).toThrow('"session.rollingDuration" must be provided an integer value when "session.rolling" is true');
@@ -251,7 +247,7 @@ describe('Config', () => {
     expect(() =>
       getConfig({
         ...defaultConfig,
-        secret: ({ key: '__test_session_secret__' } as unknown) as string // testing invalid configuration
+        secret: { key: '__test_session_secret__' } as unknown as string // testing invalid configuration
       })
     ).toThrow('"secret" must be one of [string, binary, array]');
   });
@@ -262,7 +258,7 @@ describe('Config', () => {
         ...defaultConfig,
         session: {
           cookie: {
-            httpOnly: ('__invalid_httponly__' as unknown) as boolean // testing invalid configuration
+            httpOnly: '__invalid_httponly__' as unknown as boolean // testing invalid configuration
           }
         }
       })
@@ -276,7 +272,7 @@ describe('Config', () => {
         secret: '__test_session_secret__',
         session: {
           cookie: {
-            secure: ('__invalid_secure__' as unknown) as boolean // testing invalid configuration
+            secure: '__invalid_secure__' as unknown as boolean // testing invalid configuration
           }
         }
       })
@@ -290,7 +286,7 @@ describe('Config', () => {
         secret: '__test_session_secret__',
         session: {
           cookie: {
-            sameSite: ('__invalid_samesite__' as unknown) as any // testing invalid configuration
+            sameSite: '__invalid_samesite__' as unknown as any // testing invalid configuration
           }
         }
       })
@@ -304,7 +300,7 @@ describe('Config', () => {
         secret: '__test_session_secret__',
         session: {
           cookie: {
-            domain: (false as unknown) as string // testing invalid configuration
+            domain: false as unknown as string // testing invalid configuration
           }
         }
       })
@@ -399,7 +395,7 @@ describe('Config', () => {
   });
 
   it('should not allow empty scope', () => {
-    expect(() => validateAuthorizationParams({ scope: (null as unknown) as undefined })).toThrowError(
+    expect(() => validateAuthorizationParams({ scope: null as unknown as undefined })).toThrowError(
       new TypeError('"authorizationParams.scope" must be a string')
     );
     expect(() => validateAuthorizationParams({ scope: '' })).toThrowError(
@@ -420,10 +416,10 @@ describe('Config', () => {
   });
 
   it('should not allow empty response_type', () => {
-    expect(() => validateAuthorizationParams({ response_type: (null as unknown) as undefined })).toThrowError(
+    expect(() => validateAuthorizationParams({ response_type: null as unknown as undefined })).toThrowError(
       new TypeError('"authorizationParams.response_type" must be one of [id_token, code id_token, code]')
     );
-    expect(() => validateAuthorizationParams({ response_type: ('' as unknown) as undefined })).toThrowError(
+    expect(() => validateAuthorizationParams({ response_type: '' as unknown as undefined })).toThrowError(
       new TypeError('"authorizationParams.response_type" must be one of [id_token, code id_token, code]')
     );
   });
@@ -452,16 +448,16 @@ describe('Config', () => {
   });
 
   it('should not allow empty response_mode', () => {
-    expect(() => validateAuthorizationParams({ response_mode: (null as unknown) as undefined })).toThrowError(
+    expect(() => validateAuthorizationParams({ response_mode: null as unknown as undefined })).toThrowError(
       new TypeError('"authorizationParams.response_mode" must be [form_post]')
     );
-    expect(() => validateAuthorizationParams({ response_mode: ('' as unknown) as undefined })).toThrowError(
+    expect(() => validateAuthorizationParams({ response_mode: '' as unknown as undefined })).toThrowError(
       new TypeError('"authorizationParams.response_mode" must be [form_post]')
     );
     expect(() =>
       validateAuthorizationParams({
         response_type: 'code',
-        response_mode: ('' as unknown) as undefined
+        response_mode: '' as unknown as undefined
       })
     ).toThrowError(new TypeError('"authorizationParams.response_mode" must be one of [query, form_post]'));
   });
diff --git a/tests/handlers/logout.test.ts b/tests/handlers/logout.test.ts
index 111dfc63..bfd83443 100644
--- a/tests/handlers/logout.test.ts
+++ b/tests/handlers/logout.test.ts
@@ -16,7 +16,7 @@ jest.mock('../../src/utils/assert', () => ({
 describe('logout handler', () => {
   afterEach(teardown);
 
-  test('should redirect to the identity provider', async () => {
+  test('should redirect to auth0', async () => {
     const baseUrl = await setup(withoutApi);
     const cookieJar = await login(baseUrl);
 
@@ -59,10 +59,13 @@ describe('logout handler', () => {
     });
   });
 
-  test('should use end_session_endpoint if available', async () => {
-    const baseUrl = await setup(withoutApi, {
-      discoveryOptions: { end_session_endpoint: 'https://my-end-session-endpoint/logout' }
-    });
+  test('should use end_session_endpoint when configured', async () => {
+    const baseUrl = await setup(
+      { ...withoutApi, auth0Logout: false },
+      {
+        discoveryOptions: { end_session_endpoint: 'https://my-end-session-endpoint/logout' }
+      }
+    );
     const cookieJar = await login(baseUrl);
 
     const { status, headers } = await fetch(`${baseUrl}/api/auth/logout`, {
@@ -79,6 +82,25 @@ describe('logout handler', () => {
     });
   });
 
+  test('should use auth0 logout by default even when end_session_endpoint is discovered', async () => {
+    const baseUrl = await setup(withoutApi, {
+      discoveryOptions: { end_session_endpoint: 'https://my-end-session-endpoint/logout' }
+    });
+    const cookieJar = await login(baseUrl);
+    const { status, headers } = await fetch(`${baseUrl}/api/auth/logout`, {
+      redirect: 'manual',
+      headers: {
+        cookie: cookieJar.getCookieStringSync(baseUrl)
+      }
+    });
+
+    expect(status).toBe(302);
+    expect(parseUrl(headers.get('location') as string)).toMatchObject({
+      host: 'acme.auth0.local',
+      pathname: '/v2/logout'
+    });
+  });
+
   test('should delete the session', async () => {
     const baseUrl = await setup(withoutApi, {
       discoveryOptions: { end_session_endpoint: 'https://my-end-session-endpoint/logout' }