Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import com.firebase.ui.auth.ui.screens.reauth.ReauthContentState
import com.firebase.ui.auth.util.EmailLinkConstants
import com.firebase.ui.auth.util.displayIdentifier
import com.firebase.ui.auth.util.getDisplayEmail
import com.firebaseui.android.demo.MainActivity
import com.firebaseui.android.demo.R
import com.firebaseui.android.demo.utils.EMAIL_LINK_ORIGIN_PARAM
import com.google.firebase.auth.actionCodeSettings
Expand Down Expand Up @@ -154,6 +155,8 @@ class HighLevelApiDemoActivity : ComponentActivity() {
PasswordRule.RequireLowercase,
PasswordRule.RequireUppercase,
),
// The Auth emulator does not serve the password policy.
isProjectPasswordPolicyEnabled = !MainActivity.USE_AUTH_EMULATOR,
)
)
provider(
Expand Down
6 changes: 5 additions & 1 deletion auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,11 @@ val emailProvider = AuthProvider.Email(
PasswordRule.RequireLowercase,
PasswordRule.RequireDigit,
PasswordRule.RequireSpecialCharacter
)
),

// Optional: Show the project's password policy as a sign-up checklist (default: false).
// Pass false (e.g. !useEmulator) with the Auth emulator, which has no password policy endpoint.
isProjectPasswordPolicyEnabled = true
)

