Bug 1305421 - Implement chrome.identity, r?kmag draft
authorShane Caraveo <scaraveo@mozilla.com>
Mon, 14 Nov 2016 13:02:02 -0800
changeset 438598 d486b3c3035106e4725cae6a686fb6d59c3ef41c
parent 438469 feddafb5cb546b15b160260da8632beb6b89bd71
child 438613 3da49509a8ff4c169ddc07c8a7633d1ec911b510
child 438966 0012cc06d46786a83d6877534a3151f1a8dd2d01
push id35775
push usermixedpuppy@gmail.com
push dateMon, 14 Nov 2016 21:03:44 +0000
reviewerskmag
bugs1305421
milestone53.0a1
Bug 1305421 - Implement chrome.identity, r?kmag MozReview-Commit-ID: LCAbodtV4XZ
modules/libpref/init/all.js
toolkit/components/extensions/ext-c-identity.js
toolkit/components/extensions/extensions-toolkit.manifest
toolkit/components/extensions/jar.mn
toolkit/components/extensions/moz.build
toolkit/components/extensions/schemas/identity.json
toolkit/components/extensions/schemas/jar.mn
toolkit/components/extensions/test/mochitest/chrome.ini
toolkit/components/extensions/test/mochitest/oauth.html
toolkit/components/extensions/test/mochitest/redirect_auto.sjs
toolkit/components/extensions/test/mochitest/test_chrome_ext_identity.html
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -4711,16 +4711,18 @@ pref("xpinstall.whitelist.required", tru
 pref("xpinstall.signatures.required", false);
 pref("extensions.alwaysUnpack", false);
 pref("extensions.minCompatiblePlatformVersion", "2.0");
 pref("extensions.webExtensionsMinPlatformVersion", "42.0a1");
 
 // Other webextensions prefs
 pref("extensions.webextensions.keepStorageOnUninstall", false);
 pref("extensions.webextensions.keepUuidOnUninstall", false);
+// Redirect basedomain used by identity api
+pref("extensions.webextensions.identity.redirectDomain", "extensions.allizom.org");
 
 pref("network.buffer.cache.count", 24);
 pref("network.buffer.cache.size",  32768);
 
 // Desktop Notification
 pref("notification.feature.enabled", false);
 
 // Web Notification
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/ext-c-identity.js
@@ -0,0 +1,156 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* global redirectDomain */
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr, Constructor: CC} = Components;
+
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+                                  "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils",
+                                  "resource://services-common/utils.js");
+XPCOMUtils.defineLazyPreferenceGetter(this, "redirectDomain",
+                                      "extensions.webextensions.identity.redirectDomain");
+
+let CryptoHash = CC("@mozilla.org/security/hash;1", "nsICryptoHash", "initWithString");
+Cu.importGlobalProperties(["URL", "XMLHttpRequest", "TextEncoder"]);
+
+Cu.import("resource://gre/modules/ExtensionUtils.jsm");
+const {
+  promiseDocumentLoaded,
+} = ExtensionUtils;
+
+function computeHash(str) {
+  let byteArr = new TextEncoder().encode(str);
+  let hash = new CryptoHash("sha1");
+  hash.update(byteArr, byteArr.length);
+  return CommonUtils.bytesAsHex(hash.finish(false));
+}
+
+function checkRedirected(url, redirectURI) {
+  return new Promise((resolve, reject) => {
+    let xhr = new XMLHttpRequest();
+    xhr.open("HEAD", url);
+    // We expect this if the user has not authenticated.
+    xhr.onload = () => {
+      reject(0);
+    };
+    // An unexpected error happened, log for extension authors.
+    xhr.onerror = () => {
+      reject(xhr.status);
+    };
+    // Catch redirect to our redirect_uri before a new request is made.
+    xhr.channel.notificationCallbacks = {
+      QueryInterface: XPCOMUtils.generateQI([Ci.nsIInterfaceRequestor, Ci.nsIChannelEventSync]),
+
+      getInterface: XPCOMUtils.generateQI([Ci.nsIChannelEventSink]),
+
+      asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) {
+        let responseURL = newChannel.URI.spec;
+        if (responseURL.startsWith(redirectURI)) {
+          resolve(responseURL);
+          // Cancel the redirect.
+          callback.onRedirectVerifyCallback(Components.results.NS_BINDING_ABORTED);
+          return;
+        }
+        callback.onRedirectVerifyCallback(Components.results.NS_OK);
+      },
+    };
+    xhr.send();
+  });
+}
+
+function openOAuthWindow(details, redirectURI) {
+  let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+  let supportsStringPrefURL = Cc["@mozilla.org/supports-string;1"]
+                                .createInstance(Ci.nsISupportsString);
+  supportsStringPrefURL.data = details.url;
+  args.appendElement(supportsStringPrefURL, /* weak =*/ false);
+
+  let window = Services.ww.openWindow(null,
+                                      Services.prefs.getCharPref("browser.chromeURL"),
+                                      "launchWebAuthFlow_dialog",
+                                      "chrome,location=yes,centerscreen,dialog=no,resizable=yes",
+                                      args);
+
+  return new Promise((resolve, reject) => {
+    let wpl;
+
+    // If the user just closes the window we need to reject
+    function unloadlistener() {
+      window.removeEventListener("unload", unloadlistener);
+      window.gBrowser.removeTabsProgressListener(wpl);
+      reject({message: "User cancelled or denied access."});
+    }
+
+    wpl = {
+      onLocationChange(browser, webProgress, request, locationURI) {
+        if (locationURI.spec.startsWith(redirectURI)) {
+          resolve(locationURI.spec);
+          window.removeEventListener("unload", unloadlistener);
+          window.gBrowser.removeTabsProgressListener(wpl);
+          window.close();
+        }
+      },
+      onProgressChange() {},
+      onStatusChange() {},
+      onSecurityChange() {},
+    };
+
+    promiseDocumentLoaded(window.document).then(() => {
+      window.gBrowser.addTabsProgressListener(wpl);
+      window.addEventListener("unload", unloadlistener);
+    });
+  });
+}
+
+extensions.registerSchemaAPI("identity", "addon_child", context => {
+  let {extension} = context;
+  return {
+    identity: {
+      launchWebAuthFlow: function(details) {
+        // In OAuth2 the url should have a redirect_uri param, parse the url and grab it
+        let url, redirectURI;
+        try {
+          url = new URL(details.url);
+        } catch (e) {
+          return Promise.reject({message: "details.url is invalid"});
+        }
+        try {
+          redirectURI = new URL(url.searchParams.get("redirect_uri"));
+          if (!redirectURI) {
+            return Promise.reject({message: "redirect_uri is missing"});
+          }
+        } catch (e) {
+          return Promise.reject({message: "redirect_uri is invalid"});
+        }
+        if (!redirectURI.href.startsWith(this.getRedirectURL())) {
+          // Any url will work, but we suggest addons use getRedirectURL.
+          Services.console.logStringMessage("WebExtensions: redirect_uri should use browser.identity.getRedirectURL");
+        }
+
+        // If the request is automatically redirected the user has already
+        // authorized and we do not want to show the window.
+        return checkRedirected(details.url, redirectURI).catch((requestError) => {
+          // requestError is zero or xhr.status
+          if (requestError !== 0) {
+            Cu.reportError(`browser.identity auth check failed with ${requestError}`);
+            return Promise.reject({message: "Invalid request"});
+          }
+          if (!details.interactive) {
+            return Promise.reject({message: `Requires user interaction`});
+          }
+
+          return openOAuthWindow(details, redirectURI);
+        });
+      },
+
+      getRedirectURL: function(path = "") {
+        let hash = computeHash(extension.id);
+        let url = new URL(`https://${hash}.${redirectDomain}/`);
+        url.pathname = path;
+        return url.href;
+      },
+    },
+  };
+});
--- a/toolkit/components/extensions/extensions-toolkit.manifest
+++ b/toolkit/components/extensions/extensions-toolkit.manifest
@@ -20,28 +20,34 @@ category webextension-scripts-content i1
 category webextension-scripts-content runtime chrome://extensions/content/ext-c-runtime.js
 category webextension-scripts-content test chrome://extensions/content/ext-c-test.js
 category webextension-scripts-content storage chrome://extensions/content/ext-c-storage.js
 
 # scripts that must run in the same process as addon code.
 category webextension-scripts-addon backgroundPage chrome://extensions/content/ext-c-backgroundPage.js
 category webextension-scripts-addon extension chrome://extensions/content/ext-c-extension.js
 category webextension-scripts-addon i18n chrome://extensions/content/ext-i18n.js
