Skip to content

Commit 9a032b3

Browse files
feat(auth): validatePassword method/PasswordPolicy Support (#17439)
1 parent db7e829 commit 9a032b3

File tree

11 files changed

+438
-11
lines changed

11 files changed

+438
-11
lines changed

packages/firebase_auth/firebase_auth/lib/firebase_auth.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ export 'package:firebase_auth_platform_interface/firebase_auth_platform_interfac
6060
RecaptchaVerifierOnExpired,
6161
RecaptchaVerifierOnError,
6262
RecaptchaVerifierSize,
63-
RecaptchaVerifierTheme;
63+
RecaptchaVerifierTheme,
64+
PasswordValidationStatus;
6465
export 'package:firebase_core_platform_interface/firebase_core_platform_interface.dart'
6566
show FirebaseException;
6667

packages/firebase_auth/firebase_auth/lib/src/firebase_auth.dart

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -704,15 +704,6 @@ class FirebaseAuth extends FirebasePluginPlatform {
704704
}
705705
}
706706

707-
/// Signs out the current user.
708-
///
709-
/// If successful, it also updates
710-
/// any [authStateChanges], [idTokenChanges] or [userChanges] stream
711-
/// listeners.
712-
Future<void> signOut() async {
713-
await _delegate.signOut();
714-
}
715-
716707
/// Checks a password reset code sent to the user by email or other
717708
/// out-of-band mechanism.
718709
///
@@ -819,12 +810,72 @@ class FirebaseAuth extends FirebasePluginPlatform {
819810
return _delegate.revokeTokenWithAuthorizationCode(authorizationCode);
820811
}
821812

813+
/// Signs out the current user.
814+
///
815+
/// If successful, it also updates
816+
/// any [authStateChanges], [idTokenChanges] or [userChanges] stream
817+
/// listeners.
818+
Future<void> signOut() async {
819+
await _delegate.signOut();
820+
}
821+
822822
/// Initializes the reCAPTCHA Enterprise client proactively to enhance reCAPTCHA signal collection and
823823
/// to complete reCAPTCHA-protected flows in a single attempt.
824824
Future<void> initializeRecaptchaConfig() {
825825
return _delegate.initializeRecaptchaConfig();
826826
}
827827

