Repository: auth0/nextjs-auth0
Pull request #133 (2020-06-12 13:38:36)

Keeps previously set cookies
### Description

Keeps any previously set cookies on the cookies helper in order to allow more cookies to be set other than the session one, e.g. on the callback route

### Testing
- [x] This change adds test coverage for new/changed/fixed functionality

### Checklist

- [x] I have added documentation for new/changed functionality in this PR or in auth0.com/docs
- [x] All active GitHub checks for tests, formatting, and security are passing
- [ ] The correct base branch is being used, if not `master`



Patch:
diff --git a/README.md b/README.md
index 7a5e4224..6479ddcb 100644
--- a/README.md
+++ b/README.md
@@ -114,7 +114,7 @@ export default async function login(req, res) {
 }
 ```
 
-> Note: This route supports providing `redirectTo` in the querystring, eg: (`/api/login?redirectTo=/profile`). The user will automatically be redirect to this URL after signing in.
+> Note: This route supports providing `redirectTo` in the query string, eg: (`/api/login?redirectTo=/profile`). The user will automatically be redirect to this URL after signing in.
 
 This will redirect the user to Auth0. After the transaction is completed Auth0 will redirect the user back to your application. This is why the callback route (`/pages/api/callback.js`) needs to be created which will create a session cookie:
 
@@ -131,6 +131,8 @@ export default async function callback(req, res) {
 }
 ```
 
+The `handleCallback` function ends the request and sends the response (the redirect), so if you need to set any other cookies, do it before calling the said function and they will kept in the final response.
+
 You can optionally send extra parameters to Auth0 to influence the transaction, for example:
 
 - Showing the login page
