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.ts 19KB

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