UNPKG

vue-msal

Version:

Vue plugin for using Microsoft Authentication Library (MSAL)

408 lines (402 loc) 16 kB
import _ from "lodash"; import {default as axios, Method} from "axios"; import {UserAgentApplicationExtended} from "./UserAgentApplicationExtended"; import { Auth, Request, Graph, CacheOptions, Options, DataObject, CallbackQueueObject, AuthError, AuthResponse, MSALBasic, GraphEndpoints, GraphDetailedObject, CategorizedGraphRequests } from './types'; export class MSAL implements MSALBasic { private lib: any; private tokenExpirationTimers: {[key: string]: undefined | number} = {}; public data: DataObject = { isAuthenticated: false, accessToken: '', idToken: '', user: {}, graph: {}, custom: {} }; public callbackQueue: CallbackQueueObject[] = []; private readonly auth: Auth = { clientId: '', authority: '', tenantId: 'common', tenantName: 'login.microsoftonline.com', validateAuthority: true, redirectUri: window.location.href, postLogoutRedirectUri: window.location.href, navigateToLoginRequestUrl: true, requireAuthOnInitialize: false, autoRefreshToken: true, onAuthentication: (error, response) => {}, onToken: (error, response) => {}, beforeSignOut: () => {} }; private readonly cache: CacheOptions = { cacheLocation: 'localStorage', storeAuthStateInCookie: true }; private readonly request: Request = { scopes: ["user.read"] }; private readonly graph: Graph = { callAfterInit: false, endpoints: {profile: '/me'}, baseUrl: 'https://graph.microsoft.com/v1.0', onResponse: (response) => {} }; constructor(private readonly options: Options) { if (!options.auth.clientId) { throw new Error('auth.clientId is required'); } this.auth = Object.assign(this.auth, options.auth); this.cache = Object.assign(this.cache, options.cache); this.request = Object.assign(this.request, options.request); this.graph = Object.assign(this.graph, options.graph); this.lib = new UserAgentApplicationExtended({ auth: { clientId: this.auth.clientId, authority: this.auth.authority || `https://${this.auth.tenantName}/${this.auth.tenantId}`, validateAuthority: this.auth.validateAuthority, redirectUri: this.auth.redirectUri, postLogoutRedirectUri: this.auth.postLogoutRedirectUri, navigateToLoginRequestUrl: this.auth.navigateToLoginRequestUrl }, cache: this.cache, system: options.system }); this.getSavedCallbacks(); this.executeCallbacks(); // Register Callbacks for redirect flow this.lib.handleRedirectCallback((error: AuthError, response: AuthResponse) => { if (!this.isAuthenticated()) { this.saveCallback('auth.onAuthentication', error, response); } else { this.acquireToken(); } }); if (this.auth.requireAuthOnInitialize) { this.signIn() } this.data.isAuthenticated = this.isAuthenticated(); if (this.data.isAuthenticated) { this.data.user = this.lib.getAccount(); this.acquireToken().then(() => { if (this.graph.callAfterInit) { this.initialMSGraphCall(); } }); } this.getStoredCustomData(); } signIn() { if (!this.lib.isCallback(window.location.hash) && !this.lib.getAccount()) { // request can be used for login or token request, however in more complex situations this can have diverging options this.lib.loginRedirect(this.request); } } async signOut() { if (this.options.auth.beforeSignOut) { await this.options.auth.beforeSignOut(this); } this.lib.logout(); } isAuthenticated() { return !this.lib.isCallback(window.location.hash) && !!this.lib.getAccount(); } async acquireToken(request = this.request, retries = 0) { try { //Always start with acquireTokenSilent to obtain a token in the signed in user from cache const response = await this.lib.acquireTokenSilent(request); this.handleTokenResponse(null, response); return response; } catch (error) { // Upon acquireTokenSilent failure (due to consent or interaction or login required ONLY) // Call acquireTokenRedirect if (this.requiresInteraction(error.errorCode)) { this.lib.acquireTokenRedirect(request); } else if(retries > 0) { return await new Promise((resolve) => { setTimeout(async () => { const res = await this.acquireToken(request, retries-1); resolve(res); }, 60 * 1000); }) } return false; } } private handleTokenResponse(error, response) { if (error) { this.saveCallback('auth.onToken', error, null); return; } let setCallback = false; if(response.tokenType === 'access_token' && this.data.accessToken !== response.accessToken) { this.setToken('accessToken', response.accessToken, response.expiresOn, response.scopes); setCallback = true; } if(this.data.idToken !== response.idToken.rawIdToken) { this.setToken('idToken', response.idToken.rawIdToken, new Date(response.idToken.expiration * 1000), [this.auth.clientId]); setCallback = true; } if(setCallback) { this.saveCallback('auth.onToken', null, response); } } private setToken(tokenType:string, token: string, expiresOn: Date, scopes: string[]) { const expirationOffset = this.lib.config.system.tokenRenewalOffsetSeconds * 1000; const expiration = expiresOn.getTime() - (new Date()).getTime() - expirationOffset; if (expiration >= 0) { this.data[tokenType] = token; } if (this.tokenExpirationTimers[tokenType]) clearTimeout(this.tokenExpirationTimers[tokenType]); this.tokenExpirationTimers[tokenType] = window.setTimeout(async () => { if (this.auth.autoRefreshToken) { await this.acquireToken({ scopes }, 3); } else { this.data[tokenType] = ''; } }, expiration) } private requiresInteraction(errorCode: string) { if (!errorCode || !errorCode.length) { return false; } return errorCode === "consent_required" || errorCode === "interaction_required" || errorCode === "login_required"; } // MS GRAPH async initialMSGraphCall() { const {onResponse: callback} = this.graph; let initEndpoints = this.graph.endpoints; if (typeof initEndpoints === 'object' && !_.isEmpty(initEndpoints)) { const resultsObj = {}; const forcedIds: string[] = []; try { const endpoints: { [id: string]: GraphDetailedObject & { force?: Boolean } } = {}; for (const id in initEndpoints) { endpoints[id] = this.getEndpointObject(initEndpoints[id]); if (endpoints[id].force) { forcedIds.push(id); } } let storedIds: string[] = []; let storedData = this.lib.store.getItem(`msal.msgraph-${this.data.accessToken}`); if (storedData) { storedData = JSON.parse(storedData); storedIds = Object.keys(storedData); Object.assign(resultsObj, storedData); } const {singleRequests, batchRequests} = this.categorizeRequests(endpoints, _.difference(storedIds, forcedIds)); const singlePromises = singleRequests.map(async endpoint => { const res = {}; res[endpoint.id as string] = await this.msGraph(endpoint); return res; }); const batchPromises = Object.keys(batchRequests).map(key => { const batchUrl = (key === 'default') ? undefined : key; return this.msGraph(batchRequests[key], batchUrl); }); const mixedResults = await Promise.all([...singlePromises, ...batchPromises]); mixedResults.map((res) => { for (const key in res) { res[key] = res[key].body; } Object.assign(resultsObj, res); }); const resultsToSave = {...resultsObj}; forcedIds.map(id => delete resultsToSave[id]); this.lib.store.setItem(`msal.msgraph-${this.data.accessToken}`, JSON.stringify(resultsToSave)); this.data.graph = resultsObj; } catch (error) { console.error(error); } if (callback) this.saveCallback('graph.onResponse', this.data.graph); } } async msGraph(endpoints: GraphEndpoints, batchUrl: string | undefined = undefined) { try { if (Array.isArray(endpoints)) { return await this.executeBatchRequest(endpoints, batchUrl); } else { return await this.executeSingleRequest(endpoints); } } catch (error) { throw error; } } private async executeBatchRequest(endpoints: Array<string | GraphDetailedObject>, batchUrl = this.graph.baseUrl) { const requests = endpoints.map((endpoint, index) => this.createRequest(endpoint, index)); const {data} = await axios.request({ url: `${batchUrl}/$batch`, method: 'POST' as Method, data: {requests: requests}, headers: {Authorization: `Bearer ${this.data.accessToken}`}, responseType: 'json' }); let result = {}; data.responses.map(response => { let key = response.id; delete response.id; return result[key] = response }); // Format result const keys = Object.keys(result); const numKeys = keys.sort().filter((key, index) => { if (key.search('defaultID-') === 0) { key = key.replace('defaultID-', ''); } return parseInt(key) === index; }); if (numKeys.length === keys.length) { result = _.values(result); } return result; } private async executeSingleRequest(endpoint: string | GraphDetailedObject) { const request = this.createRequest(endpoint); if (request.url.search('http') !== 0) { request.url = this.graph.baseUrl + request.url; } const res = await axios.request(_.defaultsDeep(request, { url: request.url, method: request.method as Method, responseType: 'json', headers: {Authorization: `Bearer ${this.data.accessToken}`} })); return { status: res.status, headers: res.headers, body: res.data } } private createRequest(endpoint: string | GraphDetailedObject, index = 0) { const request = { url: '', method: 'GET', id: `defaultID-${index}` }; endpoint = this.getEndpointObject(endpoint); if (endpoint.url) { Object.assign(request, endpoint); } else { throw ({error: 'invalid endpoint', endpoint: endpoint}); } return request; } private categorizeRequests(endpoints: { [id:string]: GraphDetailedObject & { batchUrl?: string } }, excludeIds: string[]): CategorizedGraphRequests { let res: CategorizedGraphRequests = { singleRequests: [], batchRequests: {} }; for (const key in endpoints) { const endpoint = { id: key, ...endpoints[key] }; if (!_.includes(excludeIds, key)) { if (endpoint.batchUrl) { const {batchUrl} = endpoint; delete endpoint.batchUrl; if (!res.batchRequests.hasOwnProperty(batchUrl)) { res.batchRequests[batchUrl] = []; } res.batchRequests[batchUrl].push(endpoint); } else { res.singleRequests.push(endpoint); } } } return res; } private getEndpointObject(endpoint: string | GraphDetailedObject): GraphDetailedObject { if (typeof endpoint === "string") { endpoint = {url: endpoint} } if (typeof endpoint === "object" && !endpoint.url) { throw new Error('invalid endpoint url') } return endpoint; } // CUSTOM DATA saveCustomData(key: string, data: any) { if (!this.data.custom.hasOwnProperty(key)) { this.data.custom[key] = null; } this.data.custom[key] = data; this.storeCustomData(); } private storeCustomData() { if (!_.isEmpty(this.data.custom)) { this.lib.store.setItem('msal.custom', JSON.stringify(this.data.custom)); } else { this.lib.store.removeItem('msal.custom'); } } private getStoredCustomData() { let customData = {}; const customDataStr = this.lib.store.getItem('msal.custom'); if (customDataStr) { customData = JSON.parse(customDataStr); } this.data.custom = customData; } // CALLBACKS private saveCallback(callbackPath: string, ...args: any[]) { if (_.get(this.options, callbackPath)) { const callbackQueueObject: CallbackQueueObject = { id: _.uniqueId(`cb-${callbackPath}`), callback: callbackPath, arguments: args }; _.remove(this.callbackQueue, (obj) => obj.id === callbackQueueObject.id); this.callbackQueue.push(callbackQueueObject); this.storeCallbackQueue(); this.executeCallbacks([callbackQueueObject]); } } private getSavedCallbacks() { const callbackQueueStr = this.lib.store.getItem('msal.callbackqueue'); if (callbackQueueStr) { this.callbackQueue = [...this.callbackQueue, ...JSON.parse(callbackQueueStr)]; } } private async executeCallbacks(callbacksToExec: CallbackQueueObject[] = this.callbackQueue) { if (callbacksToExec.length) { for (let i in callbacksToExec) { const cb = callbacksToExec[i]; const callback = _.get(this.options, cb.callback); try { await callback(this, ...cb.arguments); _.remove(this.callbackQueue, function (currentCb) { return cb.id === currentCb.id; }); this.storeCallbackQueue(); } catch (e) { console.warn(`Callback '${cb.id}' failed with error: `, e.message); } } } } private storeCallbackQueue() { if (this.callbackQueue.length) { this.lib.store.setItem('msal.callbackqueue', JSON.stringify(this.callbackQueue)); } else { this.lib.store.removeItem('msal.callbackqueue'); } } }