diff options
Diffstat (limited to '')
-rw-r--r-- | comm/mailnews/base/src/OAuth2.jsm | 364 |
1 files changed, 364 insertions, 0 deletions
diff --git a/comm/mailnews/base/src/OAuth2.jsm b/comm/mailnews/base/src/OAuth2.jsm new file mode 100644 index 0000000000..c5148d41a7 --- /dev/null +++ b/comm/mailnews/base/src/OAuth2.jsm @@ -0,0 +1,364 @@ +/* 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/. */ + +/** + * Provides OAuth 2.0 authentication. + * + * @see RFC 6749 + */ +var EXPORTED_SYMBOLS = ["OAuth2"]; + +var { CryptoUtils } = ChromeUtils.importESModule( + "resource://services-crypto/utils.sys.mjs" +); + +// Only allow one connecting window per endpoint. +var gConnecting = {}; + +/** + * Constructor for the OAuth2 object. + * + * @class + * @param {?string} scope - The scope as specified by RFC 6749 Section 3.3. + * Will not be included in the requests if falsy. + * @param {object} issuerDetails + * @param {string} issuerDetails.authorizationEndpoint - The authorization + * endpoint as defined by RFC 6749 Section 3.1. + * @param {string} issuerDetails.clientId - The client_id as specified by RFC + * 6749 Section 2.3.1. + * @param {string} issuerDetails.clientSecret - The client_secret as specified + * in RFC 6749 section 2.3.1. Will not be included in the requests if null. + * @param {boolean} issuerDetails.usePKCE - Whether to use PKCE as specified + * in RFC 7636 during the oauth registration process + * @param {string} issuerDetails.redirectionEndpoint - The redirect_uri as + * specified by RFC 6749 section 3.1.2. + * @param {string} issuerDetails.tokenEndpoint - The token endpoint as defined + * by RFC 6749 Section 3.2. + */ +function OAuth2(scope, issuerDetails) { + this.scope = scope; + this.authorizationEndpoint = issuerDetails.authorizationEndpoint; + this.clientId = issuerDetails.clientId; + this.consumerSecret = issuerDetails.clientSecret || null; + this.usePKCE = issuerDetails.usePKCE; + this.redirectionEndpoint = + issuerDetails.redirectionEndpoint || "http://localhost"; + this.tokenEndpoint = issuerDetails.tokenEndpoint; + + this.extraAuthParams = []; + + this.log = console.createInstance({ + prefix: "mailnews.oauth", + maxLogLevel: "Warn", + maxLogLevelPref: "mailnews.oauth.loglevel", + }); +} + +OAuth2.prototype = { + clientId: null, + consumerSecret: null, + requestWindowURI: "chrome://messenger/content/browserRequest.xhtml", + requestWindowFeatures: "chrome,centerscreen,width=980,height=750", + requestWindowTitle: "", + scope: null, + usePKCE: false, + codeChallenge: null, + + accessToken: null, + refreshToken: null, + tokenExpires: 0, + + connect(aSuccess, aFailure, aWithUI, aRefresh) { + this.connectSuccessCallback = aSuccess; + this.connectFailureCallback = aFailure; + + if (this.accessToken && !this.tokenExpired && !aRefresh) { + aSuccess(); + } else if (this.refreshToken) { + this.requestAccessToken(this.refreshToken, true); + } else { + if (!aWithUI) { + aFailure('{ "error": "auth_noui" }'); + return; + } + if (gConnecting[this.authorizationEndpoint]) { + aFailure("Window already open"); + return; + } + this.requestAuthorization(); + } + }, + + /** + * True if the token has expired, or will expire within the grace time. + */ + get tokenExpired() { + // 30 seconds to allow for network inefficiency, clock drift, etc. + const OAUTH_GRACE_TIME_MS = 30 * 1000; + return this.tokenExpires - OAUTH_GRACE_TIME_MS < Date.now(); + }, + + requestAuthorization() { + let params = new URLSearchParams({ + response_type: "code", + client_id: this.clientId, + redirect_uri: this.redirectionEndpoint, + }); + + // The scope is optional. + if (this.scope) { + params.append("scope", this.scope); + } + + // See rfc7636 + if (this.usePKCE) { + // Convert base64 to base64url (rfc4648#section-5) + const to_b64url = b => + b.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + + params.append("code_challenge_method", "S256"); + + // rfc7636#section-4.1 + // code_verifier = high-entropy cryptographic random STRING ... with a minimum + // length of 43 characters and a maximum length of 128 characters. + const code_verifier = to_b64url( + btoa(CryptoUtils.generateRandomBytesLegacy(64)) + ); + this.codeVerifier = code_verifier; + + // rfc7636#section-4.2 + // code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) + const code_challenge = to_b64url(CryptoUtils.sha256Base64(code_verifier)); + params.append("code_challenge", code_challenge); + } + + for (let [name, value] of this.extraAuthParams) { + params.append(name, value); + } + + let authEndpointURI = this.authorizationEndpoint + "?" + params.toString(); + this.log.info( + "Interacting with the resource owner to obtain an authorization grant " + + "from the authorization endpoint: " + + authEndpointURI + ); + + this._browserRequest = { + account: this, + url: authEndpointURI, + _active: true, + iconURI: "", + cancelled() { + if (!this._active) { + return; + } + + this.account.finishAuthorizationRequest(); + this.account.onAuthorizationFailed( + Cr.NS_ERROR_ABORT, + '{ "error": "cancelled"}' + ); + }, + + loaded(aWindow, aWebProgress) { + if (!this._active) { + return; + } + + this._listener = { + window: aWindow, + webProgress: aWebProgress, + _parent: this.account, + + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + + _cleanUp() { + this.webProgress.removeProgressListener(this); + this.window.close(); + delete this.window; + }, + + _checkForRedirect(url) { + if (!url.startsWith(this._parent.redirectionEndpoint)) { + return; + } + + this._parent.finishAuthorizationRequest(); + this._parent.onAuthorizationReceived(url); + }, + + onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) { + const wpl = Ci.nsIWebProgressListener; + if (aStateFlags & (wpl.STATE_START | wpl.STATE_IS_NETWORK)) { + let channel = aRequest.QueryInterface(Ci.nsIChannel); + this._checkForRedirect(channel.URI.spec); + } + }, + onLocationChange(aWebProgress, aRequest, aLocation) { + this._checkForRedirect(aLocation.spec); + }, + onProgressChange() {}, + onStatusChange() {}, + onSecurityChange() {}, + }; + aWebProgress.addProgressListener( + this._listener, + Ci.nsIWebProgress.NOTIFY_ALL + ); + aWindow.document.title = this.account.requestWindowTitle; + }, + }; + + const windowPrivacy = Services.prefs.getBoolPref( + "mailnews.oauth.usePrivateBrowser", + false + ) + ? "private" + : "non-private"; + const windowFeatures = `${this.requestWindowFeatures},${windowPrivacy}`; + + this.wrappedJSObject = this._browserRequest; + gConnecting[this.authorizationEndpoint] = true; + Services.ww.openWindow( + null, + this.requestWindowURI, + null, + windowFeatures, + this + ); + }, + finishAuthorizationRequest() { + gConnecting[this.authorizationEndpoint] = false; + if (!("_browserRequest" in this)) { + return; + } + + this._browserRequest._active = false; + if ("_listener" in this._browserRequest) { + this._browserRequest._listener._cleanUp(); + } + delete this._browserRequest; + }, + + /** + * @param {string} aURL - Redirection URI with additional parameters. + */ + onAuthorizationReceived(aURL) { + this.log.info("OAuth2 authorization response received: url=" + aURL); + const url = new URL(aURL); + if (url.searchParams.has("code")) { + // @see RFC 6749 section 4.1.2: Authorization Response + this.requestAccessToken(url.searchParams.get("code"), false); + } else { + // @see RFC 6749 section 4.1.2.1: Error Response + if (url.searchParams.has("error")) { + let error = url.searchParams.get("error"); + let errorDescription = url.searchParams.get("error_description") || ""; + if (error == "invalid_scope") { + errorDescription += ` Invalid scope: ${this.scope}.`; + } + if (url.searchParams.has("error_uri")) { + errorDescription += ` See ${url.searchParams.get("error_uri")}.`; + } + this.log.error(`Authorization error [${error}]: ${errorDescription}`); + } + this.onAuthorizationFailed(null, aURL); + } + }, + + onAuthorizationFailed(aError, aData) { + this.connectFailureCallback(aData); + }, + + /** + * Request a new access token, or refresh an existing one. + * + * @param {string} aCode - The token issued to the client. + * @param {boolean} aRefresh - Whether it's a refresh of a token or not. + */ + requestAccessToken(aCode, aRefresh) { + // @see RFC 6749 section 4.1.3. Access Token Request + // @see RFC 6749 section 6. Refreshing an Access Token + + let data = new URLSearchParams(); + data.append("client_id", this.clientId); + if (this.consumerSecret !== null) { + // Section 2.3.1. of RFC 6749 states that empty secrets MAY be omitted + // by the client. This OAuth implementation delegates this decision to + // the caller: If the secret is null, it will be omitted. + data.append("client_secret", this.consumerSecret); + } + + if (aRefresh) { + this.log.info( + `Making a refresh request to the token endpoint: ${this.tokenEndpoint}` + ); + data.append("grant_type", "refresh_token"); + data.append("refresh_token", aCode); + } else { + this.log.info( + `Making access token request to the token endpoint: ${this.tokenEndpoint}` + ); + data.append("grant_type", "authorization_code"); + data.append("code", aCode); + data.append("redirect_uri", this.redirectionEndpoint); + if (this.usePKCE) { + data.append("code_verifier", this.codeVerifier); + } + } + + fetch(this.tokenEndpoint, { + method: "POST", + cache: "no-cache", + body: data, + }) + .then(response => response.json()) + .then(result => { + let resultStr = JSON.stringify(result, null, 2); + if ("error" in result) { + // RFC 6749 section 5.2. Error Response + let err = result.error; + if ("error_description" in result) { + err += "; " + result.error_description; + } + if ("error_uri" in result) { + err += "; " + result.error_uri; + } + this.log.warn(`Error response from the authorization server: ${err}`); + this.log.info(`Error response details: ${resultStr}`); + + // Typically in production this would be {"error": "invalid_grant"}. + // That is, the token expired or was revoked (user changed password?). + // Reset the tokens we have and call success so that the auth flow + // will be re-triggered. + this.accessToken = null; + this.refreshToken = null; + this.connectSuccessCallback(); + return; + } + + // RFC 6749 section 5.1. Successful Response + this.log.info( + `Successful response from the authorization server: ${resultStr}` + ); + this.accessToken = result.access_token; + if ("refresh_token" in result) { + this.refreshToken = result.refresh_token; + } + if ("expires_in" in result) { + this.tokenExpires = new Date().getTime() + result.expires_in * 1000; + } else { + this.tokenExpires = Number.MAX_VALUE; + } + this.connectSuccessCallback(); + }) + .catch(err => { + this.log.info(`Connection to authorization server failed: ${err}`); + this.connectFailureCallback(err); + }); + }, +}; |