Bug 1878172 - Test getting messages with OAuth2 authentication. r=mkmelin

Differential Revision: https://phabricator.services.mozilla.com/D202534

--HG--
extra : rebase_source : f6b01674afd6ffb2054cb924e7c3408acb9c906e
extra : amend_source : 9af81d0364e40cf1ec578a08589f117acf4291bc
This commit is contained in:
Geoff Lankow 2024-02-14 12:06:37 +13:00
Родитель 5b220cc144
Коммит 258f4d8fe6
10 изменённых файлов: 621 добавлений и 12 удалений

Просмотреть файл

@ -2,10 +2,7 @@
* 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/. */
import {
Pop3Daemon,
POP3_RFC5034_handler,
} from "resource://testing-common/mailnews/Pop3d.sys.mjs";
import * as Pop3D from "resource://testing-common/mailnews/Pop3d.sys.mjs";
import { nsMailServer } from "resource://testing-common/mailnews/Maild.sys.mjs";
/**
@ -15,15 +12,18 @@ export class POP3Server {
constructor(testScope, options = {}) {
this.testScope = testScope;
this.options = options;
this.open();
this.open(options.handler);
}
open() {
open(handlerName = "RFC5034") {
if (!this.daemon) {
this.daemon = new Pop3Daemon();
this.daemon = new Pop3D.Pop3Daemon();
}
this.server = new nsMailServer(daemon => {
const handler = new POP3_RFC5034_handler(daemon, this.options);
const handler = new Pop3D[`POP3_${handlerName}_handler`](
daemon,
this.options
);
if (this.options.offerStartTLS) {
// List startTLS as a capability, even though we don't support it.
handler.kCapabilities.push("STLS");

Просмотреть файл

@ -34,6 +34,8 @@ skip-if = os == 'mac' && debug # Fails almost every time. Unsure why.
[browser_getMessages_certError.js]
[browser_getMessages_connectionError.js]
[browser_getMessages_deferredAccount.js]
[browser_getMessages_oAuth2.js]
tags = oauth
[browser_getMessages_offline.js]
[browser_goMenu.js]
skip-if = os == 'mac'

Просмотреть файл

@ -0,0 +1,268 @@
/* 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/. */
/**
* Tests fetching mail with OAuth2 authentication, including the dialog
* windows that uses.
*/
const { MessageGenerator } = ChromeUtils.importESModule(
"resource://testing-common/mailnews/MessageGenerator.sys.mjs"
);
const { OAuth2TestUtils } = ChromeUtils.importESModule(
"resource://testing-common/mailnews/OAuth2TestUtils.sys.mjs"
);
const { ServerTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/mailnews/ServerTestUtils.sys.mjs"
);
const generator = new MessageGenerator();
let localAccount, localRootFolder;
let imapServer, imapAccount, imapRootFolder, imapInbox;
let pop3Server, pop3Account, pop3RootFolder, pop3Inbox;
const allInboxes = [];
const about3Pane = document.getElementById("tabmail").currentAbout3Pane;
const getMessagesButton = about3Pane.document.getElementById(
"folderPaneGetMessages"
);
const getMessagesContext = about3Pane.document.getElementById(
"folderPaneGetMessagesContext"
);
add_setup(async function () {
Services.prefs.setStringPref("mailnews.oauth.loglevel", "Debug");
Services.prefs.setBoolPref("signon.rememberSignons", true);
localAccount = MailServices.accounts.createLocalMailAccount();
localRootFolder = localAccount.incomingServer.rootFolder;
[imapServer, pop3Server] = await ServerTestUtils.createServers(this, [
ServerTestUtils.serverDefs.imap.oAuth,
ServerTestUtils.serverDefs.pop3.oAuth,
]);
imapAccount = MailServices.accounts.createAccount();
imapAccount.addIdentity(MailServices.accounts.createIdentity());
imapAccount.incomingServer = MailServices.accounts.createIncomingServer(
"user",
"test.test",
"imap"
);
imapAccount.incomingServer.prettyName = "IMAP Account";
imapAccount.incomingServer.port = 143;
imapAccount.incomingServer.authMethod = Ci.nsMsgAuthMethod.OAuth2;
Services.prefs.getStringPref(
"mail.server." + imapAccount.incomingServer.key + ".oauth2.issuer",
"mochi.test"
);
Services.prefs.getStringPref(
"mail.server." + imapAccount.incomingServer.key + ".oauth2.scope",
"test_scope"
);
imapRootFolder = imapAccount.incomingServer.rootFolder;
imapInbox = imapRootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Inbox);
allInboxes.push(imapInbox);
pop3Account = MailServices.accounts.createAccount();
pop3Account.addIdentity(MailServices.accounts.createIdentity());
pop3Account.incomingServer = MailServices.accounts.createIncomingServer(
"user",
"test.test",
"pop3"
);
pop3Account.incomingServer.prettyName = "POP3 Account";
pop3Account.incomingServer.port = 110;
pop3Account.incomingServer.authMethod = Ci.nsMsgAuthMethod.OAuth2;
Services.prefs.getStringPref(
"mail.server." + pop3Account.incomingServer.key + ".oauth2.issuer",
"mochi.test"
);
Services.prefs.getStringPref(
"mail.server." + pop3Account.incomingServer.key + ".oauth2.scope",
"test_scope"
);
pop3RootFolder = pop3Account.incomingServer.rootFolder;
pop3Inbox = pop3RootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Inbox);
allInboxes.push(pop3Inbox);
OAuth2TestUtils.startServer(this);
registerCleanupFunction(async () => {
MailServices.accounts.removeAccount(localAccount, false);
MailServices.accounts.removeAccount(imapAccount, false);
MailServices.accounts.removeAccount(pop3Account, false);
Services.logins.removeAllLogins();
Services.prefs.clearUserPref("mailnews.oauth.loglevel");
Services.prefs.clearUserPref("signon.rememberSignons");
});
});
async function addMessagesToServer(type) {
if (type == "imap") {
await imapServer.addMessages(imapInbox, generator.makeMessages({}), false);
} else if (type == "pop3") {
await pop3Server.addMessages(generator.makeMessages({}));
}
}
async function fetchMessages(inbox) {
EventUtils.synthesizeMouseAtCenter(
getMessagesButton,
{ type: "contextmenu" },
about3Pane
);
await BrowserTestUtils.waitForPopupEvent(getMessagesContext, "shown");
getMessagesContext.activateItem(
getMessagesContext.querySelector(`[data-server-key="${inbox.server.key}"]`)
);
await BrowserTestUtils.waitForPopupEvent(getMessagesContext, "hidden");
}
async function waitForMessages(inbox) {
await TestUtils.waitForCondition(
() => inbox.getNumUnread(false) == 10 && inbox.numPendingUnread == 0,
`waiting for new ${inbox.server.type} messages to be received`
);
await promiseServerIdle(inbox.server);
info(`${inbox.server.type} messages received`);
inbox.markAllMessagesRead(window.msgWindow);
await promiseServerIdle(inbox.server);
await TestUtils.waitForCondition(
() => inbox.getNumUnread(false) == 0 && inbox.numPendingUnread == 0,
`waiting for ${inbox.server.type} messages to be marked read`
);
info(`${inbox.server.type} messages marked as read`);
}
async function handleOAuthDialog() {
const oAuthWindow = await OAuth2TestUtils.promiseOAuthWindow();
info("oauth2 window shown");
await SpecialPowers.spawn(
oAuthWindow.getBrowser(),
[{ expectedHint: "user", username: "user", password: "password" }],
OAuth2TestUtils.submitOAuthLogin
);
}
function checkSavedPassword(inbox) {
const logins = Services.logins.findLogins("oauth://test.test", "", "");
Assert.equal(
logins.length,
1,
"there should be a saved password for this server"
);
Assert.equal(logins[0].origin, "oauth://test.test", "login origin");
Assert.equal(logins[0].formActionOrigin, null, "login formActionOrigin");
Assert.equal(logins[0].httpRealm, "test_scope", "login httpRealm");
Assert.equal(logins[0].username, "user", "login username");
Assert.equal(logins[0].password, "refresh_token", "login password");
Assert.equal(logins[0].usernameField, "", "login usernameField");
Assert.equal(logins[0].passwordField, "", "login passwordField");
}
/**
* Tests getting messages when there is no access token and no refresh token.
*/
add_task(async function testNoTokens() {
for (const inbox of allInboxes) {
info(`getting messages for ${inbox.server.type} inbox with no tokens`);
await addMessagesToServer(inbox.server.type);
const oAuthPromise = handleOAuthDialog();
await fetchMessages(inbox);
await oAuthPromise;
await waitForMessages(inbox);
// TODO: check this does NOT hit the oauth server
await addMessagesToServer(inbox.server.type);
await fetchMessages(inbox);
await waitForMessages(inbox);
checkSavedPassword(inbox);
Services.logins.removeAllLogins();
await promiseServerIdle(inbox.server);
inbox.server.closeCachedConnections();
OAuth2TestUtils.forgetObjects();
}
});
/**
* Tests that with a saved refresh token, but no access token, a new access token is requested.
*/
add_task(async function testNoAccessToken() {
const loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
Ci.nsILoginInfo
);
loginInfo.init(
"oauth://test.test",
null,
"test_scope",
"user",
"refresh_token",
"",
""
);
await Services.logins.addLoginAsync(loginInfo);
for (const inbox of allInboxes) {
info(
`getting messages for ${inbox.server.type} inbox with a refresh token but no access token`
);
await addMessagesToServer(inbox.server.type);
await fetchMessages(inbox);
await waitForMessages(inbox);
checkSavedPassword(inbox);
await promiseServerIdle(inbox.server);
inbox.server.closeCachedConnections();
OAuth2TestUtils.forgetObjects();
}
Services.logins.removeAllLogins();
});
/**
* Tests that with a bad saved refresh token, new tokens are requested.
*/
add_task(async function testBadRefreshToken() {
for (const inbox of allInboxes) {
const loginInfo = Cc[
"@mozilla.org/login-manager/loginInfo;1"
].createInstance(Ci.nsILoginInfo);
loginInfo.init(
"oauth://test.test",
null,
"test_scope",
"user",
"old_refresh_token",
"",
""
);
await Services.logins.addLoginAsync(loginInfo);
info(
`getting messages for ${inbox.server.type} inbox with a bad refresh token`
);
await addMessagesToServer(inbox.server.type);
const oAuthPromise = handleOAuthDialog();
await fetchMessages(inbox);
await oAuthPromise;
await waitForMessages(inbox);
checkSavedPassword(inbox);
await promiseServerIdle(inbox.server);
inbox.server.closeCachedConnections();
OAuth2TestUtils.forgetObjects();
Services.logins.removeAllLogins();
}
});

