// @flow import { Client } from '@microsoft/microsoft-graph-client'; import rs from 'jsrsasign'; import type { Dispatch } from 'redux'; import { createDeferred } from '../../../../modules/util/helpers'; import parseURLParams from '../../base/config/parseURLParams'; import { parseStandardURIString } from '../../base/util'; import { getShareInfoText } from '../../invite'; import { setCalendarAPIAuthState } from '../actions'; /** * Constants used for interacting with the Microsoft API. * * @private * @type {object} */ const MS_API_CONFIGURATION = { /** * The URL to use when authenticating using Microsoft API. * @type {string} */ AUTH_ENDPOINT: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?', CALENDAR_ENDPOINT: '/me/calendars', /** * The Microsoft API scopes to request access for calendar. * * @type {string} */ MS_API_SCOPES: 'openid profile Calendars.ReadWrite', /** * See https://docs.microsoft.com/en-us/azure/active-directory/develop/ * v2-oauth2-implicit-grant-flow#send-the-sign-in-request. This value is * needed for passing in the proper domain_hint value when trying to refresh * a token silently. * * * @type {string} */ MS_CONSUMER_TENANT: '9188040d-6c67-4c5b-b112-36a304b66dad', /** * The redirect URL to be used by the Microsoft API on successful * authentication. * * @type {string} */ REDIRECT_URI: `${window.location.origin}/static/msredirect.html` }; /** * Store the window from an auth request. That way it can be reused if a new * request comes in and it can be used to indicate a request is in progress. * * @private * @type {Object|null} */ let popupAuthWindow = null; /** * A stateless collection of action creators that implements the expected * interface for interacting with the Microsoft API in order to get calendar * data. * * @type {Object} */ export const microsoftCalendarApi = { /** * Retrieves the current calendar events. * * @param {number} fetchStartDays - The number of days to go back * when fetching. * @param {number} fetchEndDays - The number of days to fetch. * @returns {function(Dispatch, Function): Promise} */ getCalendarEntries(fetchStartDays: ?number, fetchEndDays: ?number) { return (dispatch: Dispatch, getState: Function): Promise<*> => { const state = getState()['features/calendar-sync'] || {}; const token = state.msAuthState && state.msAuthState.accessToken; if (!token) { return Promise.reject('Not authorized, please sign in!'); } const client = Client.init({ authProvider: done => done(null, token) }); return client .api(MS_API_CONFIGURATION.CALENDAR_ENDPOINT) .get() .then(response => { const calendarIds = response.value.map(en => en.id); const getEventsPromises = calendarIds.map(id => requestCalendarEvents( client, id, fetchStartDays, fetchEndDays)); return Promise.all(getEventsPromises); }) // get .value of every element from the array of results, // which is an array of events and flatten it to one array // of events .then(result => [].concat(...result)) .then(entries => entries.map(e => formatCalendarEntry(e))); }; }, /** * Returns the email address for the currently logged in user. * * @returns {function(Dispatch<*, Function>): Promise} */ getCurrentEmail(): Function { return (dispatch: Dispatch, getState: Function) => { const { msAuthState = {} } = getState()['features/calendar-sync'] || {}; const email = msAuthState.userSigninName || ''; return Promise.resolve(email); }; }, /** * Sets the application ID to use for interacting with the Microsoft API. * * @returns {function(): Promise} */ load(): Function { return () => Promise.resolve(); }, /** * Prompts the participant to sign in to the Microsoft API Client Library. * * @returns {function(Dispatch, Function): Promise} */ signIn(): Function { return (dispatch: Dispatch, getState: Function) => { // Ensure only one popup window at a time. if (popupAuthWindow) { popupAuthWindow.focus(); return Promise.reject('Sign in already in progress.'); } const signInDeferred = createDeferred(); const guids = { authState: generateGuid(), authNonce: generateGuid() }; dispatch(setCalendarAPIAuthState(guids)); const { microsoftApiApplicationClientID } = getState()['features/base/config']; const authUrl = getAuthUrl( microsoftApiApplicationClientID, guids.authState, guids.authNonce); const h = 600; const w = 480; popupAuthWindow = window.open( authUrl, 'Auth M$', `width=${w}, height=${h}, top=${ (screen.height / 2) - (h / 2)}, left=${ (screen.width / 2) - (w / 2)}`); const windowCloseCheck = setInterval(() => { if (popupAuthWindow && popupAuthWindow.closed) { signInDeferred.reject( 'Popup closed before completing auth.'); popupAuthWindow = null; window.removeEventListener('message', handleAuth); clearInterval(windowCloseCheck); } else if (!popupAuthWindow) { // This case probably happened because the user completed // auth. clearInterval(windowCloseCheck); } }, 500); /** * Callback with scope access to other variables that are part of * the sign in request. * * @param {Object} event - The event from the post message. * @private * @returns {void} */ function handleAuth({ data }) { if (!data || data.type !== 'ms-login') { return; } window.removeEventListener('message', handleAuth); popupAuthWindow && popupAuthWindow.close(); popupAuthWindow = null; const params = getParamsFromHash(data.url); const tokenParts = getValidatedTokenParts( params, guids, microsoftApiApplicationClientID); if (!tokenParts) { signInDeferred.reject('Invalid token received'); return; } dispatch(setCalendarAPIAuthState({ authState: undefined, accessToken: tokenParts.accessToken, idToken: tokenParts.idToken, tokenExpires: params.tokenExpires, userDomainType: tokenParts.userDomainType, userSigninName: tokenParts.userSigninName })); signInDeferred.resolve(); } window.addEventListener('message', handleAuth); return signInDeferred.promise; }; }, /** * Returns whether or not the user is currently signed in. * * @returns {function(Dispatch, Function): Promise} */ _isSignedIn(): Function { return (dispatch: Dispatch, getState: Function) => { const now = new Date().getTime(); const state = getState()['features/calendar-sync'].msAuthState || {}; const tokenExpires = parseInt(state.tokenExpires, 10); const isExpired = now > tokenExpires && !isNaN(tokenExpires); if (state.accessToken && isExpired) { // token expired, let's refresh it return dispatch(refreshAuthToken()) .then(() => true) .catch(() => false); } return Promise.resolve(state.accessToken && !isExpired); }; }, /** * Updates calendar event by generating new invite URL and editing the event * adding some descriptive text and location. * * @param {string} id - The event id. * @param {string} calendarId - The id of the calendar to use. * @param {string} location - The location to save to the event. * @returns {function(Dispatch): Promise} */ updateCalendarEvent(id: string, calendarId: string, location: string) { return (dispatch: Dispatch, getState: Function): Promise<*> => { const state = getState()['features/calendar-sync'] || {}; const token = state.msAuthState && state.msAuthState.accessToken; if (!token) { return Promise.reject('Not authorized, please sign in!'); } return getShareInfoText(getState(), location, true/* use html */) .then(text => { const client = Client.init({ authProvider: done => done(null, token) }); return client .api(`/me/events/${id}`) .get() .then(description => { const body = description.body; if (description.bodyPreview) { body.content = `${description.bodyPreview}