@@ -204,6 +206,8 @@ export default async function logout(req, res) {
 }
 ```
 
+Note that the third parameter of `handleLogout` accepts an optional `returnTo` to allow request-time configuration of where to redirect the user to on logout.
+
 ### User Profile
 
 If you want to expose a route which returns the user profile to the client you can create an additional route (eg: `/pages/api/me.js`):
diff --git a/examples/sample-api/package-lock.json b/examples/sample-api/package-lock.json
index 15dbb9a2..64379411 100644
--- a/examples/sample-api/package-lock.json
+++ b/examples/sample-api/package-lock.json
@@ -335,9 +335,9 @@
       }
     },
     "express-jwt": {
-      "version": "5.3.1",
-      "resolved": "https://registry.npmjs.org/express-jwt/-/express-jwt-5.3.1.tgz",
-      "integrity": "sha512-1C9RNq0wMp/JvsH/qZMlg3SIPvKu14YkZ4YYv7gJQ1Vq+Dv8LH9tLKenS5vMNth45gTlEUGx+ycp9IHIlaHP/g==",
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/express-jwt/-/express-jwt-6.0.0.tgz",
+      "integrity": "sha512-C26y9myRjx7CyhZ+BAT3p+gQyRCoDZ7qo8plCvLDaRT6je6ALIAQknT6XLVQGFKwIy/Ux7lvM2MNap5dt0T7gA==",
       "requires": {
         "async": "^1.5.0",
         "express-unless": "^0.3.0",
diff --git a/examples/sample-api/package.json b/examples/sample-api/package.json
index 9762d215..7f890c3c 100644
--- a/examples/sample-api/package.json
+++ b/examples/sample-api/package.json
@@ -12,7 +12,7 @@
     "cors": "^2.8.5",
     "dotenv": "^8.1.0",
     "express": "^4.17.1",
-    "express-jwt": "^5.3.1",
+    "express-jwt": "^6.0.0",
     "isomorphic-unfetch": "^3.0.0",
     "jwks-rsa": "^1.6.0"
   }
diff --git a/src/handlers/logout.ts b/src/handlers/logout.ts
index 6f1328a3..a417756d 100644
--- a/src/handlers/logout.ts
+++ b/src/handlers/logout.ts
@@ -6,12 +6,12 @@ import { IOidcClientFactory } from '../utils/oidc-client';
 import CookieSessionStoreSettings from '../session/cookie-store/settings';
 import { ISessionStore } from '../session/store';
 
-function createLogoutUrl(settings: IAuth0Settings): string {
-  return (
-    `https://${settings.domain}/v2/logout?` +
-    `client_id=${settings.clientId}` +
-    `&returnTo=${encodeURIComponent(settings.postLogoutRedirectUri)}`
-  );
+export interface LogoutOptions {
+  returnTo?: string;
+}
+
+function createLogoutUrl(settings: IAuth0Settings, returnToUrl: string): string {
+  return `https://${settings.domain}/v2/logout?client_id=${settings.clientId}&returnTo=${returnToUrl}`;
 }
 
 export default function logoutHandler(
@@ -20,7 +20,7 @@ export default function logoutHandler(
   clientProvider: IOidcClientFactory,
   store: ISessionStore
 ) {
-  return async (req: NextApiRequest, res: NextApiResponse): Promise<void> => {
+  return async (req: NextApiRequest, res: NextApiResponse, options?: LogoutOptions): Promise<void> => {
     if (!req) {
       throw new Error('Request is not available');
     }
@@ -31,17 +31,18 @@ export default function logoutHandler(
 
     const session = await store.read(req);
     let endSessionUrl;
+    const returnToUrl = options?.returnTo || settings.postLogoutRedirectUri;
 
     try {
       const client = await clientProvider();
       endSessionUrl = client.endSessionUrl({
         id_token_hint: session ? session.idToken : undefined,
-        post_logout_redirect_uri: encodeURIComponent(settings.postLogoutRedirectUri)
+        post_logout_redirect_uri: returnToUrl
       });
     } catch (err) {
       if (/end_session_endpoint must be configured/.exec(err)) {
         // Use default url if end_session_endpoint is not configured
-        endSessionUrl = createLogoutUrl(settings);
+        endSessionUrl = createLogoutUrl(settings, returnToUrl);
       } else {
         throw err;
       }
diff --git a/src/instance.ts b/src/instance.ts
index d4380a21..05715bfc 100644
--- a/src/instance.ts
+++ b/src/instance.ts
@@ -5,6 +5,7 @@ import { LoginOptions } from './handlers/login';
 import { ITokenCache } from './tokens/token-cache';
 import { CallbackOptions } from './handlers/callback';
 import { ProfileOptions } from './handlers/profile';
+import { LogoutOptions } from './handlers/logout';
 import { IApiRoute } from './handlers/require-authentication';
 
 export interface ISignInWithAuth0 {
@@ -19,9 +20,9 @@ export interface ISignInWithAuth0 {
   handleCallback: (req: NextApiRequest, res: NextApiResponse, options?: CallbackOptions) => Promise<void>;
 
   /**
-   * Logout handler which will clear the local session and the Auth0 session
+   * Logout handler which will clear the local session and the Auth0 session.
    */
-  handleLogout: (req: NextApiRequest, res: NextApiResponse) => Promise<void>;
+  handleLogout: (req: NextApiRequest, res: NextApiResponse, options?: LogoutOptions) => Promise<void>;
 
   /**
    * Profile handler which return profile information about the user.
diff --git a/src/utils/cookies.ts b/src/utils/cookies.ts
index 41c8b427..73e70eb7 100644
--- a/src/utils/cookies.ts
+++ b/src/utils/cookies.ts
@@ -95,18 +95,26 @@ function serializeCookie(cookie: ICookie, secure: boolean): string {
 /**
  * Set one or more cookies.
  * @param res The HTTP response on which the cookie will be set.
+ * @returns {void}
  */
 export function setCookies(req: IncomingMessage, res: ServerResponse, cookies: Array<ICookie>): void {
-  res.setHeader(
-    'Set-Cookie',
-    cookies.map((c) => serializeCookie(c, isSecureEnvironment(req)))
-  );
+  const strCookies = cookies.map((c) => serializeCookie(c, isSecureEnvironment(req)));
+  const previousCookies = res.getHeader('Set-Cookie');
+  if (previousCookies) {
+    if (previousCookies instanceof Array) {
+      Array.prototype.push.apply(strCookies, previousCookies);
+    } else if (typeof previousCookies === 'string') {
+      strCookies.push(previousCookies);
+    }
+  }
+  res.setHeader('Set-Cookie', strCookies);
 }
 
 /**
  * Set one or more cookies.
  * @param res The HTTP response on which the cookie will be set.
+ * @returns {void}
  */
 export function setCookie(req: IncomingMessage, res: ServerResponse, cookie: ICookie): void {
-  res.setHeader('Set-Cookie', serializeCookie(cookie, isSecureEnvironment(req)));
+  setCookies(req, res, [cookie]);
 }


Test patch:
diff --git a/tests/handlers/logout.test.ts b/tests/handlers/logout.test.ts
index 16aa4e1e..5268af78 100644
--- a/tests/handlers/logout.test.ts
+++ b/tests/handlers/logout.test.ts
@@ -3,7 +3,7 @@ import { parse } from 'cookie';
 import { promisify } from 'util';
 
 import HttpServer from '../helpers/server';
-import logout from '../../src/handlers/logout';
+import logout, { LogoutOptions } from '../../src/handlers/logout';
 import { withoutApi } from '../helpers/default-settings';
 import CookieSessionStoreSettings from '../../src/session/cookie-store/settings';
 import { discovery } from '../helpers/oidc-nocks';
@@ -34,11 +34,18 @@ const sessionStore: ISessionStore = {
 
 describe('logout handler', () => {
   let httpServer: HttpServer;
+  let logoutHandler: any;
+  let logoutOptions: LogoutOptions | null;
 
   beforeEach((done) => {
-    httpServer = new HttpServer(
-      logout(withoutApi, new CookieSessionStoreSettings(withoutApi.session), getClient(withoutApi), sessionStore)
+    logoutHandler = logout(
+      withoutApi,
+      new CookieSessionStoreSettings(withoutApi.session),
+      getClient(withoutApi),
+      sessionStore
     );
+    logoutOptions = {};
+    httpServer = new HttpServer((req, res) => logoutHandler(req, res, logoutOptions));
     httpServer.start(done);
   });
 
@@ -62,6 +69,26 @@ describe('logout handler', () => {
     );
   });
 
+  test('should return to the custom path', async () => {
+    const customReturnTo = 'https://www.foo.bar';
+    logoutOptions = {
+      returnTo: customReturnTo
+    };
+    discovery(withoutApi);
+
+    const { statusCode, headers } = await getAsync({
+      url: httpServer.getUrl(),
+      followRedirect: false
+    });
+
+    expect(statusCode).toBe(302);
+    expect(headers.location).toBe(
+      `https://${withoutApi.domain}/v2/logout?client_id=${withoutApi.clientId}&returnTo=${encodeURIComponent(
+        customReturnTo
+      )}`
+    );
+  });
+
   test('should use end_session_endpoint if available', async () => {
     discovery(withoutApi, { end_session_endpoint: 'https://my-end-session-endpoint/logout' });
 
@@ -73,7 +100,7 @@ describe('logout handler', () => {
     expect(statusCode).toBe(302);
     expect(headers.location).toBe(
       `https://my-end-session-endpoint/logout` +
-        `?id_token_hint=my-id-token&post_logout_redirect_uri=https%253A%252F%252Fwww.acme.com`
+        `?id_token_hint=my-id-token&post_logout_redirect_uri=https%3A%2F%2Fwww.acme.com`
     );
   });
 
diff --git a/tests/helpers/http.ts b/tests/helpers/http.ts
index cb968af3..c7ba9a49 100644
--- a/tests/helpers/http.ts
+++ b/tests/helpers/http.ts
@@ -17,6 +17,7 @@ export default function getRequestResponse(): IHttpHelpers {
 
   const res: any = new ServerResponse(req);
   res.setHeader = jest.fn();
+  res.getHeader = jest.fn();
   res.json = jest.fn();
   res.status = jest.fn(() => res);
 
diff --git a/tests/session/cookie-store.test.ts b/tests/session/cookie-store.test.ts
index fd038532..7c24d791 100644
--- a/tests/session/cookie-store.test.ts
+++ b/tests/session/cookie-store.test.ts
@@ -28,7 +28,10 @@ describe('CookieStore', () => {
           createdAt: Date.now()
         });
 
-        expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringMatching('my-cookie='));
+        expect(res.setHeader).toHaveBeenCalledWith(
+          'Set-Cookie',
+          expect.arrayContaining([expect.stringMatching('my-cookie=')])
+        );
       });
     });
 
@@ -42,7 +45,10 @@ describe('CookieStore', () => {
           createdAt: Date.now()
         });
 
-        expect(res.setHeader).toHaveBeenCalledWith('Set-Cookie', expect.stringMatching('a0:session='));
+        expect(res.setHeader).toHaveBeenCalledWith(
+          'Set-Cookie',
+          expect.arrayContaining([expect.stringMatching('a0:session=')])
+        );
       });
     });
   });
@@ -60,7 +66,7 @@ describe('CookieStore', () => {
           createdAt: Date.now()
         });
 
-        const [, cookie] = setHeaderFn.mock.calls[0];
+        const [, [cookie]] = setHeaderFn.mock.calls[0];
         expect(parse(cookie).Domain).toBe('.quirk.fyi');
       });
     });
