import { I18nManager, Platform } from 'react-native'; import PublicClientApplication, { MSALPromptType } from 'react-native-msal'; import type { MSALAccount, MSALConfiguration, MSALInteractiveParams, MSALResult, MSALSignoutParams, MSALSilentParams, MSALWebviewParams, } from 'react-native-msal'; import SecureStoreManager from '../SecureStoreManager'; import StoreManager from '../StorageManager'; import { LanguageCode } from '../../constants/Constants'; export interface B2CPolicies { signInSignUp: string; signUp: string; passwordReset?: string; passwordChange?: string; } interface ExtensionProfileId { extension_profileId: string; } export type B2CConfiguration = Omit & { auth: { clientId: string; authorityBase: string; policies: B2CPolicies; redirectUri?: string; }; }; export type B2CSignInParams = Omit; export type B2CSilentParams = Pick; export type B2CSignOutParams = Pick< MSALSignoutParams, 'signoutFromBrowser' | 'webviewParameters' >; export class TasmuB2CClient { private static readonly B2C_PASSWORD_CHANGE = 'AADB2C90118'; private static readonly B2C_EXPIRED_GRANT = 'AADB2C90080'; private readonly policyUrls: B2CPolicies; private pca: PublicClientApplication; /** Construct a B2CClient object * @param b2cConfig The configuration object for the B2CClient */ constructor(b2cConfig: B2CConfiguration) { const { authorityBase, policies, ...restOfAuthConfig } = b2cConfig.auth; this.policyUrls = makePolicyUrls(authorityBase, policies); // Set the sign in sign up policy as the default authority for the PublicClientApplication (PCA). const authority = this.policyUrls.signInSignUp; // We need to provide all authorities we'll be using up front. // The default authority should be included in this list. const knownAuthorities = Object.values(this.policyUrls); this.pca = new PublicClientApplication({ ...b2cConfig, auth: { authority, knownAuthorities, ...restOfAuthConfig }, }); } public async init() { await this.pca.init(); return this; } /** Initiates an interactive sign-in. If the user clicks "Forgot Password", and a reset password policy * was provided to the client, it will initiate the password reset flow */ public async signIn( params: B2CSignInParams, showToast?: () => void ): Promise { const isSignedIn = await this.isSignedIn(); if (isSignedIn) { return this.clearCurrentB2CSessionAndLogin(params, showToast); // throw Error('A user is already signed in'); } try { // If we don't provide an authority, the PCA will use the one we passed to it when we created it // (the sign in sign up policy) const result = await this.pca.acquireToken({ ...params, extraQueryParameters: { ui_locales: getCurrentLocale() }, }); const { accessToken, account, expiresOn } = result as MSALResult; this.saveTokenAndExpiry( accessToken, (account.claims as ExtensionProfileId).extension_profileId, expiresOn ); if (!result) { throw new Error('Could not sign in: Result was undefined.'); } return result; } catch (error: unknown) { if ( error instanceof Error && error.message.includes(TasmuB2CClient.B2C_PASSWORD_CHANGE) && this.policyUrls.passwordReset ) { return await this.resetPassword(params, showToast); } else { throw error; } } } /** Initiates an interactive sign-up. * */ public async signUp( params: B2CSignInParams, showToast?: () => void ): Promise { const isSignedIn = await this.isSignedIn(); if (isSignedIn) { return this.clearCurrentB2CSessionAndLogin(params, showToast); // throw Error('A user is already signed in'); } try { // If we don't provide an authority, the PCA will use the one we passed to it when we created it // (the sign in sign up policy) const authority = this.policyUrls.signUp; const result = await this.pca.acquireToken({ ...params, authority, extraQueryParameters: { ui_locales: getCurrentLocale() }, }); const { accessToken, account, expiresOn } = result as MSALResult; this.saveTokenAndExpiry( accessToken, (account.claims as ExtensionProfileId).extension_profileId, expiresOn ); if (!result) { throw new Error('Could not sign in: Result was undefined.'); } return result; } catch (error: unknown) { if ( error instanceof Error && error.message.includes(TasmuB2CClient.B2C_PASSWORD_CHANGE) && this.policyUrls.passwordReset ) { return await this.resetPassword(params, showToast); } else { throw error; } } } /** Gets a token silently. Will only work if the user is already signed in */ public async acquireTokenSilent(params: B2CSilentParams) { const signedInAccount = await this.getAccountForPolicy( this.policyUrls.signInSignUp ); const registeredAccount = await this.getAccountForPolicy( this.policyUrls.signUp ); const account = signedInAccount || registeredAccount; if (account) { // We provide the account that we got when we signed in, with the matching sign in sign up authority // Which again, we set as the default authority so we don't need to provide it explicitly. try { const authority = signedInAccount ? this.policyUrls.signInSignUp : this.policyUrls.signUp; const result = await this.pca.acquireTokenSilent({ ...params, account, authority, }); const { accessToken, account: msalResultAccount, expiresOn, } = result as MSALResult; this.saveTokenAndExpiry( accessToken, (msalResultAccount.claims as ExtensionProfileId).extension_profileId, expiresOn ); if (!result) { throw new Error( 'Could not acquire token silently: Result was undefined.' ); } return result; } catch (error: unknown) { if ( error instanceof Error && error.message.includes(TasmuB2CClient.B2C_EXPIRED_GRANT) ) { this.removeUserStoredCredentials(); await this.pca.signOut({ ...params, account }); return await this.signIn(params); } else { throw error; } } } else { throw Error('Could not find existing account for sign in sign up policy'); } } /** Returns true if a user is signed in, false if not */ public async isSignedIn() { const signInAccount = await this.getAccountForPolicy( this.policyUrls.signInSignUp ); const signUpAccount = await this.getAccountForPolicy( this.policyUrls.signUp ); return signInAccount !== undefined || signUpAccount !== undefined; } /** * Function to save token, profile id in keychain and expiry time in store manager. * @param token * @param profileId * @param expiresOn */ public async saveTokenAndExpiry( token: string, profileId: string, expiresOn: number ) { await SecureStoreManager.storeItem(profileId, token, 'Auth'); await StoreManager.storeItem('timestamp', JSON.stringify(expiresOn)); } /** * Function to check user token exist. * @returns boolean true if token exist */ public async checkIfTokenExist() { try { const isTokenExist = await SecureStoreManager.retrieveItem('Auth'); return isTokenExist ? true : false; } catch (error) { return false; } } public async removeUserStoredCredentials() { await SecureStoreManager.deleteItem('Auth'); await StoreManager.removeItem('timestamp'); } /** * Removes all accounts from the device for this app. * User will have to sign in again to get a token * @param params * @returns boolean true. */ public async signOut(params?: B2CSignOutParams) { this.removeUserStoredCredentials(); const accounts = await this.pca.getAccounts(); const signOutPromises = accounts.map((account) => this.pca.signOut({ ...params, account }) ); await Promise.all(signOutPromises); return true; } public async clearCurrentB2CSessionAndLogin( params: B2CSignInParams, showToast?: () => void, signOutParams?: B2CSignOutParams ) { this.removeUserStoredCredentials(); const accounts = await this.pca.getAccounts(); const signOutPromises = accounts.map((account) => this.pca.signOut({ ...signOutParams, account }) ); await Promise.all(signOutPromises); return this.signIn(params, showToast); } private async resetPassword(params: B2CSignInParams, showToast: any) { const { webviewParameters: wvp, ...rest } = params; const webviewParameters: MSALWebviewParams = { ...wvp, // We use an ephemeral session because if we're resetting a password it means the user // is not using an identity provider, so we don't need a logged-in browser session ios_prefersEphemeralWebBrowserSession: true, }; if (this.policyUrls.passwordReset) { // Because there is no prompt before starting an iOS ephemeral session, it will be quick to // open and begin before the other one has ended, causing an error saying that only one // interactive session is allowed at a time. So we have to slow it down a little if (Platform.OS === 'ios') { await delay(1000); } // Use the password reset policy in the interactive `acquireToken` call const authority = this.policyUrls.passwordReset; await this.pca.acquireToken({ ...rest, webviewParameters, authority, extraQueryParameters: { ui_locales: getCurrentLocale() }, }); showToast('New password saved', 'success'); // Adding slight delay in android to accomodate forgot pswd toast message if (Platform.OS === 'android') { await delay(2000); } // Sign in again after resetting the password return await this.signIn(params); } else { throw Error('B2CClient missing password reset policy'); } } /** Initiates the password change flow */ public async changePassword(params: B2CSignInParams) { const { webviewParameters, ...rest } = params; if (this.policyUrls.passwordChange) { // Use the password change policy in the interactive `acquireToken` call const authority = this.policyUrls.passwordChange; await this.pca.acquireToken({ ...rest, authority, webviewParameters, promptType: Platform.OS === 'ios' ? MSALPromptType.CONSENT : MSALPromptType.WHEN_REQUIRED, extraQueryParameters: { ui_locales: getCurrentLocale() }, }); } else { throw Error('B2CClient missing password change policy'); } } private async getAccountForPolicy( policyUrl: string ): Promise { const policy = policyUrl.split('/').pop(); const accounts = await this.pca.getAccounts(); return accounts.find((account) => account.identifier.includes(policy!.toLowerCase()) ); } } function makeAuthority(authorityBase: string, policyName: string) { return `${authorityBase}/${policyName}`; } function makePolicyUrls( authorityBase: string, policyNames: B2CPolicies ): B2CPolicies { return Object.entries(policyNames).reduce( (prev, [key, policyName]) => ({ ...prev, [key]: makeAuthority(authorityBase, policyName), }), {} as B2CPolicies ); } async function delay(ms: number) { return await new Promise((resolve) => setTimeout(resolve, ms)); } function getCurrentLocale() { return I18nManager.isRTL ? LanguageCode.B2C_Arabic : LanguageCode.B2C_English; }