diff --git a/package.json b/package.json index 004ef95..4296513 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@circle-fin/w3s-pw-web-sdk", - "version": "1.1.6", + "version": "1.1.8", "description": "Javascript/Typescript SDK for Circle Programmable Wallets", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", diff --git a/src/index.test.ts b/src/index.test.ts index cafb1b6..a5bba6a 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -16,14 +16,25 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ /** * @jest-environment jsdom */ +import { FirebaseError } from 'firebase/app' +import * as firebaseAuth from 'firebase/auth' + +import { SocialLoginProvider } from './types' import { W3SSdk } from './' -import type { ChallengeResult, Error } from './types' +import type { ChallengeResult, Configs, Error } from './types' + +jest.mock('firebase/auth', () => ({ + ...jest.requireActual('firebase/auth'), + signInWithPopup: jest.fn(), + getAuth: jest.fn(), +})) describe('W3SSdk', () => { let sdk: W3SSdk @@ -101,3 +112,188 @@ describe('W3SSdk', () => { ) }) }) + +describe('W3SSdk > Apple OAuth', () => { + const localStorageMock = (() => { + let store: { [key: string]: string } = {} + + return { + getItem(key: string): string { + return store[key] || '' + }, + setItem(key: string, value: string): void { + store[key] = value + }, + removeItem(key: string): void { + delete store[key] + }, + clear(): void { + store = {} + }, + length: 0, + key(index: number): string | null { + return Object.keys(store)[index] || null + }, + } + })() + + Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + + let sdk: W3SSdk + const configs: Configs = { + appSettings: { + appId: 'test-app-id', + }, + loginConfigs: { + deviceToken: 'device-token', + deviceEncryptionKey: 'device-encryption-key', + apple: { + apiKey: 'test-api-key', + authDomain: 'test-auth-domain', + projectId: 'test-project-id', + storageBucket: 'test-storage-bucket', + messagingSenderId: 'test-messaging-sender-id', + appId: 'test-app-id', + }, + }, + } + + beforeEach(() => { + jest.clearAllMocks() + window.localStorage.clear() + }) + + it('should perform Apple login successfully', async () => { + const onLoginComplete = jest.fn() + sdk = new W3SSdk(configs, onLoginComplete) + + const mockFirebaseApp = {} + Object.defineProperty(sdk, 'firebaseApp', { + get: jest.fn(() => mockFirebaseApp), + configurable: true, + }) + + // Mock signInWithPopup to resolve to a UserCredential + const userCredentialMock = { + user: { uid: 'test-uid' }, + credential: { idToken: 'test-id-token' }, + } + ;(firebaseAuth.signInWithPopup as jest.Mock).mockResolvedValueOnce( + userCredentialMock, + ) + + // Mock extractTokenFromResultAndSave to return true + const extractTokenSpy = jest + .spyOn(sdk as any, 'extractTokenFromResultAndSave') + .mockReturnValue(true) + + // Mock verifyTokenViaService + const verifyTokenSpy = jest + .spyOn(sdk as any, 'verifyTokenViaService') + .mockImplementation(() => {}) + + await sdk.performLogin(SocialLoginProvider.APPLE) + + expect(firebaseAuth.signInWithPopup).toHaveBeenCalled() + expect(extractTokenSpy).toHaveBeenCalledWith(userCredentialMock) + expect(verifyTokenSpy).toHaveBeenCalled() + + expect(window.localStorage.getItem('socialLoginProvider')).toBe('') + }) + + it('should handle signInWithPopup error during Apple login', async () => { + const onLoginComplete = jest.fn() + sdk = new W3SSdk(configs, onLoginComplete) + + // Simulate firebaseApp being initialized + const mockFirebaseApp = {} + Object.defineProperty(sdk, 'firebaseApp', { + get: jest.fn(() => mockFirebaseApp), + configurable: true, + }) + + // Mock getAuth + const mockAuth = { getProvider: jest.fn() } + ;(firebaseAuth.getAuth as jest.Mock).mockReturnValue(mockAuth) + + // Mock signInWithPopup to reject + const error = new Error('sign in error') + ;(firebaseAuth.signInWithPopup as jest.Mock).mockRejectedValueOnce(error) + + // Mock handleLoginFailure + const handleLoginFailureSpy = jest + .spyOn(sdk as any, 'handleLoginFailure') + .mockImplementation(() => {}) + + await sdk.performLogin(SocialLoginProvider.APPLE) + + expect(firebaseAuth.signInWithPopup).toHaveBeenCalled() + expect(handleLoginFailureSpy).toHaveBeenCalled() + }) + + it('should not handle signInWithPopup auth/cancelled-popup-request error during Apple login', async () => { + const onLoginComplete = jest.fn() + sdk = new W3SSdk(configs, onLoginComplete) + + // Simulate firebaseApp being initialized + const mockFirebaseApp = {} + Object.defineProperty(sdk, 'firebaseApp', { + get: jest.fn(() => mockFirebaseApp), + configurable: true, + }) + + // Mock getAuth + const mockAuth = { getProvider: jest.fn() } + ;(firebaseAuth.getAuth as jest.Mock).mockReturnValue(mockAuth) + + // Mock signInWithPopup to reject + const error = new FirebaseError( + 'auth/cancelled-popup-request', + 'sign in error', + ) + ;(firebaseAuth.signInWithPopup as jest.Mock).mockRejectedValueOnce(error) + + // Mock handleLoginFailure + const handleLoginFailureSpy = jest + .spyOn(sdk as any, 'handleLoginFailure') + .mockImplementation(() => {}) + + await sdk.performLogin(SocialLoginProvider.APPLE) + + expect(firebaseAuth.signInWithPopup).toHaveBeenCalled() + expect(handleLoginFailureSpy).toHaveBeenCalledTimes(0) + }) + + it('should not handle signInWithPopup auth/popup-closed-by-user error during Apple login', async () => { + const onLoginComplete = jest.fn() + sdk = new W3SSdk(configs, onLoginComplete) + + // Simulate firebaseApp being initialized + const mockFirebaseApp = {} + Object.defineProperty(sdk, 'firebaseApp', { + get: jest.fn(() => mockFirebaseApp), + configurable: true, + }) + + // Mock getAuth + const mockAuth = { getProvider: jest.fn() } + ;(firebaseAuth.getAuth as jest.Mock).mockReturnValue(mockAuth) + + // Mock signInWithPopup to reject + const error = new FirebaseError( + 'auth/popup-closed-by-user', + 'sign in error', + ) + ;(firebaseAuth.signInWithPopup as jest.Mock).mockRejectedValueOnce(error) + + // Mock handleLoginFailure + const handleLoginFailureSpy = jest + .spyOn(sdk as any, 'handleLoginFailure') + .mockImplementation(() => {}) + + await sdk.performLogin(SocialLoginProvider.APPLE) + + expect(firebaseAuth.signInWithPopup).toHaveBeenCalled() + expect(handleLoginFailureSpy).toHaveBeenCalledTimes(0) + }) +}) diff --git a/src/index.ts b/src/index.ts index 3b1ba36..6b432e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,12 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { getApps, initializeApp } from 'firebase/app' +import { FirebaseError, getApps, initializeApp } from 'firebase/app' import { OAuthProvider, getAuth, getRedirectResult, - signInWithRedirect, + signInWithPopup, } from 'firebase/auth' import { decode } from 'jsonwebtoken' import { v4 as uuidv4 } from 'uuid' @@ -168,13 +168,13 @@ export class W3SSdk { * Performs social login. * @param provider - Social login provider. */ - performLogin(provider: SocialLoginProvider): void { + async performLogin(provider: SocialLoginProvider): Promise { if (provider === SocialLoginProvider.GOOGLE) { this.performGoogleLogin() } else if (provider === SocialLoginProvider.FACEBOOK) { this.performFacebookLogin() } else if (provider === SocialLoginProvider.APPLE) { - this.performAppleLogin() + await this.performAppleLogin() } else { void this.onLoginComplete?.( { @@ -381,7 +381,7 @@ export class W3SSdk { }, 1000 * 10) } - private performAppleLogin() { + private async performAppleLogin() { if (!this.firebaseApp) { void this.onLoginComplete?.( { @@ -398,7 +398,26 @@ export class W3SSdk { const provider = new OAuthProvider('apple.com') const auth = getAuth(this.firebaseApp) - void signInWithRedirect(auth, provider) + 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') || + !(error instanceof FirebaseError) + ) { + this.handleLoginFailure() + } + } } private performFacebookLogin() {