+#ifndef ANDROID
+category webextension-scripts-addon identity chrome://extensions/content/ext-c-identity.js
+#endif
 category webextension-scripts-addon runtime chrome://extensions/content/ext-c-runtime.js
 category webextension-scripts-addon test chrome://extensions/content/ext-c-test.js
 category webextension-scripts-addon storage chrome://extensions/content/ext-c-storage.js
 
 # schemas
 category webextension-schemas alarms chrome://extensions/content/schemas/alarms.json
 category webextension-schemas cookies chrome://extensions/content/schemas/cookies.json
 category webextension-schemas downloads chrome://extensions/content/schemas/downloads.json
 category webextension-schemas events chrome://extensions/content/schemas/events.json
 category webextension-schemas extension chrome://extensions/content/schemas/extension.json
 category webextension-schemas extension_types chrome://extensions/content/schemas/extension_types.json
 category webextension-schemas i18n chrome://extensions/content/schemas/i18n.json
+#ifndef ANDROID
+category webextension-schemas identity chrome://extensions/content/schemas/identity.json
+#endif
 category webextension-schemas idle chrome://extensions/content/schemas/idle.json
 category webextension-schemas management chrome://extensions/content/schemas/management.json
 category webextension-schemas native_host_manifest chrome://extensions/content/schemas/native_host_manifest.json
 category webextension-schemas notifications chrome://extensions/content/schemas/notifications.json
 category webextension-schemas runtime chrome://extensions/content/schemas/runtime.json
 category webextension-schemas storage chrome://extensions/content/schemas/storage.json
 category webextension-schemas test chrome://extensions/content/schemas/test.json
 category webextension-schemas top_sites chrome://extensions/content/schemas/top_sites.json
