Repository: auth0/auth0.js
Pull request #669 (2018-02-16 15:35:08)

Use WinChan on popup.callback again + adding origin check to keep it secure
fix https://github.com/auth0/auth0.js/issues/651


Patch:
diff --git a/src/helper/object.js b/src/helper/object.js
index f82fb7b..06366b2 100644
--- a/src/helper/object.js
+++ b/src/helper/object.js
@@ -4,7 +4,6 @@
 
 var assert = require('./assert');
 var objectAssign = require('./object-assign');
-var windowHelper = require('./window');
 
 function pick(object, keys) {
   return keys.reduce(function(prev, key) {
@@ -118,16 +117,32 @@ function toCamelCase(object, exceptions) {
   }, {});
 }
 
+function getLocationFromUrl(href) {
+  var match = href.match(
+    /^(https?:)\/\/(([^:/?#]*)(?::([0-9]+))?)([/]{0,1}[^?#]*)(\?[^#]*|)(#.*|)$/
+  );
+  return (
+    match && {
+      href: href,
+      protocol: match[1],
+      host: match[2],
+      hostname: match[3],
+      port: match[4],
+      pathname: match[5],
+      search: match[6],
+      hash: match[7]
+    }
+  );
+}
+
 function getOriginFromUrl(url) {
   if (!url) {
     return undefined;
   }
-  var doc = windowHelper.getDocument();
-  var anchor = doc.createElement('a');
-  anchor.href = url;
-  var origin = anchor.protocol + '//' + anchor.hostname;
-  if (anchor.port) {
-    origin += ':' + anchor.port;
+  var parsed = getLocationFromUrl(url);
+  var origin = parsed.protocol + '//' + parsed.hostname;
+  if (parsed.port) {
+    origin += ':' + parsed.port;
   }
   return origin;
 }
@@ -140,5 +155,6 @@ module.exports = {
   pick: pick,
   getKeysNotIn: getKeysNotIn,
   extend: extend,
-  getOriginFromUrl: getOriginFromUrl
+  getOriginFromUrl: getOriginFromUrl,
+  getLocationFromUrl: getLocationFromUrl
 };
diff --git a/src/helper/window.js b/src/helper/window.js
index 2899c15..70e7a8e 100644
--- a/src/helper/window.js
+++ b/src/helper/window.js
@@ -1,3 +1,5 @@
+var objectHelper = require('./object');
+
 function redirect(url) {
   global.window.location = url;
 }
@@ -14,8 +16,7 @@ function getOrigin() {
   var location = global.window.location;
   var origin = location.origin;
   if (!origin) {
-    origin =
-      location.protocol + '//' + location.hostname + (location.port ? ':' + location.port : '');
+    origin = objectHelper.getOriginFromUrl(location.href);
   }
   return origin;
 }
diff --git a/src/web-auth/popup.js b/src/web-auth/popup.js
index 2e27d4c..47f9f36 100644
--- a/src/web-auth/popup.js
+++ b/src/web-auth/popup.js
@@ -1,4 +1,5 @@
 var urljoin = require('url-join');
+var WinChan = require('winchan');
 
 var urlHelper = require('../helper/url');
 var assert = require('../helper/assert');
@@ -84,17 +85,41 @@ Popup.prototype.getPopupHandler = function(options, preload) {
  */
 Popup.prototype.callback = function(options) {
   var _this = this;
+  var theWindow = windowHelper.getWindow();
   options = options || {};
-  var originUrl =
-    options.popupOrigin || this.baseOptions.popupOrigin || windowHelper.getWindow().origin;
-  _this.webAuth.parseHash(options || {}, function(err, data) {
-    // {a, d} is WinChan's message format.
-    // We have to keep the same format because we're opening the popup with WinChan.
-    var response = { a: 'response', d: data };
-    if (err) {
-      response = { a: 'error', d: err };
+  var originUrl = options.popupOrigin || this.baseOptions.popupOrigin || windowHelper.getOrigin();
+
+  /*
+    in IE 11, there's a bug that makes window.opener return undefined.
+    The callback page will still call `popup.callback()` which will run this method
+    in the relay page. WinChan expects the relay page to have a global `doPost` function,
+    which will be called with the response.
+
+    IE11 Bug: https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/110920/
+   */
+  if (!theWindow.opener) {
+    theWindow.doPost = function(msg) {
+      if (theWindow.parent) {
+        theWindow.parent.postMessage(msg, originUrl);
+      }
+    };
+    return;
+  }
+
+  WinChan.onOpen(function(popupOrigin, r, cb) {
+    if (popupOrigin !== originUrl) {
+      return cb({
+        error: 'origin_mismatch',
+        error_description: "The popup's origin (" +
+          popupOrigin +
+          ') should match the `popupOrigin` parameter (' +
+          originUrl +
+          ').'
+      });
     }
-    windowHelper.getWindow().opener.postMessage(JSON.stringify(response), originUrl);
+    _this.webAuth.parseHash(options || {}, function(err, data) {
+      return cb(err || data);
+    });
   });
 };
 


Test patch:
diff --git a/test/helper/object.test.js b/test/helper/object.test.js
index 440ba87..15b212f 100644
--- a/test/helper/object.test.js
+++ b/test/helper/object.test.js
@@ -523,41 +523,51 @@ describe('helpers', function() {
       expect(objectHelper.getOriginFromUrl(null)).to.be(undefined);
     });
     it('should use an anchor to parse the url and return the origin', function() {
-      var anchor = {
-        protocol: 'https:',
-        hostname: 'test.com'
-      };
-      stub(windowHelper, 'getDocument', function() {
-        return {
-          createElement: function createElement(e) {
-            expect(e).to.be('a');
-            return anchor;
-          }
-        };
-      });
       var url = 'https://test.com/example';
       expect(objectHelper.getOriginFromUrl(url)).to.be('https://test.com');
-      expect(anchor.href).to.be(url);
-      windowHelper.getDocument.restore();
     });
     it('should use add the `port` when available', function() {
-      var anchor = {
-        protocol: 'https:',
-        hostname: 'localhost',
-        port: 3000
-      };
-      stub(windowHelper, 'getDocument', function() {
-        return {
-          createElement: function createElement(e) {
-            expect(e).to.be('a');
-            return anchor;
-          }
-        };
-      });
       var url = 'https://localhost:3000/example';
       expect(objectHelper.getOriginFromUrl(url)).to.be('https://localhost:3000');
-      expect(anchor.href).to.be(url);
-      windowHelper.getDocument.restore();
     });
   });
+  describe('getLocationFromUrl', function() {
+    const mapping = {
+      'https://localhost:3000/foo?id=1': {
+        href: 'https://localhost:3000/foo?id=1',
+        protocol: 'https:',
+        host: 'localhost:3000',
+        hostname: 'localhost',
+        port: '3000',
+        pathname: '/foo',
+        search: '?id=1',
+        hash: ''
+      },
+      'https://auth0.com/foo': {
+        href: 'https://auth0.com/foo',
+        protocol: 'https:',
+        host: 'auth0.com',
+        hostname: 'auth0.com',
+        port: undefined,
+        pathname: '/foo',
+        search: '',
+        hash: ''
+      },
+      'https://auth0.com#access_token=foo': {
+        href: 'https://auth0.com#access_token=foo',
+        protocol: 'https:',
+        host: 'auth0.com',
+        hostname: 'auth0.com',
+        port: undefined,
+        pathname: '',
+        search: '',
+        hash: '#access_token=foo'
+      }
+    };
+    for (const url in mapping) {
+      it('should map urls correctly: ' + url, function() {
+        expect(objectHelper.getLocationFromUrl(url)).to.be.eql(mapping[url]);
+      });
+    }
+  });
 });
diff --git a/test/helper/window.test.js b/test/helper/window.test.js
index 2f95de2..183752a 100644
--- a/test/helper/window.test.js
+++ b/test/helper/window.test.js
@@ -28,7 +28,7 @@ describe('helpers window', function() {
       expect(windowHelper.getOrigin()).to.be('origin');
     });
     it('should build current origin when location.origin is not available', function() {
-      global.window = { location: { protocol: 'http:', hostname: 'hostname', port: 30 } };
+      global.window = { location: { href: 'http://hostname:30/foobar' } };
       expect(windowHelper.getOrigin()).to.be('http://hostname:30');
     });
   });
diff --git a/test/web-auth/popup.test.js b/test/web-auth/popup.test.js
index 8b0a514..7a6a4c1 100644
--- a/test/web-auth/popup.test.js
+++ b/test/web-auth/popup.test.js
@@ -2,6 +2,7 @@ var expect = require('expect.js');
 var stub = require('sinon').stub;
 var spy = require('sinon').spy;
 var URL = require('url');
+var WinChan = require('winchan');
 
 var RequestMock = require('../mock/request-mock');
 var request = require('superagent');
@@ -600,83 +601,130 @@ describe('auth0.WebAuth.popup', function() {
   });
 
   context('callback', function() {
-    var windowOpenerPostMessageSpy;
     beforeEach(function() {
-      windowOpenerPostMessageSpy = spy();
       stub(windowHandler, 'getWindow', function() {
         return {
-          origin: 'https://window.origin.com',
-          opener: {
-            postMessage: windowOpenerPostMessageSpy
-          }
+          opener: {}
         };
       });
     });
     afterEach(function() {
-      this.auth0.parseHash.restore();
+      if (WinChan.onOpen.restore) {
+        WinChan.onOpen.restore();
+      }
+      if (this.auth0.parseHash.restore) {
+        this.auth0.parseHash.restore();
+      }
+      if (windowHandler.getOrigin.restore) {
+        windowHandler.getOrigin.restore();
+      }
       windowHandler.getWindow.restore();
     });
-    context('when parseHash is successfull', function() {
-      beforeEach(function() {
-        stub(this.auth0, 'parseHash', function(options, cb) {
-          cb(null, { accessToken: 'accessToken' });
-        });
-      });
-      it('should call window.opener.postMessage with correct result ', function() {
-        this.auth0.popup.callback();
-        expect(windowOpenerPostMessageSpy.calledOnce).to.be(true);
-        expect(windowOpenerPostMessageSpy.firstCall.args[0]).to.be.eql(
-          JSON.stringify({
-            a: 'response',
-            d: { accessToken: 'accessToken' }
-          })
-        );
+    it('sends parseHash result to the callback when there is no error', function(done) {
+      stub(this.auth0, 'parseHash', function(options, cb) {
+        cb(null, { accessToken: 'accessToken' });
       });
-      it('should use options.popupOrigin in postMessage', function() {
-        this.auth0.popup.callback({ popupOrigin: 'https://options.popupOrigin.com' });
-        expect(windowOpenerPostMessageSpy.calledOnce).to.be(true);
-        expect(windowOpenerPostMessageSpy.firstCall.args[1]).to.be(
-          'https://options.popupOrigin.com'
-        );
+      stub(WinChan, 'onOpen', function(onOpenCallback) {
+        onOpenCallback('https://baseoptions.popupOrigin.com', null, function(result) {
+          expect(result).to.be.eql({ accessToken: 'accessToken' });
+          done();
+        });
       });
-      it('should use baseoptions.popupOrigin in postMessage when options.popupOrigin is not available', function() {
-        this.auth0.popup.callback();
-        expect(windowOpenerPostMessageSpy.calledOnce).to.be(true);
-        expect(windowOpenerPostMessageSpy.firstCall.args[1]).to.be(
-          'https://baseoptions.popupOrigin.com'
-        );
+
+      this.auth0.popup.callback();
+    });
+    it('sends parseHash result to the callback when there is an error', function(done) {
+      stub(this.auth0, 'parseHash', function(options, cb) {
+        cb({ error: 'any_error', error_description: 'a big error message' });
       });
-      it('should use window.origin in postMessage when options.popupOrigin and baseoptions.popupOrigin are not available', function() {
-        auth0 = new WebAuth({
-          domain: 'me.auth0.com',
-          clientID: '...',
-          redirectUri: 'http://page.com/callback',
-          responseType: 'id_token',
-          _sendTelemetry: false
-        });
-        stub(auth0, 'parseHash', function(options, cb) {
-          cb(null, { accessToken: 'accessToken' });
+      stub(WinChan, 'onOpen', function(onOpenCallback) {
+        onOpenCallback('https://baseoptions.popupOrigin.com', null, function(result) {
+          expect(result).to.be.eql({
+            error: 'any_error',
+            error_description: 'a big error message'
+          });
+          done();
         });
+      });
 
-        auth0.popup.callback();
+      this.auth0.popup.callback();
+    });
+    it('validates origin with baseOptions.popupOrigin', function(done) {
+      stub(WinChan, 'onOpen', function(onOpenCallback) {
+        onOpenCallback('https://notBaseOptions.popupOrigin.com', null, function(result) {
+          expect(result).to.be.eql({
+            error: 'origin_mismatch',
+            error_description: "The popup's origin (https://notBaseOptions.popupOrigin.com) should match the `popupOrigin` parameter (https://baseoptions.popupOrigin.com)."
+          });
+          done();
+        });
+      });
 
-        expect(windowOpenerPostMessageSpy.calledOnce).to.be(true);
-        expect(windowOpenerPostMessageSpy.firstCall.args[1]).to.be('https://window.origin.com');
+      this.auth0.popup.callback();
+    });
+    it('validates origin with options.popupOrigin', function(done) {
+      stub(WinChan, 'onOpen', function(onOpenCallback) {
+        onOpenCallback('https://notOptions.popupOrigin.com', null, function(result) {
+          expect(result).to.be.eql({
+            error: 'origin_mismatch',
+            error_description: "The popup's origin (https://notOptions.popupOrigin.com) should match the `popupOrigin` parameter (https://options.popupOrigin.com)."
+          });
+          done();
+        });
       });
+
+      this.auth0.popup.callback({ popupOrigin: 'https://options.popupOrigin.com' });
     });
-    context('when parseHash returns an error', function() {
-      beforeEach(function() {
-        stub(this.auth0, 'parseHash', function(options, cb) {
-          cb({ error: 'some_error' });
+    it('validates origin with windowHelper.getOrigin()', function(done) {
+      var auth0 = new WebAuth({
+        domain: 'me.auth0.com',
+        clientID: '...',
+        redirectUri: 'http://page.com/callback',
+        responseType: 'id_token',
+        _sendTelemetry: false
+      });
+      stub(windowHandler, 'getOrigin', function() {
+        return 'https://window.popupOrigin.com';
+      });
+      stub(WinChan, 'onOpen', function(onOpenCallback) {
+        onOpenCallback('https://notWindow.popupOrigin.com', null, function(result) {
+          expect(result).to.be.eql({
+            error: 'origin_mismatch',
+            error_description: "The popup's origin (https://notWindow.popupOrigin.com) should match the `popupOrigin` parameter (https://window.popupOrigin.com)."
+          });
+          done();
         });
       });
-      it('should call window.opener.postMessage with correct result ', function() {
-        this.auth0.popup.callback();
-        expect(windowOpenerPostMessageSpy.calledOnce).to.be(true);
-        expect(windowOpenerPostMessageSpy.firstCall.args[0]).to.be.eql(
-          JSON.stringify({ a: 'error', d: { error: 'some_error' } })
-        );
+
+      auth0.popup.callback();
+    });
+    it('handles window.opener being undefined', function(done) {
+      windowHandler.getWindow.restore();
+      var theWindow = {
+        opener: undefined,
+        parent: {
+          postMessage: function(msg, origin) {
+            expect(msg).to.be.eql({ from: 'winchan' });
+            expect(origin).to.be('https://window.popupOrigin.com');
+            done();
+          }
+        }
+      };
+      stub(windowHandler, 'getWindow', function() {
+        return theWindow;
+      });
+      stub(windowHandler, 'getOrigin', function() {
+        return 'https://window.popupOrigin.com';
+      });
+      var auth0 = new WebAuth({
+        domain: 'me.auth0.com',
+        clientID: '...',
+        redirectUri: 'http://page.com/callback',
+        responseType: 'id_token',
+        _sendTelemetry: false
       });
+      auth0.popup.callback();
+      theWindow.doPost({ from: 'winchan' });
     });
   });
 });