You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

microsoftCalendar.js 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. /* @flow */
  2. import { Client } from '@microsoft/microsoft-graph-client';
  3. import rs from 'jsrsasign';
  4. import { createDeferred } from '../../../../modules/util/helpers';
  5. import parseURLParams from '../../base/config/parseURLParams';
  6. import { parseStandardURIString } from '../../base/util';
  7. import { setCalendarAPIAuthState } from '../actions';
  8. /**
  9. * Constants used for interacting with the Microsoft API.
  10. *
  11. * @private
  12. * @type {object}
  13. */
  14. const MS_API_CONFIGURATION = {
  15. /**
  16. * The URL to use when authenticating using Microsoft API.
  17. * @type {string}
  18. */
  19. AUTH_ENDPOINT:
  20. 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?',
  21. CALENDAR_ENDPOINT: '/me/calendars',
  22. /**
  23. * The Microsoft API scopes to request access for calendar.
  24. *
  25. * @type {string}
  26. */
  27. MS_API_SCOPES: 'openid profile Calendars.Read',
  28. /**
  29. * See https://docs.microsoft.com/en-us/azure/active-directory/develop/
  30. * v2-oauth2-implicit-grant-flow#send-the-sign-in-request. This value is
  31. * needed for passing in the proper domain_hint value when trying to refresh
  32. * a token silently.
  33. *
  34. *
  35. * @type {string}
  36. */
  37. MS_CONSUMER_TENANT: '9188040d-6c67-4c5b-b112-36a304b66dad',
  38. /**
  39. * The redirect URL to be used by the Microsoft API on successful
  40. * authentication.
  41. *
  42. * @type {string}
  43. */
  44. REDIRECT_URI: `${window.location.origin}/static/msredirect.html`
  45. };
  46. /**
  47. * Store the window from an auth request. That way it can be reused if a new
  48. * request comes in and it can be used to indicate a request is in progress.
  49. *
  50. * @private
  51. * @type {Object|null}
  52. */
  53. let popupAuthWindow = null;
  54. /**
  55. * A stateless collection of action creators that implements the expected
  56. * interface for interacting with the Microsoft API in order to get calendar
  57. * data.
  58. *
  59. * @type {Object}
  60. */
  61. export const microsoftCalendarApi = {
  62. /**
  63. * Retrieves the current calendar events.
  64. *
  65. * @param {number} fetchStartDays - The number of days to go back
  66. * when fetching.
  67. * @param {number} fetchEndDays - The number of days to fetch.
  68. * @returns {function(Dispatch<*>, Function): Promise<CalendarEntries>}
  69. */
  70. getCalendarEntries(fetchStartDays: ?number, fetchEndDays: ?number) {
  71. return (dispatch: Dispatch<*>, getState: Function): Promise<*> => {
  72. const state = getState()['features/calendar-sync'] || {};
  73. const token = state.msAuthState && state.msAuthState.accessToken;
  74. if (!token) {
  75. return Promise.reject('Not authorized, please sign in!');
  76. }
  77. const client = Client.init({
  78. authProvider: done => done(null, token)
  79. });
  80. return client
  81. .api(MS_API_CONFIGURATION.CALENDAR_ENDPOINT)
  82. .get()
  83. .then(response => {
  84. const calendarIds = response.value.map(en => en.id);
  85. const getEventsPromises = calendarIds.map(id =>
  86. requestCalendarEvents(
  87. client, id, fetchStartDays, fetchEndDays));
  88. return Promise.all(getEventsPromises);
  89. })
  90. // get .value of every element from the array of results,
  91. // which is an array of events and flatten it to one array
  92. // of events
  93. .then(result => [].concat(...result.map(en => en.value)))
  94. .then(entries => entries.map(e => formatCalendarEntry(e)));
  95. };
  96. },
  97. /**
  98. * Returns the email address for the currently logged in user.
  99. *
  100. * @returns {function(Dispatch<*, Function>): Promise<string>}
  101. */
  102. getCurrentEmail(): Function {
  103. return (dispatch: Dispatch<*>, getState: Function) => {
  104. const { msAuthState = {} }
  105. = getState()['features/calendar-sync'] || {};
  106. const email = msAuthState.userSigninName || '';
  107. return Promise.resolve(email);
  108. };
  109. },
  110. /**
  111. * Sets the application ID to use for interacting with the Microsoft API.
  112. *
  113. * @returns {function(): Promise<void>}
  114. */
  115. load(): Function {
  116. return () => Promise.resolve();
  117. },
  118. /**
  119. * Prompts the participant to sign in to the Microsoft API Client Library.
  120. *
  121. * @returns {function(Dispatch<*>, Function): Promise<void>}
  122. */
  123. signIn(): Function {
  124. return (dispatch: Dispatch<*>, getState: Function) => {
  125. // Ensure only one popup window at a time.
  126. if (popupAuthWindow) {
  127. popupAuthWindow.focus();
  128. return Promise.reject('Sign in already in progress.');
  129. }
  130. const signInDeferred = createDeferred();
  131. const guids = {
  132. authState: generateGuid(),
  133. authNonce: generateGuid()
  134. };
  135. dispatch(setCalendarAPIAuthState(guids));
  136. const { microsoftApiApplicationClientID }
  137. = getState()['features/base/config'];
  138. const authUrl = getAuthUrl(
  139. microsoftApiApplicationClientID,
  140. guids.authState,
  141. guids.authNonce);
  142. const h = 600;
  143. const w = 480;
  144. popupAuthWindow = window.open(
  145. authUrl,
  146. 'Auth M$',
  147. `width=${w}, height=${h}, top=${
  148. (screen.height / 2) - (h / 2)}, left=${
  149. (screen.width / 2) - (w / 2)}`);
  150. const windowCloseCheck = setInterval(() => {
  151. if (popupAuthWindow && popupAuthWindow.closed) {
  152. signInDeferred.reject(
  153. 'Popup closed before completing auth.');
  154. popupAuthWindow = null;
  155. window.removeEventListener('message', handleAuth);
  156. clearInterval(windowCloseCheck);
  157. } else if (!popupAuthWindow) {
  158. // This case probably happened because the user completed
  159. // auth.
  160. clearInterval(windowCloseCheck);
  161. }
  162. }, 500);
  163. /**
  164. * Callback with scope access to other variables that are part of
  165. * the sign in request.
  166. *
  167. * @param {Object} event - The event from the post message.
  168. * @private
  169. * @returns {void}
  170. */
  171. function handleAuth({ data }) {
  172. if (!data || data.type !== 'ms-login') {
  173. return;
  174. }
  175. window.removeEventListener('message', handleAuth);
  176. popupAuthWindow && popupAuthWindow.close();
  177. popupAuthWindow = null;
  178. const params = getParamsFromHash(data.url);
  179. const tokenParts = getValidatedTokenParts(
  180. params, guids, microsoftApiApplicationClientID);
  181. if (!tokenParts) {
  182. signInDeferred.reject('Invalid token received');
  183. return;
  184. }
  185. dispatch(setCalendarAPIAuthState({
  186. authState: undefined,
  187. accessToken: tokenParts.accessToken,
  188. idToken: tokenParts.idToken,
  189. tokenExpires: params.tokenExpires,
  190. userDomainType: tokenParts.userDomainType,
  191. userSigninName: tokenParts.userSigninName
  192. }));
  193. signInDeferred.resolve();
  194. }
  195. window.addEventListener('message', handleAuth);
  196. return signInDeferred.promise;
  197. };
  198. },
  199. /**
  200. * Returns whether or not the user is currently signed in.
  201. *
  202. * @returns {function(Dispatch<*>, Function): Promise<boolean>}
  203. */
  204. _isSignedIn(): Function {
  205. return (dispatch: Dispatch<*>, getState: Function) => {
  206. const now = new Date().getTime();
  207. const state
  208. = getState()['features/calendar-sync'].msAuthState || {};
  209. const tokenExpires = parseInt(state.tokenExpires, 10);
  210. const isExpired = now > tokenExpires && !isNaN(tokenExpires);
  211. if (state.accessToken && isExpired) {
  212. // token expired, let's refresh it
  213. return dispatch(this._refreshAuthToken())
  214. .then(() => true)
  215. .catch(() => false);
  216. }
  217. return Promise.resolve(state.accessToken && !isExpired);
  218. };
  219. },
  220. /**
  221. * Renews an existing auth token so it can continue to be used.
  222. *
  223. * @private
  224. * @returns {function(Dispatch<*>, Function): Promise<void>}
  225. */
  226. _refreshAuthToken(): Function {
  227. return (dispatch: Dispatch<*>, getState: Function) => {
  228. const { microsoftApiApplicationClientID }
  229. = getState()['features/base/config'];
  230. const { msAuthState = {} }
  231. = getState()['features/calendar-sync'] || {};
  232. const refreshAuthUrl = getAuthRefreshUrl(
  233. microsoftApiApplicationClientID,
  234. msAuthState.userDomainType,
  235. msAuthState.userSigninName);
  236. const iframe = document.createElement('iframe');
  237. iframe.setAttribute('id', 'auth-iframe');
  238. iframe.setAttribute('name', 'auth-iframe');
  239. iframe.setAttribute('style', 'display: none');
  240. iframe.setAttribute('src', refreshAuthUrl);
  241. const signInPromise = new Promise(resolve => {
  242. iframe.onload = () => {
  243. resolve(iframe.contentWindow.location.hash);
  244. };
  245. });
  246. // The check for body existence is done for flow, which also runs
  247. // against native where document.body may not be defined.
  248. if (!document.body) {
  249. return Promise.reject(
  250. 'Cannot refresh auth token in this environment');
  251. }
  252. document.body.appendChild(iframe);
  253. return signInPromise.then(hash => {
  254. const params = getParamsFromHash(hash);
  255. dispatch(setCalendarAPIAuthState({
  256. accessToken: params.access_token,
  257. idToken: params.id_token,
  258. tokenExpires: params.tokenExpires
  259. }));
  260. });
  261. };
  262. }
  263. };
  264. /**
  265. * Parses the Microsoft calendar entries to a known format.
  266. *
  267. * @param {Object} entry - The Microsoft calendar entry.
  268. * @private
  269. * @returns {{
  270. * description: string,
  271. * endDate: string,
  272. * id: string,
  273. * location: string,
  274. * startDate: string,
  275. * title: string
  276. * }}
  277. */
  278. function formatCalendarEntry(entry) {
  279. return {
  280. description: entry.body.content,
  281. endDate: entry.end.dateTime,
  282. id: entry.id,
  283. location: entry.location.displayName,
  284. startDate: entry.start.dateTime,
  285. title: entry.subject
  286. };
  287. }
  288. /**
  289. * Generate a guid to be used for verifying token validity.
  290. *
  291. * @private
  292. * @returns {string} The generated string.
  293. */
  294. function generateGuid() {
  295. const buf = new Uint16Array(8);
  296. window.crypto.getRandomValues(buf);
  297. return `${s4(buf[0])}${s4(buf[1])}-${s4(buf[2])}-${s4(buf[3])}-${
  298. s4(buf[4])}-${s4(buf[5])}${s4(buf[6])}${s4(buf[7])}`;
  299. }
  300. /**
  301. * Constructs and returns the URL to use for renewing an auth token.
  302. *
  303. * @param {string} appId - The Microsoft application id to log into.
  304. * @param {string} userDomainType - The domain type of the application as
  305. * provided by Microsoft.
  306. * @param {string} userSigninName - The email of the user signed into the
  307. * integration with Microsoft.
  308. * @private
  309. * @returns {string} - The auth URL.
  310. */
  311. function getAuthRefreshUrl(appId, userDomainType, userSigninName) {
  312. return [
  313. getAuthUrl(appId, 'undefined', 'undefined'),
  314. 'prompt=none',
  315. `domain_hint=${userDomainType}`,
  316. `login_hint=${userSigninName}`
  317. ].join('&');
  318. }
  319. /**
  320. * Constructs and returns the auth URL to use for login.
  321. *
  322. * @param {string} appId - The Microsoft application id to log into.
  323. * @param {string} authState - The authState guid to use.
  324. * @param {string} authNonce - The authNonce guid to use.
  325. * @private
  326. * @returns {string} - The auth URL.
  327. */
  328. function getAuthUrl(appId, authState, authNonce) {
  329. const authParams = [
  330. 'response_type=id_token+token',
  331. `client_id=${appId}`,
  332. `redirect_uri=${MS_API_CONFIGURATION.REDIRECT_URI}`,
  333. `scope=${MS_API_CONFIGURATION.MS_API_SCOPES}`,
  334. `state=${authState}`,
  335. `nonce=${authNonce}`,
  336. 'response_mode=fragment'
  337. ].join('&');
  338. return `${MS_API_CONFIGURATION.AUTH_ENDPOINT}${authParams}`;
  339. }
  340. /**
  341. * Converts a url from an auth redirect into an object of parameters passed
  342. * into the url.
  343. *
  344. * @param {string} url - The string to parse.
  345. * @private
  346. * @returns {Object}
  347. */
  348. function getParamsFromHash(url) {
  349. const params = parseURLParams(parseStandardURIString(url), true, 'hash');
  350. // Get the number of seconds the token is valid for, subtract 5 minutes
  351. // to account for differences in clock settings and convert to ms.
  352. const expiresIn = (parseInt(params.expires_in, 10) - 300) * 1000;
  353. const now = new Date();
  354. const expireDate = new Date(now.getTime() + expiresIn);
  355. params.tokenExpires = expireDate.getTime().toString();
  356. return params;
  357. }
  358. /**
  359. * Converts the parameters from a Microsoft auth redirect into an object of
  360. * token parts. The value "null" will be returned if the params do not produce
  361. * a valid token.
  362. *
  363. * @param {Object} tokenInfo - The token object.
  364. * @param {Object} guids - The guids for authState and authNonce that should
  365. * match in the token.
  366. * @param {Object} appId - The Microsoft application this token is for.
  367. * @private
  368. * @returns {Object|null}
  369. */
  370. function getValidatedTokenParts(tokenInfo, guids, appId) {
  371. // Make sure the token matches the request source by matching the GUID.
  372. if (tokenInfo.state !== guids.authState) {
  373. return null;
  374. }
  375. const idToken = tokenInfo.id_token;
  376. // A token must exist to be valid.
  377. if (!idToken) {
  378. return null;
  379. }
  380. const tokenParts = idToken.split('.');
  381. if (tokenParts.length !== 3) {
  382. return null;
  383. }
  384. const payload
  385. = rs.KJUR.jws.JWS.readSafeJSONString(rs.b64utoutf8(tokenParts[1]));
  386. if (payload.nonce !== guids.authNonce
  387. || payload.aud !== appId
  388. || payload.iss
  389. !== `https://login.microsoftonline.com/${payload.tid}/v2.0`) {
  390. return null;
  391. }
  392. const now = new Date();
  393. // Adjust by 5 minutes to allow for inconsistencies in system clocks.
  394. const notBefore = new Date((payload.nbf - 300) * 1000);
  395. const expires = new Date((payload.exp + 300) * 1000);
  396. if (now < notBefore || now > expires) {
  397. return null;
  398. }
  399. return {
  400. accessToken: tokenInfo.access_token,
  401. idToken,
  402. userDisplayName: payload.name,
  403. userDomainType:
  404. payload.tid === MS_API_CONFIGURATION.MS_CONSUMER_TENANT
  405. ? 'consumers' : 'organizations',
  406. userSigninName: payload.preferred_username
  407. };
  408. }
  409. /**
  410. * Retrieves calendar entries from a specific calendar.
  411. *
  412. * @param {Object} client - The Microsoft-graph-client initialized.
  413. * @param {string} calendarId - The calendar ID to use.
  414. * @param {number} fetchStartDays - The number of days to go back
  415. * when fetching.
  416. * @param {number} fetchEndDays - The number of days to fetch.
  417. * @returns {Promise<any> | Promise}
  418. * @private
  419. */
  420. function requestCalendarEvents( // eslint-disable-line max-params
  421. client,
  422. calendarId,
  423. fetchStartDays,
  424. fetchEndDays): Promise<*> {
  425. const startDate = new Date();
  426. const endDate = new Date();
  427. startDate.setDate(startDate.getDate() + fetchStartDays);
  428. endDate.setDate(endDate.getDate() + fetchEndDays);
  429. const filter = `Start/DateTime ge '${
  430. startDate.toISOString()}' and End/DateTime lt '${
  431. endDate.toISOString()}'`;
  432. return client
  433. .api(`/me/calendars/${calendarId}/events`)
  434. .filter(filter)
  435. .select('id,subject,start,end,location,body')
  436. .orderby('createdDateTime DESC')
  437. .get();
  438. }
  439. /**
  440. * Converts the passed in number to a string and ensure it is at least 4
  441. * characters in length, prepending 0's as needed.
  442. *
  443. * @param {number} num - The number to pad and convert to a string.
  444. * @private
  445. * @returns {string} - The number converted to a string.
  446. */
  447. function s4(num) {
  448. let ret = num.toString(16);
  449. while (ret.length < 4) {
  450. ret = `0${ret}`;
  451. }
  452. return ret;
  453. }