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
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) {
return this.initFromHostname(
server.serverURI.host,
server.username,
server.type,
customDetails
);
},
initFromMail(server, customDetails) {
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._getHostnameDetailsWithOverrides(hostname, type, customDetails)
: OAuth2Providers.getHostnameDetails(hostname, type);
if (!details) {
return false;
}
const { issuer, allScopes, requiredScopes } = details;
// Set pref for Yahoo/AOL/AT&T users if applicable
// TODO: Remove this when PKCE is fully rolled out for Yahoo/AOL/AT&T
const yahooLikeIssuer = ["login.yahoo.com", "login.aol.com"].includes(
details.issuer
);
if (
yahooLikeIssuer &&
!Services.prefs.getBoolPref(
"mail.inappnotifications.pkceUpgradeForYahooAol",
false
)
) {
Services.prefs.setBoolPref(
"mail.inappnotifications.pkceUpgradeForYahooAol",
true
);
}
// 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 endpoint, username
// and scope.
for (const weakRef of oAuth2Objects) {
const oauth = weakRef.deref();
if (!oauth) {
oAuth2Objects.delete(weakRef);
continue;
}
if (
oauth.authorizationEndpoint == issuerDetails.authorizationEndpoint &&
oauth.username == username &&
scopeSet(oauth.scope).isSupersetOf(this._requiredScopes)
) {
log.debug(`Found existing OAuth2 object for ${issuer}`);
this._oauth = oauth;
break;
}
}
if (!this._oauth) {
log.debug(`Creating a new OAuth2 object for ${issuer}`);
// 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.
const refreshToken = this.getRefreshToken();
// Define the OAuth property and store it.
this._oauth = new OAuth2(this._scope, issuerDetails);
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.
*/
getRefreshToken() {
for (const login of Services.logins.findLogins(
this._loginOrigin,
null,
""
)) {
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 = Services.logins.findLogins(this._loginOrigin, null, "");
let didChangePassword = false;
for (const login of logins) {
if (login.username != this._username) {
continue;
}
const loginScopes = scopeSet(login.httpRealm);
if (grantedScopes.isSupersetOf(loginScopes)) {
if (grantedScopes.size == loginScopes.size && token) {
// The scope matches, just update the token...
if (login.password != token) {
// ... but only if it actually changed.
log.debug(
`Updating existing token for ${this._loginOrigin} with scope "${scope}"`
);
const propBag = Cc[
"@mozilla.org/hash-property-bag;1"
].createInstance(Ci.nsIWritablePropertyBag);
propBag.setProperty("password", token);
propBag.setProperty("timePasswordChanged", Date.now());
Services.logins.modifyLogin(login, propBag);
}
didChangePassword = true;
} else {
// We've got a new token for this scope, remove the existing one.
log.debug(
`Removing superseded token for ${this._loginOrigin} with scope "${login.httpRealm}"`
);
Services.logins.removeLogin(login);
}
}
}
// Unless the token is null, we need to create and fill in a new login.
if (!didChangePassword && token) {
log.debug(
`Creating new login for ${this._loginOrigin} with httpRealm "${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);
}
},
/**
* 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.
*/
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 = Services.logins.findLogins(this._loginOrigin, null, "");
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._loginOrigin} with scope "${login.httpRealm}"`
);
Services.logins.removeLogin(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.
*
* If there is no known provider for the given hostname, `customDetails` must
* be non-null, and `customDetails.issuer` and `customDetails.scope` must also
* contain valid values.
*
* If there is no known provider and any of `customDetails`,
* `customDetails.issuer` or `customDetails.scopes` is empty, this function
* will return `null`.
*
* @param {string} hostname
* @param {string} type
* @param {IOAuth2CustomDetails} customDetails
* @returns {OAuth2Providers.hostnameDetails}
*/
_getHostnameDetailsWithOverrides(hostname, type, customDetails) {
let details = OAuth2Providers.getHostnameDetails(hostname, type);
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) ?? {};
const attributes = [
"clientId",
"clientSecret",
"authorizationEndpoint",
"tokenEndpoint",
"redirectionEndpoint",
"usePKCE",
];
for (const key of attributes) {
if (customDetails.hasOwnProperty(key) && customDetails[key]) {
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
) {
// 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(
`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, which is necessary in some
* testing scenarios.
*/
OAuth2Module._forgetObjects = function () {
log.debug("Clearing OAuth2 objects from cache");
oAuth2Objects.clear();
};
/**
* 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(" "));
}