`; } // replace all new lines from the text with html //
to make it pretty body.content += text.split('\n').join('
'); return client .api(`/me/calendar/events/${id}`) .patch({ body, location: { 'displayName': location } }); }); }); }; } }; /** * Parses the Microsoft calendar entries to a known format. * * @param {Object} entry - The Microsoft calendar entry. * @private * @returns {{ * calendarId: string, * description: string, * endDate: string, * id: string, * location: string, * startDate: string, * title: string * }} */ function formatCalendarEntry(entry) { return { calendarId: entry.calendarId, description: entry.body.content, endDate: entry.end.dateTime, id: entry.id, location: entry.location.displayName, startDate: entry.start.dateTime, title: entry.subject }; } /** * Generate a guid to be used for verifying token validity. * * @private * @returns {string} The generated string. */ function generateGuid() { const buf = new Uint16Array(8); window.crypto.getRandomValues(buf); return `${s4(buf[0])}${s4(buf[1])}-${s4(buf[2])}-${s4(buf[3])}-${ s4(buf[4])}-${s4(buf[5])}${s4(buf[6])}${s4(buf[7])}`; } /** * Constructs and returns the URL to use for renewing an auth token. * * @param {string} appId - The Microsoft application id to log into. * @param {string} userDomainType - The domain type of the application as * provided by Microsoft. * @param {string} userSigninName - The email of the user signed into the * integration with Microsoft. * @private * @returns {string} - The auth URL. */ function getAuthRefreshUrl(appId, userDomainType, userSigninName) { return [ getAuthUrl(appId, 'undefined', 'undefined'), 'prompt=none', `domain_hint=${userDomainType}`, `login_hint=${userSigninName}` ].join('&'); } /** * Constructs and returns the auth URL to use for login. * * @param {string} appId - The Microsoft application id to log into. * @param {string} authState - The authState guid to use. * @param {string} authNonce - The authNonce guid to use. * @private * @returns {string} - The auth URL. */ function getAuthUrl(appId, authState, authNonce) { const authParams = [ 'response_type=id_token+token', `client_id=${appId}`, `redirect_uri=${MS_API_CONFIGURATION.REDIRECT_URI}`, `scope=${MS_API_CONFIGURATION.MS_API_SCOPES}`, `state=${authState}`, `nonce=${authNonce}`, 'response_mode=fragment' ].join('&'); return `${MS_API_CONFIGURATION.AUTH_ENDPOINT}${authParams}`; } /** * Converts a url from an auth redirect into an object of parameters passed * into the url. * * @param {string} url - The string to parse. * @private * @returns {Object} */ function getParamsFromHash(url) { const params = parseURLParams(parseStandardURIString(url), true, 'hash'); // Get the number of seconds the token is valid for, subtract 5 minutes // to account for differences in clock settings and convert to ms. const expiresIn = (parseInt(params.expires_in, 10) - 300) * 1000; const now = new Date(); const expireDate = new Date(now.getTime() + expiresIn); params.tokenExpires = expireDate.getTime().toString(); return params; } /** * Converts the parameters from a Microsoft auth redirect into an object of * token parts. The value "null" will be returned if the params do not produce * a valid token. * * @param {Object} tokenInfo - The token object. * @param {Object} guids - The guids for authState and authNonce that should * match in the token. * @param {Object} appId - The Microsoft application this token is for. * @private * @returns {Object|null} */ function getValidatedTokenParts(tokenInfo, guids, appId) { // Make sure the token matches the request source by matching the GUID. if (tokenInfo.state !== guids.authState) { return null; } const idToken = tokenInfo.id_token; // A token must exist to be valid. if (!idToken) { return null; } const tokenParts = idToken.split('.'); if (tokenParts.length !== 3) { return null; } const payload = rs.KJUR.jws.JWS.readSafeJSONString(rs.b64utoutf8(tokenParts[1])); if (payload.nonce !== guids.authNonce || payload.aud !== appId || payload.iss !== `https://login.microsoftonline.com/${payload.tid}/v2.0`) { return null; } const now = new Date(); // Adjust by 5 minutes to allow for inconsistencies in system clocks. const notBefore = new Date((payload.nbf - 300) * 1000); const expires = new Date((payload.exp + 300) * 1000); if (now < notBefore || now > expires) { return null; } return { accessToken: tokenInfo.access_token, idToken, userDisplayName: payload.name, userDomainType: payload.tid === MS_API_CONFIGURATION.MS_CONSUMER_TENANT ? 'consumers' : 'organizations', userSigninName: payload.preferred_username }; } /** * Renews an existing auth token so it can continue to be used. * * @private * @returns {function(Dispatch, Function): Promise} */ function refreshAuthToken(): Function { return (dispatch: Dispatch, getState: Function) => { const { microsoftApiApplicationClientID } = getState()['features/base/config']; const { msAuthState = {} } = getState()['features/calendar-sync'] || {}; const refreshAuthUrl = getAuthRefreshUrl( microsoftApiApplicationClientID, msAuthState.userDomainType, msAuthState.userSigninName); const iframe = document.createElement('iframe'); iframe.setAttribute('id', 'auth-iframe'); iframe.setAttribute('name', 'auth-iframe'); iframe.setAttribute('style', 'display: none'); iframe.setAttribute('src', refreshAuthUrl); const signInPromise = new Promise(resolve => { iframe.onload = () => { resolve(iframe.contentWindow.location.hash); }; }); // The check for body existence is done for flow, which also runs // against native where document.body may not be defined. if (!document.body) { return Promise.reject( 'Cannot refresh auth token in this environment'); } document.body.appendChild(iframe); return signInPromise.then(hash => { const params = getParamsFromHash(hash); dispatch(setCalendarAPIAuthState({ accessToken: params.access_token, idToken: params.id_token, tokenExpires: params.tokenExpires })); }); }; } /** * Retrieves calendar entries from a specific calendar. * * @param {Object} client - The Microsoft-graph-client initialized. * @param {string} calendarId - The calendar ID to use. * @param {number} fetchStartDays - The number of days to go back * when fetching. * @param {number} fetchEndDays - The number of days to fetch. * @returns {Promise | Promise} * @private */ function requestCalendarEvents( // eslint-disable-line max-params client, calendarId, fetchStartDays, fetchEndDays): Promise<*> { const startDate = new Date(); const endDate = new Date(); startDate.setDate(startDate.getDate() + fetchStartDays); endDate.setDate(endDate.getDate() + fetchEndDays); const filter = `Start/DateTime ge '${ startDate.toISOString()}' and End/DateTime lt '${ endDate.toISOString()}'`; return client .api(`/me/calendars/${calendarId}/events`) .filter(filter) .select('id,subject,start,end,location,body') .orderby('createdDateTime DESC') .get() .then(result => result.value.map(item => { return { ...item, calendarId }; })); } /** * Converts the passed in number to a string and ensure it is at least 4 * characters in length, prepending 0's as needed. * * @param {number} num - The number to pad and convert to a string. * @private * @returns {string} - The number converted to a string. */ function s4(num) { let ret = num.toString(16); while (ret.length < 4) { ret = `0${ret}`; } return ret; }