Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
ec8d519
feat(auth): PasswordPolicy Support
MichaelVerdon Jun 19, 2025
55e8400
feat: license headers and impl start
MichaelVerdon Jun 19, 2025
1823649
feat: Password Policy Logic finished
MichaelVerdon Jun 20, 2025
226fcde
feat: add unit tests
MichaelVerdon Jun 20, 2025
5ab2b37
feat: expose method
MichaelVerdon Jun 20, 2025
1d23e57
fix: rename method
MichaelVerdon Jun 20, 2025
928f5e2
chore: refactor, make explicit as possible
MichaelVerdon Jun 20, 2025
8060d70
feat: add e2e
MichaelVerdon Jun 26, 2025
21c9ad1
feat: change field types
MichaelVerdon Jun 26, 2025
7d8d60f
chore: add license headers
MichaelVerdon Jun 26, 2025
c21b3bc
chore: fix analyze
MichaelVerdon Jun 26, 2025
70b7e49
chore: format-ci
MichaelVerdon Jun 26, 2025
1be1baa
chore: remove duplicate
MichaelVerdon Jun 26, 2025
b0ef9e2
chore: undo accidental deletion
MichaelVerdon Jun 26, 2025
9936e46
chore: fix analyze
MichaelVerdon Jun 26, 2025
9c8554d
fix: expose apis
MichaelVerdon Jun 26, 2025
de5e14d
chore: formatting
MichaelVerdon Jun 26, 2025
0555134
chore: sort dependencies alphabeticaly
MichaelVerdon Jun 26, 2025
b13f33e
chore: more e2e tests
MichaelVerdon Jun 26, 2025
03e3f8d
chore: refactor
MichaelVerdon Jul 17, 2025
0b78622
chore: refactor
MichaelVerdon Jul 17, 2025
69391a1
chore: refactor
MichaelVerdon Jul 17, 2025
87899f8
chore: fix
MichaelVerdon Jul 18, 2025
0e68d2b
chore: create internals
MichaelVerdon Jul 21, 2025
fa7c0c2
chore: run format
MichaelVerdon Jul 21, 2025
27f3476
fix: shift into platform_interface
MichaelVerdon Jul 24, 2025
15705e1
fix: readd method
MichaelVerdon Jul 24, 2025
18c5d51
fix: pass apikey through method instead
MichaelVerdon Jul 24, 2025
cfb5190
format: melos run format
MichaelVerdon Jul 24, 2025
3cd99e1
chore: remove import
MichaelVerdon Jul 24, 2025
4871d79
chore: keep internals internal
MichaelVerdon Jul 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
chore: refactor, make explicit as possible
  • Loading branch information
MichaelVerdon committed Jul 18, 2025
commit 928f5e25edf33cfb8db9f508a267a2b824b42e10
18 changes: 10 additions & 8 deletions packages/firebase_auth/firebase_auth/lib/src/firebase_auth.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@

part of '../firebase_auth.dart';

import 'password_policy/password_policy_impl.dart';
import 'password_policy/password_policy_api.dart';
// import 'password_policy/password_policy_impl.dart';
// import 'password_policy/password_policy_api.dart';
// import 'password_policy/password_policy.dart';
// import 'password_policy/password_policy_status.dart';