@@ -75,7 +81,7 @@ describe('CookieStore', () => {
           createdAt: Date.now()
         });
 
-        const [, cookie] = setHeaderFn.mock.calls[0];
+        const [, [cookie]] = setHeaderFn.mock.calls[0];
         expect(parse(cookie).Domain).toBeUndefined();
       });
     });
@@ -94,7 +100,7 @@ describe('CookieStore', () => {
           createdAt: Date.now()
         });
 
-        const [, cookie] = setHeaderFn.mock.calls[0];
+        const [, [cookie]] = setHeaderFn.mock.calls[0];
         expect(parse(cookie).SameSite).toBe('Strict');
       });
     });
@@ -109,7 +115,7 @@ describe('CookieStore', () => {
           createdAt: Date.now()
         });
 
-        const [, cookie] = setHeaderFn.mock.calls[0];
+        const [, [cookie]] = setHeaderFn.mock.calls[0];
         expect(parse(cookie).SameSite).toBe('Lax');
       });
     });
@@ -125,7 +131,7 @@ describe('CookieStore', () => {
           createdAt: Date.now()
         });
 
-        const [, cookie] = setHeaderFn.mock.calls[0];
+        const [, [cookie]] = setHeaderFn.mock.calls[0];
         expect(parse(cookie).SameSite).toBeUndefined();
       });
     });
