UNPKG

@circle-fin/w3s-pw-web-sdk

Version:

Javascript/Typescript SDK for Circle Programmable Wallets

874 lines (769 loc) 25.7 kB
// Copyright (c) 2023, Circle Technologies, LLC. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { FirebaseError, getApps, initializeApp } from 'firebase/app' import { OAuthProvider, getAuth, getRedirectResult, signInWithPopup, } from 'firebase/auth' import { decode } from 'jsonwebtoken' import { v4 as uuidv4 } from 'uuid' import packageInfo from '../package.json' import { SocialLoginProvider } from './types' import type { AppSettings, Authentication, Challenge, ChallengeCompleteCallback, Configs, CustomLinks, DeviceInfo, Localizations, LoginCompleteCallback, PostMessageEvent, Resources, SecurityQuestion, ThemeColor, } from './types' import type { FirebaseApp } from 'firebase/app' import type { UserCredential } from 'firebase/auth' import type { JwtPayload } from 'jsonwebtoken' export class W3SSdk { private readonly serviceUrl = 'https://pw-auth.circle.com' private static instance: W3SSdk | null = null private readonly iframe!: HTMLIFrameElement private readonly window: Window = window private configs?: Configs private challenge?: Challenge private securityQuestions?: SecurityQuestion[] | null private securityQuestionsRequiredCount = 2 private securityConfirmItems?: string[] private localizations?: Localizations private themeColor?: ThemeColor private resources?: Resources private customLinks?: CustomLinks private deviceInfo?: DeviceInfo private socialLoginToken?: string | null private socialLoginProvider?: SocialLoginProvider private firebaseApp?: FirebaseApp /** * Callback function that is called when the challenge is completed. */ private onComplete?: ChallengeCompleteCallback /** * Callback function that is called when the page is redirected back from the social login provider and receives the verification result. */ private onLoginComplete?: LoginCompleteCallback private shouldCloseModalOnForgotPin = false /** * Callback function that is called when the user clicks the forgot pin button. */ private onForgotPin?: () => void private receivedResponseFromService = false /** * Promise that is resolved when the device ID is received. */ private resolveDeviceIdPromise?: (deviceId: string) => void /** * Promise that is rejected when the device ID is not received. */ private rejectDeviceIdPromise?: (reason: string) => void /** * Callback function that is called when the user clicks the resend OTP email button. */ private onResendOtpEmail?: () => void constructor(configs?: Configs, onLoginComplete?: LoginCompleteCallback) { if (W3SSdk.instance != null) { this.setupInstance(configs, onLoginComplete) return W3SSdk.instance } this.iframe = document.createElement('iframe') this.setupInstance(configs, onLoginComplete) W3SSdk.instance = this } /** * Sets the application settings. * This method will be deprecated in the future. Please use the constructor to set the application settings. * @param appSettings - Application settings. */ setAppSettings(appSettings: AppSettings): void { if (this.configs) { this.configs.appSettings = appSettings } else { this.configs = { appSettings } } } /** * Sets the authentication information. * @param auth - Authentication information. */ setAuthentication(auth: Authentication): void { if (this.configs) { this.configs.authentication = auth } else { this.configs = { appSettings: { appId: '', }, authentication: auth, } } } /** * Updates the configurations. * @param configs - Configurations. * @param onLoginComplete - Callback function that is called when the page is redirected back from the social login provider and receives the verification result. */ updateConfigs(configs?: Configs, onLoginComplete?: LoginCompleteCallback) { this.setupInstance(configs, onLoginComplete ?? this.onLoginComplete) } /** * Gets the device ID. * @returns Promise<string> - Device ID. */ getDeviceId(): Promise<string> { return new Promise<string>((resolve, reject) => { this.resolveDeviceIdPromise = resolve this.rejectDeviceIdPromise = reject this.subscribeMessage() this.appendIframe(false, 'device-id') setTimeout(() => { if (!this.receivedResponseFromService) { this.rejectDeviceIdPromise?.('Failed to receive deviceId') this.closeModal() this.unSubscribeMessage() } }, 1000 * 10) }) } /** * Performs social login. * @param provider - Social login provider. */ async performLogin(provider: SocialLoginProvider): Promise<void> { if (provider === SocialLoginProvider.GOOGLE) { this.performGoogleLogin() } else if (provider === SocialLoginProvider.FACEBOOK) { this.performFacebookLogin() } else if (provider === SocialLoginProvider.APPLE) { await this.performAppleLogin() } else { void this.onLoginComplete?.( { code: 155140, message: 'Invalid social login provider', }, undefined, ) } } /** * Executes email OTP verification. */ verifyOtp() { this.subscribeMessage() this.appendIframe(true, 'social/verify-email') setTimeout(() => { if (!this.receivedResponseFromService) { void this.onComplete?.( { code: 155706, message: 'Network error', }, undefined, ) } }, 1000 * 10) } /** * Executes the challenge. * @param challengeId - Challenge ID. * @param onCompleted - Callback function that is called when the challenge is completed. */ execute(challengeId: string, onCompleted?: ChallengeCompleteCallback): void { this.subscribeMessage() this.setChallenge({ challengeId }) this.exec(onCompleted, false) } /** * Sets the custom security questions. If the user doesn't provide the custom security questions, the default security questions will be used. * @param questions - Custom security questions. * @param requiredCount - Required number of security questions. * @param securityConfirmItems - Security confirm disclaimer items. */ setCustomSecurityQuestions( questions?: SecurityQuestion[] | null, requiredCount = 2, securityConfirmItems?: string[], ): void { this.securityQuestions = questions this.securityConfirmItems = securityConfirmItems if (requiredCount <= 0) { this.securityQuestionsRequiredCount = 2 } else { this.securityQuestionsRequiredCount = requiredCount } } /** * Sets the localizations. * @param localizations - Localizations. */ setLocalizations(localizations: Localizations): void { this.localizations = localizations } /** * Sets the resources. * @param resources - Resources. */ setResources(resources: Resources): void { this.resources = resources } /** * Sets the theme color. * @param themeColor - Theme color. */ setThemeColor(themeColor: ThemeColor): void { this.themeColor = themeColor } /** * Sets the custom links. * @param customLinks - Custom links. */ setCustomLinks(customLinks: CustomLinks): void { this.customLinks = customLinks } /** * Sets the callback function that is called when the user clicks the forgot pin button. * @param onForgotPin - Callback function that is called when the user clicks the forgot pin button. * @param shouldCloseModalOnForgotPin - Indicates whether the modal should be closed when the user clicks the forgot pin button. Default is false. */ setOnForgotPin( onForgotPin: () => void, shouldCloseModalOnForgotPin = false, ): void { this.shouldCloseModalOnForgotPin = shouldCloseModalOnForgotPin this.onForgotPin = () => { if (this.shouldCloseModalOnForgotPin) { this.closeModal() } onForgotPin?.() } } /** * Sets the callback function that is called when the user clicks the resend OTP email button. * @param onResendOtpEmail - Callback function that is called when the user clicks the resend OTP email button. */ setOnResendOtpEmail(onResendOtpEmail: () => void): void { this.onResendOtpEmail = onResendOtpEmail } /** * Sets up the instance. * @param configs - Configurations. * @param onLoginComplete - Callback function that is called when the page is redirected back from the social login provider and receives the verification result. */ private setupInstance( configs?: Configs, onLoginComplete?: LoginCompleteCallback, ) { if (configs?.loginConfigs?.apple && getApps().length === 0) { this.firebaseApp = initializeApp(configs.loginConfigs.apple) } else if (getApps().length !== 0) { this.firebaseApp = getApps()[0] } this.onLoginComplete = onLoginComplete this.configs = configs this.deviceInfo = { model: 'Web', version: packageInfo.version, } void this.execSocialLoginStatusCheck() } /** * Sets the challenge. * @param challenge - Challenge. */ private setChallenge(challenge: Challenge): void { this.challenge = challenge } /** * Appends the iframe to the document body. * @param showIframe - Indicates whether the iframe should be shown. Default is true. * @param subRoute - Sub route. */ private appendIframe(showIframe = true, subRoute = '') { const protocol = this.window.location.protocol const host = this.window.location.host const fullDomainWithProtocol = `${protocol}//${host}` this.iframe.src = `${this.serviceUrl}/${subRoute}?origin=${fullDomainWithProtocol}` this.iframe.id = 'sdkIframe' this.iframe.width = showIframe ? '100%' : '0%' this.iframe.height = showIframe ? '100%' : '0%' this.iframe.style.zIndex = showIframe ? '2147483647' : '-1' this.iframe.style.display = 'none' if (showIframe) { this.iframe.style.position = 'fixed' this.iframe.style.top = '50%' this.iframe.style.left = '50%' this.iframe.style.transform = 'translate(-50%, -50%)' this.iframe.style.display = '' } document.body.appendChild(this.iframe) } /** * Executes the challenge. * @param onCompleted - Callback function that is called when the challenge is completed. * @param showIframe - Indicates whether the iframe should be shown. Default is true. */ private exec(onCompleted?: ChallengeCompleteCallback, showIframe = true) { this.appendIframe(showIframe) this.onComplete = onCompleted setTimeout(() => { if (!this.receivedResponseFromService) { void this.onComplete?.( { code: 155706, message: 'Network error', }, undefined, ) } }, 1000 * 10) } private async performAppleLogin() { if (!this.firebaseApp) { void this.onLoginComplete?.( { code: 155140, message: 'Please provide the Apple social login configurations.', }, undefined, ) return } this.saveOAuthInfo(SocialLoginProvider.APPLE) const provider = new OAuthProvider('apple.com') const auth = getAuth(this.firebaseApp) try { const cred = await signInWithPopup(auth, provider) if (!this.extractTokenFromResultAndSave(cred)) { return } // Send the token to the verification service and reset the social login provider this.verifyTokenViaService() this.window.localStorage.setItem('socialLoginProvider', '') } catch (error) { if ( error instanceof FirebaseError && error.code !== 'auth/cancelled-popup-request' && error.code !== 'auth/popup-closed-by-user' ) { await this.handleFirebaseFailure(error) } else if (!(error instanceof FirebaseError)) { this.handleLoginFailure() } } } private performFacebookLogin() { if (!this?.configs?.loginConfigs?.facebook) { void this.onLoginComplete?.( { code: 155140, message: 'Please provide the Facebook social login configurations.', }, undefined, ) return } const { appId, redirectUri } = this.configs.loginConfigs.facebook const { url = '', state = '' } = this.generateOauthUrlWithParams( SocialLoginProvider.FACEBOOK, appId, redirectUri, ) || {} this.saveOAuthInfo(SocialLoginProvider.FACEBOOK, state) this.window.location.href = url } private performGoogleLogin() { if (!this.configs?.loginConfigs?.google) { void this.onLoginComplete?.( { code: 155140, message: 'Please provide the Google social login configurations.', }, undefined, ) return } const { clientId, redirectUri, selectAccountPrompt } = this.configs.loginConfigs.google const { url = '', state = '', nonce = '', } = this.generateOauthUrlWithParams( SocialLoginProvider.GOOGLE, clientId, redirectUri, selectAccountPrompt, ) || {} this.saveOAuthInfo(SocialLoginProvider.GOOGLE, state, nonce) this.window.location.href = url } /** * Generates the OAuth URL with the necessary parameters. * @param provider - Social login provider. * @param id - Client ID or Application ID. * @param redirectUri - Redirect URI. * @param selectAccountPrompt - Indicates whether the user should select the account. Default is false. * @returns OAuth URL with the necessary parameters. */ private generateOauthUrlWithParams( provider: SocialLoginProvider, id: string, redirectUri: string, selectAccountPrompt: boolean = false, ): | { url: string state: string nonce?: string } | undefined { const state = uuidv4() if (provider === SocialLoginProvider.GOOGLE) { const scope = encodeURIComponent( 'openid https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email', ) const responseType = encodeURIComponent('id_token token') const nonce = uuidv4() return { url: `https://accounts.google.com/o/oauth2/v2/auth?client_id=${id}&redirect_uri=${encodeURIComponent( redirectUri, )}&scope=${scope}&state=${state}&response_type=${responseType}&nonce=${nonce}&prompt=${ selectAccountPrompt ? 'select_account' : 'none' }`, state, nonce, } } else if (provider === SocialLoginProvider.FACEBOOK) { const scope = encodeURIComponent('email') return { url: `https://www.facebook.com/v13.0/dialog/oauth?client_id=${id}&redirect_uri=${encodeURIComponent( redirectUri, )}&scope=${scope}&state=${state}&response_type=token`, state, } } } /** * Executes the social login status check before sending the token to the verification service. */ private async execSocialLoginStatusCheck(): Promise<void> { const socialLoginProvider = this.window.localStorage.getItem( 'socialLoginProvider', ) as SocialLoginProvider if (socialLoginProvider === SocialLoginProvider.APPLE) { await this.handleAppleLoginResponse() } else if (this.isValidHash(this.window.location.hash)) { this.handleHashLoginResponse(socialLoginProvider) } } /** * Handles the Apple login response. * @returns Promise<void>. */ private async handleAppleLoginResponse(): Promise<void> { const auth = getAuth(this.firebaseApp) try { const result = await getRedirectResult(auth) if (!result || !this.extractTokenFromResultAndSave(result)) { return } // Send the token to the verification service and reset the social login provider this.verifyTokenViaService() this.window.localStorage.setItem('socialLoginProvider', '') } catch (error) { this.handleLoginFailure() } } /** * Handles the hash login responses. * @param socialLoginProvider - Social login provider. */ private handleHashLoginResponse( socialLoginProvider: SocialLoginProvider, ): void { const hashParams = new URLSearchParams(window.location.hash.slice(1)) if (socialLoginProvider === SocialLoginProvider.GOOGLE) { this.handleGoogleLogin(hashParams) } else if (socialLoginProvider === SocialLoginProvider.FACEBOOK) { this.handleFacebookLogin(hashParams) } // Send the token to the verification service this.verifyTokenViaService() // Clear the hash history.replaceState(null, '', window.location.href.split('#')[0]) } private handleGoogleLogin(hashParams: URLSearchParams): void { if ( this.isLoginStateValid(hashParams) && this.isLoginNonceValid(hashParams) ) { this.socialLoginToken = hashParams.get('id_token') this.socialLoginProvider = SocialLoginProvider.GOOGLE } } private handleFacebookLogin(hashParams: URLSearchParams): void { if (this.isLoginStateValid(hashParams)) { this.socialLoginToken = hashParams.get('access_token') this.socialLoginProvider = SocialLoginProvider.FACEBOOK } } private isLoginStateValid(hashParams: URLSearchParams): boolean { return this.checkSocialLoginState(hashParams) } private isLoginNonceValid(hashParams: URLSearchParams): boolean { return this.checkSocialLoginNonce(hashParams) } private isValidHash(hash: string): boolean { const validHashPattern = /^#(?:[a-zA-Z0-9-_.%]+=[^&]*&)*[a-zA-Z0-9-_.%]+=[^&]*$/ return validHashPattern.test(hash) } private extractTokenFromResultAndSave(result: UserCredential): boolean { const credential = OAuthProvider.credentialFromResult(result) if (credential && credential.idToken) { this.socialLoginToken = credential.idToken this.socialLoginProvider = SocialLoginProvider.APPLE return true } return false } private async handleFirebaseFailure(error: FirebaseError): Promise<void> { await this.onLoginComplete?.( { code: -1, message: error.message, }, undefined, ) } private handleLoginFailure(): void { void this.onLoginComplete?.( { code: 155140, message: 'Failed to validate the idToken / accessToken', }, undefined, ) } private verifyTokenViaService(): void { this.subscribeMessage() this.appendIframe(false, 'social/verify-token') setTimeout(() => { if (!this.receivedResponseFromService) { void this.onComplete?.( { code: 155706, message: 'Network error', }, undefined, ) } }, 1000 * 10) } /** * Saves the OAuth information to the local storage in order to check the state and nonce value later. * @param provider - Social login provider. * @param state - State value. * @param nonce - Nonce value. */ private saveOAuthInfo( provider: SocialLoginProvider, state?: string, nonce?: string, ): void { this.window.localStorage.setItem('socialLoginProvider', provider) this.window.localStorage.setItem('state', state ?? '') this.window.localStorage.setItem('nonce', nonce ?? '') } /** * Checks the state value from the social login response. * @param hashParams - Hash parameters. * @returns Indicates whether the state value is valid. */ private checkSocialLoginState(hashParams: URLSearchParams) { const state = hashParams.get('state') const storedState = this.window.localStorage.getItem('state') if (!storedState || state !== storedState) { void this.onLoginComplete?.( { code: 155140, message: 'Failed to validate the idToken / accessToken', }, undefined, ) return false } return true } /** * Checks the nonce value from the social login response. Only id token is going to have nonce value. * @param hashParams - Hash parameters. * @returns Indicates whether the nonce value is valid. */ private checkSocialLoginNonce(hashParams: URLSearchParams): boolean { const token = hashParams.get('id_token') const decodedToken = decode(token || '') const errorPayload = { code: 155140, message: 'Failed to validate the idToken/ accessToken', } if (decodedToken === null) { void this.onLoginComplete?.(errorPayload, undefined) return false } try { const storedNonce = this.window.localStorage.getItem('nonce') if (!storedNonce || (decodedToken as JwtPayload)?.nonce !== storedNonce) { void this.onLoginComplete?.(errorPayload, undefined) return false } } catch { void this.onLoginComplete?.(errorPayload, undefined) return false } return true } /** * Handles the postMessage event. * @param event - PostMessageEvent. */ readonly messageHandler = (event: PostMessageEvent): void => { if (event.origin !== this.serviceUrl) { return } if (event.data?.onFrameReady) { this.receivedResponseFromService = true const iframe = this.window.document.getElementById( 'sdkIframe', ) as HTMLIFrameElement iframe?.contentWindow?.postMessage( { w3s: { appSettings: this.configs?.appSettings, auth: this.configs?.authentication, challenge: this.challenge, customizations: { securityQuestions: { questions: this.securityQuestions, requiredCount: this.securityQuestionsRequiredCount, securityConfirmItems: this.securityConfirmItems, }, themeColor: this.themeColor, localizations: this.localizations, resources: this.resources, customLinks: this.customLinks, }, deviceInfo: this.deviceInfo, socialVerification: { token: this.socialLoginToken, deviceToken: this.configs?.loginConfigs?.deviceToken, deviceEncryptionKey: this.configs?.loginConfigs?.deviceEncryptionKey, socialLoginProvider: this.socialLoginProvider, }, emailVerification: { deviceToken: this.configs?.loginConfigs?.deviceToken, deviceEncryptionKey: this.configs?.loginConfigs?.deviceEncryptionKey, otpToken: this.configs?.loginConfigs?.otpToken, }, }, }, this.serviceUrl, ) } else if (event.data?.onForgotPin) { this.onForgotPin?.() } else if (event.data?.onComplete) { const iframe = this.window.document.getElementById( 'sdkIframe', ) as HTMLIFrameElement iframe?.parentNode?.removeChild(iframe) void this.onComplete?.(undefined, event.data?.result) } else if (event.data?.deviceId) { this.resolveDeviceIdPromise?.(event.data.deviceId) this.closeModal() this.unSubscribeMessage() } else if (event.data?.showUi) { this.iframe.width = '100%' this.iframe.height = '100%' this.iframe.style.zIndex = '2147483647' this.iframe.style.position = 'fixed' this.iframe.style.top = '50%' this.iframe.style.left = '50%' this.iframe.style.transform = 'translate(-50%, -50%)' this.iframe.style.display = '' } else if (event.data?.onSocialLoginVerified) { void this.onLoginComplete?.( event.data.onSocialLoginVerified.error, event.data.onSocialLoginVerified.result, ) this.closeModal() this.unSubscribeMessage() } else if (event.data?.onEmailLoginVerified) { void this.onLoginComplete?.( event.data.onEmailLoginVerified.error, event.data.onEmailLoginVerified.result, ) if ( event.data.onEmailLoginVerified.result && !event.data.onEmailLoginVerified.error ) { this.unSubscribeMessage() this.closeModal() } } else if (event.data?.onResendOtpEmail) { this.onResendOtpEmail?.() } else if (event.data?.onError) { void this.onComplete?.(event.data?.error, undefined) } else if (event.data?.onClose) { this.closeModal() this.unSubscribeMessage() } } /** * Closes the modal. */ private closeModal(): void { const iframe = this.window.document.getElementById( 'sdkIframe', ) as HTMLIFrameElement iframe?.parentNode?.removeChild(iframe) } /** * Subscribes to the postMessage event. */ private subscribeMessage(): void { this.window.addEventListener('message', this.messageHandler, false) } /** * Unsubscribes to the postMessage event. */ private unSubscribeMessage(): void { this.window.removeEventListener('message', this.messageHandler, false) } }