Repository: auth0/nextjs-auth0
Pull request #19 (2019-10-10 08:19:34)

Dont require secure cookie when running on localhost
### Description

All cookies will be forced to HTTPS (`Secure`) if the application is running with `NODE_ENV=production` and not running on localhost. This is important when running `next start` locally (which creates a production build). 

### References

https://github.com/zeit/next.js/issues/9003


Patch:
diff --git a/README.md b/README.md
index b45599ad..95c9fb56 100644
--- a/README.md
+++ b/README.md
@@ -246,6 +246,12 @@ If the user is authenticated then your API route will simply execute, but if the
 }
 ```
 
+## Documentation
+
+### Cookies
+
+All cookies will be set as `HttpOnly` cookies and will be forced to HTTPS (`Secure`) if the application is running with `NODE_ENV=production` and not running on localhost.
+
 ## Contributing
 
 Run NPM install first to install the dependencies of this project:
diff --git a/src/handlers/login.ts b/src/handlers/login.ts
index a4799b8a..a0df19bb 100644
--- a/src/handlers/login.ts
+++ b/src/handlers/login.ts
@@ -23,7 +23,7 @@ function telemetry(): string {
 }
 
 export default function loginHandler(settings: IAuth0Settings, clientProvider: IOidcClientFactory) {
-  return async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
+  return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
     if (!res) {
       throw new Error('Response is not available');
     }
@@ -41,7 +41,7 @@ export default function loginHandler(settings: IAuth0Settings, clientProvider: I
     });
 
     // Set the necessary cookies
-    setCookies(res, [
+    setCookies(req, res, [
       {
         name: 'a0:state',
         value: state,
diff --git a/src/handlers/logout.ts b/src/handlers/logout.ts
index 262c327d..7a42d197 100644
--- a/src/handlers/logout.ts
+++ b/src/handlers/logout.ts
@@ -13,13 +13,13 @@ function createLogoutUrl(settings: IAuth0Settings): string {
 }
 
 export default function logoutHandler(settings: IAuth0Settings, sessionSettings: CookieSessionStoreSettings) {
-  return async (_: IncomingMessage, res: ServerResponse): Promise<void> => {
+  return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
     if (!res) {
       throw new Error('Response is not available');
     }
 
     // Remove the cookies
-    setCookies(res, [
+    setCookies(req, res, [
       {
         name: 'a0:state',
         value: '',
diff --git a/src/session/cookie-store/index.ts b/src/session/cookie-store/index.ts
index 80c4bd18..a8cfe872 100644
--- a/src/session/cookie-store/index.ts
+++ b/src/session/cookie-store/index.ts
@@ -38,7 +38,7 @@ export default class CookieSessionStore implements ISessionStore {
    * Write the session to the cookie.
    * @param req HTTP request
    */
-  async save(_: IncomingMessage, res: ServerResponse, session: ISession): Promise<void> {
+  async save(req: IncomingMessage, res: ServerResponse, session: ISession): Promise<void> {
     const {
       cookieSecret, cookieName, cookiePath, cookieLifetime
     } = this.settings;
@@ -61,7 +61,7 @@ export default class CookieSessionStore implements ISessionStore {
     }
 
     const encryptedSession = await Iron.seal(persistedSession, cookieSecret, Iron.defaults);
-    setCookie(res, {
+    setCookie(req, res, {
       name: cookieName,
       value: encryptedSession,
       path: cookiePath,
diff --git a/src/utils/cookies.ts b/src/utils/cookies.ts
index 719d5d45..6af1083d 100644
--- a/src/utils/cookies.ts
+++ b/src/utils/cookies.ts
@@ -45,16 +45,37 @@ export function parseCookies(req: IncomingMessage): ICookies {
   return parse(cookie || '');
 }
 
+/**
+ * Based on the environment and the request we know if a secure cookie can be set.
+ */
+function isSecureEnvironment(req: IncomingMessage): boolean {
+  if (!req || !req.headers || !req.headers.host) {
+    throw new Error('The "host" request header is not available');
+  }
+
+  if (process.env.NODE_ENV !== 'production') {
+    return false;
+  }
+
+  const host = (req.headers.host.indexOf(':') > -1 && req.headers.host.split(':')[0]) || req.headers.host;
+  if (['localhost', '127.0.0.1'].indexOf(host) > -1) {
+    return false;
+  }
+
+  return true;
+}
+
 /**
  * Serialize a cookie to a string.
  * @param cookie The cookie to serialize
+ * @param secure Create a secure cookie.
  */
-function serializeCookie(cookie: ICookie): string {
+function serializeCookie(cookie: ICookie, secure: boolean): string {
   return serialize(cookie.name, cookie.value, {
     maxAge: cookie.maxAge,
     expires: new Date(Date.now() + cookie.maxAge * 1000),
     httpOnly: true,
-    secure: process.env.NODE_ENV === 'production',
+    secure,
     path: cookie.path
   });
 }
@@ -63,14 +84,14 @@ function serializeCookie(cookie: ICookie): string {
  * Set one or more cookies.
  * @param res The HTTP response on which the cookie will be set.
  */
-export function setCookies(res: ServerResponse, cookies: Array<ICookie>): void {
-  res.setHeader('Set-Cookie', cookies.map(serializeCookie));
+export function setCookies(req: IncomingMessage, res: ServerResponse, cookies: Array<ICookie>): void {
+  res.setHeader('Set-Cookie', cookies.map((c) => serializeCookie(c, isSecureEnvironment(req))));
 }
 
 /**
  * Set one or more cookies.
  * @param res The HTTP response on which the cookie will be set.
  */
-export function setCookie(res: ServerResponse, cookie: ICookie): void {
-  res.setHeader('Set-Cookie', serializeCookie(cookie));
+export function setCookie(req: IncomingMessage, res: ServerResponse, cookie: ICookie): void {
+  res.setHeader('Set-Cookie', serializeCookie(cookie, isSecureEnvironment(req)));
 }


Test patch:
diff --git a/tests/helpers/http.ts b/tests/helpers/http.ts
index 94c8e6b1..2d24fe84 100644
--- a/tests/helpers/http.ts
+++ b/tests/helpers/http.ts
@@ -10,9 +10,12 @@ export interface IHttpHelpers {
 }
 
 export default function getRequestResponse(): IHttpHelpers {
-  const req: any = new IncomingMessage(
+  const req: IncomingMessage = new IncomingMessage(
     new Socket()
   );
+  req.headers = {
+    host: 'localhost'
+  };
 
   const res: any = new ServerResponse(req);
   res.setHeader = jest.fn();
diff --git a/tests/session/cookie-store.test.ts b/tests/session/cookie-store.test.ts
index 5f3d7e90..20ba0652 100644
--- a/tests/session/cookie-store.test.ts
+++ b/tests/session/cookie-store.test.ts
@@ -1,7 +1,6 @@
-import { Socket } from 'net';
 import { parse } from 'cookie';
-import { ServerResponse, IncomingMessage } from 'http';
 
+import getRequestResponse from '../helpers/http';
 import CookieStore from '../../src/session/cookie-store';
 import CookieSessionStoreSettings from '../../src/session/cookie-store/settings';
 
@@ -16,22 +15,6 @@ describe('cookie store', () => {
     return store;
   };
 
-  const getRequestResponse = (): { req: IncomingMessage; res: ServerResponse; setHeaderFn: jest.Mock } => {
-    const setHeaderFn = jest.fn();
-    const req = new IncomingMessage(
-      new Socket()
-    );
-
-    const res = new ServerResponse(req);
-    res.setHeader = setHeaderFn;
-
-    return {
-      req,
-      res,
-      setHeaderFn
-    };
-  };
-
   describe('with cookie name', () => {
     describe('configured', () => {
       const store = getStore({
diff --git a/tests/utils/cookies.test.ts b/tests/utils/cookies.test.ts
new file mode 100644
index 00000000..62f35141
--- /dev/null
+++ b/tests/utils/cookies.test.ts
@@ -0,0 +1,94 @@
+import timekeeper from 'timekeeper';
+
+import getRequestResponse from '../helpers/http';
+import { setCookie } from '../../src/utils/cookies';
+
+const originalEnv = process.env.NODE_ENV;
+const timeString = 'Tue, 01 Jan 2019 01:17:41 GMT';
+
+describe('cookies', () => {
+  beforeEach(() => {
+    const time = new Date(1546304461001);
+    timekeeper.freeze(time);
+  });
+
+  afterEach(() => {
+    timekeeper.reset();
+    process.env.NODE_ENV = originalEnv;
+  });
+
+  describe('when running in production', () => {
+    beforeEach(() => {
+      process.env.NODE_ENV = 'production';
+    });
+
+    test('should set the cookie on the response', () => {
+      const { req, res } = getRequestResponse();
+
+      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`]
+      ]);
+    });
+
+    describe('when running on localhost', () => {
+      test('should not set a secure cookie on the response', async () => {
+        const { req, res } = getRequestResponse();
+        req.headers.host = 'localhost';
+
+        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`]
+        ]);
+      });
+    });
+
+    describe('when running on localhost with port', () => {
+      test('should not set a secure cookie on the response', async () => {
+        const { req, res } = getRequestResponse();
+        req.headers.host = 'localhost:3000';
+
+        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`]
+        ]);
+      });
+
+      describe('when not running on localhost', () => {
+        test('should set a secure cookie on the response', async () => {
+          const { req, res } = getRequestResponse();
+          req.headers.host = 'www.acme.com';
+
+          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; Secure`]
+          ]);
+        });
+      });
+    });
+  });
+});