Repository: auth0/nextjs-auth0
Pull request #113 (2020-05-18 11:51:10)

getSession from NextPageRequest works too
It should be possible to call `getSession` from a NextJS page as well as a NextJS API handler. This PR ensures you can from a TypeScript perspective.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0a53057e..a43c5202 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,13 @@
+## [v0.15.0](https://github.com/auth0/nextjs-auth0/tree/v0.15.0) (2020-07-13)
+
+- Improve redirect URI validation when double forward slashes are provided ([commit](https://github.com/auth0/nextjs-auth0/commit/88959971958e5c6ed5bd874828c97363d2224f74))
+- Fix double encoding issue of `postLogoutRedirectUri` when using a different OIDC IdP ([#127](https://github.com/auth0/nextjs-auth0/pull/127))
+- Keep previously set cookies in the `callbackHandler` ([#133](https://github.com/auth0/nextjs-auth0/pull/133))
+
+## [v0.14.0](https://github.com/auth0/nextjs-auth0/tree/v0.14.0) (2020-07-08)
+
+- Allow overriding the `returnTo` setting when signing out a user (in the `logoutHandler`)
+
 ## [v0.13.0](https://github.com/auth0/nextjs-auth0/tree/v0.13.0) (2020-05-15)
 
 - Updated handlers to use `NextApiRequest` and `NextApiResponse`
diff --git a/README.md b/README.md
index 7a5e4224..694196c8 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`):
@@ -272,7 +276,7 @@ Profile.getInitialProps = async ({ req, res }) => {
 The user's access token can be stored in the user's session. However, this token will expire after some amount of time. It is possible to use a refresh token to retrieve a new access token. A token cache is available that will handle all of the checks and necessary refresh logic for you:
 
 ```js
-const tokenCache = await auth0.tokenCache(req, res);
+const tokenCache = auth0.tokenCache(req, res);
 const { accessToken } = await tokenCache.getAccessToken();
 ```
 
@@ -285,7 +289,7 @@ If for some reason it's not possible to get a new access token an error will be
 You can also require a scope to be present in the requested access token. If you need to call an endpoint which requires the `delete:file` scope you can require this as such:
 
 ```js
-const tokenCache = await auth0.tokenCache(req, res);
+const tokenCache = auth0.tokenCache(req, res);
 const { accessToken } = await tokenCache.getAccessToken({
   scope: [`delete:file`]
 });
@@ -333,7 +337,7 @@ import auth0 from '../../utils/auth0';
 
 export default async function getCustomers(req, res) {
   try {
-    const tokenCache = await auth0.tokenCache(req, res);
+    const tokenCache = auth0.tokenCache(req, res);
     const { accessToken } = await tokenCache.getAccessToken();
 
     const apiClient = new MyApiClient(accessToken);
diff --git a/examples/api-call-example/package.json b/examples/api-call-example/package.json
index 6fe131f1..1211f184 100644
--- a/examples/api-call-example/package.json
+++ b/examples/api-call-example/package.json
@@ -10,7 +10,7 @@
   "author": "",
   "license": "MIT",
   "dependencies": {
-    "@auth0/nextjs-auth0": "^0.13.0",
+    "@auth0/nextjs-auth0": "^0.15.0",
     "dotenv": "^8.2.0",
     "isomorphic-unfetch": "^3.0.0",
     "next": "^9.4.0",
diff --git a/examples/basic-example/package-lock.json b/examples/basic-example/package-lock.json
index 5162ddd2..3464109a 100644
--- a/examples/basic-example/package-lock.json
+++ b/examples/basic-example/package-lock.json
@@ -52,9 +52,9 @@
       }
     },
     "@auth0/nextjs-auth0": {
-      "version": "0.13.0",
-      "resolved": "https://registry.npmjs.org/@auth0/nextjs-auth0/-/nextjs-auth0-0.13.0.tgz",
-      "integrity": "sha512-1UZ/Fjm9mHCZtqMsHPfQex64n8Ili5/hWEd6xSauy44eM6+RqTz/Hy3DQCMtLGV+mh7TATNtz+TIDn63qMlv7A==",
+      "version": "0.15.0",
+      "resolved": "https://registry.npmjs.org/@auth0/nextjs-auth0/-/nextjs-auth0-0.15.0.tgz",
+      "integrity": "sha512-UN1Hsbpet6xMTTFl8kvyylyN8C8sFfR4M97IBY5mPdYq9ydhBckY/1L8LlG/Ie5P7fyoRQgf97XWxIzV8JH6MQ==",
       "requires": {
         "@hapi/iron": "^5.1.4",
         "base64url": "^3.0.1",
@@ -1208,9 +1208,9 @@
       }
     },
     "@types/node": {
-      "version": "14.0.1",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.1.tgz",
-      "integrity": "sha512-FAYBGwC+W6F9+huFIDtn43cpy7+SzG+atzRiTfdp3inUKL2hXnd4rG8hylJLIh4+hqrQy1P17kvJByE/z825hA=="
+      "version": "14.0.23",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.23.tgz",
+      "integrity": "sha512-Z4U8yDAl5TFkmYsZdFPdjeMa57NOvnaf1tljHzhouaPEp7LCj2JKkejpI1ODviIAQuW4CcQmxkQ77rnLsOOoKw=="
     },
     "@types/q": {
       "version": "1.5.3",
@@ -2833,9 +2833,9 @@
       "integrity": "sha512-QKMcpfA/fCOnqFHsZvKr2haQQb3eXkDI17zT+4hHxJJThyN5nShcG6q1VR8vRiE/2GCJM+0p3PzinYknkdsBYg=="
     },
     "elliptic": {
-      "version": "6.5.2",
-      "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz",
-      "integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==",
+      "version": "6.5.3",
+      "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz",
+      "integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==",
       "requires": {
         "bn.js": "^4.4.0",
         "brorand": "^1.0.1",
@@ -2847,9 +2847,9 @@
       },
       "dependencies": {
         "bn.js": {
-          "version": "4.11.8",
-          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz",
-          "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA=="
+          "version": "4.11.9",
+          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.9.tgz",
+          "integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw=="
         }
       }
     },
@@ -3933,9 +3933,9 @@
       }
     },
     "jose": {
-      "version": "1.27.0",
-      "resolved": "https://registry.npmjs.org/jose/-/jose-1.27.0.tgz",
-      "integrity": "sha512-SxYPCM9pWDaK070CXbxgL4ktVzLlE0yJxevDJtbWxv2WMQwYfpBZLYlG8PhChsiOfOXp6FrceRgTuZh1vZeDlg==",
+      "version": "1.27.2",
+      "resolved": "https://registry.npmjs.org/jose/-/jose-1.27.2.tgz",
+      "integrity": "sha512-zLIwnMa8dh5A2jFo56KvhiXCaW0hFjdNvG0I5GScL8Wro+/r/SnyIYTbnX3fYztPNSfgQp56sDMHUuS9c3e6bw==",
       "requires": {
         "@panva/asn1.js": "^1.0.0"
       }
@@ -4033,9 +4033,9 @@
       }
     },
     "lodash": {
-      "version": "4.17.15",
-      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
-      "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
+      "version": "4.17.19",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
+      "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ=="
     },
     "lodash.memoize": {
       "version": "4.1.2",
@@ -4671,20 +4671,34 @@
       }
     },
     "openid-client": {
-      "version": "3.15.1",
-      "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-3.15.1.tgz",
-      "integrity": "sha512-USoxzLuL08IhRiA+4z5FW25nsLgBM6lOoh+U/XWqyKJzrMbjfmVWNfof7706RgMypyvAFcAPCxPtSFqb+GpHjA==",
+      "version": "3.15.8",
+      "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-3.15.8.tgz",
+      "integrity": "sha512-3GZWg9vO1vrdz60anD1LTuUyyhwpA3eqwf3Ax1Gj3AjPl+cG8aD/6Y4eyDpOlQN7iWQcGLsqMU2xBxlH4r+xEQ==",
       "requires": {
         "@types/got": "^9.6.9",
         "base64url": "^3.0.1",
         "got": "^9.6.0",
-        "jose": "^1.25.2",
-        "lodash": "^4.17.15",
-        "lru-cache": "^5.1.1",
+        "jose": "^1.27.1",
+        "lru-cache": "^6.0.0",
         "make-error": "^1.3.6",
         "object-hash": "^2.0.1",
         "oidc-token-hash": "^5.0.0",
         "p-any": "^3.0.0"
+      },
+      "dependencies": {
+        "lru-cache": {
+          "version": "6.0.0",
+          "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+          "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+          "requires": {
+            "yallist": "^4.0.0"
+          }
+        },
+        "yallist": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+          "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
+        }
       }
     },
     "os-browserify": {
diff --git a/examples/basic-example/package.json b/examples/basic-example/package.json
index 6fe131f1..1211f184 100644
--- a/examples/basic-example/package.json
+++ b/examples/basic-example/package.json
@@ -10,7 +10,7 @@
   "author": "",
   "license": "MIT",
   "dependencies": {
-    "@auth0/nextjs-auth0": "^0.13.0",
+    "@auth0/nextjs-auth0": "^0.15.0",
     "dotenv": "^8.2.0",
     "isomorphic-unfetch": "^3.0.0",
     "next": "^9.4.0",
diff --git a/examples/sample-api/package-lock.json b/examples/sample-api/package-lock.json
index 15dbb9a2..c861df59 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",
@@ -596,9 +596,9 @@
       "integrity": "sha1-/sfervF+fDoKVeHaBCgD4l2RdF0="
     },
     "lodash": {
-      "version": "4.17.15",
-      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
-      "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
+      "version": "4.17.19",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
+      "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ=="
     },
     "lodash.includes": {
       "version": "4.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/package-lock.json b/package-lock.json
index 13938704..e5dc3e16 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "@auth0/nextjs-auth0",
-  "version": "0.13.0",
+  "version": "0.15.0",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -4264,9 +4264,9 @@
       "dev": true
     },
     "elliptic": {
-      "version": "6.5.2",
-      "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz",
-      "integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==",
+      "version": "6.5.3",
+      "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz",
+      "integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==",
       "dev": true,
       "requires": {
         "bn.js": "^4.4.0",
@@ -4279,9 +4279,9 @@
       },
       "dependencies": {
         "bn.js": {
-          "version": "4.11.8",
-          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz",
-          "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==",
+          "version": "4.11.9",
+          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.9.tgz",
+          "integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==",
           "dev": true
         }
       }
@@ -7267,9 +7267,9 @@
       }
     },
     "lodash": {
-      "version": "4.17.15",
-      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
-      "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
+      "version": "4.17.19",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
+      "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ=="
     },
     "lodash.memoize": {
       "version": "4.1.2",
diff --git a/package.json b/package.json
index 79685f6b..2478e315 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@auth0/nextjs-auth0",
-  "version": "0.13.0",
+  "version": "0.15.0",
   "description": "Next.js SDK for signing in with Auth0",
   "main": "dist/index.js",
   "types": "dist/index.d.ts",
diff --git a/src/handlers/login.ts b/src/handlers/login.ts
index 2972a63f..88341a2e 100644
--- a/src/handlers/login.ts
+++ b/src/handlers/login.ts
@@ -2,7 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
 
 import version from '../version';
 import IAuth0Settings from '../settings';
-import isRelative from '../utils/url-helpers';
+import isSafeRedirect from '../utils/url-helpers';
 
 import { setCookies } from '../utils/cookies';
 import { createState } from '../utils/state';
@@ -53,7 +53,7 @@ export default function loginHandler(settings: IAuth0Settings, clientProvider: I
         throw new Error('Invalid value provided for redirectTo, must be a string');
       }
 
-      if (!isRelative(req.query.redirectTo)) {
+      if (!isSafeRedirect(req.query.redirectTo)) {
         throw new Error('Invalid value provided for redirectTo, must be a relative url');
       }
     }
diff --git a/src/handlers/logout.ts b/src/handlers/logout.ts
index 6f1328a3..3e13689c 100644
--- a/src/handlers/logout.ts
+++ b/src/handlers/logout.ts
@@ -6,12 +6,14 @@ 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=${encodeURIComponent(
+    returnToUrl
+  )}`;
 }
 
 export default function logoutHandler(
@@ -20,7 +22,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 +33,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/handlers/session.ts b/src/handlers/session.ts
index 4e8da6f2..4d4ae688 100644
--- a/src/handlers/session.ts
+++ b/src/handlers/session.ts
@@ -1,10 +1,10 @@
-import { NextApiRequest } from 'next';
+import { IncomingMessage } from 'http';
 
 import { ISession } from '../session/session';
 import { ISessionStore } from '../session/store';
 
 export default function sessionHandler(sessionStore: ISessionStore) {
-  return (req: NextApiRequest): Promise<ISession | null | undefined> => {
+  return (req: IncomingMessage): Promise<ISession | null | undefined> => {
     if (!req) {
       throw new Error('Request is not available');
     }
diff --git a/src/instance.ts b/src/instance.ts
index d4380a21..fbdb34a4 100644
--- a/src/instance.ts
+++ b/src/instance.ts
@@ -1,10 +1,11 @@
 import { NextApiRequest, NextApiResponse } from 'next';
-
+import { IncomingMessage } from 'http';
 import { ISession } from './session/session';
 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.
@@ -31,7 +32,7 @@ export interface ISignInWithAuth0 {
   /**
    * Session handler which returns the current session
    */
-  getSession: (req: NextApiRequest) => Promise<ISession | null | undefined>;
+  getSession: (req: IncomingMessage) => Promise<ISession | null | undefined>;
 
   /**
    * Handle to require authentication for an API route.
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]);
 }
diff --git a/src/utils/url-helpers.ts b/src/utils/url-helpers.ts
index c6921ecd..ac0fbcf6 100644
--- a/src/utils/url-helpers.ts
+++ b/src/utils/url-helpers.ts
@@ -1,6 +1,16 @@
-export default function isRelative(url: string): boolean {
+/**
+ * Helper which tests if a URL can safely be redirected to. Requires the URL to be relative.
+ * @param url
+ */
+export default function isSafeRedirect(url: string): boolean {
   if (typeof url !== 'string') {
     throw new TypeError(`Invalid url: ${url}`);
   }
+
+  // Prevent open redirects using the //foo.com format (double forward slash).
+  if (/\/\//.test(url)) {
+    return false;
+  }
+
   return !/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url);
 }
diff --git a/src/version.ts b/src/version.ts
index d27a33e4..41a61535 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,1 +1,1 @@
-export default '0.13.0';
+export default '0.15.0';


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`]]
           ]);
         });
       });
