Bug 1715923 - Test address book and calendar OAuth2 authentication. r=mkmelin
authorGeoff Lankow <geoff@darktrojan.net>
Fri, 11 Jun 2021 13:38:51 +0300
changeset 32793 6e566c0786603484844de111e5b21f2973b82358
parent 32792 216ae92d031799c0863840da57c518159819f424
child 32794 b88e52a88b7d5e6bf5c0a390b01c421ba2d2cb9a
push id18866
push usermkmelin@iki.fi
push dateFri, 11 Jun 2021 10:41:09 +0000
treeherdercomm-central@a009a421cf65 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmkmelin
bugs1715923
Bug 1715923 - Test address book and calendar OAuth2 authentication. r=mkmelin We need to know if anything is going to break in the changes we're about to make. Differential Revision: https://phabricator.services.mozilla.com/D117488
calendar/providers/caldav/modules/CalDavSession.jsm
calendar/test/browser/browser.ini
calendar/test/browser/browser_calDAV_oAuth.js
mailnews/addrbook/modules/CardDAVDirectory.jsm
mailnews/addrbook/test/browser/browser.ini
mailnews/addrbook/test/browser/browser_cardDAV_oAuth.js
mailnews/addrbook/test/browser/data/auth_headers.sjs
mailnews/addrbook/test/browser/data/redirect_auto.sjs
mailnews/addrbook/test/browser/data/token.sjs
mailnews/addrbook/test/moz.build
mailnews/base/src/OAuth2Module.jsm
mailnews/base/src/OAuth2Providers.jsm
--- a/calendar/providers/caldav/modules/CalDavSession.jsm
+++ b/calendar/providers/caldav/modules/CalDavSession.jsm
@@ -39,36 +39,43 @@ class CalDavGoogleOAuth extends OAuth2 {
       OAUTH_HASH
     );
     /*  eslint-enable no-undef */
 
     this.id = sessionId;
     this.origin = "oauth:" + sessionId;
     this.pwMgrId = "Google CalDAV v2";
 
