Skip to content

feat(auth): PasswordPolicy Support #17439

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
29 changes: 29 additions & 0 deletions packages/firebase_auth/firebase_auth/lib/src/firebase_auth.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

part of '../firebase_auth.dart';

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

/// The entry point of the Firebase Authentication SDK.
class FirebaseAuth extends FirebasePluginPlatform {
// Cached instances of [FirebaseAuth].
Expand Down Expand Up @@ -713,6 +716,32 @@ class FirebaseAuth extends FirebasePluginPlatform {
await _delegate.signOut();
}

/// Validates the 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.
Future Map<String, dynamic> validatePassword(FirebaseAuth auth, String password) async {
PasswordPolicyApi passwordPolicyApi = PasswordPolicyApi(auth);
Map<String, dynamic> passwordPolicy = await passwordPolicyApi.fetchPasswordPolicy();

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,43 @@
// 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<Map<String, dynamic>> 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');
}

return json.decode(response.body);
} 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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// 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 'dart:convert';

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

// Backend enforced minimum
final int MIN_PASSWORD_LENGTH = 6;

final Map<String, dynamic> customStrengthOptions = {};
late final String enforcementState;
late final bool forceUpgradeOnSignin;
late final int schemaVersion;
late final List<String> allowedNonAlphanumericCharacters;

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,
};

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

return status;
}

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

if (minPasswordLength != null) {
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;
}
}
}

void validatePasswordCharacterOptions(String password, Map<String,dynamic> status) {
bool? requireLowercase = customStrengthOptions['requireLowercase'];
bool? requireUppercase = customStrengthOptions['requireUppercase'];
bool? requireDigits = customStrengthOptions['requireDigits'];
bool? requireSymbols = customStrengthOptions['requireSymbols'];

if (requireLowercase == true) {
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;
}
}
if (requireDigits == true) {
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) {
// Check against allowed symbols
for (String symbol in allowedNonAlphanumericCharacters) {
if (password.contains(symbol)) {
hasSymbol = true;
break;
}
}
} else {
// Check for any non-alphanumeric character
hasSymbol = password.contains(RegExp(r'[^a-zA-Z0-9]'));
}
status['meetsSymbolsRequirement'] = hasSymbol;
if (!hasSymbol) {
status['status'] = false;
}
}
}
}
2 changes: 1 addition & 1 deletion packages/firebase_auth/firebase_auth/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ dependencies:
flutter:
sdk: flutter
meta: ^1.8.0

http: ^1.1.0
dev_dependencies:
async: ^2.5.0
flutter_test:
Expand Down
59 changes: 59 additions & 0 deletions packages/firebase_auth/firebase_auth/test/firebase_auth_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
import '../lib/src/password_policy/password_policy_impl.dart';

import './mock.dart';

Expand All @@ -37,6 +38,25 @@ 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<String, dynamic> kMockPasswordPolicy = {
'customStrengthOptions': {
'minPasswordLength': 6,
'maxPasswordLength': 12,
'containsLowercaseCharacter': true,
'containsUppercaseCharacter': true,
'containsNumericCharacter': true,
'containsNonAlphanumericCharacter': true,
},
'allowedNonAlphanumericCharacters': ['!'],
'schemaVersion': 1,
'enforcement': 'OFF',
};
const int kMockPort = 31337;

final TestAuthProvider testAuthProvider = TestAuthProvider();
Expand Down Expand Up @@ -767,6 +787,45 @@ 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(kMockPasswordPolicy);

test('should return true for valid password', () async {
final 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);
});

test('should return false for invalid password with no capital characters', () async {
final 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);
});

test('should return false for invalid password with no numbers', () async {
final 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);
});
});

test('toString()', () async {
expect(
auth.toString(),
Expand Down
Loading