summaryrefslogtreecommitdiffstats
path: root/comm/calendar/providers/caldav/modules/CalDavSession.jsm
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--comm/calendar/providers/caldav/modules/CalDavSession.jsm573
1 files changed, 573 insertions, 0 deletions
diff --git a/comm/calendar/providers/caldav/modules/CalDavSession.jsm b/comm/calendar/providers/caldav/modules/CalDavSession.jsm
new file mode 100644
index 0000000000..c94bfdaff7
--- /dev/null
+++ b/comm/calendar/providers/caldav/modules/CalDavSession.jsm
@@ -0,0 +1,573 @@
+/* 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/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { OAuth2 } = ChromeUtils.import("resource:///modules/OAuth2.jsm");
+var { setTimeout } = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(lazy, "OAuth2Providers", "resource:///modules/OAuth2Providers.jsm");
+
+/**
+ * Session and authentication tools for the caldav provider
+ */
+
+const EXPORTED_SYMBOLS = ["CalDavDetectionSession", "CalDavSession"];
+/* exported CalDavDetectionSession, CalDavSession */
+
+const OAUTH_GRACE_TIME = 30 * 1000;
+
+class CalDavOAuth extends OAuth2 {
+ /**
+ * Returns true if the token has expired, or will expire within the grace time.
+ */
+ get tokenExpired() {
+ let now = new Date().getTime();
+ return this.tokenExpires - OAUTH_GRACE_TIME < now;
+ }
+
+ /**
+ * Retrieves the refresh token from the password manager. The token is cached.
+ */
+ get refreshToken() {
+ cal.ASSERT(this.id, `This ${this.constructor.name} object has no id.`);
+ if (!this._refreshToken) {
+ let pass = { value: null };
+ try {
+ cal.auth.passwordManagerGet(this.id, pass, this.origin, this.pwMgrId);
+ } catch (e) {
+ // User might have cancelled the primary password prompt, that's ok
+ if (e.result != Cr.NS_ERROR_ABORT) {
+ throw e;
+ }
+ }
+ this._refreshToken = pass.value;
+ }
+ return this._refreshToken;
+ }
+
+ /**
+ * Saves the refresh token in the password manager
+ *
+ * @param {string} aVal - The value to set
+ */
+ set refreshToken(aVal) {
+ try {
+ if (aVal) {
+ cal.auth.passwordManagerSave(this.id, aVal, this.origin, this.pwMgrId);
+ } else {
+ cal.auth.passwordManagerRemove(this.id, this.origin, this.pwMgrId);
+ }
+ } catch (e) {
+ // User might have cancelled the primary password prompt, that's ok
+ if (e.result != Cr.NS_ERROR_ABORT) {
+ throw e;
+ }
+ }
+ this._refreshToken = aVal;
+ }
+
+ /**
+ * Wait for the calendar window to appear.
+ *
+ * This is a workaround for bug 901329: If the calendar window isn't loaded yet the master
+ * password prompt will show just the buttons and possibly hang. If we postpone until the window
+ * is loaded, all is well.
+ *
+ * @returns {Promise} A promise resolved without value when the window is loaded
+ */
+ waitForCalendarWindow() {
+ return new Promise(resolve => {
+ // eslint-disable-next-line func-names, require-jsdoc
+ function postpone() {
+ let win = cal.window.getCalendarWindow();
+ if (!win || win.document.readyState != "complete") {
+ setTimeout(postpone, 0);
+ } else {
+ resolve();
+ }
+ }
+ setTimeout(postpone, 0);
+ });
+ }
+
+ /**
+ * Promisified version of |connect|, using all means necessary to gracefully display the
+ * authentication prompt.
+ *
+ * @param {boolean} aWithUI - If UI should be shown for authentication
+ * @param {boolean} aRefresh - Force refresh the token TODO default false
+ * @returns {Promise} A promise resolved when the OAuth process is completed
+ */
+ promiseConnect(aWithUI = true, aRefresh = true) {
+ return this.waitForCalendarWindow().then(() => {
+ return new Promise((resolve, reject) => {
+ let self = this;
+ let asyncprompter = Cc["@mozilla.org/messenger/msgAsyncPrompter;1"].getService(
+ Ci.nsIMsgAsyncPrompter
+ );
+ asyncprompter.queueAsyncAuthPrompt(this.id, false, {
+ onPromptStartAsync(callback) {
+ this.onPromptAuthAvailable(callback);
+ },
+
+ onPromptAuthAvailable(callback) {
+ self.connect(
+ () => {
+ if (callback) {
+ callback.onAuthResult(true);
+ }
+ resolve();
+ },
+ () => {
+ if (callback) {
+ callback.onAuthResult(false);
+ }
+ reject();
+ },
+ aWithUI,
+ aRefresh
+ );
+ },
+ onPromptCanceled: reject,
+ onPromptStart() {},
+ });
+ });
+ });
+ }
+
+ /**
+ * Prepare the given channel for an OAuth request
+ *
+ * @param {nsIChannel} aChannel - The channel to prepare
+ */
+ async prepareRequest(aChannel) {
+ if (!this.accessToken || this.tokenExpired) {
+ // The token has expired, we need to reauthenticate first
+ cal.LOG("CalDAV: OAuth token expired or empty, refreshing");
+ await this.promiseConnect();
+ }
+
+ let hdr = "Bearer " + this.accessToken;
+ aChannel.setRequestHeader("Authorization", hdr, false);
+ }
+
+ /**
+ * Prepare the redirect, copying the auth header to the new channel
+ *
+ * @param {nsIChannel} aOldChannel - The old channel that is being redirected
+ * @param {nsIChannel} aNewChannel - The new channel to prepare
+ */
+ async prepareRedirect(aOldChannel, aNewChannel) {
+ try {
+ let hdrValue = aOldChannel.getRequestHeader("Authorization");
+ if (hdrValue) {
+ aNewChannel.setRequestHeader("Authorization", hdrValue, false);
+ }
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_NOT_AVAILABLE) {
+ // The header could possibly not be available, ignore that
+ // case but throw otherwise
+ throw e;
+ }
+ }
+ }
+
+ /**
+ * Check for OAuth auth errors and restart the request without a token if necessary
+ *
+ * @param {CalDavResponseBase} aResponse - The response to inspect for completion
+ * @returns {Promise} A promise resolved when complete, with
+ * CalDavSession.RESTART_REQUEST or null
+ */
+ async completeRequest(aResponse) {
+ // Check for OAuth errors
+ let wwwauth = aResponse.getHeader("WWW-Authenticate");
+ if (this.oauth && wwwauth && wwwauth.startsWith("Bearer") && wwwauth.includes("error=")) {
+ this.oauth.accessToken = null;
+
+ return CalDavSession.RESTART_REQUEST;
+ }
+ return null;
+ }
+}
+
+/**
+ * Authentication provider for Google's OAuth.
+ */
+class CalDavGoogleOAuth extends CalDavOAuth {
+ /**
+ * Constructs a new Google OAuth authentication provider
+ *
+ * @param {string} sessionId - The session id, used in the password manager
+ * @param {string} name - The user-readable description of this session
+ */
+ constructor(sessionId, name) {
+ /* eslint-disable no-undef */
+ super("https://www.googleapis.com/auth/calendar", {
+ authorizationEndpoint: "https://accounts.google.com/o/oauth2/auth",
+ tokenEndpoint: "https://www.googleapis.com/oauth2/v3/token",
+ clientId: OAUTH_CLIENT_ID,
+ clientSecret: OAUTH_HASH,
+ });
+ /* eslint-enable no-undef */
+
+ this.id = sessionId;
+ this.origin = "oauth:" + sessionId;
+ this.pwMgrId = "Google CalDAV v2";
+
+ this._maybeUpgrade(name);
+
+ this.requestWindowTitle = cal.l10n.getAnyString(
+ "global",
+ "commonDialogs",
+ "EnterUserPasswordFor2",
+ [name]
+ );
+ this.extraAuthParams = [["login_hint", name]];
+ }
+
+ /**
+ * If no token is found for "Google CalDAV v2", this is either a new session (in which case
+ * it should use Thunderbird's credentials) or it's already using Thunderbird's credentials.
+ * Detect those situations and switch credentials if necessary.
+ */
+ _maybeUpgrade() {
+ if (!this.refreshToken) {
+ const issuerDetails = lazy.OAuth2Providers.getIssuerDetails("accounts.google.com");
+ this.clientId = issuerDetails.clientId;
+ this.consumerSecret = issuerDetails.clientSecret;
+
+ this.origin = "oauth://accounts.google.com";
+ this.pwMgrId = "https://www.googleapis.com/auth/calendar";
+ }
+ }
+}
+
+/**
+ * Authentication provider for Fastmail's OAuth.
+ */
+class CalDavFastmailOAuth extends CalDavOAuth {
+ /**
+ * Constructs a new Fastmail OAuth authentication provider
+ *
+ * @param {string} sessionId - The session id, used in the password manager
+ * @param {string} name - The user-readable description of this session
+ */
+ constructor(sessionId, name) {
+ /* eslint-disable no-undef */
+ super("https://www.fastmail.com/dev/protocol-caldav", {
+ authorizationEndpoint: "https://api.fastmail.com/oauth/authorize",
+ tokenEndpoint: "https://api.fastmail.com/oauth/refresh",
+ clientId: OAUTH_CLIENT_ID,
+ clientSecret: OAUTH_HASH,
+ usePKCE: true,
+ });
+ /* eslint-enable no-undef */
+
+ this.id = sessionId;
+ this.origin = "oauth:" + sessionId;
+ this.pwMgrId = "Fastmail CalDAV";
+
+ this._maybeUpgrade(name);
+
+ this.requestWindowTitle = cal.l10n.getAnyString(
+ "global",
+ "commonDialogs",
+ "EnterUserPasswordFor2",
+ [name]
+ );
+ this.extraAuthParams = [["login_hint", name]];
+ }
+
+ /**
+ * If no token is found for "Fastmail CalDAV", this is either a new session (in which case
+ * it should use Thunderbird's credentials) or it's already using Thunderbird's credentials.
+ * Detect those situations and switch credentials if necessary.
+ */
+ _maybeUpgrade() {
+ if (!this.refreshToken) {
+ const issuerDetails = lazy.OAuth2Providers.getIssuerDetails("www.fastmail.com");
+ this.clientId = issuerDetails.clientId;
+
+ this.origin = "oauth://www.fastmail.com";
+ this.pwMgrId = "https://www.fastmail.com/dev/protocol-caldav";
+ }
+ }
+}
+
+/**
+ * A modified version of CalDavGoogleOAuth for testing. This class mimics the
+ * real class as closely as possible.
+ */
+class CalDavTestOAuth extends CalDavGoogleOAuth {
+ constructor(sessionId, name) {
+ super(sessionId, name);
+
+ // Override these values with test values.
+ this.authorizationEndpoint =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/redirect_auto.sjs";
+ this.tokenEndpoint =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/token.sjs";
+ this.scope = "test_scope";
+ this.clientId = "test_client_id";
+ this.consumerSecret = "test_scope";
+
+ // I don't know why, but tests refuse to work with a plain HTTP endpoint
+ // (the request is redirected to HTTPS, which we're not listening to).
+ // Just use an HTTPS endpoint.
+ this.redirectionEndpoint = "https://localhost";
+ }
+
+ _maybeUpgrade() {
+ if (!this.refreshToken) {
+ const issuerDetails = lazy.OAuth2Providers.getIssuerDetails("mochi.test");
+ this.clientId = issuerDetails.clientId;
+ this.consumerSecret = issuerDetails.clientSecret;
+
+ this.origin = "oauth://mochi.test";
+ this.pwMgrId = "test_scope";
+ }
+ }
+}
+
+/**
+ * A session for the caldav provider. Two or more calendars can share a session if they have the
+ * same auth credentials.
+ */
+class CalDavSession {
+ QueryInterface = ChromeUtils.generateQI(["nsIInterfaceRequestor"]);
+
+ /**
+ * Dictionary of hostname => auth adapter. Before a request is made to a hostname
+ * in the dictionary, the auth adapter will be called to modify the request.
+ */
+ authAdapters = {};
+
+ /**
+ * Constant returned by |completeRequest| when the request should be restarted
+ *
+ * @returns {number} The constant
+ */
+ static get RESTART_REQUEST() {
+ return 1;
+ }
+
+ /**
+ * Creates a new caldav session
+ *
+ * @param {string} aSessionId - The session id, used in the password manager
+ * @param {string} aName - The user-readable description of this session
+ */
+ constructor(aSessionId, aName) {
+ this.id = aSessionId;
+ this.name = aName;
+
+ // Only create an auth adapter if we're going to use it.
+ XPCOMUtils.defineLazyGetter(
+ this.authAdapters,
+ "apidata.googleusercontent.com",
+ () => new CalDavGoogleOAuth(aSessionId, aName)
+ );
+ XPCOMUtils.defineLazyGetter(
+ this.authAdapters,
+ "caldav.fastmail.com",
+ () => new CalDavFastmailOAuth(aSessionId, aName)
+ );
+ XPCOMUtils.defineLazyGetter(
+ this.authAdapters,
+ "mochi.test",
+ () => new CalDavTestOAuth(aSessionId, aName)
+ );
+ }
+
+ /**
+ * Implement nsIInterfaceRequestor. The base class has no extra interfaces, but a subclass of
+ * the session may.
+ *
+ * @param {nsIIDRef} aIID - The IID of the interface being requested
+ * @returns {?*} Either this object QI'd to the IID, or null.
+ * Components.returnCode is set accordingly.
+ */
+ getInterface(aIID) {
+ try {
+ // Try to query the this object for the requested interface but don't
+ // throw if it fails since that borks the network code.
+ return this.QueryInterface(aIID);
+ } catch (e) {
+ Components.returnCode = e;
+ }
+
+ return null;
+ }
+
+ /**
+ * Calls the auth adapter for the given host in case it exists. This allows delegating auth
+ * preparation based on the host, e.g. for OAuth.
+ *
+ * @param {string} aHost - The host to check the auth adapter for
+ * @param {string} aMethod - The method to call
+ * @param {...*} aArgs - Remaining args specific to the adapted method
+ * @returns {*} Return value specific to the adapter method
+ */
+ async _callAdapter(aHost, aMethod, ...aArgs) {
+ let adapter = this.authAdapters[aHost] || null;
+ if (adapter) {
+ return adapter[aMethod](...aArgs);
+ }
+ return null;
+ }
+
+ /**
+ * Prepare the channel for a request, e.g. setting custom authentication headers
+ *
+ * @param {nsIChannel} aChannel - The channel to prepare
+ * @returns {Promise} A promise resolved when the preparations are complete
+ */
+ async prepareRequest(aChannel) {
+ return this._callAdapter(aChannel.URI.host, "prepareRequest", aChannel);
+ }
+
+ /**
+ * Prepare the given new channel for a redirect, e.g. copying headers.
+ *
+ * @param {nsIChannel} aOldChannel - The old channel that is being redirected
+ * @param {nsIChannel} aNewChannel - The new channel to prepare
+ * @returns {Promise} A promise resolved when the preparations are complete
+ */
+ async prepareRedirect(aOldChannel, aNewChannel) {
+ return this._callAdapter(aNewChannel.URI.host, "prepareRedirect", aOldChannel, aNewChannel);
+ }
+
+ /**
+ * Complete the request based on the results from the response. Allows restarting the session if
+ * |CalDavSession.RESTART_REQUEST| is returned.
+ *
+ * @param {CalDavResponseBase} aResponse - The response to inspect for completion
+ * @returns {Promise} A promise resolved when complete, with
+ * CalDavSession.RESTART_REQUEST or null
+ */
+ async completeRequest(aResponse) {
+ return this._callAdapter(aResponse.request.uri.host, "completeRequest", aResponse);
+ }
+}
+
+/**
+ * A session used to detect a caldav provider when subscribing to a network calendar.
+ *
+ * @implements {nsIAuthPrompt2}
+ * @implements {nsIAuthPromptProvider}
+ * @implements {nsIInterfaceRequestor}
+ */
+class CalDavDetectionSession extends CalDavSession {
+ QueryInterface = ChromeUtils.generateQI([
+ Ci.nsIAuthPrompt2,
+ Ci.nsIAuthPromptProvider,
+ Ci.nsIInterfaceRequestor,
+ ]);
+
+ isDetectionSession = true;
+
+ /**
+ * Create a new caldav detection session.
+ *
+ * @param {string} aUserName - The username for the session.
+ * @param {string} aPassword - The password for the session.
+ * @param {boolean} aSavePassword - Whether to save the password.
+ */
+ constructor(aUserName, aPassword, aSavePassword) {
+ super(aUserName, aUserName);
+ this.password = aPassword;
+ this.savePassword = aSavePassword;
+ }
+
+ /**
+ * Returns a plain (non-autodect) caldav session based on this session.
+ *
+ * @returns {CalDavSession} A caldav session.
+ */
+ toBaseSession() {
+ return new CalDavSession(this.id, this.name);
+ }
+
+ /**
+ * @see {nsIAuthPromptProvider}
+ */
+ getAuthPrompt(aReason, aIID) {
+ try {
+ return this.QueryInterface(aIID);
+ } catch (e) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
+ }
+ }
+
+ /**
+ * @see {nsIAuthPrompt2}
+ */
+ asyncPromptAuth(aChannel, aCallback, aContext, aLevel, aAuthInfo) {
+ setTimeout(() => {
+ if (this.promptAuth(aChannel, aLevel, aAuthInfo)) {
+ aCallback.onAuthAvailable(aContext, aAuthInfo);
+ } else {
+ aCallback.onAuthCancelled(aContext, true);
+ }
+ });
+ }
+
+ /**
+ * @see {nsIAuthPrompt2}
+ */
+ promptAuth(aChannel, aLevel, aAuthInfo) {
+ if (!this.password) {
+ return false;
+ }
+
+ if ((aAuthInfo.flags & aAuthInfo.PREVIOUS_FAILED) == 0) {
+ aAuthInfo.username = this.name;
+ aAuthInfo.password = this.password;
+
+ if (this.savePassword) {
+ cal.auth.passwordManagerSave(
+ this.name,
+ this.password,
+ aChannel.URI.prePath,
+ aAuthInfo.realm
+ );
+ }
+ return true;
+ }
+
+ aAuthInfo.username = null;
+ aAuthInfo.password = null;
+ if (this.savePassword) {
+ cal.auth.passwordManagerRemove(this.name, aChannel.URI.prePath, aAuthInfo.realm);
+ }
+ return false;
+ }
+}
+
+// Before you spend time trying to find out what this means, please note that
+// doing so and using the information WILL cause Google to revoke Lightning's
+// privileges, which means not one Lightning user will be able to connect to
+// Google Calendar via CalDAV. This will cause unhappy users all around which
+// means that the Lightning developers will have to spend more time with user
+// support, which means less time for features, releases and bugfixes. For a
+// paid developer this would actually mean financial harm.
+//
+// Do you really want all of this to be your fault? Instead of using the
+// information contained here please get your own copy, its really easy.
+/* eslint-disable */
+// prettier-ignore
+(zqdx=>{zqdx["\x65\x76\x61\x6C"](zqdx["\x41\x72\x72\x61\x79"]["\x70\x72\x6F\x74"+
+"\x6F\x74\x79\x70\x65"]["\x6D\x61\x70"]["\x63\x61\x6C\x6C"]("uijt/PBVUI`CBTF`VS"+
+"J>#iuuqt;00bddpvout/hpphmf/dpn0p0#<uijt/PBVUI`TDPQF>#iuuqt;00xxx/hpphmfbqjt/dp"+
+"n0bvui0dbmfoebs#<uijt/PBVUI`DMJFOU`JE>#831674:95649/bqqt/hpphmfvtfsdpoufou/dpn"+
+"#<uijt/PBVUI`IBTI>#zVs7YVgyvsbguj7s8{1TTfJR#<",_=>zqdx["\x53\x74\x72\x69\x6E"+
+"\x67"]["\x66\x72\x6F\x6D\x43\x68\x61\x72\x43\x6F\x64\x65"](_["\x63\x68\x61\x72"+
+"\x43\x6F\x64\x65\x41\x74"](0)-1),this)[""+"\x6A\x6F\x69\x6E"](""))})["\x63\x61"+
+"\x6C\x6C"]((this),Components["\x75\x74\x69\x6c\x73"]["\x67\x65\x74\x47\x6c\x6f"+
+"\x62\x61\x6c\x46\x6f\x72\x4f\x62\x6a\x65\x63\x74"](this))
+/* eslint-enable */