/// The entry point of the Firebase Authentication SDK.
class FirebaseAuth extends FirebasePluginPlatform {
Expand Down Expand Up @@ -734,13 +736,13 @@ class FirebaseAuth extends FirebasePluginPlatform {
/// - **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.
Future Map<String, dynamic> validatePassword(FirebaseAuth auth, String password) async {
PasswordPolicyApi passwordPolicyApi = PasswordPolicyApi(auth);
Map<String, dynamic> passwordPolicy = await passwordPolicyApi.fetchPasswordPolicy();
// Future PasswordPolicyStatus validatePassword(FirebaseAuth auth, String password) async {
// PasswordPolicyApi passwordPolicyApi = PasswordPolicyApi(auth);
// PasswordPolicy passwordPolicy = await passwordPolicyApi.fetchPasswordPolicy();

PasswordPolicyImpl passwordPolicyImpl = PasswordPolicyImpl(passwordPolicy);
return passwordPolicyImpl.isPasswordValid(password);
}
// PasswordPolicyImpl passwordPolicyImpl = PasswordPolicyImpl(passwordPolicy);
// return passwordPolicyImpl.isPasswordValid(password);
// }

/// Checks a password reset code sent to the user by email or other
/// out-of-band mechanism.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
class PasswordPolicy {
final Map<String, dynamic> 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<String> allowedNonAlphanumericCharacters;
late final String enforcementState;

PasswordPolicy(this.policy){
initialize();
}

void initialize() {
final Map<String, dynamic> 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<String>.from(
policy['allowedNonAlphanumericCharacters'] ??
customStrengthOptions['allowedNonAlphanumericCharacters'] ??
[]
);

final enforcement = policy['enforcement'] ?? policy['enforcementState'];
enforcementState = enforcement == 'ENFORCEMENT_STATE_UNSPECIFIED'
? 'OFF'
: (enforcement ?? 'OFF');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,21 @@

import 'package:firebase_auth/firebase_auth.dart';
import 'package:http/http.dart' as http;
import 'password_policy.dart';
import 'dart:convert';
import 'dart:core';

class PasswordPolicyApi {
final FirebaseAuth auth;
final FirebaseAuth _auth;
final String _apiUrl = 'https://identitytoolkit.googleapis.com/v2/passwordPolicy?key=';

PasswordPolicyApi(this.auth);
PasswordPolicyApi(this._auth);

final int schemaVersion = 1;
final int _schemaVersion = 1;

Future<Map<String, dynamic>> fetchPasswordPolicy() async {
try {
final String _apiKey = auth.app.options.apiKey;
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);
Expand All @@ -28,6 +29,9 @@ class PasswordPolicyApi {
throw Exception('Schema Version mismatch, expected version 1 but got $policy');
}

Map<String, dynamic> rawPolicy = json.decode(response.body);


return json.decode(response.body);
} else {
throw Exception('Failed to fetch password policy, status code: ${response.statusCode}');
Expand All @@ -37,7 +41,7 @@ class PasswordPolicyApi {
}
}

bool isCorrectSchemaVersion(int _schemaVersion) {
return schemaVersion == _schemaVersion;
bool isCorrectSchemaVersion(int schemaVersion) {
return _schemaVersion == schemaVersion;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,120 +3,74 @@
// BSD-style license that can be found in the LICENSE file.
import 'dart:core';
import 'dart:convert';
import 'password_policy.dart';
import 'password_policy_status.dart';

class PasswordPolicyImpl {
final Map<String, dynamic> policy;
final PasswordPolicy _policy;

// Backend enforced minimum
final int MIN_PASSWORD_LENGTH = 6;
PasswordPolicyImpl(this._policy);

final Map<String, dynamic> customStrengthOptions = {};
late final String enforcementState;
late final bool forceUpgradeOnSignin;
late final int schemaVersion;
late final List<String> allowedNonAlphanumericCharacters;
// Getter to access the policy
PasswordPolicy get policy => _policy;

PasswordPolicyImpl(this.policy) {
_setParametersFromResponse();
}

void _setParametersFromResponse() {
final responseOptions = policy['customStrengthOptions'] ?? {};

customStrengthOptions['minPasswordLength'] = responseOptions['minPasswordLength'] ?? MIN_PASSWORD_LENGTH;
if (responseOptions['maxPasswordLength'] != null) {
customStrengthOptions['maxPasswordLength'] = responseOptions['maxPasswordLength'];
}
if (responseOptions['containsLowercaseCharacter'] != null) {
customStrengthOptions['requireLowercase'] = responseOptions['containsLowercaseCharacter'];
}
if (responseOptions['containsUppercaseCharacter'] != null) {
customStrengthOptions['requireUppercase'] = responseOptions['containsUppercaseCharacter'];
}
if (responseOptions['containsNumericCharacter'] != null) {
customStrengthOptions['requireDigits'] = responseOptions['containsNumericCharacter'];
}
if (responseOptions['containsNonAlphanumericCharacter'] != null) {
customStrengthOptions['requireSymbols'] = responseOptions['containsNonAlphanumericCharacter'];
}

// Handle both 'enforcementState' and 'enforcement' field names
final enforcement = policy['enforcementState'] ?? policy['enforcement'];
enforcementState = enforcement == 'ENFORCEMENT_STATE_UNSPECIFIED'
? 'OFF'
: (enforcement ?? 'OFF');

// allowedNonAlphanumericCharacters can be at top level or in customStrengthOptions
allowedNonAlphanumericCharacters = List<String>.from(
policy['allowedNonAlphanumericCharacters'] ??
responseOptions['allowedNonAlphanumericCharacters'] ??
[]
);

forceUpgradeOnSignin = policy['forceUpgradeOnSignin'] ?? false;
schemaVersion = policy['schemaVersion'] ?? 1; // Default to 1 if not provided
}

Map<String,dynamic> isPasswordValid(String password) {
Map<String,dynamic> status = {
'status': true,
'passwordPolicy': policy,
};
PasswordPolicyStatus isPasswordValid(String password) {
PasswordPolicyStatus status = PasswordPolicyStatus(true, _policy);

validatePasswordLengthOptions(password, status);
validatePasswordCharacterOptions(password, status);
_validatePasswordLengthOptions(password, status);
_validatePasswordCharacterOptions(password, status);

return status;
}

void validatePasswordLengthOptions(String password, Map<String,dynamic> status) {
int? minPasswordLength = customStrengthOptions['minPasswordLength'];
int? maxPasswordLength = customStrengthOptions['maxPasswordLength'];
void _validatePasswordLengthOptions(String password, PasswordPolicyStatus status) {
int? minPasswordLength = _policy.minPasswordLength;
int? maxPasswordLength = _policy.maxPasswordLength;

if (minPasswordLength != null) {
status['meetsMinPasswordLength'] = password.length >= minPasswordLength;
if (!(status['meetsMinPasswordLength'] as bool)) {
status['status'] = false;
status.meetsMinPasswordLength = password.length >= minPasswordLength;
if (!(status.meetsMinPasswordLength as bool)) {
status.status = false;
}
}
if (maxPasswordLength != null) {
status['meetsMaxPasswordLength'] = password.length <= maxPasswordLength;
if (!(status['meetsMaxPasswordLength'] as bool)) {
status['status'] = false;
status.meetsMaxPasswordLength = password.length <= maxPasswordLength;
if (!(status.meetsMaxPasswordLength as bool)) {
status.status = false;
}
}
}

void validatePasswordCharacterOptions(String password, Map<String,dynamic> status) {
bool? requireLowercase = customStrengthOptions['requireLowercase'];
bool? requireUppercase = customStrengthOptions['requireUppercase'];
bool? requireDigits = customStrengthOptions['requireDigits'];
bool? requireSymbols = customStrengthOptions['requireSymbols'];
void _validatePasswordCharacterOptions(String password, PasswordPolicyStatus status) {
bool? requireLowercase = _policy.containsLowercaseCharacter;
bool? requireUppercase = _policy.containsUppercaseCharacter;
bool? requireDigits = _policy.containsNumericCharacter;
bool? requireSymbols = _policy.containsNonAlphanumericCharacter;

if (requireLowercase == true) {
status['meetsLowercaseRequirement'] = password.contains(RegExp(r'[a-z]'));
if (!(status['meetsLowercaseRequirement'] as bool)) {
status['status'] = false;
status.meetsLowercaseRequirement = password.contains(RegExp(r'[a-z]'));
if (!(status.meetsLowercaseRequirement as bool)) {
status.status = false;
}
}
if (requireUppercase == true) {
status['meetsUppercaseRequirement'] = password.contains(RegExp(r'[A-Z]'));
if (!(status['meetsUppercaseRequirement'] as bool)) {
status['status'] = false;
status.meetsUppercaseRequirement = password.contains(RegExp(r'[A-Z]'));
if (!(status.meetsUppercaseRequirement as bool)) {
status.status = false;
}
}
if (requireDigits == true) {
status['meetsDigitsRequirement'] = password.contains(RegExp(r'[0-9]'));
if (!(status['meetsDigitsRequirement'] as bool)) {
status['status'] = false;
status.meetsDigitsRequirement = password.contains(RegExp(r'[0-9]'));
if (!(status.meetsDigitsRequirement as bool)) {
status.status = false;
}
}
if (requireSymbols == true) {
// Check if password contains any non-alphanumeric characters
bool hasSymbol = false;
if (allowedNonAlphanumericCharacters.isNotEmpty) {
if (_policy.allowedNonAlphanumericCharacters.isNotEmpty) {
// Check against allowed symbols
for (String symbol in allowedNonAlphanumericCharacters) {
for (String symbol in _policy.allowedNonAlphanumericCharacters) {
if (password.contains(symbol)) {
hasSymbol = true;
break;
Expand All @@ -126,9 +80,9 @@ class PasswordPolicyImpl {
// Check for any non-alphanumeric character
hasSymbol = password.contains(RegExp(r'[^a-zA-Z0-9]'));
}
status['meetsSymbolsRequirement'] = hasSymbol;
status.meetsSymbolsRequirement = hasSymbol;
if (!hasSymbol) {
status['status'] = false;
status.status = false;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import 'password_policy.dart';

class PasswordPolicyStatus {
Comment thread
MichaelVerdon marked this conversation as resolved.
Outdated
bool status;
Comment thread
MichaelVerdon marked this conversation as resolved.
Outdated
final PasswordPolicy passwordPolicy;

late bool meetsMinPasswordLength;
late bool meetsMaxPasswordLength;
late bool meetsLowercaseRequirement;
late bool meetsUppercaseRequirement;
late bool meetsDigitsRequirement;
late bool meetsSymbolsRequirement;

PasswordPolicyStatus(this.status, this.passwordPolicy);
}
33 changes: 18 additions & 15 deletions packages/firebase_auth/firebase_auth/test/firebase_auth_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import '../lib/src/password_policy/password_policy_impl.dart';
import '../lib/src/password_policy/password_policy.dart';
import '../lib/src/password_policy/password_policy_status.dart';

import './mock.dart';

Expand Down Expand Up @@ -57,6 +59,7 @@ void main() {
'schemaVersion': 1,
'enforcement': 'OFF',
};
final PasswordPolicy kMockPasswordPolicyObject = PasswordPolicy(kMockPasswordPolicy);
const int kMockPort = 31337;

final TestAuthProvider testAuthProvider = TestAuthProvider();
Expand Down Expand Up @@ -789,40 +792,40 @@ void main() {

group('passwordPolicy', () {
test('passwordPolicy should be initialized with correct parameters', () async {
PasswordPolicyImpl passwordPolicy = PasswordPolicyImpl(kMockPasswordPolicy);
expect(passwordPolicy.policy, equals(kMockPasswordPolicy));
PasswordPolicyImpl passwordPolicy = PasswordPolicyImpl(kMockPasswordPolicyObject);
expect(passwordPolicy.policy, equals(kMockPasswordPolicyObject));
});

PasswordPolicyImpl passwordPolicy = PasswordPolicyImpl(kMockPasswordPolicy);
PasswordPolicyImpl passwordPolicy = PasswordPolicyImpl(kMockPasswordPolicyObject);

test('should return true for valid password', () async {
final status = passwordPolicy.isPasswordValid(kMockValidPassword);
expect(status['status'], isTrue);
final PasswordPolicyStatus status = passwordPolicy.isPasswordValid(kMockValidPassword);
expect(status.status, isTrue);
});

test('should return false for invalid password that is too short', () async {
final status = passwordPolicy.isPasswordValid(kMockInvalidPassword);
expect(status['status'], isFalse);
final PasswordPolicyStatus status = passwordPolicy.isPasswordValid(kMockInvalidPassword);
expect(status.status, isFalse);
});

test('should return false for invalid password with no capital characters', () async {
final status = passwordPolicy.isPasswordValid(kMockInvalidPassword2);
expect(status['status'], isFalse);
final PasswordPolicyStatus status = passwordPolicy.isPasswordValid(kMockInvalidPassword2);
expect(status.status, isFalse);
});

test('should return false for invalid password with no lowercase characters', () async {
final status = passwordPolicy.isPasswordValid(kMockInvalidPassword3);
expect(status['status'], isFalse);
final PasswordPolicyStatus status = passwordPolicy.isPasswordValid(kMockInvalidPassword3);
expect(status.status, isFalse);
});

test('should return false for invalid password with no numbers', () async {
final status = passwordPolicy.isPasswordValid(kMockInvalidPassword4);
expect(status['status'], isFalse);
final PasswordPolicyStatus status = passwordPolicy.isPasswordValid(kMockInvalidPassword4);
expect(status.status, isFalse);
});

test('should return false for invalid password with no symbols', () async {
final status = passwordPolicy.isPasswordValid(kMockInvalidPassword5);
expect(status['status'], isFalse);
final PasswordPolicyStatus status = passwordPolicy.isPasswordValid(kMockInvalidPassword5);
expect(status.status, isFalse);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,19 @@ void main() {
},
skip: true,
);

group('validatePassword()', () {
test('should validate password', () async {
final status = await FirebaseAuth.instance.validatePassword(FirebaseAuth.instance, testPassword);
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);
});
});
},
// macOS skipped because it needs keychain sharing entitlement. See: https://github.com/firebase/flutterfire/issues/9538
skip: defaultTargetPlatform == TargetPlatform.macOS,
Expand Down