828+
/// Validates a password against the password policy configured for the project or tenant.
829+
///
830+
/// If no tenant ID is set on the Auth instance, then this method will use the password policy configured for the project.
831+
/// Otherwise, this method will use the policy configured for the tenant. If a password policy has not been configured,
832+
/// then the default policy configured for all projects will be used.
833+
///
834+
/// If an auth flow fails because a submitted password does not meet the password policy requirements and this method has previously been called,
835+
/// then this method will use the most recent policy available when called again.
836+
///
837+
/// Returns a map with the following keys:
838+
/// - **status**: A boolean indicating if the password is valid.
839+
/// - **passwordPolicy**: The password policy used to validate the password.
840+
/// - **meetsMinPasswordLength**: A boolean indicating if the password meets the minimum length requirement.
841+
/// - **meetsMaxPasswordLength**: A boolean indicating if the password meets the maximum length requirement.
842+
/// - **meetsLowercaseRequirement**: A boolean indicating if the password meets the lowercase requirement.
843+
/// - **meetsUppercaseRequirement**: A boolean indicating if the password meets the uppercase requirement.
844+
/// - **meetsDigitsRequirement**: A boolean indicating if the password meets the digits requirement.
845+
/// - **meetsSymbolsRequirement**: A boolean indicating if the password meets the symbols requirement.
846+
///
847+
/// A [FirebaseAuthException] maybe thrown with the following error code:
848+
/// - **invalid-password**:
849+
/// - Thrown if the password is invalid.
850+
/// - **network-request-failed**:
851+
/// - Thrown if there was a network request error, for example the user
852+
/// doesn't have internet connection
853+
/// - **INVALID_LOGIN_CREDENTIALS** or **invalid-credential**:
854+
/// - Thrown if the password is invalid for the given email, or the account
855+
/// corresponding to the email does not have a password set.
856+
/// Depending on if you are using firebase emulator or not the code is
857+
/// different
858+
/// - **operation-not-allowed**:
859+
/// - Thrown if email/password accounts are not enabled. Enable
860+
/// email/password accounts in the Firebase Console, under the Auth tab.
861+
Future<PasswordValidationStatus> validatePassword(
862+
FirebaseAuth auth,
863+
String? password,
864+
) async {
865+
if (password == null || password.isEmpty) {
866+
throw FirebaseAuthException(
867+
code: 'invalid-password',
868+
message: 'Password cannot be null or empty',
869+
);
870+
}
871+
PasswordPolicyApi passwordPolicyApi =
872+
PasswordPolicyApi(auth.app.options.apiKey);
873+
PasswordPolicy passwordPolicy =
874+
await passwordPolicyApi.fetchPasswordPolicy();
875+
PasswordPolicyImpl passwordPolicyImpl = PasswordPolicyImpl(passwordPolicy);
876+
return passwordPolicyImpl.isPasswordValid(password);
877+
}
878+
828879
@override
829880
String toString() {
830881
return 'FirebaseAuth(app: ${app.name})';

packages/firebase_auth/firebase_auth/pubspec.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ dependencies:
2727
flutter:
2828
sdk: flutter
2929
meta: ^1.8.0
30-
3130
dev_dependencies:
3231
async: ^2.5.0
3332
flutter_test:

packages/firebase_auth/firebase_auth/test/firebase_auth_test.dart

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,28 @@ void main() {
3737
const String kMockOobCode = 'oobcode';
3838
const String kMockURL = 'http://www.example.com';
3939
const String kMockHost = 'www.example.com';
40+
const String kMockValidPassword =
41+
'Password123!'; // For password policy impl testing
42+
const String kMockInvalidPassword = 'Pa1!';
43+
const String kMockInvalidPassword2 = 'password123!';
44+
const String kMockInvalidPassword3 = 'PASSWORD123!';
45+
const String kMockInvalidPassword4 = 'password!';
46+
const String kMockInvalidPassword5 = 'Password123';
47+
const Map<String, dynamic> kMockPasswordPolicy = {
48+
'customStrengthOptions': {
49+
'minPasswordLength': 6,
50+
'maxPasswordLength': 12,
51+
'containsLowercaseCharacter': true,
52+
'containsUppercaseCharacter': true,
53+
'containsNumericCharacter': true,
54+
'containsNonAlphanumericCharacter': true,
55+
},
56+
'allowedNonAlphanumericCharacters': ['!'],
57+
'schemaVersion': 1,
58+
'enforcement': 'OFF',
59+
};
60+
final PasswordPolicy kMockPasswordPolicyObject =
61+
PasswordPolicy(kMockPasswordPolicy);
4062
const int kMockPort = 31337;
4163

4264
final TestAuthProvider testAuthProvider = TestAuthProvider();
@@ -767,6 +789,61 @@ void main() {
767789
});
768790
});
769791