-    // If no token is found for "Google CalDAV v2", this is either a new session (in which case
-    // it should use Thunderbird's credentials) or it's already using Thunderbird's credentials.
-    if (!this.refreshToken) {
-      [this.clientId, this.consumerSecret] = OAuth2Providers.getIssuerDetails(
-        "accounts.google.com"
-      );
-      this.origin = "oauth://accounts.google.com";
-      this.pwMgrId = "https://www.googleapis.com/auth/calendar";
-    }
+    this._maybeUpgrade(name);
 
     this.requestWindowTitle = cal.l10n.getAnyString(
       "global",
       "commonDialogs",
       "EnterUserPasswordFor2",
       [name]
     );
     this.extraAuthParams = [["login_hint", name]];
   }
 
   /**
+   * If no token is found for "Google CalDAV v2", this is either a new session (in which case
+   * it should use Thunderbird's credentials) or it's already using Thunderbird's credentials.
+   * Detect those situations and switch credentials if necessary.
+   */
+  _maybeUpgrade() {
+    if (!this.refreshToken) {
+      [this.clientId, this.consumerSecret] = OAuth2Providers.getIssuerDetails(
+        "accounts.google.com"
+      );
+      this.origin = "oauth://accounts.google.com";
+      this.pwMgrId = "https://www.googleapis.com/auth/calendar";
+    }
+  }
+
+  /**
    * Returns true if the token has expired, or will expire within the grace time.
    */
   get tokenExpired() {
     let now = new Date().getTime();
     return this.tokenExpires - OAUTH_GRACE_TIME < now;
   }
 
   /**
@@ -231,16 +238,48 @@ class CalDavGoogleOAuth extends OAuth2 {
 
       return CalDavSession.RESTART_REQUEST;
     }
     return null;
   }
 }
 
 /**
+ * A modified version of CalDavGoogleOAuth for testing. This class mimics the
+ * real class as closely as possible.
+ */
+class CalDavTestOAuth extends CalDavGoogleOAuth {
+  constructor(sessionId, name) {
+    super(sessionId, name);
+
+    // Override these values with test values.
+    this.authorizationEndpoint =
+      "http://mochi.test:8888/browser/comm/mailnews/addrbook/test/browser/data/redirect_auto.sjs";
+    this.tokenEndpoint =
+      "http://mochi.test:8888/browser/comm/mailnews/addrbook/test/browser/data/token.sjs";
+    this.scope = "test_scope";
+    this.clientId = "test_client_id";
+    this.consumerSecret = "test_scope";
+
+    // I don't know why, but tests refuse to work with a plain HTTP endpoint
+    // (the request is redirected to HTTPS, which we're not listening to).
+    // Just use an HTTPS endpoint.
+    this.redirectionEndpoint = "https://localhost";
+  }
+
+  _maybeUpgrade() {
+    if (!this.refreshToken) {
+      [this.clientId, this.consumerSecret] = OAuth2Providers.getIssuerDetails("mochi.test");
+      this.origin = "oauth://mochi.test";
+      this.pwMgrId = "test_scope";
+    }
+  }
+}
+
+/**
  * A session for the caldav provider. Two or more calendars can share a session if they have the
  * same auth credentials.
  */
 class CalDavSession {
   QueryInterface = ChromeUtils.generateQI(["nsIInterfaceRequestor"]);
 
   /**
    * Constant returned by |completeRequest| when the request should be restarted
@@ -258,16 +297,17 @@ class CalDavSession {
    */
   constructor(aSessionId, aName) {
     this.id = aSessionId;
     this.name = aName;
 
     // There is only one right now, but for better separation this is ready for more oauth hosts
     this.authAdapters = {
       "apidata.googleusercontent.com": new CalDavGoogleOAuth(aSessionId, aName),
+      "mochi.test": new CalDavTestOAuth(aSessionId, aName),
     };
   }
 
   /**
    * Implement nsIInterfaceRequestor. The base class has no extra interfaces, but a subclass of
    * the session may.
    *
    * @param {nsIIDRef} aIID       The IID of the interface being requested
--- a/calendar/test/browser/browser.ini
+++ b/calendar/test/browser/browser.ini
@@ -1,22 +1,26 @@
 [default]
 head = head.js
 prefs =
   calendar.timezone.local=UTC
   mail.provider.suppress_dialog_on_startup=true
   mail.spotlight.firstRunDone=true
   mail.winsearch.firstRunDone=true
+  mailnews.oauth.loglevel=Debug
   mailnews.start_page.override_url=about:blank
   mailnews.start_page.url=about:blank
+  signon.rememberSignons=true
 subsuite = thunderbird
 support-files = data/**
 
 [browser_basicFunctionality.js]
 skip-if = os == 'win' # Bug 306495
+[browser_calDAV_oAuth.js]
+tags = oauth
 [browser_calendarList.js]
 [browser_calendarTelemetry.js]
 [browser_calendarUnifinder.js]
 [browser_dragEventItem.js]
 [browser_eventDisplay.js]
 [browser_import.js]
 [browser_localICS.js]
 [browser_taskDelete.js]
new file mode 100644
--- /dev/null
+++ b/calendar/test/browser/browser_calDAV_oAuth.js
@@ -0,0 +1,202 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Creates calendars in various configurations (current and legacy) and performs
+// requests in each of them to prove that OAuth2 authentication is working as expected.
+
+var { CalDavCalendar } = ChromeUtils.import("resource:///modules/CalDavCalendar.jsm");
+var { CalDavGenericRequest } = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm");
+var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+var LoginInfo = Components.Constructor(
+  "@mozilla.org/login-manager/loginInfo;1",
+  Ci.nsILoginInfo,
+  "init"
+);
+
+// Ideal login info. This is what would be saved if you created a new calendar after the changes.
+const ORIGIN = "oauth://mochi.test";
+const SCOPE = "test_scope";
+const USERNAME = "bob@test.invalid";
+const VALID_TOKEN = "bobs_refresh_token";
+
+/**
+ * Set a string pref for the given calendar.
+ *
+ * @param {string} calendarId
+ * @param {string} key
+ * @param {string} value
+ */
+function setPref(calendarId, key, value) {
+  Services.prefs.setStringPref(`calendar.registry.${calendarId}.${key}`, value);
+}
+
+/**
+ * Clear any existing saved logins and add the given ones.
+ *
+ * @param {string[][]} - Zero or more arrays consisting of origin, realm, username, and password.
+ */
+function setLogins(...logins) {
+  Services.logins.removeAllLogins();
+  for (let [origin, realm, username, password] of logins) {
+    Services.logins.addLogin(new LoginInfo(origin, null, realm, username, password, "", ""));
+  }
+}
+
+/**
+ * Create a calendar with the given id, perform a request, and check that the correct
+ * authorisation header was used. If the user is required to re-authenticate with the provider,
+ * check that the new token is stored in the right place.
+ *
+ * @param {string} calendarId - ID of the new calendar
+ * @param {string} [newTokenUsername] - If given, re-authentication must happen and the new token
+ *   stored with this user name.
+ */
+async function subtest(calendarId, newTokenUsername) {
+  let calendar = new CalDavCalendar();
+  calendar.id = calendarId;
+
+  let request = new CalDavGenericRequest(
+    calendar.wrappedJSObject.session,
+    calendar,
+    "GET",
+    Services.io.newURI(
+      "http://mochi.test:8888/browser/comm/mailnews/addrbook/test/browser/data/auth_headers.sjs"
+    )
+  );
+  let response = await request.commit();
+  let headers = JSON.parse(response.text);
+
+  if (newTokenUsername) {
+    Assert.equal(headers.authorization, "Bearer new_access_token");
+
+    let logins = Services.logins
+      .findLogins(ORIGIN, null, SCOPE)
+      .filter(l => l.username == newTokenUsername);
+    Assert.equal(logins.length, 1);
+    Assert.equal(logins[0].username, newTokenUsername);
+    Assert.equal(logins[0].password, "new_refresh_token");
+  } else {
+    Assert.equal(headers.authorization, "Bearer bobs_access_token");
+  }
+
+  Services.logins.removeAllLogins();
+}
+
+// Test making a request when there is no matching token stored.
+
+/** No token stored, no username or session ID set. */
+add_task(function testCalendarOAuth_id_none() {
+  let calendarId = "testCalendarOAuth_id_none";
+  return subtest(calendarId, calendarId);
+});
+
+/** No token stored, session ID set. */
+add_task(function testCalendarOAuth_sessionId_none() {
+  let calendarId = "testCalendarOAuth_sessionId_none";
+  setPref(calendarId, "sessionId", "test_session");
+  return subtest(calendarId, "test_session");
+});
+
+/** No token stored, username set. */
+add_task(function testCalendarOAuth_username_none() {
+  let calendarId = "testCalendarOAuth_username_none";
+  setPref(calendarId, "username", USERNAME);
+  return subtest(calendarId, calendarId); // USERNAME after changes.
+});
+
+// Test making a request when there IS a matching token, but the server rejects it.
+// Currently a new token is not requested on failure.
+
+/** Expired token stored with calendar ID. */
+add_task(function testCalendarOAuth_id_expired() {
+  let calendarId = "testCalendarOAuth_id_expired";
+  setLogins([`oauth:${calendarId}`, "Google CalDAV v2", calendarId, "expired_token"]);
+  return subtest(calendarId, calendarId);
+}).skip(); // Broken.
+
+/** Expired token stored with session ID. */
+add_task(function testCalendarOAuth_sessionId_expired() {
+  let calendarId = "testCalendarOAuth_sessionId_expired";
+  setPref(calendarId, "sessionId", "test_session");
+  setLogins(["oauth:test_session", "Google CalDAV v2", "test_session", "expired_token"]);
+  return subtest(calendarId, "test_session");
+}).skip(); // Broken.
+
+/** Expired token stored with calendar ID, username set. */
+add_task(function testCalendarOAuth_username_expired() {
+  let calendarId = "testCalendarOAuth_username_expired";
+  setPref(calendarId, "username", USERNAME);
+  setLogins([`oauth:${calendarId}`, "Google CalDAV v2", calendarId, "expired_token"]);
+  return subtest(calendarId); // USERNAME after changes.
+}).skip(); // Broken.
+
+// Test making a request with a valid token, using Lightning's client ID and secret.
+
+/** Valid token stored with calendar ID. */
+add_task(function testCalendarOAuth_id_valid() {
+  let calendarId = "testCalendarOAuth_id_valid";
+  setLogins([`oauth:${calendarId}`, "Google CalDAV v2", calendarId, VALID_TOKEN]);
+  return subtest(calendarId);
+});
+
+/** Valid token stored with session ID. */
+add_task(function testCalendarOAuth_sessionId_valid() {
+  let calendarId = "testCalendarOAuth_sessionId_valid";
+  setPref(calendarId, "sessionId", "test_session");
+  setLogins(["oauth:test_session", "Google CalDAV v2", "test_session", VALID_TOKEN]);
+  return subtest(calendarId);
+});
+
+/** Valid token stored with calendar ID, username set. */
+add_task(function testCalendarOAuth_username_valid() {
+  let calendarId = "testCalendarOAuth_username_valid";
+  setPref(calendarId, "username", USERNAME);
+  setLogins([`oauth:${calendarId}`, "Google CalDAV v2", calendarId, VALID_TOKEN]);
+  return subtest(calendarId); // New token required after changes.
+});
+
+// Test making a request with a valid token, using Thunderbird's client ID and secret.
+
+/** Valid token stored with calendar ID. */
+add_task(function testCalendarOAuthTB_id_valid() {
+  let calendarId = "testCalendarOAuthTB_id_valid";
+  setLogins([ORIGIN, SCOPE, calendarId, VALID_TOKEN]);
+  return subtest(calendarId);
+});
+
+/** Valid token stored with session ID. */
+add_task(function testCalendarOAuthTB_sessionId_valid() {
+  let calendarId = "testCalendarOAuthTB_sessionId_valid";
+  setPref(calendarId, "sessionId", "test_session");
+  setLogins([ORIGIN, SCOPE, "test_session", VALID_TOKEN]);
+  return subtest(calendarId);
+});
+
+/** Valid token stored with calendar ID, username set. */
+add_task(function testCalendarOAuthTB_username_valid() {
+  let calendarId = "testCalendarOAuthTB_username_valid";
+  setPref(calendarId, "username", USERNAME);
+  setLogins([ORIGIN, SCOPE, calendarId, VALID_TOKEN]);
+  return subtest(calendarId); // New token required after changes.
+});
+
+/** Valid token stored with username, exact scope. */
+add_task(function testCalendarOAuthTB_username_validSingle() {
+  let calendarId = "testCalendarOAuthTB_username_validSingle";
+  setPref(calendarId, "username", USERNAME);
+  setLogins(
+    [ORIGIN, SCOPE, USERNAME, VALID_TOKEN],
+    [ORIGIN, "other_scope", USERNAME, "other_refresh_token"]
+  );
+  return subtest(calendarId);
+}).skip(); // Enable after changes.
+
+/** Valid token stored with username, many scopes. */
+add_task(function testCalendarOAuthTB_username_validMultiple() {
+  let calendarId = "testCalendarOAuthTB_username_validMultiple";
+  setPref(calendarId, "username", USERNAME);
+  setLogins([ORIGIN, "scope test_scope other_scope", USERNAME, VALID_TOKEN]);
+  return subtest(calendarId);
+}).skip(); // Enable after changes.
--- a/mailnews/addrbook/modules/CardDAVDirectory.jsm
+++ b/mailnews/addrbook/modules/CardDAVDirectory.jsm
@@ -159,18 +159,17 @@ class CardDAVDirectory extends SQLiteDir
    * @param {Object} details - See CardDAVUtils.makeRequest.
    * @return {Promise<Object>} - See CardDAVUtils.makeRequest.
    */
   async _makeRequest(path, details = {}) {
     let serverURI = Services.io.newURI(this._serverURL);
     let uri = serverURI.resolve(path);
 
     if (!("_oAuth" in this)) {
-      let details = OAuth2Providers.getHostnameDetails(serverURI.host);
-      if (details) {
+      if (OAuth2Providers.getHostnameDetails(serverURI.host)) {
         this._oAuth = new OAuth2Module();
         this._oAuth.initFromABDirectory(this, serverURI.host);
       } else {
         this._oAuth = null;
       }
     }
     details.oAuth = this._oAuth;
 
new file mode 100644
--- /dev/null
+++ b/mailnews/addrbook/test/browser/browser.ini
@@ -0,0 +1,15 @@
+[DEFAULT]
+prefs =
+  mail.provider.suppress_dialog_on_startup=true
+  mail.spotlight.firstRunDone=true
+  mail.winsearch.firstRunDone=true
+  mailnews.oauth.loglevel=Debug
+  mailnews.start_page.override_url=about:blank
+  mailnews.start_page.url=about:blank
+  signon.rememberSignons=true
+support-files = data/**
+subsuite = thunderbird
+tags = addrbook carddav
+
+[browser_cardDAV_oAuth.js]
+tags = oauth
new file mode 100644
--- /dev/null
+++ b/mailnews/addrbook/test/browser/browser_cardDAV_oAuth.js
@@ -0,0 +1,145 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Creates address books in various configurations (current and legacy) and
+// performs requests in each of them to prove that OAuth2 authentication is
+// working as expected.
+
+var { CardDAVDirectory } = ChromeUtils.import(
+  "resource:///modules/CardDAVDirectory.jsm"
+);
+var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+var LoginInfo = Components.Constructor(
+  "@mozilla.org/login-manager/loginInfo;1",
+  Ci.nsILoginInfo,
+  "init"
+);
+
+// Ideal login info. This is what would be saved if you created a new calendar
+// after the changes.
+const ORIGIN = "oauth://mochi.test";
+const SCOPE = "test_scope";
+const USERNAME = "bob@test.invalid";
+const VALID_TOKEN = "bobs_refresh_token";
+
+const PATH = "comm/mailnews/addrbook/test/browser/data/";
+const URL = `http://mochi.test:8888/browser/${PATH}`;
+
+/**
+ * Set a string pref for the given directory.
+ *
+ * @param {string} dirPrefId
+ * @param {string} key
+ * @param {string} value
+ */
+function setPref(dirPrefId, key, value) {
+  Services.prefs.setStringPref(`ldap_2.servers.${dirPrefId}.${key}`, value);
+}
+
+/**
+ * Clear any existing saved logins and add the given ones.
+ *
+ * @param {string[][]} - Zero or more arrays consisting of origin, realm,
+ *   username, and password.
+ */
+function setLogins(...logins) {
+  Services.logins.removeAllLogins();
+  for (let [origin, realm, username, password] of logins) {
+    Services.logins.addLogin(
+      new LoginInfo(origin, null, realm, username, password, "", "")
+    );
+  }
+}
+
+/**
+ * Create a directory with the given id, perform a request, and check that the
+ * correct authorisation header was used. If the user is required to
+ * re-authenticate with the provider, check that the new token is stored in the
+ * right place.
+ *
+ * @param {string} dirPrefId - Pref ID of the new directory.
+ * @param {string} uid - UID of the new directory.
+ * @param {string} [newTokenUsername] - If given, re-authentication must happen
+ *   and the new token stored with this user name.
+ */
+async function subtest(dirPrefId, uid, newTokenUsername) {
+  let directory = new CardDAVDirectory();
+  directory._dirPrefId = dirPrefId;
+  directory._uid = uid;
+  directory.__prefBranch = Services.prefs.getBranch(
+    `ldap_2.servers.${dirPrefId}.`
+  );
+  directory.__prefBranch.setStringPref("carddav.url", URL);
+
+  let response = await directory._makeRequest("auth_headers.sjs");
+  Assert.equal(response.status, 200);
+  let headers = JSON.parse(response.text);
+
+  if (newTokenUsername) {
+    Assert.equal(headers.authorization, "Bearer new_access_token");
+
+    let logins = Services.logins
+      .findLogins(ORIGIN, null, SCOPE)
+      .filter(l => l.username == newTokenUsername);
+    Assert.equal(logins.length, 1);
+    Assert.equal(logins[0].username, newTokenUsername);
+    Assert.equal(logins[0].password, "new_refresh_token");
+  } else {
+    Assert.equal(headers.authorization, "Bearer bobs_access_token");
+  }
+
+  Services.logins.removeAllLogins();
+}
+
+// Test making a request when there is no matching token stored.
+
+/** No token stored, no username set. */
+add_task(function testAddressBookOAuth_uid_none() {
+  let dirPrefId = "uid_none";
+  let uid = "testAddressBookOAuth_uid_none";
+  return subtest(dirPrefId, uid, uid);
+});
+
+// Test making a request when there IS a matching token, but the server rejects
+// it. Currently a new token is not requested on failure.
+
+/** Expired token stored with UID. */
+add_task(function testAddressBookOAuth_uid_expired() {
+  let dirPrefId = "uid_expired";
+  let uid = "testAddressBookOAuth_uid_expired";
+  setLogins([ORIGIN, SCOPE, uid, "expired_token"]);
+  return subtest(dirPrefId, uid, uid);
+}).skip(); // Broken.
+
+// Test making a request with a valid token.
+
+/** Valid token stored with UID. This is the old way of storing the token. */
+add_task(function testAddressBookOAuth_uid_valid() {
+  let dirPrefId = "uid_valid";
+  let uid = "testAddressBookOAuth_uid_valid";
+  setLogins([ORIGIN, SCOPE, uid, VALID_TOKEN]);
+  return subtest(dirPrefId, uid);
+});
+
+/** Valid token stored with username, exact scope. */
+add_task(function testAddressBookOAuth_username_validSingle() {
+  let dirPrefId = "username_validSingle";
+  let uid = "testAddressBookOAuth_username_validSingle";
+  setPref(dirPrefId, "carddav.username", USERNAME);
+  setLogins(
+    [ORIGIN, SCOPE, USERNAME, VALID_TOKEN],
+    [ORIGIN, "other_scope", USERNAME, "other_refresh_token"]
+  );
+  return subtest(dirPrefId, uid);
+}).skip(); // Enable after changes.
+
+/** Valid token stored with username, many scopes. */
+add_task(function testAddressBookOAuth_username_validMultiple() {
+  let dirPrefId = "username_validMultiple";
+  let uid = "testAddressBookOAuth_username_validMultiple";
+  setPref(dirPrefId, "carddav.username", USERNAME);
+  setLogins([ORIGIN, "scope test_scope other_scope", USERNAME, VALID_TOKEN]);
+  return subtest(dirPrefId, uid);
+}).skip(); // Enable after changes.
new file mode 100644
--- /dev/null
+++ b/mailnews/addrbook/test/browser/data/auth_headers.sjs
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Echoes request headers as JSON so a test can check what was sent.
+
+/* eslint-disable-next-line mozilla/reject-importGlobalProperties */
+Cu.importGlobalProperties(["URLSearchParams"]);
+
+function handleRequest(request, response) {
+  if (!request.hasHeader("Authorization")) {
+    response.setStatusLine("1.1", 401, "Unauthorized");
+    response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+  }
+
+  response.setHeader("Content-Type", "application/json", false);
+
+  let headers = {};
+  let enumerator = request.headers;
+  while (enumerator.hasMoreElements()) {
+    let header = enumerator.getNext().data;
+    headers[header.toLowerCase()] = request.getHeader(header);
+  }
+
+  response.write(JSON.stringify(headers));
+}
new file mode 100644
--- /dev/null
+++ b/mailnews/addrbook/test/browser/data/redirect_auto.sjs
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Serves as the authorisation endpoint for OAuth2 testing.
+
+/* eslint-disable-next-line mozilla/reject-importGlobalProperties */
+Cu.importGlobalProperties(["URLSearchParams", "URL"]);
+
+function handleRequest(request, response) {
+  let params = new URLSearchParams(request.queryString);
+
+  if (request.method == "POST") {
+    response.setStatusLine(request.httpVersion, 303, "Redirected");
+  } else {
+    response.setStatusLine(request.httpVersion, 302, "Moved Temporarily");
+  }
+
+  let url = new URL(params.get("redirect_uri"));
+  url.searchParams.set("code", "success");
+  response.setHeader("Location", url.href);
+}
new file mode 100644
--- /dev/null
+++ b/mailnews/addrbook/test/browser/data/token.sjs
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Serves as the token endpoint for OAuth2 testing.
+
+/* eslint-disable-next-line mozilla/reject-importGlobalProperties */
+Cu.importGlobalProperties(["URLSearchParams"]);
+
+function handleRequest(request, response) {
+  let stream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+    Ci.nsIBinaryInputStream
+  );
+  stream.setInputStream(request.bodyInputStream);
+
+  let input = stream.readBytes(request.bodyInputStream.available());
+  let params = new URLSearchParams(input);
+
+  response.setHeader("Content-Type", "application/json", false);
+
+  if (params.get("refresh_token") == "expired_token") {
+    response.setStatusLine("1.1", 400, "Bad Request");
+    response.write(JSON.stringify({ error: "invalid_grant" }));
+    return;
+  }
+
+  let data = { access_token: "bobs_access_token" };
+
+  if (params.get("code") == "success") {
+    // Authorisation just happened, set a different access token so the test
+    // can detect it, and provide a refresh token.
+    data.access_token = "new_access_token";
+    data.refresh_token = "new_refresh_token";
+  }
+
+  response.write(JSON.stringify(data));
+}
--- a/mailnews/addrbook/test/moz.build
+++ b/mailnews/addrbook/test/moz.build
@@ -3,13 +3,17 @@
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 TESTING_JS_MODULES += [
     "CardDAVServer.jsm",
     "LDAPServer.jsm",
 ]
 
+BROWSER_CHROME_MANIFESTS += [
+    "browser/browser.ini",
+]
+
 XPCSHELL_TESTS_MANIFESTS += [
     "unit/xpcshell-jsm.ini",
     "unit/xpcshell.ini",
     "unit/xpcshell_cardDAV.ini",
 ]
--- a/mailnews/base/src/OAuth2Module.jsm
+++ b/mailnews/base/src/OAuth2Module.jsm
@@ -35,16 +35,22 @@ OAuth2Module.prototype = {
     return this._initPrefs(
       "mail.server." + aServer.key + ".",
       aServer.realUsername,
       aServer.realHostName
     );
   },
   initFromABDirectory(aDirectory, aHostname) {
     this._initPrefs(aDirectory.dirPrefId + ".", aDirectory.UID, aHostname);
+    if (aHostname == "mochi.test") {
+      // I don't know why, but tests refuse to work with a plain HTTP endpoint
+      // (the request is redirected to HTTPS, which we're not listening to).
+      // Just use an HTTPS endpoint.
+      this._oauth.redirectionEndpoint = "https://localhost";
+    }
   },
   _initPrefs(root, aUsername, aHostname) {
     // Load all of the parameters from preferences.
     let issuer = Services.prefs.getStringPref(root + "oauth2.issuer", "");
     let scope = Services.prefs.getStringPref(root + "oauth2.scope", "");
 
     // These properties are absolutely essential to OAuth2 support. If we don't
     // have them, we don't support OAuth2.
--- a/mailnews/base/src/OAuth2Providers.jsm
+++ b/mailnews/base/src/OAuth2Providers.jsm
@@ -45,16 +45,19 @@ var kHostnames = new Map([
   ],
   [
     "smtp.office365.com",
     [
       "login.microsoftonline.com",
       "https://outlook.office365.com/IMAP.AccessAsUser.All https://outlook.office365.com/POP.AccessAsUser.All https://outlook.office365.com/SMTP.Send offline_access",
     ],
   ],
+
+  // For testing purposes.
+  ["mochi.test", ["mochi.test", "test_scope"]],
 ]);
 
 /**
  * Map of issuers to clientId, clientSecret, authorizationEndpoint, tokenEndpoint.
  * Issuer is a unique string for the organization that a Thunderbird account
  * was registered at.
  *
  * For the moment these details are hard-coded, since dynamic client
@@ -115,16 +118,27 @@ var kIssuers = new Map([
     [
       "08162f7c-0fd2-4200-a84a-f25a4db0b584", // Application (client) ID
       "TxRBilcHdC6WGBee]fs?QR:SJ8nI[g82", // @see App registrations | Certificates & secrets
       // https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols#endpoints
       "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
       "https://login.microsoftonline.com/common/oauth2/v2.0/token",
     ],
   ],
+
+  // For testing purposes.
+  [
+    "mochi.test",
+    [
+      "test_client_id",
+      "test_secret",
+      "http://mochi.test:8888/browser/comm/mailnews/addrbook/test/browser/data/redirect_auto.sjs",
+      "http://mochi.test:8888/browser/comm/mailnews/addrbook/test/browser/data/token.sjs",
+    ],
+  ],
 ]);
 
 /**
  * OAuth2Providers: Methods to lookup OAuth2 parameters for supported OAuth2
  * providers.
  */
 var OAuth2Providers = {
   /**