Просмотреть файл

@ -25,10 +25,9 @@ var alertHook = {
},
get alertService() {
delete this.alertService;
return (this.alertService = Cc["@mozilla.org/alerts-service;1"].getService(
Ci.nsIAlertsService
));
// Don't store a reference to the alerts service, as it can be swapped out
// during tests.
return Cc["@mozilla.org/alerts-service;1"].getService(Ci.nsIAlertsService);
},
get brandShortName() {

Просмотреть файл

@ -78,6 +78,7 @@ var kHostnames = new Map([
// For testing purposes.
["mochi.test", ["mochi.test", "test_scope"]],
["test.test", ["test.test", "test_scope"]],
]);
/**
@ -192,6 +193,16 @@ var kIssuers = new Map([
redirectionEndpoint: "https://localhost",
},
],
[
"test.test",
{
clientId: "test_client_id",
clientSecret: "test_secret",
authorizationEndpoint: "http://oauth.test.test/form",
tokenEndpoint: "http://oauth.test.test/token",
redirectionEndpoint: "https://localhost",
},
],
]);
/**

Просмотреть файл

@ -66,6 +66,7 @@ TESTING_JS_MODULES.mailnews += [
"test/resources/MessageGenerator.sys.mjs",
"test/resources/MessageInjection.sys.mjs",
"test/resources/NetworkTestUtils.sys.mjs",
"test/resources/OAuth2TestUtils.sys.mjs",
"test/resources/PromiseTestUtils.sys.mjs",
"test/resources/SmimeUtils.sys.mjs",
]

Просмотреть файл

@ -2475,6 +2475,29 @@ export var IMAP_RFC2195_extension = {
},
};
/**
* Implements XOAUTH2 authentication.
*/
export var IMAP_OAUTH2_extension = {
kAuthSchemes: ["XOAUTH2"],
preload(handler) {
handler._kAuthSchemeStartFunction.XOAUTH2 = this.authXOAUTH2Start;
},
authXOAUTH2Start(lineRest) {
const [user, auth] = atob(lineRest).split("\u0001");
if (
user == `user=${this.kUsername}` &&
auth == `auth=Bearer ${this.kPassword}`
) {
this._state = IMAP_STATE_AUTHED;
return "OK Yeah, that's the right access token.";
}
return "BAD Yeah, nah, that's the wrong access token.";
},
};
// FETCH BODYSTRUCTURE
function bodystructure(msg, extension) {
if (!msg || msg == "") {

Просмотреть файл

@ -460,3 +460,33 @@ export class POP3_RFC5034_handler extends POP3_RFC2449_handler {
return "-ERR Wrong username or password, crook!";
}
}
/**
* Implements XOAUTH2 authentication.
*/
export class POP3_OAUTH2_handler extends POP3_RFC5034_handler {
kAuthSchemes = ["XOAUTH2"];
constructor(daemon, options) {
super(daemon, options);
this._kAuthSchemeStartFunction.XOAUTH2 = this.authXOAUTH2Start;
}
authXOAUTH2Start(lineRest) {
this._nextAuthFunction = this.authXOAUTH2Cred;
this._multiline = true;
return "+";
}
authXOAUTH2Cred(lineRest) {
const [user, auth] = atob(lineRest).split("\u0001");
if (
user == `user=${this.kUsername}` &&
auth == `auth=Bearer ${this.kPassword}`
) {
this._state = kStateTransaction;
return "+OK Yeah, that's the right access token.";
}
return "-BAD Yeah, nah, that's the wrong access token.";
}
}

Просмотреть файл

@ -161,6 +161,12 @@ const serverDefs = {
hostname: "expired.test.test",
port: 993,
},
oAuth: {
type: "imap",
baseOptions: { extensions: ["OAUTH2"], password: "access_token" },
hostname: "test.test",
port: 143,
},
},
pop3: {
plain: {
@ -199,6 +205,16 @@ const serverDefs = {
hostname: "expired.test.test",
port: 995,
},
oAuth: {
type: "pop3",
baseOptions: {
handler: ["OAUTH2"],
username: "user",
password: "access_token",
},
hostname: "test.test",
port: 110,
},
},
smtp: {
plain: {

Просмотреть файл

@ -0,0 +1,259 @@
/* 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/. */
/**
* Utils for testing interactions with OAuth2 authentication servers.
*/
import { BrowserTestUtils } from "resource://testing-common/BrowserTestUtils.sys.mjs";
import { CommonUtils } from "resource://services-common/utils.sys.mjs";
import { HttpServer, HTTP_405 } from "resource://testing-common/httpd.sys.mjs";
import { NetworkTestUtils } from "resource://testing-common/mailnews/NetworkTestUtils.sys.mjs";
const { OAuth2Module } = ChromeUtils.import(
"resource:///modules/OAuth2Module.jsm"
);
const validCodes = new Set();
export const OAuth2TestUtils = {
/**
* Start an OAuth2 server and add it to the proxy at oauth.test.test:80.
*
* @param {object} testScope - The JS scope for the current test, so
* `registerCleanupFunction` can be used.
*/
startServer(testScope) {
const oAuth2Server = new OAuth2Server(testScope);
oAuth2Server.httpServer.identity.add("http", "oauth.test.test", 80);
NetworkTestUtils.configureProxy(
"oauth.test.test",
80,
oAuth2Server.httpServer.identity.primaryPort
);
testScope.registerCleanupFunction(() => {
NetworkTestUtils.clearProxy();
});
return oAuth2Server;
},
/**
* Forget any `OAuth2` objects remembered by OAuth2Module.jsm
*/
forgetObjects() {
OAuth2Module._forgetObjects();
},
/**
* Waits for a login prompt window to appear and load.
*
* @returns {Window}
*/
async promiseOAuthWindow() {
const oAuthWindow = await BrowserTestUtils.domWindowOpenedAndLoaded(
undefined,
win =>
win.document.documentURI ==
"chrome://messenger/content/browserRequest.xhtml"
);
const oAuthBrowser = oAuthWindow.getBrowser();
if (
oAuthBrowser.webProgress?.isLoadingDocument ||
oAuthBrowser.currentURI.spec == "about:blank"
) {
await BrowserTestUtils.browserLoaded(oAuthBrowser);
}
return oAuthWindow;
},
/**
* Callback function to run in a login prompt window. Note: This function is
* serialized by SpecialPowers, so it can't use function shorthand.
*
* @param {object} options
* @param {string} [options.expectedHint] - If given, the login_hint URL parameter
* will be checked.
* @param {string} options.username - The username to use to log in.
* @param {string} options.password - The password to use to log in.
*/
submitOAuthLogin: ({ expectedHint, username, password }) => {
/* globals content, EventUtils */
const searchParams = new URL(content.location).searchParams;
Assert.equal(
searchParams.get("response_type"),
"code",
"request response_type"
);
Assert.equal(
searchParams.get("client_id"),
"test_client_id",
"request client_id"
);
Assert.equal(
searchParams.get("redirect_uri"),
"https://localhost",
"request redirect_uri"
);
Assert.equal(searchParams.get("scope"), "test_scope", "request scope");
Assert.equal(
searchParams.get("login_hint"),
expectedHint,
"request login_hint"
);
EventUtils.synthesizeMouseAtCenter(
content.document.querySelector(`input[name="username"]`),
{},
content
);
EventUtils.sendString(username, content);
EventUtils.synthesizeMouseAtCenter(
content.document.querySelector(`input[name="password"]`),
{},
content
);
EventUtils.sendString(password, content);
EventUtils.synthesizeMouseAtCenter(
content.document.querySelector(`input[type="submit"]`),
{},
content
);
},
};
class OAuth2Server {
username = "user";
password = "password";
accessToken = "access_token";
refreshToken = "refresh_token";
expiry = null;
constructor(testScope) {
this.httpServer = new HttpServer();
this.httpServer.registerPathHandler("/form", this.formHandler.bind(this));
this.httpServer.registerPathHandler(
"/authorize",
this.authorizeHandler.bind(this)
);
this.httpServer.registerPathHandler("/token", this.tokenHandler.bind(this));
this.httpServer.start(-1);
const port = this.httpServer.identity.primaryPort;
dump(`OAuth2 server at localhost:${port} opened\n`);
testScope.registerCleanupFunction(() => {
this.httpServer.stop();
dump(`OAuth2 server at localhost:${port} closed\n`);
});
}
formHandler(request, response) {
if (request.method != "GET") {
throw HTTP_405;
}
const params = new URLSearchParams(request.queryString);
this._formHandler(response, params.get("redirect_uri"));
}
_formHandler(response, redirectUri) {
response.setHeader("Content-Type", "text/html", false);
response.write(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Log in to test.test</title>
</head>
<body>
<form action="/authorize" method="post">
<input type="text" name="redirect_uri" readonly="readonly" value="${redirectUri}" />
<input type="text" name="username" />
<input type="password" name="password" />
<input type="submit" />
</form>
</body>
</html>
`);
}
authorizeHandler(request, response) {
if (request.method != "POST") {
throw HTTP_405;
}
const input = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
const params = new URLSearchParams(input);
if (
params.get("username") != this.username ||
params.get("password") != this.password
) {
this._formHandler(response, params.get("redirect_uri"));
return;
}
// Create a unique code. It will become invalid after the first use.
const bytes = new Uint8Array(12);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = Math.floor(Math.random() * 255);
}
const code = ChromeUtils.base64URLEncode(bytes, { pad: false });
validCodes.add(code);
const url = new URL(params.get("redirect_uri"));
url.searchParams.set("code", code);
response.setStatusLine(request.httpVersion, 303, "Redirected");
response.setHeader("Location", url.href);
}
tokenHandler(request, response) {
if (request.method != "POST") {
throw HTTP_405;
}
const stream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
Ci.nsIBinaryInputStream
);
stream.setInputStream(request.bodyInputStream);
const input = stream.readBytes(request.bodyInputStream.available());
const params = new URLSearchParams(input);
const goodRequest =
params.get("client_id") == "test_client_id" &&
params.get("client_secret") == "test_secret";
const grantType = params.get("grant_type");
const code = params.get("code");
const data = {};
if (
goodRequest &&
grantType == "authorization_code" &&
code &&
validCodes.has(code)
) {
// Authorisation just happened.
validCodes.delete(code);
data.access_token = this.accessToken;
data.refresh_token = this.refreshToken;
} else if (
goodRequest &&
grantType == "refresh_token" &&
params.get("refresh_token") == this.refreshToken
) {
// Client provided a valid refresh token.
data.access_token = this.accessToken;
} else {
response.setStatusLine("1.1", 400, "Bad Request");
data.error = "invalid_grant";
}
if (data.accessToken && this.expiry !== null) {
data.expires_in = this.expiry;
}
response.setHeader("Content-Type", "application/json", false);
response.write(JSON.stringify(data));
}
}