--- a/toolkit/components/extensions/jar.mn
+++ b/toolkit/components/extensions/jar.mn
@@ -16,11 +16,14 @@ toolkit.jar:
     content/extensions/ext-webRequest.js
     content/extensions/ext-webNavigation.js
     content/extensions/ext-runtime.js
     content/extensions/ext-extension.js
     content/extensions/ext-storage.js
     content/extensions/ext-topSites.js
     content/extensions/ext-c-backgroundPage.js
     content/extensions/ext-c-extension.js
+#ifndef ANDROID
+    content/extensions/ext-c-identity.js
+#endif
     content/extensions/ext-c-runtime.js
     content/extensions/ext-c-storage.js
     content/extensions/ext-c-test.js
--- a/toolkit/components/extensions/moz.build
+++ b/toolkit/components/extensions/moz.build
@@ -16,17 +16,17 @@ EXTRA_JS_MODULES += [
     'ExtensionStorageSync.jsm',
     'ExtensionUtils.jsm',
     'LegacyExtensionsUtils.jsm',
     'MessageChannel.jsm',
     'NativeMessaging.jsm',
     'Schemas.jsm',
 ]
 
-EXTRA_COMPONENTS += [
+EXTRA_PP_COMPONENTS += [
     'extensions-toolkit.manifest',
 ]
 
 TESTING_JS_MODULES += [
     'ExtensionTestCommon.jsm',
     'ExtensionXPCShellUtils.jsm',
 ]
 
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/schemas/identity.json
@@ -0,0 +1,218 @@
+[
+  {
+    "namespace": "manifest",
+    "types": [
+      {
+        "$extend": "Permission",
+        "choices": [{
+          "type": "string",
+          "enum": [
+            "identity"
+          ]
+        }]
+      }
+    ]
+  },
+  {
+    "namespace": "identity",
+    "description": "Use the chrome.identity API to get OAuth2 access tokens. ",
+    "permissions": ["identity"],
+    "types": [
+      {
+        "id": "AccountInfo",
+        "type": "object",
+        "description": "An object encapsulating an OAuth account id.",
+        "properties": {
+          "id": {
+            "type": "string",
+            "description": "A unique identifier for the account. This ID will not change for the lifetime of the account. "
+          }
+        }
+      }
+    ],
+    "functions": [
+      {
+        "name": "getAccounts",
+        "type": "function",
+        "unsupported": true,
+        "description": "Retrieves a list of AccountInfo objects describing the accounts present on the profile.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "callback",
+            "type": "function",
+            "parameters": [
+              {
+                "name": "results",
+                "type": "array",
+                "items": {
+                  "$ref": "AccountInfo"
+                }
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "name": "getAuthToken",
+        "type": "function",
+        "unsupported": true,
+        "description": "Gets an OAuth2 access token using the client ID and scopes specified in the oauth2 section of manifest.json.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "optional": true,
+            "type": "object",
+            "properties": {
+              "interactive": {
+                "optional": true,
+                "type": "boolean"
+              },
+              "account": {
+                "optional": true,
+                "$ref": "AccountInfo"
+              },
+              "scopes": {
+                "optional": true,
+                "type": "array",
+                "items": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          {
+            "name": "callback",
+            "optional": true,
+            "type": "function",
+            "parameters": [
+              {
+                "name": "results",
+                "type": "array",
+                "items": {
+                  "$ref": "AccountInfo"
+                }
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "name": "getProfileUserInfo",
+        "type": "function",
+        "unsupported": true,
+        "description": "Retrieves email address and obfuscated gaia id of the user signed into a profile.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "callback",
+            "type": "function",
+            "parameters": [
+              {
+                "name": "userinfo",
+                "type": "object",
+                "properties": {
+                  "email": {"type": "string"},
+                  "id": { "type": "string" }
+                }
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "name": "removeCachedAuthToken",
+        "type": "function",
+        "unsupported": true,
+        "description": "Removes an OAuth2 access token from the Identity API's token cache.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "type": "object",
+            "properties": {
+              "token": {"type": "string"}
+            }
+          },
+          {
+            "name": "callback",
+            "optional": true,
+            "type": "function",
+            "parameters": [
+              {
+                "name": "userinfo",
+                "type": "object",
+                "properties": {
+                  "email": {"type": "string"},
+                  "id": { "type": "string" }
+                }
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "name": "launchWebAuthFlow",
+        "type": "function",
+        "description": "Starts an auth flow at the specified URL.",
+        "async": "callback",
+        "parameters": [
+          {
+            "name": "details",
+            "type": "object",
+            "properties": {
+              "url": {"type": "string"},
+              "interactive": {"type": "boolean", "optional": true}
+            }
+          },
+          {
+            "name": "callback",
+            "type": "function",
+            "parameters": [
+              {
+                "name": " responseUrl",
+                "type": "string",
+                "optional": true
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "name": "getRedirectURL",
+        "type": "function",
+        "description": "Generates a redirect URL to be used in |launchWebAuthFlow|.",
+        "parameters": [
+          {
+            "name": " path",
+            "type": "string",
+            "optional": true,
+            "description": "The path appended to the end of the generated URL. "
+          }
+        ],
+        "returns": {
+          "string": "path"
+        }
+      }
+    ],
+    "events": [
+      {
+        "name": "onSignInChanged",
+        "unsupported": true,
+        "type": "function",
+        "description": "Fired when signin state changes for an account on the user's profile.",
+        "parameters": [
+          {
+            "name": "account",
+            "$ref": "AccountInfo"
+          },
+          {
+            "name": "signedIn",
+            "type": "boolean"
+          }
+        ]
+      }
+    ]
+  }
+]
--- a/toolkit/components/extensions/schemas/jar.mn
+++ b/toolkit/components/extensions/schemas/jar.mn
@@ -7,16 +7,19 @@ toolkit.jar:
     content/extensions/schemas/alarms.json
     content/extensions/schemas/cookies.json
     content/extensions/schemas/downloads.json
     content/extensions/schemas/events.json
     content/extensions/schemas/experiments.json
     content/extensions/schemas/extension.json
     content/extensions/schemas/extension_types.json
     content/extensions/schemas/i18n.json
+#ifndef ANDROID
+    content/extensions/schemas/identity.json
+#endif
     content/extensions/schemas/idle.json
     content/extensions/schemas/management.json
     content/extensions/schemas/manifest.json
     content/extensions/schemas/native_host_manifest.json
     content/extensions/schemas/notifications.json
     content/extensions/schemas/runtime.json
     content/extensions/schemas/storage.json
     content/extensions/schemas/test.json
--- a/toolkit/components/extensions/test/mochitest/chrome.ini
+++ b/toolkit/components/extensions/test/mochitest/chrome.ini
@@ -1,15 +1,17 @@
 [DEFAULT]
 support-files =
   chrome_head.js
   head.js
   file_sample.html
   webrequest_chromeworker.js
   webrequest_test.jsm
+  oauth.html
+  redirect_auto.sjs
 tags = webextensions
 
 [test_chrome_ext_background_debug_global.html]
 skip-if = (os == 'android') # android doesn't have devtools
 [test_chrome_ext_background_page.html]
 skip-if = (toolkit == 'android') # android doesn't have devtools
 [test_chrome_ext_eventpage_warning.html]
 [test_chrome_ext_contentscript_unrecognizedprop_warning.html]
@@ -23,11 +25,13 @@ skip-if = (os == 'android') # browser.ta
 skip-if = os != "mac" && os != "linux"
 [test_ext_cookies_expiry.html]
 [test_ext_cookies_permissions.html]
 [test_ext_cookies_containers.html]
 [test_ext_jsversion.html]
 [test_ext_schema.html]
 [test_chrome_ext_storage_cleanup.html]
 [test_chrome_ext_idle.html]
+[test_chrome_ext_identity.html]
+skip-if = os == 'android' # unsupported.
 [test_chrome_ext_downloads_saveAs.html]
 [test_chrome_ext_webrequest_background_events.html]
 skip-if = os == 'android' # webrequest api unsupported (bug 1258975).
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/oauth.html
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <script>
+  "use strict";
+
+  var url = new URL(location);
+  var end = new URL(url.searchParams.get("redirect_uri"));
+  end.searchParams.set("access_token", "here ya go");
+  location.href = end.href;
+  </script>
+</head>
+<body>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/redirect_auto.sjs
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+Components.utils.importGlobalProperties(["URLSearchParams", "URL"]);
+
+function handleRequest(request, response) {
+  let params = new URLSearchParams(request.queryString);
+  if (params.has("no_redirect")) {
+    response.setStatusLine(request.httpVersion, 200, "OK");
+    response.write("ok");
+  } else {
+    response.setStatusLine(request.httpVersion, 302, "Moved Temporarily");
+    let url = new URL(params.get("redirect_uri"));
+    url.searchParams.set("access_token", "here ya go");
+    response.setHeader("Location", url.href);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_identity.html
@@ -0,0 +1,200 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>Test for WebExtension Identity</title>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SpawnTask.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+  <script type="text/javascript" src="chrome_head.js"></script>
+  <script type="text/javascript" src="head.js"></script>
+  <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(function* setup() {
+  yield SpecialPowers.pushPrefEnv({
+    set: [["extensions.webextensions.identity.redirectDomain", "example.com"]],
+  });
+});
+
+add_task(function* test_noPermission() {
+  let extension = ExtensionTestUtils.loadExtension({
+    background() {
+      browser.test.assertEq(undefined, browser.identity, "No identity api without permission");
+      browser.test.sendMessage("done");
+    },
+  });
+
+  yield extension.startup();
+  yield extension.awaitMessage("done");
+  yield extension.unload();
+});
+
+add_task(function* test_badAuthURI() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "permissions": [
+        "identity",
+        "https://example.com/",
+      ],
+    },
+    async background() {
+      await browser.test.assertRejects(browser.identity.launchWebAuthFlow({interactive: true, url: "foobar"}),
+                                       "details.url is invalid", "invalid param url");
+      browser.test.sendMessage("done");
+    },
+  });
+
+  yield extension.startup();
+  yield extension.awaitMessage("done");
+  yield extension.unload();
+});
+
+
+add_task(function* test_badRequestURI() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "permissions": [
+        "identity",
+        "https://example.com/",
+      ],
+    },
+    async background() {
+      let base_uri = "https://example.com/chrome/toolkit/components/extensions/test/mochitest/";
+      let url = `${base_uri}?redirect_uri=badrobot}`;
+      await browser.test.assertRejects(browser.identity.launchWebAuthFlow({interactive: true, url}),
+                                       "redirect_uri is invalid", "invalid redirect url");
+      browser.test.sendMessage("done");
+    },
+  });
+
+  yield extension.startup();
+  yield extension.awaitMessage("done");
+  yield extension.unload();
+});
+
+add_task(function* test_otherRedirectURL() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "permissions": [
+        "identity",
+        "https://example.com/",
+      ],
+    },
+    async background() {
+      let base_uri = "https://example.com/chrome/toolkit/components/extensions/test/mochitest/";
+      let url = `${base_uri}?redirect_uri=https://somesite.com/redirect`;
+      await browser.test.assertRejects(browser.identity.launchWebAuthFlow({interactive: false, url}),
+                                       "Requires user interaction", "alternate redirect_uri ok");
+      browser.test.sendMessage("done");
+    },
+  });
+
+  yield extension.startup();
+  yield extension.awaitMessage("done");
+  yield extension.unload();
+});
+
+function background_launchWebAuthFlow(interactive, path, redirect = true) {
+  let expected_redirect = "https://35b64b676900f491c00e7f618d43f7040e88422e.example.com/identity_cb";
+  let base_uri = "https://example.com/chrome/toolkit/components/extensions/test/mochitest/";
+  let redirect_uri = browser.identity.getRedirectURL("/identity_cb");
+  browser.test.assertEq(expected_redirect, redirect_uri, "expected redirect uri matches hash");
+  let url = `${base_uri}${path}?redirect_uri=${encodeURIComponent(redirect_uri)}`;
+  if (!redirect) {
+    url = `${url}&no_redirect=1`;
+  }
+
+  browser.identity.launchWebAuthFlow({interactive, url}).then((redirectURL) => {
+    browser.test.assertTrue(redirectURL.startsWith(redirect_uri), `correct redirect url ${redirectURL}`);
+    if (redirect) {
+      let url = new URL(redirectURL);
+      browser.test.assertEq("here ya go", url.searchParams.get("access_token"), "Handled auto redirection");
+    }
+    browser.test.sendMessage("done");
+  }).catch((error) => {
+    if (redirect) {
+      browser.test.fail(error.message);
+    } else {
+      browser.test.assertEq("Requires user interaction", error.message, "Auth page loaded, interaction required.");
+    }
+    browser.test.sendMessage("done");
+  });
+}
+
+// Tests the situation where the oauth provider has already granted access and
+// simply redirects the oauth client to provide the access key or code.
+add_task(function* test_autoRedirect() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "applications": {
+        "gecko": {
+          "id": "identity@mozilla.org",
+        },
+      },
+      "permissions": [
+        "identity",
+        "https://example.com/",
+      ],
+    },
+    background: `(${background_launchWebAuthFlow})(false, "redirect_auto.sjs")`,
+  });
+
+  yield extension.startup();
+  yield extension.awaitMessage("done");
+  yield extension.unload();
+});
+
+// Tests the situation where the oauth provider has not granted access and interactive=false
+add_task(function* test_noRedirect() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "applications": {
+        "gecko": {
+          "id": "identity@mozilla.org",
+        },
+      },
+      "permissions": [
+        "identity",
+        "https://example.com/",
+      ],
+    },
+    background: `(${background_launchWebAuthFlow})(false, "redirect_auto.sjs", false)`,
+  });
+
+  yield extension.startup();
+  yield extension.awaitMessage("done");
+  yield extension.unload();
+});
+
+// Tests the situation where the oauth provider must show a window where
+// presumably the user interacts, then the redirect occurs and access key or
+// code is provided.  We bypass any real interaction, but want the window to
+// open and result in a redirect.
+add_task(function* test_interaction() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "applications": {
+        "gecko": {
+          "id": "identity@mozilla.org",
+        },
+      },
+      "permissions": [
+        "identity",
+        "https://example.com/",
+      ],
+    },
+    background: `(${background_launchWebAuthFlow})(true, "oauth.html")`,
+  });
+
+  yield extension.startup();
+  yield extension.awaitMessage("done");
+  yield extension.unload();
+});
+</script>
+
+</body>
+</html>