@@ -147,7 +153,7 @@ describe('CookieStore', () => {
           accessTokenExpiresAt: 500
         });
 
-        const [, cookie] = setHeaderFn.mock.calls[0];
+        const [, [cookie]] = setHeaderFn.mock.calls[0];
         req.headers = {
           cookie: `a0:session=${parse(cookie)['a0:session']}`
         };
@@ -177,7 +183,7 @@ describe('CookieStore', () => {
           accessTokenExpiresAt: 500
         });
 
-        const [, cookie] = setHeaderFn.mock.calls[0];
+        const [, [cookie]] = setHeaderFn.mock.calls[0];
         req.headers = {
           cookie: `a0:session=${parse(cookie)['a0:session']}`
         };
@@ -210,7 +216,7 @@ describe('CookieStore', () => {
           idToken: 'my-id-token'
         });
 
-        const [, cookie] = setHeaderFn.mock.calls[0];
+        const [, [cookie]] = setHeaderFn.mock.calls[0];
         req.headers = {
           cookie: `a0:session=${parse(cookie)['a0:session']}`
         };
@@ -240,7 +246,7 @@ describe('CookieStore', () => {
           idToken: 'my-id-token'
         });
 
-        const [, cookie] = setHeaderFn.mock.calls[0];
+        const [, [cookie]] = setHeaderFn.mock.calls[0];
         req.headers = {
           cookie: `a0:session=${parse(cookie)['a0:session']}`
         };
@@ -272,7 +278,7 @@ describe('CookieStore', () => {
           refreshToken: 'my-refresh-token'
         });
 
-        const [, cookie] = setHeaderFn.mock.calls[0];
+        const [, [cookie]] = setHeaderFn.mock.calls[0];
         req.headers = {
           cookie: `a0:session=${parse(cookie)['a0:session']}`
         };
@@ -303,7 +309,7 @@ describe('CookieStore', () => {
           refreshToken: 'my-refresh-token'
         });
 