diff --git a/tests/utils/url-helpers.test.ts b/tests/utils/url-helpers.test.ts
index 9d6e2ffd..29d76466 100644
--- a/tests/utils/url-helpers.test.ts
+++ b/tests/utils/url-helpers.test.ts
@@ -1,16 +1,21 @@
-import isRelative from '../../src/utils/url-helpers';
+import isSafeRedirect from '../../src/utils/url-helpers';
 
 describe('url-helpers', () => {
-  describe('isRelative', () => {
+  describe('isSafeRedirect', () => {
     test('should not allow absolute urls', () => {
-      expect(isRelative('file://foo')).toEqual(false);
-      expect(isRelative('https://foo')).toEqual(false);
-      expect(isRelative('http://foo')).toEqual(false);
+      expect(isSafeRedirect('file://foo')).toEqual(false);
+      expect(isSafeRedirect('https://foo')).toEqual(false);
+      expect(isSafeRedirect('http://foo')).toEqual(false);
     });
 
     test('should allow relative urls', () => {
-      expect(isRelative('/foo')).toEqual(true);
-      expect(isRelative('/foo?some=value')).toEqual(true);
+      expect(isSafeRedirect('/foo')).toEqual(true);
+      expect(isSafeRedirect('/foo?some=value')).toEqual(true);
+    });
+
+    test('should prevent open redirects', () => {
+      expect(isSafeRedirect('//google.com')).toEqual(false);
+      expect(isSafeRedirect('///google.com')).toEqual(false);
     });
   });
 });