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/. */
import { MailStringUtils } from "resource:///modules/MailStringUtils.sys.mjs";
import { OAuth2 } from "resource:///modules/OAuth2.sys.mjs";
import { OAuth2CustomDetails } from "resource:///modules/OAuth2CustomDetails.sys.mjs";
import { OAuth2Providers } from "resource:///modules/OAuth2Providers.sys.mjs";
import { enforcePrimaryPassword } from "resource:///modules/PrimaryPassword.sys.mjs";
const log = console.createInstance({
prefix: "mailnews.oauth",
maxLogLevel: "Warn",
maxLogLevelPref: "mailnews.oauth.loglevel",
});
/**
* A collection of `OAuth2` objects that have previously been created.
* Only weak references are stored here, so if all the owners of an `OAuth2`
* is cleaned up, so is the object itself.
*/
const oAuth2Objects = new Set();
/**
* OAuth2Module is the glue layer that gives XPCOM access to an OAuth2
* bearer token it can use to authenticate in SASL steps.
* It also takes care of persising the refreshToken for later usage.
*
* @implements {msgIOAuth2Module}
*/
export function OAuth2Module() {}
OAuth2Module.prototype = {
QueryInterface: ChromeUtils.generateQI(["msgIOAuth2Module"]),
initFromOutgoing(server, customDetails) {
customDetails ??= OAuth2CustomDetails.fromOutgoingServer(server);
return this.initFromHostname(
server.serverURI.host,
server.username,
server.type,
customDetails
);
},
initFromMail(server, customDetails) {
customDetails ??= OAuth2CustomDetails.fromIncomingServer(server);
return this.initFromHostname(
server.hostname,
server.username,
server.type,
customDetails
);
},
initFromHostname(hostname, username, type, customDetails) {
if (typeof customDetails == "undefined") {
customDetails = null;
}
const overridePrefEnabled = Services.prefs.getBoolPref(
"experimental.mail.ews.overrideOAuth.enabled",
false
);
const doOverrides =
overridePrefEnabled && customDetails && customDetails.useCustomDetails;
const details = doOverrides
? this._getDetailsWithOverrides(hostname, username, type, customDetails)
: OAuth2Providers.getHostnameDetails(hostname, type, username);
if (!details) {
return false;
}
const { issuer, allScopes, requiredScopes } = details;
// Find the app key we need for the OAuth2 string. Eventually, this should
// be using dynamic client registration, but there are no current
// implementations that we can test this with.
const issuerDetails = doOverrides
? this._getIssuerWithOverrides(issuer, customDetails)
: OAuth2Providers.getIssuerDetails(issuer);
if (!issuerDetails.clientId) {
return false;
}
// Username is needed to generate the XOAUTH2 string.
this._username = username;
// loginOrigin is needed to save the refresh token in the password manager.
this._loginOrigin = "oauth://" + issuer;
// We use the scope to indicate realm when storing in the password manager.
this._scope = allScopes;
this._requiredScopes = scopeSet(requiredScopes);
// Look for an existing `OAuth2` object with the same issuer, endpoint,
// username, and scope.
for (const weakRef of oAuth2Objects) {
const oauth = weakRef.deref();
if (!oauth) {
oAuth2Objects.delete(weakRef);
continue;
}
if (
oauth.loginOrigin == this._loginOrigin &&
oauth.authorizationEndpoint == issuerDetails.authorizationEndpoint &&
oauth.username == username &&
scopeSet(oauth.scope).isSupersetOf(this._requiredScopes)
) {
log.debug(
`Found existing OAuth2 object for ${this._username} at ${issuer}`
);
this._oauth = oauth;
break;
}
}
if (!this._oauth) {
log.debug(
`Creating a new OAuth2 object for ${this._username} at ${issuer} with scope=${this._scope}`
);
// This gets the refresh token from the login manager. It may change
// `this._scope` if a refresh token was found for the required scopes
// but not all of the wanted scopes.
let refreshToken;
let finished = false;
this.getRefreshToken()
.then(ok => (refreshToken = ok))
.finally(() => (finished = true));
Services.tm.spinEventLoopUntilOrQuit(
"OAuth2Module:initFromHostname",
() => finished
);
// Define the OAuth property and store it.
this._oauth = new OAuth2(this._scope, issuerDetails);
this._oauth.loginOrigin = this._loginOrigin;
this._oauth.username = username;
oAuth2Objects.add(new WeakRef(this._oauth));
// Try hinting the username...
this._oauth.extraAuthParams = [["login_hint", username]];
// Set the window title to something more useful than "Unnamed"
this._oauth.requestWindowTitle = Services.strings
.formatStringFromName("oauth2WindowTitle", [username, hostname]);
this._oauth.refreshToken = refreshToken;
}
return true;
},
/**
* Get a refresh token that matches this object from the login manager, if
* one exists. May set `this._scope` if the scope of the token is wider than
* `this._requiredScopes`.
*
* @returns {string} - A refresh token, or an empty string.
*/
async getRefreshToken() {
for (const login of await Services.logins.searchLoginsAsync({
origin: this._loginOrigin,
})) {
if (login.username != this._username) {
continue;
}
if (scopeSet(login.httpRealm).isSupersetOf(this._requiredScopes)) {
this._scope = login.httpRealm;
return login.password;
}
}
return "";
},
/**
* Set the refresh token in the login manager, modifying any existing logins
* that this token supersedes, or adding a new login if none exists.
*
* @param {string} token
*/
async setRefreshToken(token) {
// This might seem unnecessary, given that `setRefreshToken` gets called
// because of a change in `this._oauth.refreshToken`, but we also might
// be here because of an external caller, e.g. an add-on.
this._oauth.refreshToken = token;
const scope = this._oauth.scope ?? this._scope;
const grantedScopes = scopeSet(scope);
// Update any existing logins matching this origin, username, and scope.
const logins = await Services.logins.searchLoginsAsync({
origin: this._loginOrigin,
});
let didChangePassword = false;
for (const login of logins) {
if (login.username != this._username) {
continue;
}
log.debug(`Found matching login for ${this._username}`);
const loginScopes = scopeSet(login.httpRealm);
if (grantedScopes.isSupersetOf(loginScopes) && token) {
const propBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
Ci.nsIWritablePropertyBag
);
let updated = false;
if (login.password != token) {
// Update the token...
propBag.setProperty("password", token);
updated = true;
}
if (grantedScopes.size > loginScopes.size) {
// ... and the scopes...
propBag.setProperty("httpRealm", scope);
updated = true;
}
if (updated) {
// ... but only if something actually changed.
log.debug(
`Updating existing token for ${this._username} at ${this._loginOrigin} with scope "${scope}"`
);
propBag.setProperty("timePasswordChanged", Date.now());
await Services.logins.modifyLoginAsync(login, propBag);
}
didChangePassword = true;
}
}
// Unless the token is null, we need to create and fill in a new login.
if (!didChangePassword && token) {
if (!enforcePrimaryPassword()) {
log.debug(`Need primary password. Will skip creating new login.`);
return;
}
log.debug(
`Creating new login for ${this._username} at ${this._loginOrigin} with scope "${scope}"`
);
const login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
Ci.nsILoginInfo
);
login.init(this._loginOrigin, null, scope, this._username, token, "", "");
await Services.logins.addLoginAsync(login);
} else {
log.debug(
`Not creating new login for ${this._username} at ${this._loginOrigin} with scope "${scope}" (changed=${didChangePassword}; token length=${token.length})`
);
}
},
/**
* Clear the access token in memory, so that the next attempt to access it
* must query the server.
*/
clearAccessToken() {
this._oauth.accessToken = null;
},
/**
* Clear the refresh and access tokens in memory, and the refresh token from
* the login manager, so the the next attempt to use this object must
* re-authenticate.
*/
async clearTokens() {
this._oauth.refreshToken = null;
this._oauth.accessToken = null;
const scope = this._oauth.scope ?? this._scope;
const grantedScopes = scopeSet(scope);
// Update any existing logins matching this origin, username, and scope.
const logins = await Services.logins.searchLoginsAsync({
origin: this._loginOrigin,
});
for (const login of logins) {
if (login.username != this._username) {
continue;
}
const loginScopes = scopeSet(login.httpRealm);
if (grantedScopes.isSupersetOf(loginScopes)) {
// We've got a new token for this scope, remove the existing one.
log.debug(
`Removing obsolete token for ${this._username} at ${this._loginOrigin} with scope "${login.httpRealm}"`
);
await Services.logins.removeLoginAsync(login);
}
}
},
connect(withUI, listener) {
this._fetchAccessToken(listener, withUI, true);
},
getAccessToken(listener) {
this._fetchAccessToken(listener, true, false);
},
/**
* Return the hostname details with the given issuer and scopes override values applied.
*
* The username is passed to the provider lookup so that an
* extension-provided OAuth configuration can be limited to operate on
* specific username domains. If the username is not email-like, or no
* matching extension registration exists, the normal built-in provider lookup is used.
*
* Generic custom details replace the registered hostname details completely.
* Protocol-specific details (i.e., Exchange) are instead applied on top of
* registered hostname details, allowing empty values to inherit defaults.
*
* @param {string} hostname
* @param {string} username
* @param {string} type
* @param {IOAuth2CustomDetails} customDetails
* @returns {OAuth2Providers.hostnameDetails}
*/
_getDetailsWithOverrides(hostname, username, type, customDetails) {
if (customDetails instanceof OAuth2CustomDetails) {
return {
issuer: customDetails.issuer,
allScopes: customDetails.scopes,
requiredScopes: customDetails.scopes,
};
}
let details = OAuth2Providers.getHostnameDetails(hostname, type, username);
if (!customDetails) {
return details;
}
if (!details) {
// If it's not a known issuer, then we have to have a custom issuer and scopes.
// We are allowing overrides because the previous check didn't return.
if (!customDetails.issuer || !customDetails.scopes) {
return null;
}
details = {
issuer: customDetails.issuer,
allScopes: customDetails.scopes,
requiredScopes: customDetails.scopes,
};
} else {
// If it's a known issuer, and we're allowing overrides, then
// override the known values with the custom values.
details.issuer = customDetails.issuer || details.issuer;
details.allScopes = customDetails.scopes || details.allScopes;
details.requiredScopes = customDetails.scopes || details.requiredScopes;
}
return details;
},
/**
* Return the details for the given `issuer` from the `OAuth2Provider` with
* the given `customDetails` applied on top of them.
*
* If there are no known details for the given `issuer`, and no custom details
* are provided, then this function will return null.
*
* @param {string} issuer
* @param {IOAuth2CustomDetails} customDetails
* @returns {Array<string>}
*/
_getIssuerWithOverrides(issuer, customDetails) {
let issuerDetails = OAuth2Providers.getIssuerDetails(issuer);
if (typeof customDetails != "undefined" && customDetails) {
// Don't overwrite the object we got from the static configuration so we
// can roll back to it if overrides are disabled later.
issuerDetails = structuredClone(issuerDetails) ?? {};
// string attributes, which can fall back to a default if left empty
const attributes = [
"clientId",
"authorizationEndpoint",
"tokenEndpoint",
"redirectionEndpoint",
"clientSecret",
"issuerIdentifier",
];
for (const key of attributes) {
if (customDetails.hasOwnProperty(key) && customDetails[key]) {
issuerDetails[key] = customDetails[key];
}
}
// bool attributes, which always override any existing configuration
for (const key of ["usePKCE", "useExternalBrowser"]) {
issuerDetails[key] = customDetails[key];
}
}
if (typeof issuerDetails == "undefined") {
return null;
}
return issuerDetails;
},
/**
* Cancel any pending OAuth prompt.
*/
cancelPrompt() {
this._oauth?.onAuthorizationFailed(null, {}, "cancelled");
this._oauth?.finishAuthorizationRequest();
},
/**
* Gets a current access token for the provider.
*
* @param {msgIOAuth2ModuleListener} listener - The listener for the results
* of authentication.
* @param {bool} shouldPrompt - If true and user input is needed to complete
* authentication (such as logging in to the provider), prompt the user.
* Otherwise, return an error.
* @param {bool} shouldMakeSaslToken - If true, return an access token
* formatted for use with SASL XOAUTH2. Otherwise, return the access token
* unmodified.
*/
_fetchAccessToken(listener, shouldPrompt, shouldMakeSaslToken) {
// NOTE: `onPromptStartAsync` and `onPromptAuthAvailable` have _different_
// values for `this` due to differences in how arrow functions bind `this`
// (i.e., to the surrounding lexical scope rather than the object of which)
// they are a member).
const promptListener = {
onPromptStartAsync(callback) {
this.onPromptAuthAvailable(callback);
},
onPromptAuthAvailable: callback => {
const oldRefreshToken = this._oauth.refreshToken;
this._oauth.connect(shouldPrompt, false).then(
async () => {
if (
this._oauth.refreshToken != oldRefreshToken ||
this._oauth.scope != this._scope
) {
// We don't have a username, but we might be able to get it now.
if (this._username === null) {
if (
this._oauth.accessToken &&
[
].includes(this._loginOrigin)
) {
const jwt = JSON.parse(
new TextDecoder().decode(
ChromeUtils.base64URLDecode(
this._oauth.accessToken.split(".")[1],
{ padding: "ignore" }
)
)
);
this._oauth.username = this._username =
jwt.preferred_username;
log.debug(
`Found a username for ${this._loginOrigin}: ${this._username}`
);
} else {
log.error(
`Couldn't find a username for ${this._loginOrigin}`
);
listener.onFailure(Cr.NS_ERROR_ABORT);
callback?.onAuthResult(false);
return;
}
}
// Refresh token and/or scope changed; save them.
await this.setRefreshToken(this._oauth.refreshToken);
this._scope = this._oauth.scope;
}
let retval = this._oauth.accessToken;
if (shouldMakeSaslToken) {
// Pre-format the return value for an SASL XOAUTH2 client response
// if that's what the consumer is expecting.
retval = btoa(
MailStringUtils.stringToByteString(
`user=${this._username}\x01auth=Bearer ${retval}\x01\x01`
)
);
}
listener.onSuccess(retval);
callback?.onAuthResult(true);
},
() => {
listener.onFailure(Cr.NS_ERROR_ABORT);
callback?.onAuthResult(false);
}
);
},
onPromptCanceled() {
listener.onFailure(Cr.NS_ERROR_ABORT);
},
onPromptStart() {},
};
const asyncPrompter = Cc[
"@mozilla.org/messenger/msgAsyncPrompter;1"
].getService(Ci.nsIMsgAsyncPrompter);
const promptKey = `${this._loginOrigin}/${this._username}`;
asyncPrompter.queueAsyncAuthPrompt(promptKey, false, promptListener);
},
};
/**
* Forget any `OAuth2` objects we've stored.
*/
OAuth2Module._forgetObjects = function () {
log.debug("Clearing all OAuth2 objects from cache");
for (const weakRef of oAuth2Objects) {
const oauth = weakRef.deref();
if (oauth) {
oauth.refreshToken = null;
oauth.accessToken = null;
}
}
oAuth2Objects.clear();
OAuth2.clearState();
};
/**
* Forget any `OAuth2` objects we've stored matching `origin` and `username`.
*
* @param {string} origin
* @param {string} username
*/
OAuth2Module._forgetObjectsMatching = function (origin, username) {
log.debug(`Clearing OAuth2 objects for ${username} at ${origin} from cache`);
for (const weakRef of oAuth2Objects) {
const oauth = weakRef.deref();
if (oauth?.loginOrigin == origin && oauth.username == username) {
oauth.refreshToken = null;
oauth.accessToken = null;
oAuth2Objects.delete(weakRef);
}
}
};
/**
* Listens for passwords being removed and forgets `OAuth2` objects associated
* with them.
*/
const observer = {
observe(subject, topic, data) {
if (topic == "passwordmgr-storage-changed") {
if (data == "removeLogin") {
subject.QueryInterface(Ci.nsILoginInfo);
if (subject.origin.startsWith("oauth://")) {
OAuth2Module._forgetObjectsMatching(subject.origin, subject.username);
}
} else if (data == "removeAllLogins") {
OAuth2Module._forgetObjects();
}
} else if (topic == "quit-application-granted") {
Services.obs.removeObserver(this, "passwordmgr-storage-changed");
Services.obs.removeObserver(this, "quit-application-granted");
}
},
};
Services.obs.addObserver(observer, "passwordmgr-storage-changed");
Services.obs.addObserver(observer, "quit-application-granted");
/**
* Turns a space-delimited string of scopes into a Set containing the scopes.
*
* @param {string} scopeString
* @returns {Set}
*/
function scopeSet(scopeString) {
if (!scopeString) {
return new Set();
}
return new Set(scopeString.split(" "));
}