-        const [, cookie] = setHeaderFn.mock.calls[0];
+        const [, [cookie]] = setHeaderFn.mock.calls[0];
         req.headers = {
           ...req.headers,
           cookie: `a0:session=${parse(cookie)['a0:session']}`
diff --git a/tests/utils/cookies.test.ts b/tests/utils/cookies.test.ts
index 62f35141..402d7103 100644
--- a/tests/utils/cookies.test.ts
+++ b/tests/utils/cookies.test.ts
@@ -1,4 +1,5 @@
 import timekeeper from 'timekeeper';
+import { serialize } from 'cookie';
 
 import getRequestResponse from '../helpers/http';
 import { setCookie } from '../../src/utils/cookies';
@@ -33,7 +34,55 @@ describe('cookies', () => {
       });
 
       expect(res.setHeader.mock.calls).toEqual([
-        ['Set-Cookie', `foo=bar; Max-Age=1000; Path=/; Expires=${timeString}; HttpOnly`]
+        ['Set-Cookie', [`foo=bar; Max-Age=1000; Path=/; Expires=${timeString}; HttpOnly`]]
+      ]);
+    });
+
+    test('should keep the previously set cookie on the response', () => {
+      const { req, res } = getRequestResponse();
+      const previousCookie = serialize('previous', 'value');
+      res.getHeader.mockReturnValueOnce([previousCookie]);
+      setCookie(req, res, {
+        name: 'foo',
+        value: 'bar',
+        maxAge: 1000,
+        path: '/'
+      });
+
+      expect(res.setHeader.mock.calls).toEqual([
+        ['Set-Cookie', [`foo=bar; Max-Age=1000; Path=/; Expires=${timeString}; HttpOnly`, previousCookie]]
+      ]);
+    });
+
+    test('should keep the previously set single string cookie on the response', () => {
+      const { req, res } = getRequestResponse();
+      const previousCookie = serialize('previous', 'value');
+      res.getHeader.mockReturnValueOnce(previousCookie);
+      setCookie(req, res, {
+        name: 'foo',
+        value: 'bar',
+        maxAge: 1000,
+        path: '/'
+      });
+
+      expect(res.setHeader.mock.calls).toEqual([
+        ['Set-Cookie', [`foo=bar; Max-Age=1000; Path=/; Expires=${timeString}; HttpOnly`, previousCookie]]
+      ]);
+    });
+
+    test('should keep multiple previously set cookies on the response', () => {
+      const { req, res } = getRequestResponse();
+      const previousCookies = [serialize('previous', 'value'), serialize('lady', 'gaga')];
+      res.getHeader.mockReturnValueOnce(previousCookies);
+      setCookie(req, res, {
+        name: 'foo',
+        value: 'bar',
+        maxAge: 1000,
+        path: '/'
+      });
+
+      expect(res.setHeader.mock.calls).toEqual([
+        ['Set-Cookie', [`foo=bar; Max-Age=1000; Path=/; Expires=${timeString}; HttpOnly`, ...previousCookies]]
       ]);
     });
 
@@ -50,7 +99,7 @@ describe('cookies', () => {
         });
 
         expect(res.setHeader.mock.calls).toEqual([
-          ['Set-Cookie', `foo=bar; Max-Age=1000; Path=/; Expires=${timeString}; HttpOnly`]
+          ['Set-Cookie', [`foo=bar; Max-Age=1000; Path=/; Expires=${timeString}; HttpOnly`]]
         ]);
       });
     });
@@ -68,7 +117,7 @@ describe('cookies', () => {
         });
 
         expect(res.setHeader.mock.calls).toEqual([
-          ['Set-Cookie', `foo=bar; Max-Age=1000; Path=/; Expires=${timeString}; HttpOnly`]
+          ['Set-Cookie', [`foo=bar; Max-Age=1000; Path=/; Expires=${timeString}; HttpOnly`]]
         ]);
       });
 
@@ -85,7 +134,7 @@ describe('cookies', () => {
           });
 
           expect(res.setHeader.mock.calls).toEqual([
-            ['Set-Cookie', `foo=bar; Max-Age=1000; Path=/; Expires=${timeString}; HttpOnly; Secure`]
+            ['Set-Cookie', [`foo=bar; Max-Age=1000; Path=/; Expires=${timeString}; HttpOnly; Secure`]]
           ]);
         });
       });