diff --git a/packages/firebase_auth/firebase_auth/lib/firebase_auth.dart b/packages/firebase_auth/firebase_auth/lib/firebase_auth.dart index dc842c43a8e6..f27e7df3fbea 100755 --- a/packages/firebase_auth/firebase_auth/lib/firebase_auth.dart +++ b/packages/firebase_auth/firebase_auth/lib/firebase_auth.dart @@ -9,6 +9,11 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core_platform_interface/firebase_core_platform_interface.dart'; import 'package:flutter/foundation.dart'; +import 'src/password_policy/password_policy_impl.dart'; +import 'src/password_policy/password_policy_api.dart'; +import 'src/password_policy/password_policy.dart'; +import 'src/password_policy/password_policy_status.dart'; + export 'package:firebase_auth_platform_interface/firebase_auth_platform_interface.dart' show FirebaseAuthException, @@ -64,6 +69,12 @@ export 'package:firebase_auth_platform_interface/firebase_auth_platform_interfac export 'package:firebase_core_platform_interface/firebase_core_platform_interface.dart' show FirebaseException; +// Export password policy classes +export 'src/password_policy/password_policy.dart'; +export 'src/password_policy/password_policy_status.dart'; +export 'src/password_policy/password_policy_impl.dart'; +export 'src/password_policy/password_policy_api.dart'; + part 'src/confirmation_result.dart'; part 'src/firebase_auth.dart'; part 'src/multi_factor.dart'; diff --git a/packages/firebase_auth/firebase_auth/lib/src/firebase_auth.dart b/packages/firebase_auth/firebase_auth/lib/src/firebase_auth.dart index c0f72b4b27b2..3a2b203d8723 100644 --- a/packages/firebase_auth/firebase_auth/lib/src/firebase_auth.dart +++ b/packages/firebase_auth/firebase_auth/lib/src/firebase_auth.dart @@ -704,15 +704,6 @@ class FirebaseAuth extends FirebasePluginPlatform { } } - /// Signs out the current user. - /// - /// If successful, it also updates - /// any [authStateChanges], [idTokenChanges] or [userChanges] stream - /// listeners. - Future signOut() async { - await _delegate.signOut(); - } - /// Checks a password reset code sent to the user by email or other /// out-of-band mechanism. /// @@ -819,12 +810,71 @@ class FirebaseAuth extends FirebasePluginPlatform { return _delegate.revokeTokenWithAuthorizationCode(authorizationCode); } + /// Signs out the current user. + /// + /// If successful, it also updates + /// any [authStateChanges], [idTokenChanges] or [userChanges] stream + /// listeners. + Future signOut() async { + await _delegate.signOut(); + } + /// Initializes the reCAPTCHA Enterprise client proactively to enhance reCAPTCHA signal collection and /// to complete reCAPTCHA-protected flows in a single attempt. Future initializeRecaptchaConfig() { return _delegate.initializeRecaptchaConfig(); } + /// Validates a password against the password policy configured for the project or tenant. + /// + /// If no tenant ID is set on the Auth instance, then this method will use the password policy configured for the project. + /// Otherwise, this method will use the policy configured for the tenant. If a password policy has not been configured, + /// then the default policy configured for all projects will be used. + /// + /// If an auth flow fails because a submitted password does not meet the password policy requirements and this method has previously been called, + /// then this method will use the most recent policy available when called again. + /// + /// Returns a map with the following keys: + /// - **status**: A boolean indicating if the password is valid. + /// - **passwordPolicy**: The password policy used to validate the password. + /// - **meetsMinPasswordLength**: A boolean indicating if the password meets the minimum length requirement. + /// - **meetsMaxPasswordLength**: A boolean indicating if the password meets the maximum length requirement. + /// - **meetsLowercaseRequirement**: A boolean indicating if the password meets the lowercase requirement. + /// - **meetsUppercaseRequirement**: A boolean indicating if the password meets the uppercase requirement. + /// - **meetsDigitsRequirement**: A boolean indicating if the password meets the digits requirement. + /// - **meetsSymbolsRequirement**: A boolean indicating if the password meets the symbols requirement. + /// + /// A [FirebaseAuthException] maybe thrown with the following error code: + /// - **invalid-password**: + /// - Thrown if the password is invalid. + /// - **network-request-failed**: + /// - Thrown if there was a network request error, for example the user + /// doesn't have internet connection + /// - **INVALID_LOGIN_CREDENTIALS** or **invalid-credential**: + /// - Thrown if the password is invalid for the given email, or the account + /// corresponding to the email does not have a password set. + /// Depending on if you are using firebase emulator or not the code is + /// different + /// - **operation-not-allowed**: + /// - Thrown if email/password accounts are not enabled. Enable + /// email/password accounts in the Firebase Console, under the Auth tab. + Future validatePassword( + FirebaseAuth auth, + String? password, + ) async { + if (password == null || password.isEmpty) { + throw FirebaseAuthException( + code: 'invalid-password', + message: 'Password cannot be null or empty', + ); + } + PasswordPolicyApi passwordPolicyApi = PasswordPolicyApi(auth); + PasswordPolicy passwordPolicy = + await passwordPolicyApi.fetchPasswordPolicy(); + PasswordPolicyImpl passwordPolicyImpl = PasswordPolicyImpl(passwordPolicy); + return passwordPolicyImpl.isPasswordValid(password); + } + @override String toString() { return 'FirebaseAuth(app: ${app.name})'; diff --git a/packages/firebase_auth/firebase_auth/lib/src/password_policy/password_policy.dart b/packages/firebase_auth/firebase_auth/lib/src/password_policy/password_policy.dart new file mode 100644 index 000000000000..2688d22a466a --- /dev/null +++ b/packages/firebase_auth/firebase_auth/lib/src/password_policy/password_policy.dart @@ -0,0 +1,49 @@ +// Copyright 2025, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +class PasswordPolicy { + final Map policy; + + // Backend enforced minimum + late final int minPasswordLength; + late final int? maxPasswordLength; + late final bool? containsLowercaseCharacter; + late final bool? containsUppercaseCharacter; + late final bool? containsNumericCharacter; + late final bool? containsNonAlphanumericCharacter; + late final int schemaVersion; + late final List allowedNonAlphanumericCharacters; + late final String enforcementState; + + PasswordPolicy(this.policy) { + initialize(); + } + + void initialize() { + final Map customStrengthOptions = + policy['customStrengthOptions'] ?? {}; + + minPasswordLength = customStrengthOptions['minPasswordLength'] ?? 6; + maxPasswordLength = customStrengthOptions['maxPasswordLength']; + containsLowercaseCharacter = + customStrengthOptions['containsLowercaseCharacter']; + containsUppercaseCharacter = + customStrengthOptions['containsUppercaseCharacter']; + containsNumericCharacter = + customStrengthOptions['containsNumericCharacter']; + containsNonAlphanumericCharacter = + customStrengthOptions['containsNonAlphanumericCharacter']; + + schemaVersion = policy['schemaVersion'] ?? 1; + allowedNonAlphanumericCharacters = List.from( + policy['allowedNonAlphanumericCharacters'] ?? + customStrengthOptions['allowedNonAlphanumericCharacters'] ?? + [], + ); + + final enforcement = policy['enforcement'] ?? policy['enforcementState']; + enforcementState = enforcement == 'ENFORCEMENT_STATE_UNSPECIFIED' + ? 'OFF' + : (enforcement ?? 'OFF'); + } +} diff --git a/packages/firebase_auth/firebase_auth/lib/src/password_policy/password_policy_api.dart b/packages/firebase_auth/firebase_auth/lib/src/password_policy/password_policy_api.dart new file mode 100644 index 000000000000..3bc55dfc6e84 --- /dev/null +++ b/packages/firebase_auth/firebase_auth/lib/src/password_policy/password_policy_api.dart @@ -0,0 +1,49 @@ +// Copyright 2025, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; +import 'dart:core'; + +class PasswordPolicyApi { + final FirebaseAuth _auth; + final String _apiUrl = + 'https://identitytoolkit.googleapis.com/v2/passwordPolicy?key='; + + PasswordPolicyApi(this._auth); + + final int _schemaVersion = 1; + + Future fetchPasswordPolicy() async { + try { + final String _apiKey = _auth.app.options.apiKey; + final response = await http.get(Uri.parse('$_apiUrl$_apiKey')); + if (response.statusCode == 200) { + final policy = json.decode(response.body); + + // Validate schema version + final _schemaVersion = policy['schemaVersion']; + if (!isCorrectSchemaVersion(_schemaVersion)) { + throw Exception( + 'Schema Version mismatch, expected version 1 but got $policy', + ); + } + + Map rawPolicy = json.decode(response.body); + return PasswordPolicy(rawPolicy); + } else { + throw Exception( + 'Failed to fetch password policy, status code: ${response.statusCode}', + ); + } + } catch (e) { + throw Exception('Failed to fetch password policy: $e'); + } + } + + bool isCorrectSchemaVersion(int schemaVersion) { + return _schemaVersion == schemaVersion; + } +} diff --git a/packages/firebase_auth/firebase_auth/lib/src/password_policy/password_policy_impl.dart b/packages/firebase_auth/firebase_auth/lib/src/password_policy/password_policy_impl.dart new file mode 100644 index 000000000000..6ac7c63c468a --- /dev/null +++ b/packages/firebase_auth/firebase_auth/lib/src/password_policy/password_policy_impl.dart @@ -0,0 +1,92 @@ +// Copyright 2025, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +import 'dart:core'; +import 'password_policy.dart'; +import 'password_policy_status.dart'; + +class PasswordPolicyImpl { + final PasswordPolicy _policy; + + PasswordPolicyImpl(this._policy); + + // Getter to access the policy + PasswordPolicy get policy => _policy; + + PasswordPolicyStatus isPasswordValid(String password) { + PasswordPolicyStatus status = PasswordPolicyStatus(true, _policy); + + _validatePasswordLengthOptions(password, status); + _validatePasswordCharacterOptions(password, status); + + return status; + } + + void _validatePasswordLengthOptions( + String password, + PasswordPolicyStatus status, + ) { + int minPasswordLength = _policy.minPasswordLength; + int? maxPasswordLength = _policy.maxPasswordLength; + + status.meetsMinPasswordLength = password.length >= minPasswordLength; + if (!status.meetsMinPasswordLength) { + status.status = false; + } + if (maxPasswordLength != null) { + status.meetsMaxPasswordLength = password.length <= maxPasswordLength; + if (!status.meetsMaxPasswordLength) { + status.status = false; + } + } + } + + void _validatePasswordCharacterOptions( + String password, + PasswordPolicyStatus status, + ) { + bool? requireLowercase = _policy.containsLowercaseCharacter; + bool? requireUppercase = _policy.containsUppercaseCharacter; + bool? requireDigits = _policy.containsNumericCharacter; + bool? requireSymbols = _policy.containsNonAlphanumericCharacter; + + if (requireLowercase ?? false) { + status.meetsLowercaseRequirement = password.contains(RegExp('[a-z]')); + if (!status.meetsLowercaseRequirement) { + status.status = false; + } + } + if (requireUppercase ?? false) { + status.meetsUppercaseRequirement = password.contains(RegExp('[A-Z]')); + if (!status.meetsUppercaseRequirement) { + status.status = false; + } + } + if (requireDigits ?? false) { + status.meetsDigitsRequirement = password.contains(RegExp('[0-9]')); + if (!status.meetsDigitsRequirement) { + status.status = false; + } + } + if (requireSymbols ?? false) { + // Check if password contains any non-alphanumeric characters + bool hasSymbol = false; + if (_policy.allowedNonAlphanumericCharacters.isNotEmpty) { + // Check against allowed symbols + for (final String symbol in _policy.allowedNonAlphanumericCharacters) { + if (password.contains(symbol)) { + hasSymbol = true; + break; + } + } + } else { + // Check for any non-alphanumeric character + hasSymbol = password.contains(RegExp('[^a-zA-Z0-9]')); + } + status.meetsSymbolsRequirement = hasSymbol; + if (!hasSymbol) { + status.status = false; + } + } + } +} diff --git a/packages/firebase_auth/firebase_auth/lib/src/password_policy/password_policy_status.dart b/packages/firebase_auth/firebase_auth/lib/src/password_policy/password_policy_status.dart new file mode 100644 index 000000000000..33ae7d2273ba --- /dev/null +++ b/packages/firebase_auth/firebase_auth/lib/src/password_policy/password_policy_status.dart @@ -0,0 +1,19 @@ +// Copyright 2025, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +import 'password_policy.dart'; + +class PasswordPolicyStatus { + bool status; + final PasswordPolicy passwordPolicy; + + // Initialize all fields to true by default (meaning they pass validation) + bool meetsMinPasswordLength = true; + bool meetsMaxPasswordLength = true; + bool meetsLowercaseRequirement = true; + bool meetsUppercaseRequirement = true; + bool meetsDigitsRequirement = true; + bool meetsSymbolsRequirement = true; + + PasswordPolicyStatus(this.status, this.passwordPolicy); +} diff --git a/packages/firebase_auth/firebase_auth/pubspec.yaml b/packages/firebase_auth/firebase_auth/pubspec.yaml index 6a6ee6148c08..f2a0d52693ff 100755 --- a/packages/firebase_auth/firebase_auth/pubspec.yaml +++ b/packages/firebase_auth/firebase_auth/pubspec.yaml @@ -26,8 +26,8 @@ dependencies: firebase_core_platform_interface: ^5.3.1 flutter: sdk: flutter + http: ^1.1.0 meta: ^1.8.0 - dev_dependencies: async: ^2.5.0 flutter_test: diff --git a/packages/firebase_auth/firebase_auth/test/firebase_auth_test.dart b/packages/firebase_auth/firebase_auth/test/firebase_auth_test.dart index 3cb31a469c09..a5203d6b3ac5 100644 --- a/packages/firebase_auth/firebase_auth/test/firebase_auth_test.dart +++ b/packages/firebase_auth/firebase_auth/test/firebase_auth_test.dart @@ -14,6 +14,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +// Password policy classes are now exported from the main library import './mock.dart'; @@ -37,6 +38,28 @@ void main() { const String kMockOobCode = 'oobcode'; const String kMockURL = 'http://www.example.com'; const String kMockHost = 'www.example.com'; + const String kMockValidPassword = + 'Password123!'; // For password policy impl testing + const String kMockInvalidPassword = 'Pa1!'; + const String kMockInvalidPassword2 = 'password123!'; + const String kMockInvalidPassword3 = 'PASSWORD123!'; + const String kMockInvalidPassword4 = 'password!'; + const String kMockInvalidPassword5 = 'Password123'; + const Map kMockPasswordPolicy = { + 'customStrengthOptions': { + 'minPasswordLength': 6, + 'maxPasswordLength': 12, + 'containsLowercaseCharacter': true, + 'containsUppercaseCharacter': true, + 'containsNumericCharacter': true, + 'containsNonAlphanumericCharacter': true, + }, + 'allowedNonAlphanumericCharacters': ['!'], + 'schemaVersion': 1, + 'enforcement': 'OFF', + }; + final PasswordPolicy kMockPasswordPolicyObject = + PasswordPolicy(kMockPasswordPolicy); const int kMockPort = 31337; final TestAuthProvider testAuthProvider = TestAuthProvider(); @@ -767,6 +790,61 @@ void main() { }); }); + group('passwordPolicy', () { + test('passwordPolicy should be initialized with correct parameters', + () async { + PasswordPolicyImpl passwordPolicy = + PasswordPolicyImpl(kMockPasswordPolicyObject); + expect(passwordPolicy.policy, equals(kMockPasswordPolicyObject)); + }); + + PasswordPolicyImpl passwordPolicy = + PasswordPolicyImpl(kMockPasswordPolicyObject); + + test('should return true for valid password', () async { + final PasswordPolicyStatus status = + passwordPolicy.isPasswordValid(kMockValidPassword); + expect(status.status, isTrue); + }); + + test('should return false for invalid password that is too short', + () async { + final PasswordPolicyStatus status = + passwordPolicy.isPasswordValid(kMockInvalidPassword); + expect(status.status, isFalse); + }); + + test( + 'should return false for invalid password with no capital characters', + () async { + final PasswordPolicyStatus status = + passwordPolicy.isPasswordValid(kMockInvalidPassword2); + expect(status.status, isFalse); + }); + + test( + 'should return false for invalid password with no lowercase characters', + () async { + final PasswordPolicyStatus status = + passwordPolicy.isPasswordValid(kMockInvalidPassword3); + expect(status.status, isFalse); + }); + + test('should return false for invalid password with no numbers', + () async { + final PasswordPolicyStatus status = + passwordPolicy.isPasswordValid(kMockInvalidPassword4); + expect(status.status, isFalse); + }); + + test('should return false for invalid password with no symbols', + () async { + final PasswordPolicyStatus status = + passwordPolicy.isPasswordValid(kMockInvalidPassword5); + expect(status.status, isFalse); + }); + }); + test('toString()', () async { expect( auth.toString(), diff --git a/tests/integration_test/firebase_auth/firebase_auth_instance_e2e_test.dart b/tests/integration_test/firebase_auth/firebase_auth_instance_e2e_test.dart index feef347f78e3..e92fcc010a2c 100644 --- a/tests/integration_test/firebase_auth/firebase_auth_instance_e2e_test.dart +++ b/tests/integration_test/firebase_auth/firebase_auth_instance_e2e_test.dart @@ -1062,6 +1062,92 @@ void main() { }, skip: true, ); + + group('validatePassword()', () { + + const String validPassword = 'Password123!'; // For password policy impl testing + const String invalidPassword = 'Pa1!'; + const String invalidPassword2 = 'password123!'; + const String invalidPassword3 = 'PASSWORD123!'; + const String invalidPassword4 = 'password!'; + const String invalidPassword5 = 'Password123'; + + test('should validate password that is correct', () async { + final PasswordPolicyStatus status = await FirebaseAuth.instance.validatePassword(FirebaseAuth.instance, validPassword); + expect(status.status, isTrue); + expect(status.meetsMinPasswordLength, isTrue); + expect(status.meetsMaxPasswordLength, isTrue); + expect(status.meetsLowercaseRequirement, isTrue); + expect(status.meetsUppercaseRequirement, isTrue); + expect(status.meetsDigitsRequirement, isTrue); + expect(status.meetsSymbolsRequirement, isTrue); + }); + + test('should not validate a password that is too short', () async { + final PasswordPolicyStatus status = await FirebaseAuth.instance.validatePassword(FirebaseAuth.instance, invalidPassword); + expect(status.status, isFalse); + expect(status.meetsMinPasswordLength, isFalse); + }); + + test('should not validate a password that has no uppercase characters', () async { + final PasswordPolicyStatus status = await FirebaseAuth.instance.validatePassword(FirebaseAuth.instance, invalidPassword2); + expect(status.status, isFalse); + expect(status.meetsUppercaseRequirement, isFalse); + }); + + test('should not validate a password that has no lowercase characters', () async { + final PasswordPolicyStatus status = await FirebaseAuth.instance.validatePassword(FirebaseAuth.instance, invalidPassword3); + expect(status.status, isFalse); + }); + + test('should not validate a password that has no digits', () async { + final PasswordPolicyStatus status = await FirebaseAuth.instance.validatePassword(FirebaseAuth.instance, invalidPassword4); + expect(status.status, isFalse); + expect(status.meetsDigitsRequirement, isFalse); + }); + + test('should not validate a password that has no symbols', () async { + final PasswordPolicyStatus status = await FirebaseAuth.instance.validatePassword(FirebaseAuth.instance, invalidPassword5); + expect(status.status, isFalse); + expect(status.meetsSymbolsRequirement, isFalse); + }); + + test('should throw an exception if the password is empty', () async { + try { + await FirebaseAuth.instance.validatePassword( + FirebaseAuth.instance, + '', + ); + } catch (e) { + expect( + e, + isA().having( + (e) => e.code, + 'code', + equals('invalid-password'), + ), + ); + } + }); + + test('should throw an exception if the password is null', () async { + try { + await FirebaseAuth.instance.validatePassword( + FirebaseAuth.instance, + null, + ); + } catch (e) { + expect( + e, + isA().having( + (e) => e.code, + 'code', + equals('invalid-password'), + ), + ); + } + }); + }); }, // macOS skipped because it needs keychain sharing entitlement. See: https://github.com/firebase/flutterfire/issues/9538 skip: defaultTargetPlatform == TargetPlatform.macOS,