792+
group('passwordPolicy', () {
793+
test('passwordPolicy should be initialized with correct parameters',
794+
() async {
795+
PasswordPolicyImpl passwordPolicy =
796+
PasswordPolicyImpl(kMockPasswordPolicyObject);
797+
expect(passwordPolicy.policy, equals(kMockPasswordPolicyObject));
798+
});
799+
800+
PasswordPolicyImpl passwordPolicy =
801+
PasswordPolicyImpl(kMockPasswordPolicyObject);
802+
803+
test('should return true for valid password', () async {
804+
final PasswordValidationStatus status =
805+
passwordPolicy.isPasswordValid(kMockValidPassword);
806+
expect(status.isValid, isTrue);
807+
});
808+
809+
test('should return false for invalid password that is too short',
810+
() async {
811+
final PasswordValidationStatus status =
812+
passwordPolicy.isPasswordValid(kMockInvalidPassword);
813+
expect(status.isValid, isFalse);
814+
});
815+
816+
test(
817+
'should return false for invalid password with no capital characters',
818+
() async {
819+
final PasswordValidationStatus status =
820+
passwordPolicy.isPasswordValid(kMockInvalidPassword2);
821+
expect(status.isValid, isFalse);
822+
});
823+
824+
test(
825+
'should return false for invalid password with no lowercase characters',
826+
() async {
827+
final PasswordValidationStatus status =
828+
passwordPolicy.isPasswordValid(kMockInvalidPassword3);
829+
expect(status.isValid, isFalse);
830+
});
831+
832+
test('should return false for invalid password with no numbers',
833+
() async {
834+
final PasswordValidationStatus status =
835+
passwordPolicy.isPasswordValid(kMockInvalidPassword4);
836+
expect(status.isValid, isFalse);
837+
});
838+
839+
test('should return false for invalid password with no symbols',
840+
() async {
841+
final PasswordValidationStatus status =
842+
passwordPolicy.isPasswordValid(kMockInvalidPassword5);
843+
expect(status.isValid, isFalse);
844+
});
845+
});
846+
770847
test('toString()', () async {
771848
expect(
772849
auth.toString(),

packages/firebase_auth/firebase_auth_platform_interface/lib/firebase_auth_platform_interface.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,7 @@ export 'src/providers/play_games_auth.dart';
3939
export 'src/types.dart';
4040
export 'src/user_info.dart';
4141
export 'src/user_metadata.dart';
42+
export 'src/password_policy/password_policy_api.dart';
43+
export 'src/password_policy/password_policy_impl.dart';
44+
export 'src/password_policy/password_policy.dart';
45+
export 'src/password_policy/password_validation_status.dart';
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright 2025, the Chromium project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
class PasswordPolicy {
5+
final Map<String, dynamic> policy;
6+
7+
// Backend enforced minimum
8+
late final int minPasswordLength;
9+
late final int? maxPasswordLength;
10+
late final bool? containsLowercaseCharacter;
11+
late final bool? containsUppercaseCharacter;
12+
late final bool? containsNumericCharacter;
13+
late final bool? containsNonAlphanumericCharacter;
14+
late final int schemaVersion;
15+
late final List<String> allowedNonAlphanumericCharacters;
16+
late final String enforcementState;
17+
18+
PasswordPolicy(this.policy) {
19+
initialize();
20+
}
21+
22+
void initialize() {
23+
final Map<String, dynamic> customStrengthOptions =
24+
policy['customStrengthOptions'] ?? {};
25+
26+
minPasswordLength = customStrengthOptions['minPasswordLength'] ?? 6;
27+
maxPasswordLength = customStrengthOptions['maxPasswordLength'];
28+
containsLowercaseCharacter =
29+
customStrengthOptions['containsLowercaseCharacter'];
30+
containsUppercaseCharacter =
31+
customStrengthOptions['containsUppercaseCharacter'];
32+
containsNumericCharacter =
33+
customStrengthOptions['containsNumericCharacter'];
34+
containsNonAlphanumericCharacter =
35+
customStrengthOptions['containsNonAlphanumericCharacter'];
36+
37+
schemaVersion = policy['schemaVersion'] ?? 1;
38+
allowedNonAlphanumericCharacters = List<String>.from(
39+
policy['allowedNonAlphanumericCharacters'] ??
40+
customStrengthOptions['allowedNonAlphanumericCharacters'] ??
41+
[],
42+
);
43+
44+
final enforcement = policy['enforcement'] ?? policy['enforcementState'];
45+
enforcementState = enforcement == 'ENFORCEMENT_STATE_UNSPECIFIED'
46+
? 'OFF'
47+
: (enforcement ?? 'OFF');
48+
}
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright 2025, the Chromium project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'package:http/http.dart' as http;
6+
import 'dart:convert';
7+
import 'dart:core';
8+
import 'password_policy.dart';
9+
10+
class PasswordPolicyApi {
11+
final String _apiKey;
12+
final String _apiUrl =
13+
'https://identitytoolkit.googleapis.com/v2/passwordPolicy?key=';
14+
15+
PasswordPolicyApi(this._apiKey);
16+
17+
final int _schemaVersion = 1;
18+
19+
Future<PasswordPolicy> fetchPasswordPolicy() async {
20+
try {
21+
final response = await http.get(Uri.parse('$_apiUrl$_apiKey'));
22+
if (response.statusCode == 200) {
23+
final policy = json.decode(response.body);
24+
25+
// Validate schema version
26+
final _schemaVersion = policy['schemaVersion'];
27+
if (!isCorrectSchemaVersion(_schemaVersion)) {
28+
throw Exception(
29+
'Schema Version mismatch, expected version 1 but got $policy',
30+
);
31+
}
32+
33+
Map<String, dynamic> rawPolicy = json.decode(response.body);
34+
return PasswordPolicy(rawPolicy);
35+
} else {
36+
throw Exception(
37+
'Failed to fetch password policy, status code: ${response.statusCode}',
38+
);
39+
}
40+
} catch (e) {
41+
throw Exception('Failed to fetch password policy: $e');
42+
}
43+
}
44+
45+
bool isCorrectSchemaVersion(int schemaVersion) {
46+
return _schemaVersion == schemaVersion;
47+
}
48+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright 2025, the Chromium project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
import 'dart:core';
5+
import 'password_policy.dart';
6+
import 'password_validation_status.dart';
7+
8+
class PasswordPolicyImpl {
9+
final PasswordPolicy _policy;
10+
11+
PasswordPolicyImpl(this._policy);
12+
13+
// Getter to access the policy
14+
PasswordPolicy get policy => _policy;
15+
16+
PasswordValidationStatus isPasswordValid(String password) {
17+
PasswordValidationStatus status = PasswordValidationStatus(true, _policy);
18+
19+
_validatePasswordLengthOptions(password, status);
20+
_validatePasswordCharacterOptions(password, status);
21+
22+
return status;
23+
}
24+
25+
void _validatePasswordLengthOptions(
26+
String password,
27+
PasswordValidationStatus status,
28+
) {
29+
int minPasswordLength = _policy.minPasswordLength;
30+
int? maxPasswordLength = _policy.maxPasswordLength;
31+
32+
status.meetsMinPasswordLength = password.length >= minPasswordLength;
33+
if (!status.meetsMinPasswordLength) {
34+
status.isValid = false;
35+
}
36+
if (maxPasswordLength != null) {
37+
status.meetsMaxPasswordLength = password.length <= maxPasswordLength;
38+
if (!status.meetsMaxPasswordLength) {
39+
status.isValid = false;
40+
}
41+
}
42+
}
43+
44+
void _validatePasswordCharacterOptions(
45+
String password,
46+
PasswordValidationStatus status,
47+
) {
48+
bool? requireLowercase = _policy.containsLowercaseCharacter;
49+
bool? requireUppercase = _policy.containsUppercaseCharacter;
50+
bool? requireDigits = _policy.containsNumericCharacter;
51+
bool? requireSymbols = _policy.containsNonAlphanumericCharacter;
52+
53+
if (requireLowercase ?? false) {
54+
status.meetsLowercaseRequirement = password.contains(RegExp('[a-z]'));
55+
if (!status.meetsLowercaseRequirement) {
56+
status.isValid = false;
57+
}
58+
}
59+
if (requireUppercase ?? false) {
60+
status.meetsUppercaseRequirement = password.contains(RegExp('[A-Z]'));
61+
if (!status.meetsUppercaseRequirement) {
62+
status.isValid = false;
63+
}
64+
}
65+
if (requireDigits ?? false) {
66+
status.meetsDigitsRequirement = password.contains(RegExp('[0-9]'));
67+
if (!status.meetsDigitsRequirement) {
68+
status.isValid = false;
69+
}
70+
}
71+
if (requireSymbols ?? false) {
72+
// Check if password contains any non-alphanumeric characters
73+
bool hasSymbol = false;
74+
if (_policy.allowedNonAlphanumericCharacters.isNotEmpty) {
75+
// Check against allowed symbols
76+
for (final String symbol in _policy.allowedNonAlphanumericCharacters) {
77+
if (password.contains(symbol)) {
78+
hasSymbol = true;
79+
break;
80+
}
81+
}
82+
} else {
83+
// Check for any non-alphanumeric character
84+
hasSymbol = password.contains(RegExp('[^a-zA-Z0-9]'));
85+
}
86+
status.meetsSymbolsRequirement = hasSymbol;
87+
if (!hasSymbol) {
88+
status.isValid = false;
89+
}
90+
}
91+
}
92+
}

0 commit comments

Comments
 (0)