vue-msal
Version:
Vue plugin for using Microsoft Authentication Library (MSAL)
408 lines (402 loc) • 16 kB
text/typescript
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');
}
}
}