diff --git a/packages/auth/__tests__/validatePassword.test.js b/packages/auth/__tests__/validatePassword.test.js new file mode 100644 index 0000000000..daecca77dd --- /dev/null +++ b/packages/auth/__tests__/validatePassword.test.js @@ -0,0 +1,322 @@ +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library 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 { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { PasswordPolicyImpl } from '../lib/password-policy/PasswordPolicyImpl.js'; +import { PasswordPolicyMixin } from '../lib/password-policy/PasswordPolicyMixin.js'; + +const mockPasswordPolicy = { + schemaVersion: 1, + customStrengthOptions: { + minPasswordLength: 8, + maxPasswordLength: 100, + containsLowercaseCharacter: true, + containsUppercaseCharacter: true, + containsNumericCharacter: true, + containsNonAlphanumericCharacter: true, + }, + allowedNonAlphanumericCharacters: ['!', '@', '#', '$', '%'], + enforcementState: 'ENFORCE', +}; + +describe('PasswordPolicyMixin', () => { + describe('_getPasswordPolicyInternal', () => { + it('should return project policy when tenantId is null', () => { + const projectPolicy = new PasswordPolicyImpl(mockPasswordPolicy); + const auth = { + _tenantId: null, + _projectPasswordPolicy: projectPolicy, + _tenantPasswordPolicies: {}, + }; + Object.assign(auth, { + _getPasswordPolicyInternal: PasswordPolicyMixin._getPasswordPolicyInternal, + }); + + const result = auth._getPasswordPolicyInternal(); + + expect(result).toBe(projectPolicy); + }); + + it('should return tenant policy when tenantId is set', () => { + const tenantPolicy = new PasswordPolicyImpl(mockPasswordPolicy); + const auth = { + _tenantId: 'tenant-1', + _projectPasswordPolicy: null, + _tenantPasswordPolicies: { 'tenant-1': tenantPolicy }, + }; + Object.assign(auth, { + _getPasswordPolicyInternal: PasswordPolicyMixin._getPasswordPolicyInternal, + }); + + const result = auth._getPasswordPolicyInternal(); + + expect(result).toBe(tenantPolicy); + }); + + it('should return undefined when no policy is cached', () => { + const auth = { + _tenantId: null, + _projectPasswordPolicy: null, + _tenantPasswordPolicies: {}, + }; + Object.assign(auth, { + _getPasswordPolicyInternal: PasswordPolicyMixin._getPasswordPolicyInternal, + }); + + const result = auth._getPasswordPolicyInternal(); + + expect(result).toBeNull(); + }); + }); +}); + +describe('validatePassword (integration)', () => { + let mockAuth; + let mockFetchPasswordPolicy; + + beforeEach(() => { + mockFetchPasswordPolicy = jest.fn().mockResolvedValue(mockPasswordPolicy); + + // Create mock auth with the mixin, but override _updatePasswordPolicy to use our mock + mockAuth = { + app: { + name: '[DEFAULT]', + options: { apiKey: 'test-api-key-default' }, + }, + _tenantId: null, + _projectPasswordPolicy: null, + _tenantPasswordPolicies: {}, + }; + + // Apply the real mixin methods + Object.assign(mockAuth, { + _getPasswordPolicyInternal: PasswordPolicyMixin._getPasswordPolicyInternal, + _recachePasswordPolicy: PasswordPolicyMixin._recachePasswordPolicy, + validatePassword: PasswordPolicyMixin.validatePassword, + }); + + // Override _updatePasswordPolicy to use our mock fetch + mockAuth._updatePasswordPolicy = async function () { + const response = await mockFetchPasswordPolicy(this); + const passwordPolicy = new PasswordPolicyImpl(response); + if (this._tenantId === null) { + this._projectPasswordPolicy = passwordPolicy; + } else { + this._tenantPasswordPolicies[this._tenantId] = passwordPolicy; + } + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('caching behavior', () => { + it('should fetch password policy on first call', async () => { + await mockAuth.validatePassword('Password123$'); + + expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1); + expect(mockFetchPasswordPolicy).toHaveBeenCalledWith(mockAuth); + }); + + it('should use cached policy on subsequent calls for same auth instance', async () => { + await mockAuth.validatePassword('Password123$'); + expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1); + + await mockAuth.validatePassword('AnotherPassword1!'); + expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1); + + await mockAuth.validatePassword('YetAnother1@'); + expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1); + }); + + it('should cache at project level when tenantId is null', async () => { + await mockAuth.validatePassword('Password123$'); + + expect(mockAuth._projectPasswordPolicy).not.toBeNull(); + expect(Object.keys(mockAuth._tenantPasswordPolicies).length).toBe(0); + }); + + it('should cache separately per tenant', async () => { + // First tenant + mockAuth._tenantId = 'tenant-1'; + await mockAuth.validatePassword('Password123$'); + expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1); + + // Same tenant should use cache + await mockAuth.validatePassword('AnotherPassword1!'); + expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1); + + // Different tenant should fetch again + mockAuth._tenantId = 'tenant-2'; + await mockAuth.validatePassword('Password123$'); + expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(2); + + // Back to first tenant should use its cache + mockAuth._tenantId = 'tenant-1'; + await mockAuth.validatePassword('Password123$'); + expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(2); + + // Verify both tenant policies are cached + expect(mockAuth._tenantPasswordPolicies['tenant-1']).toBeDefined(); + expect(mockAuth._tenantPasswordPolicies['tenant-2']).toBeDefined(); + }); + + it('should keep project and tenant caches separate', async () => { + // Project level (no tenant) + await mockAuth.validatePassword('Password123$'); + expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1); + + // Tenant level + mockAuth._tenantId = 'tenant-1'; + await mockAuth.validatePassword('Password123$'); + expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(2); + + // Back to project level should use project cache + mockAuth._tenantId = null; + await mockAuth.validatePassword('Password123$'); + expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(2); + + // Verify both caches exist + expect(mockAuth._projectPasswordPolicy).not.toBeNull(); + expect(mockAuth._tenantPasswordPolicies['tenant-1']).toBeDefined(); + }); + + it('should return correct validation status using cached policy', async () => { + const status1 = await mockAuth.validatePassword('Password123$'); + expect(status1.isValid).toBe(true); + + const status2 = await mockAuth.validatePassword('weak'); + expect(status2.isValid).toBe(false); + + expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1); + }); + }); + + describe('schema validation', () => { + it('should throw error on unsupported schema version', async () => { + const unsupportedPolicy = { + ...mockPasswordPolicy, + schemaVersion: 2, + }; + mockFetchPasswordPolicy.mockResolvedValueOnce(unsupportedPolicy); + + await expect(mockAuth.validatePassword('Password123$')).rejects.toThrow( + 'auth/unsupported-password-policy-schema-version', + ); + }); + + it('should accept schema version 1', async () => { + const validPolicy = { + ...mockPasswordPolicy, + schemaVersion: 1, + }; + mockFetchPasswordPolicy.mockResolvedValueOnce(validPolicy); + + const status = await mockAuth.validatePassword('Password123$'); + expect(status.isValid).toBe(true); + }); + }); + + describe('cache invalidation', () => { + it('should refresh cache when _recachePasswordPolicy is called with existing cache', async () => { + // First call caches the policy + await mockAuth.validatePassword('Password123$'); + expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(1); + + // Simulate cache invalidation + await mockAuth._recachePasswordPolicy(); + expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(2); + }); + + it('should not fetch when _recachePasswordPolicy is called without existing cache', async () => { + // No prior validation, so no cache exists + await mockAuth._recachePasswordPolicy(); + expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(0); + }); + + it('should refresh correct tenant cache on invalidation', async () => { + // Cache for tenant-1 + mockAuth._tenantId = 'tenant-1'; + await mockAuth.validatePassword('Password123$'); + + // Cache for tenant-2 + mockAuth._tenantId = 'tenant-2'; + await mockAuth.validatePassword('Password123$'); + + expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(2); + + // Invalidate tenant-1 cache + mockAuth._tenantId = 'tenant-1'; + await mockAuth._recachePasswordPolicy(); + + // Should have fetched again for tenant-1 + expect(mockFetchPasswordPolicy).toHaveBeenCalledTimes(3); + }); + }); +}); + +describe('validatePassword (modular API)', () => { + let validatePassword; + let mockAuth; + + beforeEach(async () => { + const modular = await import('../lib/modular/index.js'); + validatePassword = modular.validatePassword; + + mockAuth = { + app: { name: '[DEFAULT]', options: { apiKey: 'test-api-key' } }, + validatePassword: jest.fn(), + }; + }); + + it('should throw error for undefined auth', async () => { + await expect(validatePassword(undefined, 'Password123$')).rejects.toThrow( + "firebase.auth().validatePassword(*) 'auth' must be a valid Auth instance with an 'app' property", + ); + }); + + it('should throw error for auth without app property', async () => { + await expect(validatePassword({}, 'Password123$')).rejects.toThrow( + "firebase.auth().validatePassword(*) 'auth' must be a valid Auth instance with an 'app' property", + ); + }); + + it('should throw error for null password', async () => { + await expect(validatePassword(mockAuth, null)).rejects.toThrow( + "firebase.auth().validatePassword(*) expected 'password' to be a non-null or a defined value.", + ); + }); + + it('should throw error for undefined password', async () => { + await expect(validatePassword(mockAuth, undefined)).rejects.toThrow( + "firebase.auth().validatePassword(*) expected 'password' to be a non-null or a defined value.", + ); + }); + + it('should delegate to auth.validatePassword for valid password', async () => { + mockAuth.validatePassword.mockResolvedValue({ isValid: true }); + + const result = await validatePassword(mockAuth, 'Password123$'); + + expect(mockAuth.validatePassword).toHaveBeenCalledWith( + 'Password123$', + 'react-native-firebase-modular-method-call', + ); + expect(result).toEqual({ isValid: true }); + }); +}); diff --git a/packages/auth/lib/index.js b/packages/auth/lib/index.js index 21d3e8db1d..5bf86bd531 100644 --- a/packages/auth/lib/index.js +++ b/packages/auth/lib/index.js @@ -51,6 +51,7 @@ import TwitterAuthProvider from './providers/TwitterAuthProvider'; import { TotpSecret } from './TotpSecret'; import version from './version'; import fallBackModule from './web/RNFBAuthModule'; +import { PasswordPolicyMixin } from './password-policy/PasswordPolicyMixin'; const PhoneAuthState = { CODE_SENT: 'sent', @@ -103,6 +104,8 @@ class FirebaseAuthModule extends FirebaseModule { this._authResult = false; this._languageCode = this.native.APP_LANGUAGE[this.app._name]; this._tenantId = null; + this._projectPasswordPolicy = null; + this._tenantPasswordPolicies = {}; if (!this.languageCode) { this._languageCode = this.native.APP_LANGUAGE['[DEFAULT]']; @@ -336,15 +339,45 @@ class FirebaseAuthModule extends FirebaseModule { } createUserWithEmailAndPassword(email, password) { - return this.native - .createUserWithEmailAndPassword(email, password) - .then(userCredential => this._setUserCredential(userCredential)); + return ( + this.native + .createUserWithEmailAndPassword(email, password) + .then(userCredential => this._setUserCredential(userCredential)) + /* istanbul ignore next - native error handling cannot be unit tested */ + .catch(error => { + if (error.code === 'auth/password-does-not-meet-requirements') { + return this._recachePasswordPolicy() + .catch(() => { + // Silently ignore recache failures - the original error matters more + }) + .then(() => { + throw error; + }); + } + throw error; + }) + ); } signInWithEmailAndPassword(email, password) { - return this.native - .signInWithEmailAndPassword(email, password) - .then(userCredential => this._setUserCredential(userCredential)); + return ( + this.native + .signInWithEmailAndPassword(email, password) + .then(userCredential => this._setUserCredential(userCredential)) + /* istanbul ignore next - native error handling cannot be unit tested */ + .catch(error => { + if (error.code === 'auth/password-does-not-meet-requirements') { + return this._recachePasswordPolicy() + .catch(() => { + // Silently ignore recache failures - the original error matters more + }) + .then(() => { + throw error; + }); + } + throw error; + }) + ); } signInWithCustomToken(customToken) { @@ -382,7 +415,23 @@ class FirebaseAuthModule extends FirebaseModule { } confirmPasswordReset(code, newPassword) { - return this.native.confirmPasswordReset(code, newPassword); + return ( + this.native + .confirmPasswordReset(code, newPassword) + /* istanbul ignore next - native error handling cannot be unit tested */ + .catch(error => { + if (error.code === 'auth/password-does-not-meet-requirements') { + return this._recachePasswordPolicy() + .catch(() => { + // Silently ignore recache failures - the original error matters more + }) + .then(() => { + throw error; + }); + } + throw error; + }) + ); } applyActionCode(code) { @@ -493,6 +542,9 @@ class FirebaseAuthModule extends FirebaseModule { } } +// Apply password policy mixin to FirebaseAuthModule +Object.assign(FirebaseAuthModule.prototype, PasswordPolicyMixin); + // import { SDK_VERSION } from '@react-native-firebase/auth'; export const SDK_VERSION = version; diff --git a/packages/auth/lib/modular/index.js b/packages/auth/lib/modular/index.js index b9385d9c46..f99e4293ec 100644 --- a/packages/auth/lib/modular/index.js +++ b/packages/auth/lib/modular/index.js @@ -16,8 +16,6 @@ */ import { getApp } from '@react-native-firebase/app'; -import { fetchPasswordPolicy } from '../password-policy/passwordPolicyApi'; -import { PasswordPolicyImpl } from '../password-policy/PasswordPolicyImpl'; import { MultiFactorUser } from '../multiFactor'; import { MODULAR_DEPRECATION_ARG } from '@react-native-firebase/app/lib/common'; @@ -637,15 +635,17 @@ export function getCustomAuthDomain(auth) { * @returns {Promise} */ export async function validatePassword(auth, password) { + if (!auth || !auth.app) { + throw new Error( + "firebase.auth().validatePassword(*) 'auth' must be a valid Auth instance with an 'app' property. Received: undefined", + ); + } + if (password === null || password === undefined) { throw new Error( "firebase.auth().validatePassword(*) expected 'password' to be a non-null or a defined value.", ); } - let passwordPolicy = await fetchPasswordPolicy(auth); - - const passwordPolicyImpl = await new PasswordPolicyImpl(passwordPolicy); - let status = passwordPolicyImpl.validatePassword(password); - return status; + return auth.validatePassword.call(auth, password, MODULAR_DEPRECATION_ARG); } diff --git a/packages/auth/lib/password-policy/PasswordPolicyMixin.js b/packages/auth/lib/password-policy/PasswordPolicyMixin.js new file mode 100644 index 0000000000..e8c916dd3a --- /dev/null +++ b/packages/auth/lib/password-policy/PasswordPolicyMixin.js @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library 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 { fetchPasswordPolicy } from './passwordPolicyApi'; +import { PasswordPolicyImpl } from './PasswordPolicyImpl'; + +const EXPECTED_PASSWORD_POLICY_SCHEMA_VERSION = 1; + +/** + * Password policy mixin - provides password policy caching and validation. + * Expects the target object to have: _tenantId, _projectPasswordPolicy, + * _tenantPasswordPolicies, and app.options.apiKey + */ +export const PasswordPolicyMixin = { + _getPasswordPolicyInternal() { + if (this._tenantId === null) { + return this._projectPasswordPolicy; + } + return this._tenantPasswordPolicies[this._tenantId]; + }, + + async _updatePasswordPolicy() { + const response = await fetchPasswordPolicy(this); + const passwordPolicy = new PasswordPolicyImpl(response); + if (this._tenantId === null) { + this._projectPasswordPolicy = passwordPolicy; + } else { + this._tenantPasswordPolicies[this._tenantId] = passwordPolicy; + } + }, + + async _recachePasswordPolicy() { + if (this._getPasswordPolicyInternal()) { + await this._updatePasswordPolicy(); + } + }, + + async validatePassword(password) { + if (!this._getPasswordPolicyInternal()) { + await this._updatePasswordPolicy(); + } + const passwordPolicy = this._getPasswordPolicyInternal(); + + if (passwordPolicy.schemaVersion !== EXPECTED_PASSWORD_POLICY_SCHEMA_VERSION) { + throw new Error( + 'auth/unsupported-password-policy-schema-version: The password policy received from the backend uses a schema version that is not supported by this version of the SDK.', + ); + } + + return passwordPolicy.validatePassword(password); + }, +}; diff --git a/packages/auth/lib/password-policy/passwordPolicyApi.js b/packages/auth/lib/password-policy/passwordPolicyApi.js index 8f68cf9796..7d375deae8 100644 --- a/packages/auth/lib/password-policy/passwordPolicyApi.js +++ b/packages/auth/lib/password-policy/passwordPolicyApi.js @@ -23,8 +23,6 @@ * @throws {Error} Throws an error if the request fails or encounters an issue. */ export async function fetchPasswordPolicy(auth) { - let schemaVersion = 1; - try { // Identity toolkit API endpoint for password policy. Ensure this is enabled on Google cloud. const baseURL = 'https://identitytoolkit.googleapis.com/v2/passwordPolicy?key='; @@ -37,19 +35,10 @@ export async function fetchPasswordPolicy(auth) { `firebase.auth().validatePassword(*) failed to fetch password policy from Firebase Console: ${response.statusText}. Details: ${errorDetails}`, ); } - const passwordPolicy = await response.json(); - - if (passwordPolicy.schemaVersion !== schemaVersion) { - throw new Error( - `Password policy schema version mismatch. Expected: ${schemaVersion}, received: ${passwordPolicy.schemaVersion}`, - ); - } - return passwordPolicy; + return await response.json(); } catch (error) { throw new Error( `firebase.auth().validatePassword(*) Failed to fetch password policy: ${error.message}`, ); } } - -module.exports = { fetchPasswordPolicy };