Revision control

Copy as Markdown

Other Tools

/* 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 { Assert } from "resource://testing-common/Assert.sys.mjs";
import { BrowserTestUtils } from "resource://testing-common/BrowserTestUtils.sys.mjs";
import { CommonUtils } from "resource://services-common/utils.sys.mjs";
import { CryptoUtils } from "moz-src:///services/crypto/modules/utils.sys.mjs";
import { HttpServer, HTTP_405 } from "resource://testing-common/httpd.sys.mjs";
import { MockExternalProtocolService } from "resource://testing-common/mailnews/MockExternalProtocolService.sys.mjs";
import { TestUtils } from "resource://testing-common/TestUtils.sys.mjs";
import { OAuth2Module } from "resource:///modules/OAuth2Module.sys.mjs";
import { OAuth2PageGenerator } from "moz-src:///comm/mailnews/base/src/OAuth2PageGenerator.sys.mjs";
/**
* Map, but values are removed as they are retrieved. For items that should
* only be used once.
*/
class SingleUseMap extends Map {
get(key) {
const value = super.get(key);
super.delete(key);
return value;
}
}
/**
* A map of states to PKCE code challenges.
*
* @type {Map<string, string>}
*/
const codeChallenges = new SingleUseMap();
/**
* A map of codes to PKCE code challenges.
*
* @type {Map<string, string>}
*/
const validCodes = new SingleUseMap();
/**
* A map of tokens to granted scopes.
*
* @type {Map<string, string>}
*/
const tokens = new Map();
export const OAuth2TestUtils = {
/**
* Start an OAuth2 server and add it to the proxy at oauth.test.test:443.
*
* @param {object} [serverOptions] - See the `OAuth2Server` constructor.
*/
async startServer(serverOptions) {
this._oAuth2Server = new OAuth2Server(serverOptions);
this._proxy = await HttpsProxy.create(
this._oAuth2Server.httpServer.identity.primaryPort,
"oauth",
"oauth.test.test"
);
TestUtils.promiseTestFinished?.then(() => {
this.stopServer();
this.forgetObjects();
// Clear out any PKCE code challenges left over. In tests where OAuth
// completes successfully, there should be no codes left, but not all
// tests complete the process.
validCodes.clear();
codeChallenges.clear();
});
return this._oAuth2Server;
},
stopServer() {
this._proxy?.destroy();
this._proxy = null;
this._oAuth2Server?.close();
this._oAuth2Server = null;
},
/**
* Forget any `OAuth2` objects remembered by OAuth2Module.sys.mjs
*/
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 ==
);
// Loading the OAuth login page navigates the window's browser to web
// content, which can trigger a remoteness swap. BrowserTestUtils.browserLoaded()
// hard-rejects ("window unloaded ...") on the transient unload that swap
// produces, so poll for the (current) browser to finish loading a real
// document instead.
await TestUtils.waitForCondition(() => {
const oAuthBrowser = oAuthWindow.getBrowser();
return (
oAuthBrowser?.currentURI &&
oAuthBrowser.currentURI.spec != "about:blank" &&
oAuthBrowser.webProgress &&
!oAuthBrowser.webProgress.isLoadingDocument
);
}, "the OAuth login page should finish loading");
return oAuthWindow;
},
/**
* Wait for an OAuth request to be opened in the external browser.
*
* @returns {Promise<string>}
*/
async promiseExternalOAuthURL() {
MockExternalProtocolService.init();
try {
return await MockExternalProtocolService.promiseLoad();
} finally {
MockExternalProtocolService.cleanup();
}
},
/**
* Check an OAuth request URL, then emulate submitting the server-side login
* form and following its redirect back to the localhost callback listener.
*
* @param {string} url
* @param {object} options
* @param {string} [options.expectedHint] - If given, the login_hint URL
* parameter
* @param {string} [options.expectedScope] - If given, the scope URL parameter
* will be checked. A space-separated list.
* @param {string} options.username - The username to use to log in.
* @param {string} options.password - The password to use to log in.
* @param {string} [options.grantedScope] - A subset of `expectedScope` to
* grant permission for. If not given, all scopes will be allowed. If an
* empty string, no scopes will be allowed.
* @param {string} [options.callbackState] - If given, override the state sent
* to the callback listener. Useful for negative tests.
* @param {boolean} [options.expectSuccess=true] - Whether the request
* should result in the success page.
*/
async submitOAuthURL(
url,
{
expectedHint,
expectedScope = "test_mail test_addressbook test_calendar",
username,
password,
grantedScope,
callbackState,
expectSuccess = true,
}
) {
// e.g.:
// response_type=code&
// client_id=test_client_id&
// scope=test_mail&
// state=DZ2G3YecvD5DKp2Xk-afXruKn6gHRzzNgdAKlaH7mUA&
// code_challenge_method=S256&
// code_challenge=nLDXlD8TosVgBMchtfh1krc9oRjFnbO6IYcNRYKVjas&
// login_hint=romeo%40foo.invalid&
// redirect_uri=net.thunderbird%3A%2F%2Foauth2%2Fcallback
const authURL = new URL(url);
const searchParams = authURL.searchParams;
Assert.equal(
searchParams.get("response_type"),
"code",
"request response_type"
);
Assert.equal(
searchParams.get("client_id"),
"test_client_id",
"request client_id"
);
const redirectURI = new URL(searchParams.get("redirect_uri"));
if (redirectURI.protocol == "http:") {
Assert.equal(
redirectURI.hostname,
"localhost",
"request redirect_uri hostname"
);
} else if (redirectURI.protocol == "net.thunderbird:") {
Assert.equal(
redirectURI.hostname,
"oauth2",
"request redirect_uri hostname"
);
} else {
Assert.ok(
false,
"redirect_uri should be an http: or net.net-thunderbird: URL"
);
}
Assert.equal(searchParams.get("scope"), expectedScope, "request scope");
if (expectedHint) {
Assert.equal(
searchParams.get("login_hint"),
expectedHint,
"request login_hint"
);
}
// Record the PKCE code challenge.
const state = searchParams.get("state");
const codeChallenge = searchParams.get("code_challenge");
Assert.ok(state, "request state");
Assert.ok(codeChallenge, "request code challenge");
Assert.equal(searchParams.get("code_challenge_method"), "S256");
codeChallenges.set(state, codeChallenge);
// Simulate the browser authorization form.
const body = new URLSearchParams();
body.set("redirect_uri", redirectURI.href);
body.set("state", callbackState ?? searchParams.get("state"));
body.set("username", username);
body.set("password", password);
if (grantedScope === undefined) {
grantedScope = expectedScope;
}
if (grantedScope) {
for (const scope of grantedScope.split(" ")) {
body.append("scope", scope);
}
}
if (redirectURI.protocol == "net.thunderbird:") {
// We can't do HTTP redirect to the callback URL, but we can call the
// code that generates it and pretend the redirect happened.
const redirectedURL = this._oAuth2Server.getRedirectURL(body.toString());
Cc["@mozilla.org/mail/oauth2-url-handler;1"]
.getService(Ci.nsIObserver)
.observe(null, "net-thunderbird-url", redirectedURL);
return;
}
// The form data is sent to the OAuth server, which sends a 303 Redirected
// response, sending the browser to our callback listener socket.
const authorizeURL = new URL("/authorize", authURL);
const authorizeResponse = await fetch(authorizeURL, {
method: "POST",
body,
});
Assert.equal(authorizeResponse.status, 200, "callback response status");
Assert.equal(
new URL(authorizeResponse.url).origin,
redirectURI.origin,
"authorization should redirect to the callback listener"
);
const source = await authorizeResponse.text();
const resultPageSource = await (expectSuccess
? OAuth2PageGenerator.generateSuccessPage()
: OAuth2PageGenerator.generateErrorPage());
if (source == resultPageSource) {
Assert.equal(
"<result page source>",
"<result page source>",
"Should return the expected result page (truncated for log size)"
);
} else {
Assert.equal(
source,
resultPageSource,
"Should return the expected result page"
);
}
// At this point the browser is displaying a message to close the tab and
// return to Thunderbird.
},
/**
* 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
* @param {string} [options.expectedScope] - If given, the scope URL parameter
* will be checked. A space-separated list.
* @param {string} options.username - The username to use to log in.
* @param {string} options.password - The password to use to log in.
* @param {string} [options.grantedScope] - A subset of `expectedScope` to grant
* permission for. If not given, all scopes will be allowed. If an empty string,
* no scopes will be allowed.
*/
submitOAuthLogin: async ({
expectedHint,
expectedScope = "test_mail test_addressbook test_calendar",
username,
password,
grantedScope,
}) => {
/* 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"),
"request redirect_uri"
);
Assert.ok(searchParams.get("state"), "request state");
Assert.equal(searchParams.get("scope"), expectedScope, "request scope");
if (expectedHint) {
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);
if (grantedScope === undefined) {
grantedScope = expectedScope;
}
if (grantedScope) {
for (const scope of grantedScope.split(" ")) {
content.document.querySelector(
`input[name="scope"][value="${scope}"]`
).checked = true;
}
}
EventUtils.synthesizeMouseAtCenter(
content.document.querySelector(`input[type="submit"]`),
{},
content
);
},
/**
* Remove `token` from the list of valid tokens.
*
* @param {string} token
*/
revokeToken(token) {
tokens.delete(token);
},
/**
* Check that the granted `token` is valid for the `scope`.
*
* @param {string} token
* @param {string} scope
* @returns {boolean}
*/
validateToken(token, scope) {
const grantedScope = tokens.get(token);
if (!grantedScope) {
return false;
}
return grantedScope.split(" ").includes(scope);
},
/**
* Check the recorded telemetry values match what we expect. Don't forget to
* reset the data `Services.fog.testResetFOG()` at the start of the test.
*
* @param {object[]} expectedEvents - What should have been recorded.
*/
checkTelemetry(expectedEvents) {
const events = Glean.mail.oauth2Authentication.testGetValue();
if (expectedEvents.length) {
if (events) {
Assert.equal(
events.length,
expectedEvents.length,
"OAuth telemetry should have been recorded"
);
for (let i = 0; i < expectedEvents.length; i++) {
Assert.deepEqual(events[i].extra, expectedEvents[i]);
}
} else {
Assert.notEqual(
events,
null,
"OAuth telemetry should have been recorded"
);
}
} else {
Assert.equal(
events,
null,
"no OAuth telemetry should have been recorded"
);
}
},
};
class OAuth2Server {
/**
* @param {object} options
* @param {string} [options.username="user"]
* @param {string} [options.password="password"]
* @param {string} [options.accessToken="access_token"]
* @param {string} [options.refreshToken="refresh_token"]
* @param {boolean} [options.rotateTokens=false]
* @param {?number} [options.expiry=null]
*/
constructor({
username = "user",
password = "password",
accessToken = "access_token",
refreshToken = "refresh_token",
rotateTokens = false,
expiry = null,
} = {}) {
this.username = username;
this.password = password;
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.rotateTokens = rotateTokens;
this.expiry = expiry;
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`);
}
close() {
const port = this.httpServer.identity.primaryPort;
this.httpServer.stop();
dump(`OAuth2 server at localhost:${port} closed\n`);
tokens.clear();
}
/**
* @param {nsIHttpRequest} request
* @param {nsIHttpResponse} response
*/
formHandler(request, response) {
if (request.method != "GET") {
throw HTTP_405;
}
const params = new URLSearchParams(request.queryString);
// Record the PKCE code challenge.
const state = params.get("state");
const codeChallenge = params.get("code_challenge");
Assert.ok(state, "request state");
Assert.ok(codeChallenge, "request code challenge");
Assert.equal(params.get("code_challenge_method"), "S256");
codeChallenges.set(state, codeChallenge);
response.setHeader("Content-Type", "text/html", false);
const scopeCheckboxes = params
.get("scope")
.split(" ")
.map(
scope =>
`<label><input type="checkbox" name="scope" value="${scope}"> ${scope}</label>`
);
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">
<label for="redirect_uri">redirect URI: </label>
<input type="text" name="redirect_uri" readonly="readonly" value="${params.get("redirect_uri")}" /><br/>
<label for="state">state token: </label>
<input type="text" name="state" readonly="readonly" value="${state}" /><br/>
<label for="username">username: </label>
<input type="text" name="username" /><br/>
<label for="password">password: </label>
<input type="password" name="password" /><br/>
${scopeCheckboxes.join("")}<br/>
<input type="submit" />
</form>
</body>
</html>
`);
}
/**
* @param {nsIHttpRequest} request
* @param {nsIHttpResponse} response
*/
authorizeHandler(request, response) {
if (request.method != "POST") {
throw HTTP_405;
}
const input = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
const url = this.getRedirectURL(input);
response.setStatusLine(request.httpVersion, 303, "Redirected");
response.setHeader("Location", url.href);
}
/**
* Get the complete redirect URL for a given authorization.
*
* @param {string} input - URL-encoded parameters.
* @returns {URL}
*/
getRedirectURL(input) {
const params = new URLSearchParams(input);
const state = params.get("state");
Assert.ok(state, "request state");
const url = new URL(params.get("redirect_uri"));
if (params.getAll("scope").includes("bad_scope")) {
url.searchParams.set("error", "invalid_scope");
} else {
this.grantedScope = params.getAll("scope").join(" ");
// Create a unique code. It will become invalid after the first use.
const bytes = CryptoUtils.generateRandomBytes(12);
const code = ChromeUtils.base64URLEncode(bytes, { pad: false });
validCodes.set(code, codeChallenges.get(state));
url.searchParams.set("code", code);
}
url.searchParams.set("state", state);
return url;
}
/**
* @param {nsIHttpRequest} request
* @param {nsIHttpResponse} response
*/
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.
const codeVerifier = params.get("code_verifier");
Assert.ok(codeVerifier, "request code verifier");
Assert.equal(
toBase64URL(CryptoUtils.sha256Base64(codeVerifier)),
validCodes.get(code),
"PKCE codes should match"
);
data.access_token = this.accessToken;
data.refresh_token = this.refreshToken;
tokens.set(this.accessToken, this.grantedScope);
} else if (
goodRequest &&
grantType == "refresh_token" &&
params.get("refresh_token") == this.refreshToken
) {
// Client provided a valid refresh token.
data.access_token = this.accessToken;
if (this.rotateTokens) {
if (/\d+$/.test(this.refreshToken)) {
this.refreshToken = this.refreshToken.replace(
/\d+$/,
suffix => parseInt(suffix, 10) + 1
);
} else {
this.refreshToken = this.refreshToken + "_1";
}
data.refresh_token = this.refreshToken;
}
tokens.set(this.accessToken, this.grantedScope);
} else {
response.setStatusLine("1.1", 400, "Bad Request");
data.error = "invalid_grant";
}
if (typeof this.grantedScope == "string") {
data.scope = this.grantedScope;
}
if (data.access_token && this.expiry !== null) {
data.expires_in = this.expiry;
}
response.setHeader("Content-Type", "application/json", false);
response.write(JSON.stringify(data));
}
}
function toBase64URL(base64) {
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}