@mattbillfred/mgt-msal2-provider
Version:
The Microsoft Graph Toolkit Msal 2.0 Provider
752 lines (691 loc) • 19.2 kB
text/typescript
/**
* -------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License.
* See License in the project root for license information.
* -------------------------------------------------------------------------------------------
*/
import {
IProvider,
LoginType,
ProviderState,
createFromProvider,
IProviderAccount,
GraphEndpoint,
error
} from '@microsoft/mgt-element';
import {
Configuration,
PublicClientApplication,
SilentRequest,
PopupRequest,
RedirectRequest,
AccountInfo,
EndSessionRequest,
InteractionRequiredAuthError,
SsoSilentRequest
} from '@azure/msal-browser';
import { AuthenticationProviderOptions } from '@microsoft/microsoft-graph-client';
/**
* base config for MSAL 2.0 authentication
*
* @export
* @interface Msal2ConfigBase
*/
interface Msal2ConfigBase {
/**
* Redirect URI
*
* @type {string}
* @memberof Msal2Config
*/
redirectUri?: string;
/**
* Authority URL
*
* @type {string}
* @memberof Msal2Config
*/
authority?: string;
/**
* Other options
*
* @type {Configuration}
* @memberof Msal2Config
*/
options?: Configuration;
/**
* List of scopes required
*
* @type {string[]}
* @memberof Msal2ConfigBase
*/
scopes?: string[];
/**
* loginType if login uses popup
*
* @type {LoginType}
* @memberof Msal2ConfigBase
*/
loginType?: LoginType;
/**
* login hint value
*
* @type {string}
* @memberof Msal2ConfigBase
*/
loginHint?: string;
/**
* Domain hint value
*
* @type {string}
* @memberof Msal2ConfigBase
*/
domainHint?: string;
/**
* Prompt type
*
* @type {string}
* @memberof Msal2ConfigBase
*/
prompt?: PromptType;
/**
* Session ID
*
* @type {string}
* @memberof Msal2Config
*/
sid?: string;
/**
* Specifies if incremental consent is disabled
*
* @type {boolean}
* @memberof Msal2ConfigBase
*/
isIncrementalConsentDisabled?: boolean;
/**
* Disable multi account functionality
*
* @type {boolean}
* @memberof Msal2Config
*/
isMultiAccountDisabled?: boolean;
}
/**
* Config for MSAL2.0 Authentication
*
* @export
* @interface Msal2Config
*/
export interface Msal2Config extends Msal2ConfigBase {
/**
* Client ID of app registration
*
* @type {boolean}
* @memberof Msal2Config
*/
clientId: string;
/**
* Disable multi account functionality
*
* @type {boolean}
* @memberof Msal2Config
*/
isMultiAccountEnabled?: boolean;
/**
* The base URL for the graph client
*/
baseURL?: GraphEndpoint;
/**
* CustomHosts
*
* @type {string[]}
* @memberof Msal2Config
*/
customHosts?: string[];
}
/**
* Config for MSAL 2.0 Authentication where a PublicClientApplication already exists
*
* @export
* @interface Msal2PublicClientApplicationConfig
*/
export interface Msal2PublicClientApplicationConfig extends Msal2ConfigBase {
/**
* Existing PublicClientApplication instance to use
*
* @type {PublicClientApplication}
* @memberof Msal2PublicClientApplicationConfig
*/
publicClientApplication: PublicClientApplication;
}
/**
* Prompt type enum
*
* @export
* @enum {number}
*/
export enum PromptType {
SELECT_ACCOUNT = 'select_account',
LOGIN = 'login',
CONSENT = 'consent'
}
/**
* MSAL2Provider using msal-browser to acquire tokens for authentication
*
* @export
* @class Msal2Provider
* @extends {IProvider}
*/
export class Msal2Provider extends IProvider {
private _publicClientApplication: PublicClientApplication;
/**
* Login type, Either Redirect or Popup
*
* @private
* @type {LoginType}
* @memberof Msal2Provider
*/
private _loginType: LoginType;
/**
* Login hint, if provided
*
* @private
* @memberof Msal2Provider
*/
private _loginHint: string;
/**
* Domain hint if provided
*
* @private
* @memberof Msal2Provider
*/
private _domainHint: string;
/**
* Prompt type
*
* @private
* @type {string}
* @memberof Msal2Provider
*/
private _prompt: string;
/**
* Session ID, if provided
*
* @private
* @memberof Msal2Provider
*/
private _sid: string;
// /**
// * Specifies if incremental consent is disabled
// *
// * @type {boolean}
// * @memberof Msal2ConfigBase
// */
// private _isIncrementalConsentDisabled: boolean = false;
/**
* Configuration settings for authentication
*
* @private
* @type {Configuration}
* @memberof Msal2Provider
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
private ms_config: Configuration;
/**
* Gets the PublicClientApplication Instance
*
* @private
* @type {PublicClientApplication}
* @memberof Msal2Provider
*/
public get publicClientApplication() {
return this._publicClientApplication;
}
/**
* Name used for analytics
*
* @readonly
* @memberof IProvider
*/
public get name() {
return 'MgtMsal2Provider';
}
/**
* List of scopes
*
* @type {string[]}
* @memberof Msal2Provider
*/
public scopes: string[];
/**
* Enables multi account functionality if true, disables if false
*
* @private
* @type {boolean}
* @memberof Msal2Provider
*/
public isMultipleAccountEnabled = true;
/**
* Indicates if multi account functionality is disabled
*
* @protected
* @type {boolean}
* @memberof Msal2Provider
*/
protected get isMultiAccountDisabled(): boolean {
return !this.isMultipleAccountEnabled;
}
/**
* Disables or enables multi account functionality
* Uses isMultipleAccountEnabled as the backing property
* Property provided to ensure adherence to the IProvider interface
*
* @protected
* @memberof Msal2Provider
*/
protected set isMultiAccountDisabled(value: boolean) {
this.isMultipleAccountEnabled = !value;
}
/**
* Specifies if Multi account functionality is supported by the provider and enabled.
*
* @readonly
* @type {boolean}
* @memberof IProvider
*/
public get isMultiAccountSupportedAndEnabled(): boolean {
return this.isMultipleAccountEnabled;
}
private get sessionStorageRequestedScopesKey() {
return 'mgt-requested-scopes';
}
private get sessionStorageDeniedScopesKey() {
return 'mgt-denied-scopes';
}
private get homeAccountKey() {
return '275f3731-e4a4-468a-bf9c-baca24b31e26';
}
public constructor(config: Msal2Config | Msal2PublicClientApplicationConfig) {
super();
void this.initProvider(config);
}
/**
* Initialize provider with configuration details
*
* @private
* @param {Msal2Config} config
* @memberof Msal2Provider
*/
private async initProvider(config: Msal2Config | Msal2PublicClientApplicationConfig) {
const msalConfig: Configuration = config.options || { auth: { clientId: '' } };
this.ms_config = msalConfig;
this.ms_config.cache = msalConfig.cache || {};
this.ms_config.cache.cacheLocation = msalConfig.cache.cacheLocation || 'localStorage';
if (
typeof this.ms_config.cache.storeAuthStateInCookie === 'undefined' ||
this.ms_config.cache.storeAuthStateInCookie === null
) {
this.ms_config.cache.storeAuthStateInCookie = true;
}
this.ms_config.system = msalConfig.system || {};
this.ms_config.system.iframeHashTimeout = msalConfig.system.iframeHashTimeout || 10000;
if (config.authority) {
this.ms_config.auth.authority = config.authority;
}
if (config.redirectUri) {
this.ms_config.auth.redirectUri = config.redirectUri;
}
if ('clientId' in config) {
if (config.clientId) {
this.ms_config.auth.clientId = config.clientId;
this._publicClientApplication = new PublicClientApplication(this.ms_config);
} else {
throw new Error('clientId must be provided');
}
} else if ('publicClientApplication' in config) {
if (config.publicClientApplication) {
this._publicClientApplication = config.publicClientApplication;
} else {
throw new Error('publicClientApplication must be provided');
}
} else {
throw new Error('either clientId or publicClientApplication must be provided');
}
await this._publicClientApplication.initialize();
this.ms_config.system = msalConfig.system || {};
this.ms_config.system.iframeHashTimeout = msalConfig.system.iframeHashTimeout || 10000;
this._loginType = typeof config.loginType !== 'undefined' ? config.loginType : LoginType.Redirect;
this._loginHint = typeof config.loginHint !== 'undefined' ? config.loginHint : null;
this._sid = typeof config.sid !== 'undefined' ? config.sid : null;
this.isIncrementalConsentDisabled =
typeof config.isIncrementalConsentDisabled !== 'undefined' ? config.isIncrementalConsentDisabled : false;
this._domainHint = typeof config.domainHint !== 'undefined' ? config.domainHint : null;
this.scopes = typeof config.scopes !== 'undefined' ? config.scopes : ['user.read'];
this._prompt = typeof config.prompt !== 'undefined' ? config.prompt : PromptType.SELECT_ACCOUNT;
const msal2config = config as Msal2Config;
this.isMultipleAccountEnabled =
typeof msal2config.isMultiAccountEnabled !== 'undefined' ? msal2config.isMultiAccountEnabled : true;
this.baseURL = typeof msal2config.baseURL !== 'undefined' ? msal2config.baseURL : this.baseURL;
this.customHosts = msal2config.customHosts;
this.graph = createFromProvider(this);
try {
const tokenResponse = await this._publicClientApplication.handleRedirectPromise();
if (tokenResponse !== null) {
this.handleResponse(tokenResponse?.account);
} else {
await this.trySilentSignIn();
}
} catch (e) {
error('Problem attempting to sign in', e);
throw e;
}
}
/**
* Attempts to sign in user silently
*
* @memberof Msal2Provider
*/
public async trySilentSignIn() {
const silentRequest: SsoSilentRequest = {
scopes: this.scopes,
domainHint: this._domainHint
};
if (this._sid || this._loginHint) {
silentRequest.sid = this._sid;
silentRequest.loginHint = this._loginHint;
try {
this.setState(ProviderState.Loading);
const response = await this._publicClientApplication.ssoSilent(silentRequest);
if (response) {
this.handleResponse(response?.account);
}
} catch (e) {
this.setState(ProviderState.SignedOut);
}
} else {
const account: AccountInfo = this.getAccount();
if (account) {
if (await this.getAccessToken(null)) {
this.handleResponse(account);
return;
}
}
this.setState(ProviderState.SignedOut);
}
}
/**
* Log in the user
*
* @return {*} {Promise<void>}
* @memberof Msal2Provider
*/
public async login(): Promise<void> {
const loginRequest: PopupRequest = {
scopes: this.scopes,
loginHint: this._loginHint,
prompt: this._prompt,
domainHint: this._domainHint
};
if (this._loginType === LoginType.Popup) {
const response = await this._publicClientApplication.loginPopup(loginRequest);
this.handleResponse(response?.account);
} else {
const loginRedirectRequest: RedirectRequest = { ...loginRequest };
await this._publicClientApplication.loginRedirect(loginRedirectRequest);
}
}
/**
* Get all signed in accounts
*
* @return {*}
* @memberof Msal2Provider
*/
public getAllAccounts() {
const usernames: IProviderAccount[] = [];
this._publicClientApplication.getAllAccounts().forEach((account: AccountInfo) => {
usernames.push({ name: account.name, mail: account.username, id: account.homeAccountId } as IProviderAccount);
});
return usernames;
}
/**
* Switching between accounts
*
* @param {*} user
* @memberof Msal2Provider
*/
public setActiveAccount(user: IProviderAccount) {
this._publicClientApplication.setActiveAccount(this._publicClientApplication.getAccountByHomeId(user.id));
this.setStoredAccount();
super.setActiveAccount(user);
}
/**
* Gets active account
*
* @return {*}
* @memberof Msal2Provider
*/
public getActiveAccount() {
const account = this._publicClientApplication.getActiveAccount();
return {
name: account.name,
mail: account.username,
id: account.homeAccountId,
tenantId: account.tenantId
} as IProviderAccount;
}
/**
* Once a succesful login occurs, set the active account and store it
*
* @param {(AuthenticationResult | null)} account
* @memberof Msal2Provider
*/
handleResponse(account: AccountInfo) {
if (account !== null) {
this.setActiveAccount({
name: account.name,
id: account.homeAccountId,
mail: account.username
} as IProviderAccount);
this.setState(ProviderState.SignedIn);
} else {
this.setState(ProviderState.SignedOut);
}
this.clearRequestedScopes();
}
private storage() {
switch (this.ms_config.cache.cacheLocation) {
case 'localStorage':
return window.localStorage;
case 'sessionStorage':
default:
return window.sessionStorage;
}
}
/**
* Store the currently signed in account in storage
*
* @private
* @memberof Msal2Provider
*/
private setStoredAccount() {
this.clearStoredAccount();
this.storage().setItem(this.homeAccountKey, this._publicClientApplication.getActiveAccount().homeAccountId);
}
/**
* Get the stored account from storage
*
* @private
* @return {*}
* @memberof Msal2Provider
*/
private getStoredAccount() {
const homeId = this.storage().getItem(this.homeAccountKey);
return this._publicClientApplication.getAccountByHomeId(homeId);
}
/**
* Clears the stored account from storage
*
* @private
* @memberof Msal2Provider
*/
private clearStoredAccount() {
this.storage().removeItem(this.homeAccountKey);
}
/**
* Adds scopes that have already been requested to sessionstorage
*
* @protected
* @param {string[]} scopes
* @memberof Msal2Provider
*/
protected setRequestedScopes(scopes: string[]) {
if (scopes) {
sessionStorage.setItem(this.sessionStorageRequestedScopesKey, JSON.stringify(scopes));
}
}
/**
* Adds denied scopes to session storage
*
* @protected
* @param {string[]} scopes
* @memberof Msal2Provider
*/
protected addDeniedScopes(scopes: string[]) {
if (scopes) {
let deniedScopes: string[] = this.getDeniedScopes() || [];
deniedScopes = deniedScopes.concat(scopes);
let index = deniedScopes.indexOf('openid');
if (index !== -1) {
deniedScopes.splice(index, 1);
}
index = deniedScopes.indexOf('profile');
if (index !== -1) {
deniedScopes.splice(index, 1);
}
sessionStorage.setItem(this.sessionStorageDeniedScopesKey, JSON.stringify(deniedScopes));
}
}
/**
* Gets denied scopes
*
* @protected
* @return {*}
* @memberof Msal2Provider
*/
protected getDeniedScopes() {
const scopesStr = sessionStorage.getItem(this.sessionStorageDeniedScopesKey);
return scopesStr ? (JSON.parse(scopesStr) as string[]) : null;
}
/**
* Checks if scopes were denied previously
*
* @protected
* @param {string[]} scopes
* @return {*}
* @memberof Msal2Provider
*/
protected areScopesDenied(scopes: string[]) {
if (scopes) {
const deniedScopes = this.getDeniedScopes();
if (deniedScopes && deniedScopes.filter(s => -1 !== scopes.indexOf(s)).length > 0) {
return true;
}
}
return false;
}
/**
* Clears all requested scopes from session storage
*
* @protected
* @memberof Msal2Provider
*/
protected clearRequestedScopes() {
sessionStorage.removeItem(this.sessionStorageRequestedScopesKey);
}
/**
* Gets stored account if available, otherwise fetches the first account in the list of signed in accounts
*
* @private
* @return {*} {(AccountInfo | null)}
* @memberof Msal2Provider
*/
protected getAccount(): AccountInfo | null {
const account = this.getStoredAccount();
if (account) {
return account;
} else if (this._publicClientApplication.getAllAccounts().length > 0) {
return this._publicClientApplication.getAllAccounts()[0];
}
return null;
}
/**
* Logs out user
*
* @memberof Msal2Provider
*/
public async logout() {
const logOutAccount = this._publicClientApplication.getActiveAccount();
const logOutRequest: EndSessionRequest = {
account: logOutAccount
};
this.clearStoredAccount();
if (this._loginType === LoginType.Redirect) {
this.setState(ProviderState.SignedOut);
await this._publicClientApplication.logoutRedirect(logOutRequest);
} else {
await this._publicClientApplication.logoutPopup({ ...logOutRequest });
if (this._publicClientApplication.getAllAccounts.length === 1 || !this.isMultipleAccountEnabled) {
this.setState(ProviderState.SignedOut);
} else {
await this.trySilentSignIn();
}
}
}
/**
* Returns access token for scopes
*
* @param {AuthenticationProviderOptions} [options]
* @return {*} {Promise<string>}
* @memberof Msal2Provider
*/
public async getAccessToken(options?: AuthenticationProviderOptions): Promise<string> {
const scopes = options ? options.scopes || this.scopes : this.scopes;
const accessTokenRequest: SilentRequest = {
scopes,
account: this.getAccount()
};
try {
const silentRequest: SilentRequest = accessTokenRequest;
const response = await this._publicClientApplication.acquireTokenSilent(silentRequest);
return response.accessToken;
} catch (e) {
if (e instanceof InteractionRequiredAuthError) {
if (this.isIncrementalConsentDisabled) {
return null;
}
if (this._loginType === LoginType.Redirect) {
if (!this.areScopesDenied(scopes)) {
this.setRequestedScopes(scopes);
await this._publicClientApplication.acquireTokenRedirect(accessTokenRequest);
} else {
throw e;
}
} else {
try {
const response = await this._publicClientApplication.acquireTokenPopup(accessTokenRequest);
return response.accessToken;
} catch (popUpErr) {
error('problem with pop-up sign in', popUpErr);
throw popUpErr;
}
}
} else {
// if we don't know what the error is, just ask the user to sign in again
this.setState(ProviderState.SignedOut);
}
}
// TODO: work out if we can throw something more meaningful here
// eslint-disable-next-line no-throw-literal
throw null;
}
}