val configuration = authUIConfiguration {
Expand Down
11 changes: 11 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import com.firebase.ui.auth.configuration.auth_provider.Provider
import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException
import com.firebase.ui.auth.configuration.auth_provider.signOutFromFacebook
import com.firebase.ui.auth.configuration.auth_provider.signOutFromGoogle
import com.firebase.ui.auth.configuration.password_policy.PasswordPolicyRepository
import com.firebase.ui.auth.ui.screens.reauth.toReauthConfiguration
import com.firebase.ui.auth.util.ProviderAvailability
import com.google.firebase.Firebase
Expand Down Expand Up @@ -101,6 +102,16 @@ class FirebaseAuthUI private constructor(
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
internal var testLoginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null

/** Replaces [passwordPolicyRepository]; read once, on its first use. */
@Volatile
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
internal var testPasswordPolicyRepository: PasswordPolicyRepository? = null

/** One per instance, so the fetched policy outlives the sign-up screen. */
internal val passwordPolicyRepository: PasswordPolicyRepository by lazy {
testPasswordPolicyRepository ?: PasswordPolicyRepository()
}

/**
* Checks whether a user is currently signed in.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,41 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
* A list of custom password validation rules.
*/
val passwordValidationRules: List<PasswordRule>,

/**
* Fetches the project's password policy and shows its requirements as a live checklist on
* sign-up. Hints only: the server still enforces the policy. Defaults to false when
* omitted, via the overload without this parameter.
*
* The Auth emulator does not serve the policy, so pass `!useEmulator` when you call
* `FirebaseAuth.useEmulator`.
*/
val isProjectPasswordPolicyEnabled: Boolean,
) : AuthProvider(providerId = Provider.EMAIL.id, providerName = Provider.EMAIL.providerName) {

/**
* Creates an email provider with [isProjectPasswordPolicyEnabled] set to false. Kept as a
* plain overload so existing Kotlin and Java callers still compile and link.
*/
constructor(
isDisplayNameRequired: Boolean = true,
isEmailLinkSignInEnabled: Boolean = false,
isEmailLinkForceSameDeviceEnabled: Boolean = true,
emailLinkActionCodeSettings: ActionCodeSettings?,
isNewAccountsAllowed: Boolean = true,
minimumPasswordLength: Int = 6,
passwordValidationRules: List<PasswordRule>,
) : this(
isDisplayNameRequired = isDisplayNameRequired,
isEmailLinkSignInEnabled = isEmailLinkSignInEnabled,
isEmailLinkForceSameDeviceEnabled = isEmailLinkForceSameDeviceEnabled,
emailLinkActionCodeSettings = emailLinkActionCodeSettings,
isNewAccountsAllowed = isNewAccountsAllowed,
minimumPasswordLength = minimumPasswordLength,
passwordValidationRules = passwordValidationRules,
isProjectPasswordPolicyEnabled = false,
)

companion object {
const val SESSION_ID_LENGTH = 10
val KEY_EMAIL = stringPreferencesKey("com.firebase.ui.auth.data.client.email")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import com.google.firebase.auth.FirebaseAuth
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -134,6 +138,14 @@ internal class PasswordPolicyRepository(

private val cache = ConcurrentHashMap<Key, Entry>()
private val locks = ConcurrentHashMap<Key, Mutex>()
private val revisions = ConcurrentHashMap<Key, MutableStateFlow<Int>>()

/**
* Incremented each time [refetchIfCached] stores a new policy for [auth]'s app and tenant, so
* readers of that key can re-read. Other keys are unaffected.
*/
fun revision(auth: FirebaseAuth): StateFlow<Int> =
revisionFor(Key(auth.app.name, auth.tenantId)).asStateFlow()

/** Returns the cached policy for [auth], fetching it if absent or a failure has expired. */
suspend fun getPolicy(context: Context, auth: FirebaseAuth): PasswordPolicy? {
Expand All @@ -156,12 +168,18 @@ internal class PasswordPolicyRepository(
val key = Key(auth.app.name, auth.tenantId)
return lockFor(key).withLock {
val previous = (cache[key] as? Entry.Fetched)?.policy ?: return@withLock null
fetch(context, auth)?.also { cache[key] = Entry.Fetched(it) } ?: previous
fetch(context, auth)?.also {
cache[key] = Entry.Fetched(it)
revisionFor(key).update { revision -> revision + 1 }
} ?: previous
}
}

private fun lockFor(key: Key): Mutex = locks.getOrPut(key) { Mutex() }

private fun revisionFor(key: Key): MutableStateFlow<Int> =
revisions.getOrPut(key) { MutableStateFlow(0) }

private suspend fun fetchAndStore(
context: Context,
auth: FirebaseAuth,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import com.firebase.ui.auth.rememberAuthFlowScope
import android.content.Context
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
Expand Down Expand Up @@ -226,6 +227,20 @@ fun EmailAuthScreen(
val isEmailLinkSignInOffered = configuration.isEmailLinkSignInOffered()

val authFlowScope = rememberAuthFlowScope(authUI, configuration)
// Remembered: a new source per recomposition would restart the checklist's fetch. Keyed on the
// tenant the source is bound to; tenantId is not Compose state, so a tenant change is picked up
// on the screen's next recomposition.
val passwordPolicySource = remember(
authUI,
authUI.auth.tenantId,
provider.isProjectPasswordPolicyEnabled,
) {
if (provider.isProjectPasswordPolicyEnabled) {
authUI.passwordPolicyRepository.sourceFor(authUI.auth)
} else {
null
}
}
// Under a reauthentication request this is that request's phase, not the host's state.
val authState by authFlowScope.state
val isLoading = authState is AuthState.Loading ||
Expand Down Expand Up @@ -375,6 +390,13 @@ fun EmailAuthScreen(
)
} catch (e: Exception) {
onError(AuthException.from(e, stringProvider))
// JS SDK parity: a rejection may mean the policy changed, so the checklist
// re-reads it. No-op unless a policy is cached.
if (passwordPolicySource != null &&
e is AuthException.PasswordPolicyViolationException
) {
authUI.passwordPolicyRepository.refetchIfCached(context, authUI.auth)
}
}
}
},
Expand All @@ -398,14 +420,17 @@ fun EmailAuthScreen(
onGoToEmailLinkSignIn = { goToMode(EmailAuthMode.EmailLinkSignIn) },
)

if (content != null) {
content(state)
} else {
DefaultEmailAuthContent(
configuration = configuration,
state = state,
onCancel = onCancel
)
// Null when the flag is off, so SignUpUI fetches nothing.
CompositionLocalProvider(LocalPasswordPolicySource provides passwordPolicySource) {
if (content != null) {
content(state)
} else {
DefaultEmailAuthContent(
configuration = configuration,
state = state,
onCancel = onCancel
)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,15 @@ import com.firebase.ui.auth.configuration.password_policy.PasswordPolicy
import com.firebase.ui.auth.configuration.password_policy.PasswordPolicyRepository
import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
import com.google.firebase.auth.FirebaseAuth
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf

/** Supplies the project password policy to [SignUpUI]. Must return `null` rather than throw. */
internal fun interface PasswordPolicySource {
suspend fun getPolicy(context: Context): PasswordPolicy?

/** Emits on collection and again whenever [getPolicy] may return a newer policy. */
val revision: Flow<Int> get() = flowOf(0)
}

/**
Expand All @@ -52,8 +57,13 @@ internal fun interface PasswordPolicySource {
internal val LocalPasswordPolicySource = staticCompositionLocalOf<PasswordPolicySource?> { null }

/** A [PasswordPolicySource] for [auth]'s app and tenant, cached by this repository. */
internal fun PasswordPolicyRepository.sourceFor(auth: FirebaseAuth) =
PasswordPolicySource { context -> getPolicy(context, auth) }
internal fun PasswordPolicyRepository.sourceFor(auth: FirebaseAuth): PasswordPolicySource {
val repository = this
return object : PasswordPolicySource {
override suspend fun getPolicy(context: Context) = repository.getPolicy(context, auth)
override val revision: Flow<Int> = repository.revision(auth)
}
}

/** One checklist line. [symbols] is set only on the special character line. */
internal data class PasswordRequirement(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import com.firebase.ui.auth.ui.FirebaseAuthTestTags
import com.firebase.ui.auth.ui.components.AuthTextField
import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm
import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds
import kotlinx.coroutines.flow.collectLatest

@OptIn(ExperimentalMaterial3Api::class)
@Composable
Expand Down Expand Up @@ -106,9 +107,12 @@ fun SignUpUI(
}
val passwordPolicySource = LocalPasswordPolicySource.current
// Hints only (never part of isFormValid). No source, no policy or OFF: nothing is shown.
// Re-read on each revision, so a refetch after a server rejection updates the checklist.
val passwordPolicy by produceState<PasswordPolicy?>(null, passwordPolicySource, context) {
value = passwordPolicySource?.getPolicy(context)
?.takeIf { it.enforcementState == PasswordPolicy.EnforcementState.ENFORCE }
passwordPolicySource?.revision?.collectLatest {
value = passwordPolicySource.getPolicy(context)
?.takeIf { it.enforcementState == PasswordPolicy.EnforcementState.ENFORCE }
}
}
Comment on lines 111 to 116

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Jetpack Compose, produceState does not automatically reset its state value to the initialValue (which is null here) when its keys (passwordPolicySource or context) change. It only cancels and restarts the producer coroutine.

If passwordPolicySource becomes null (for example, if the password policy is disabled or the tenant changes to one without a policy), the producer coroutine will restart, but since passwordPolicySource is null, the safe-call passwordPolicySource?.revision?.collectLatest will do nothing, and value will retain the old PasswordPolicy from the previous non-null source. This results in a state leak where the old checklist requirements might still be displayed.

To fix this, we should explicitly set value = null when passwordPolicySource is null.

Suggested change
val passwordPolicy by produceState<PasswordPolicy?>(null, passwordPolicySource, context) {
value = passwordPolicySource?.getPolicy(context)
?.takeIf { it.enforcementState == PasswordPolicy.EnforcementState.ENFORCE }
passwordPolicySource?.revision?.collectLatest {
value = passwordPolicySource.getPolicy(context)
?.takeIf { it.enforcementState == PasswordPolicy.EnforcementState.ENFORCE }
}
}
val passwordPolicy by produceState<PasswordPolicy?>(null, passwordPolicySource, context) {
if (passwordPolicySource == null) {
value = null
} else {
passwordPolicySource.revision.collectLatest {
value = passwordPolicySource.getPolicy(context)
?.takeIf { it.enforcementState == PasswordPolicy.EnforcementState.ENFORCE }
}
}
}

val checklistRequirements = remember(passwordPolicy, password, stringProvider) {
passwordPolicy?.let { passwordRequirements(it, password, stringProvider) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.firebase.ui.auth.R
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.PasswordRule
import com.firebase.ui.auth.configuration.authUIConfiguration
import com.google.common.truth.Truth.assertThat
import com.google.firebase.auth.ActionCodeSettings
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.UserInfo
Expand Down Expand Up @@ -72,6 +74,70 @@ class AuthProviderTest {
provider.validate()
}

@Test
fun `email provider project password policy is off by default`() {
val provider = AuthProvider.Email(
emailLinkActionCodeSettings = null,
passwordValidationRules = listOf()
)

assertThat(provider.isProjectPasswordPolicyEnabled).isFalse()
assertThat(
AuthProvider.Email(
emailLinkActionCodeSettings = null,
passwordValidationRules = listOf(),
isProjectPasswordPolicyEnabled = true,
).isProjectPasswordPolicyEnabled
).isTrue()
}

// The exact JVM constructors callers compiled before isProjectPasswordPolicyEnabled link against.
@Test
fun `email provider keeps the pre-policy constructor for binary compatibility`() {
val rules = listOf(PasswordRule.RequireDigit)
val provider = AuthProvider.Email::class.java.getConstructor(
Boolean::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType,
ActionCodeSettings::class.java,
Boolean::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
List::class.java,
).newInstance(false, true, false, null, false, 9, rules)

assertThat(provider.isDisplayNameRequired).isFalse()
assertThat(provider.isEmailLinkSignInEnabled).isTrue()
assertThat(provider.isEmailLinkForceSameDeviceEnabled).isFalse()
assertThat(provider.isNewAccountsAllowed).isFalse()
assertThat(provider.minimumPasswordLength).isEqualTo(9)
assertThat(provider.passwordValidationRules).isEqualTo(rules)
assertThat(provider.isProjectPasswordPolicyEnabled).isFalse()
}

@Test
fun `email provider keeps the pre-policy default-arguments constructor`() {
// Bits 0, 1, 2, 4 and 5: every parameter that had a default was left out at the call site.
val defaultsMask = 0b110111
val provider = AuthProvider.Email::class.java.getConstructor(
Boolean::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType,
ActionCodeSettings::class.java,
Boolean::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
List::class.java,
Int::class.javaPrimitiveType,
Class.forName("kotlin.jvm.internal.DefaultConstructorMarker"),
).newInstance(false, false, false, null, false, 0, emptyList<PasswordRule>(), defaultsMask, null)

assertThat(provider.isDisplayNameRequired).isTrue()
assertThat(provider.isEmailLinkSignInEnabled).isFalse()
assertThat(provider.isEmailLinkForceSameDeviceEnabled).isTrue()
assertThat(provider.isNewAccountsAllowed).isTrue()
assertThat(provider.minimumPasswordLength).isEqualTo(6)
assertThat(provider.isProjectPasswordPolicyEnabled).isFalse()
}

@Test
fun `email provider with email link enabled and valid action code settings should succeed`() {
val actionCodeSettings = actionCodeSettings {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,21 @@ class PasswordPolicyRepositoryTest {
assertThat(http.requests).hasSize(1)
}

@Test
fun `refetch bumps only its own key's revision`() = runTest {
val repo = repository()
val tenantA = auth(tenantId = "tenant-a")
val tenantB = auth(tenantId = "tenant-b")
repo.getPolicy(context, tenantA)
repo.getPolicy(context, tenantB)

repo.refetchIfCached(context, tenantA)

assertThat(repo.revision(tenantA).value).isEqualTo(1)
assertThat(repo.revision(tenantB).value).isEqualTo(0)
assertThat(repo.revision(auth(appName = "other")).value).isEqualTo(0)
}

private companion object {
const val API_KEY = "AIzaFakeKeyForTests123"
const val CERT = "0123456789ABCDEF0123456789ABCDEF01234567"
Expand Down
Loading
Loading