| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638 | // @flow
import { Client } from '@microsoft/microsoft-graph-client';
import base64js from 'base64-js';
import type { Dispatch } from 'redux';
import { findWindows } from 'windows-iana';
import { createDeferred } from '../../../../modules/util/helpers';
import { parseStandardURIString, parseURLParams } 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<any>, Function): Promise<CalendarEntries>}
     */
    getCalendarEntries(fetchStartDays: ?number, fetchEndDays: ?number) {
        return (dispatch: Dispatch<any>, 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<string>}
     */
    getCurrentEmail(): Function {
        return (dispatch: Dispatch<any>, 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<void>}
     */
    load(): Function {
        return () => Promise.resolve();
    },
    /**
     * Prompts the participant to sign in to the Microsoft API Client Library.
     *
     * @returns {function(Dispatch<any>, Function): Promise<void>}
     */
    signIn(): Function {
        return (dispatch: Dispatch<any>, 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<any>, Function): Promise<boolean>}
     */
    _isSignedIn(): Function {
        return (dispatch: Dispatch<any>, 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<any>): Promise<string|never>}
     */
    updateCalendarEvent(id: string, calendarId: string, location: string) {
        return (dispatch: Dispatch<any>, 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}<br><br>`;
                            }
                            // replace all new lines from the text with html
                            // <br> to make it pretty
                            body.content += text.split('\n').join('<br>');
                            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;
    }
    let payload;
    try {
        payload = JSON.parse(b64utoutf8(tokenParts[1]));
    } catch (e) {
        return null;
    }
    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<any>, Function): Promise<void>}
 */
function refreshAuthToken(): Function {
    return (dispatch: Dispatch<any>, 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<any> | 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()}'`;
    const ianaTimeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
    const windowsTimeZone = findWindows(ianaTimeZone);
    return client
        .api(`/me/calendars/${calendarId}/events`)
        .filter(filter)
        .header('Prefer', `outlook.timezone="${windowsTimeZone}"`)
        .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;
}
/**
 * Convert a Base64URL encoded string to a UTF-8 encoded string including CJK or Latin.
 *
 * @param {string} str - The string that needs conversion.
 * @private
 * @returns {string} - The converted string.
 */
function b64utoutf8(str) {
    let s = str;
    // Convert from Base64URL to Base64.
    if (s.length % 4 === 2) {
        s += '==';
    } else if (s.length % 4 === 3) {
        s += '=';
    }
    s = s.replace(/-/g, '+').replace(/_/g, '/');
    // Convert Base64 to a byte array.
    const bytes = base64js.toByteArray(s);
    // Convert bytes to hex.
    s = bytes.reduce((str_, byte) => str_ + byte.toString(16).padStart(2, '0'), '');
    // Convert a hexadecimal string to a URLComponent string
    s = s.replace(/(..)/g, '%$1');
    // Decodee the URI component
    return decodeURIComponent(s);
}
 |