diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt index ef8bb0771..d44a9d6d9 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthProvider.kt @@ -14,8 +14,15 @@ package com.firebase.ui.auth.compose.configuration +import android.content.Context import android.graphics.Color +import android.util.Log import androidx.compose.ui.graphics.vector.ImageVector +import com.firebase.ui.auth.AuthUI +import com.firebase.ui.auth.R +import com.firebase.ui.auth.util.Preconditions +import com.firebase.ui.auth.util.data.PhoneNumberUtils +import com.firebase.ui.auth.util.data.ProviderAvailability import com.google.firebase.auth.ActionCodeSettings import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.FacebookAuthProvider @@ -78,6 +85,14 @@ abstract class AuthProvider(open val providerId: String) { */ val isEmailLinkSignInEnabled: Boolean = false, + /** + * Forces email link sign-in to complete on the same device that initiated it. + * + * When enabled, prevents email links from being opened on different devices, + * which is required for security when upgrading anonymous users. Defaults to true. + */ + val isEmailLinkForceSameDeviceEnabled: Boolean = true, + /** * Settings for email link actions. */ @@ -100,11 +115,10 @@ abstract class AuthProvider(open val providerId: String) { ) : AuthProvider(providerId = Provider.EMAIL.id) { fun validate() { if (isEmailLinkSignInEnabled) { - val actionCodeSettings = actionCodeSettings - ?: requireNotNull(actionCodeSettings) { - "ActionCodeSettings cannot be null when using " + - "email link sign in." - } + val actionCodeSettings = requireNotNull(actionCodeSettings) { + "ActionCodeSettings cannot be null when using " + + "email link sign in." + } check(actionCodeSettings.canHandleCodeInApp()) { "You must set canHandleCodeInApp in your " + @@ -118,6 +132,11 @@ abstract class AuthProvider(open val providerId: String) { * Phone number authentication provider configuration. */ class Phone( + /** + * The phone number in international format. + */ + val defaultNumber: String?, + /** * The default country code to pre-select. */ @@ -147,7 +166,31 @@ abstract class AuthProvider(open val providerId: String) { * Enables automatic retrieval of the SMS code. Defaults to true. */ val isAutoRetrievalEnabled: Boolean = true - ) : AuthProvider(providerId = Provider.PHONE.id) + ) : AuthProvider(providerId = Provider.PHONE.id) { + fun validate() { + defaultNumber?.let { + check(PhoneNumberUtils.isValid(it)) { + "Invalid phone number: $it" + } + } + + defaultCountryCode?.let { + check(PhoneNumberUtils.isValidIso(it)) { + "Invalid country iso: $it" + } + } + + allowedCountries?.forEach { code -> + check( + PhoneNumberUtils.isValidIso(code) || + PhoneNumberUtils.isValid(code) + ) { + "Invalid input: You must provide a valid country iso (alpha-2) " + + "or code (e-164). e.g. 'us' or '+1'. Invalid code: $code" + } + } + } + } /** * Google Sign-In provider configuration. @@ -186,12 +229,40 @@ abstract class AuthProvider(open val providerId: String) { providerId = Provider.GOOGLE.id, scopes = scopes, customParameters = customParameters - ) + ) { + fun validate(context: Context) { + if (serverClientId == null) { + Preconditions.checkConfigured( + context, + "Check your google-services plugin configuration, the" + + " default_web_client_id string wasn't populated.", + R.string.default_web_client_id + ) + } else { + require(serverClientId.isNotBlank()) { + "Server client ID cannot be blank." + } + } + + val hasEmailScope = scopes.contains("email") + if (!hasEmailScope) { + Log.w( + "AuthProvider.Google", + "The scopes do not include 'email'. In most cases this is a mistake!" + ) + } + } + } /** * Facebook Login provider configuration. */ class Facebook( + /** + * The Facebook application ID. + */ + val applicationId: String? = null, + /** * The list of scopes (permissions) to request. Defaults to email and public_profile. */ @@ -210,7 +281,30 @@ abstract class AuthProvider(open val providerId: String) { providerId = Provider.FACEBOOK.id, scopes = scopes, customParameters = customParameters - ) + ) { + fun validate(context: Context) { + if (!ProviderAvailability.IS_FACEBOOK_AVAILABLE) { + throw RuntimeException( + "Facebook provider cannot be configured " + + "without dependency. Did you forget to add " + + "'com.facebook.android:facebook-login:VERSION' dependency?" + ) + } + + if (applicationId == null) { + Preconditions.checkConfigured( + context, + "Facebook provider unconfigured. Make sure to " + + "add a `facebook_application_id` string or provide applicationId parameter.", + R.string.facebook_application_id + ) + } else { + require(applicationId.isNotBlank()) { + "Facebook application ID cannot be blank" + } + } + } + } /** * Twitter/X authentication provider configuration. @@ -314,7 +408,16 @@ abstract class AuthProvider(open val providerId: String) { /** * Anonymous authentication provider. It has no configurable properties. */ - object Anonymous : AuthProvider(providerId = Provider.ANONYMOUS.id) + object Anonymous : AuthProvider(providerId = Provider.ANONYMOUS.id) { + fun validate(providers: List) { + if (providers.size == 1 && providers.first() is Anonymous) { + throw IllegalStateException( + "Sign in as guest cannot be the only sign in method. " + + "In this case, sign the user in anonymously your self; no UI is needed." + ) + } + } + } /** * A generic OAuth provider for any unsupported provider. @@ -353,5 +456,15 @@ abstract class AuthProvider(open val providerId: String) { providerId = providerId, scopes = scopes, customParameters = customParameters - ) + ) { + fun validate() { + require(providerId.isNotBlank()) { + "Provider ID cannot be null or empty" + } + + require(buttonLabel.isNotBlank()) { + "Button label cannot be null or empty" + } + } + } } \ No newline at end of file diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt index ef70c5f3b..98be20ac0 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt @@ -68,12 +68,7 @@ class AuthUIConfigurationBuilder { } // Cannot have only anonymous provider - if (providers.size == 1 && providers.first() is AuthProvider.Anonymous) { - throw IllegalStateException( - "Sign in as guest cannot be the only sign in method. " + - "In this case, sign the user in anonymously your self; no UI is needed." - ) - } + AuthProvider.Anonymous.validate(providers) // Check for duplicate providers val providerIds = providers.map { it.providerId } @@ -89,7 +84,21 @@ class AuthUIConfigurationBuilder { // Provider specific validations providers.forEach { provider -> when (provider) { - is AuthProvider.Email -> provider.validate() + is AuthProvider.Email -> { + provider.validate() + + if (isAnonymousUpgradeEnabled && provider.isEmailLinkSignInEnabled) { + check(provider.isEmailLinkForceSameDeviceEnabled) { + "You must force the same device flow when using email link sign in " + + "with anonymous user upgrade" + } + } + } + + is AuthProvider.Phone -> provider.validate() + is AuthProvider.Google -> provider.validate(context) + is AuthProvider.Facebook -> provider.validate(context) + is AuthProvider.GenericOAuth -> provider.validate() else -> null } } diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt new file mode 100644 index 000000000..27685f859 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthProviderTest.kt @@ -0,0 +1,399 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.configuration + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.google.firebase.auth.actionCodeSettings +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [AuthProvider] covering provider validation rules, configuration constraints, + * and error handling for all supported authentication providers. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class AuthProviderTest { + + private lateinit var applicationContext: Context + + @Before + fun setUp() { + applicationContext = ApplicationProvider.getApplicationContext() + } + + // ============================================================================================= + // Email Provider Tests + // ============================================================================================= + + @Test + fun `email provider with valid configuration should succeed`() { + val provider = AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = listOf() + ) + + provider.validate() + } + + @Test + fun `email provider with email link enabled and valid action code settings should succeed`() { + val actionCodeSettings = actionCodeSettings { + url = "https://example.com/verify" + handleCodeInApp = true + } + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + actionCodeSettings = actionCodeSettings, + passwordValidationRules = listOf() + ) + + provider.validate() + } + + @Test + fun `email provider with email link enabled but null action code settings should throw`() { + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + actionCodeSettings = null, + passwordValidationRules = listOf() + ) + + try { + provider.validate() + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalArgumentException::class.java) + assertThat(e.message).isEqualTo( + "ActionCodeSettings cannot be null when using " + + "email link sign in." + ) + } + } + + @Test + fun `email provider with email link enabled but canHandleCodeInApp false should throw`() { + val actionCodeSettings = actionCodeSettings { + url = "https://example.com/verify" + handleCodeInApp = false + } + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + actionCodeSettings = actionCodeSettings, + passwordValidationRules = listOf() + ) + + try { + provider.validate() + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo( + "You must set canHandleCodeInApp in your " + + "ActionCodeSettings to true for Email-Link Sign-in." + ) + } + } + + // ============================================================================================= + // Phone Provider Tests + // ============================================================================================= + + @Test + fun `phone provider with valid configuration should succeed`() { + val provider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + + provider.validate() + } + + @Test + fun `phone provider with valid default number should succeed`() { + val provider = AuthProvider.Phone( + defaultNumber = "+1234567890", + defaultCountryCode = null, + allowedCountries = null + ) + + provider.validate() + } + + @Test + fun `phone provider with invalid default number should throw`() { + val provider = AuthProvider.Phone( + defaultNumber = "invalid_number", + defaultCountryCode = null, + allowedCountries = null + ) + + try { + provider.validate() + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo("Invalid phone number: invalid_number") + } + } + + @Test + fun `phone provider with valid default country code should succeed`() { + val provider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = "US", + allowedCountries = null + ) + + provider.validate() + } + + @Test + fun `phone provider with invalid default country code should throw`() { + val provider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = "invalid", + allowedCountries = null + ) + + try { + provider.validate() + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo("Invalid country iso: invalid") + } + } + + @Test + fun `phone provider with valid allowed countries should succeed`() { + val provider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = listOf("US", "CA", "+1") + ) + + provider.validate() + } + + @Test + fun `phone provider with invalid country in allowed list should throw`() { + val provider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = listOf("US", "invalid_country") + ) + + try { + provider.validate() + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo( + "Invalid input: You must provide a valid country iso (alpha-2) " + + "or code (e-164). e.g. 'us' or '+1'. Invalid code: invalid_country" + ) + } + } + + @Test + fun `phone provider with valid default number, country code and compatible allowed countries should succeed`() { + val provider = AuthProvider.Phone( + defaultNumber = "+1234567890", + defaultCountryCode = "US", + allowedCountries = listOf("US", "CA") + ) + + provider.validate() + } + + // ============================================================================================= + // Google Provider Tests + // ============================================================================================= + + @Test + fun `google provider with valid configuration should succeed`() { + val provider = AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "test_client_id" + ) + + provider.validate(applicationContext) + } + + @Test + fun `google provider with empty serverClientId string throws`() { + val provider = AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "" + ) + + try { + provider.validate(applicationContext) + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalArgumentException::class.java) + assertThat(e.message).isEqualTo("Server client ID cannot be blank.") + } + } + + @Test + fun `google provider validates default_web_client_id when serverClientId is null`() { + val provider = AuthProvider.Google( + scopes = listOf("email"), + serverClientId = null + ) + + try { + provider.validate(applicationContext) + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo( + "Check your google-services plugin " + + "configuration, the default_web_client_id string wasn't populated." + ) + } + } + + // ============================================================================================= + // Facebook Provider Tests + // ============================================================================================= + + @Test + fun `facebook provider with valid configuration should succeed`() { + val provider = AuthProvider.Facebook(applicationId = "application_id") + + provider.validate(applicationContext) + } + + @Test + fun `facebook provider with empty application id throws`() { + val provider = AuthProvider.Facebook(applicationId = "") + + try { + provider.validate(applicationContext) + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalArgumentException::class.java) + assertThat(e.message).isEqualTo("Facebook application ID cannot be blank") + } + } + + @Test + fun `facebook provider validates facebook_application_id when applicationId is null`() { + val provider = AuthProvider.Facebook() + + try { + provider.validate(applicationContext) + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo( + "Facebook provider unconfigured. Make sure to " + + "add a `facebook_application_id` string or provide applicationId parameter." + ) + } + } + + // ============================================================================================= + // Anonymous Provider Tests + // ============================================================================================= + + @Test + fun `anonymous provider as only provider should throw`() { + val providers = listOf(AuthProvider.Anonymous) + + try { + AuthProvider.Anonymous.validate(providers) + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo( + "Sign in as guest cannot be the only sign in method. " + + "In this case, sign the user in anonymously your self; no UI is needed." + ) + } + } + + @Test + fun `anonymous provider with other providers should succeed`() { + val providers = listOf( + AuthProvider.Anonymous, + AuthProvider.Email( + actionCodeSettings = null, + passwordValidationRules = listOf() + ) + ) + + AuthProvider.Anonymous.validate(providers) + } + + // ============================================================================================= + // GenericOAuth Provider Tests + // ============================================================================================= + + @Test + fun `generic oauth provider with valid configuration should succeed`() { + val provider = AuthProvider.GenericOAuth( + providerId = "custom.provider", + scopes = listOf("read"), + customParameters = mapOf(), + buttonLabel = "Sign in with Custom", + buttonIcon = null, + buttonColor = null + ) + + provider.validate() + } + + @Test + fun `generic oauth provider with blank provider id should throw`() { + val provider = AuthProvider.GenericOAuth( + providerId = "", + scopes = listOf("read"), + customParameters = mapOf(), + buttonLabel = "Sign in with Custom", + buttonIcon = null, + buttonColor = null + ) + + try { + provider.validate() + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalArgumentException::class.java) + assertThat(e.message).isEqualTo("Provider ID cannot be null or empty") + } + } + + @Test + fun `generic oauth provider with blank button label should throw`() { + val provider = AuthProvider.GenericOAuth( + providerId = "custom.provider", + scopes = listOf("read"), + customParameters = mapOf(), + buttonLabel = "", + buttonIcon = null, + buttonColor = null + ) + + try { + provider.validate() + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalArgumentException::class.java) + assertThat(e.message).isEqualTo("Button label cannot be null or empty") + } + } +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt index 95164f638..96a13795e 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt @@ -64,7 +64,7 @@ class AuthUIConfigurationTest { provider( AuthProvider.Google( scopes = listOf(), - serverClientId = "" + serverClientId = "test_client_id" ) ) } @@ -103,7 +103,7 @@ class AuthUIConfigurationTest { provider( AuthProvider.Google( scopes = listOf(), - serverClientId = "" + serverClientId = "test_client_id" ) ) provider( @@ -152,7 +152,7 @@ class AuthUIConfigurationTest { provider( AuthProvider.Google( scopes = listOf(), - serverClientId = "" + serverClientId = "test_client_id" ) ) } @@ -190,7 +190,7 @@ class AuthUIConfigurationTest { provider( AuthProvider.Google( scopes = listOf(), - serverClientId = "" + serverClientId = "test_client_id" ) ) } @@ -215,7 +215,7 @@ class AuthUIConfigurationTest { provider( AuthProvider.Google( scopes = listOf(), - serverClientId = "" + serverClientId = "test_client_id" ) ) } @@ -235,8 +235,8 @@ class AuthUIConfigurationTest { providers { provider( AuthProvider.Google( - scopes = listOf(), serverClientId - = "" + scopes = listOf(), + serverClientId = "test_client_id" ) ) } @@ -261,7 +261,7 @@ class AuthUIConfigurationTest { authUIConfiguration { context = applicationContext providers { - provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) + provider(AuthProvider.Google(scopes = listOf(), serverClientId = "test_client_id")) } } } catch (e: Exception) { @@ -285,14 +285,14 @@ class AuthUIConfigurationTest { val config = authUIConfiguration { context = applicationContext providers { - provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) - provider(AuthProvider.Facebook()) + provider(AuthProvider.Google(scopes = listOf(), serverClientId = "test_client_id")) + provider(AuthProvider.Facebook(applicationId = "test_app_id")) provider(AuthProvider.Twitter(customParameters = mapOf())) provider(AuthProvider.Github(customParameters = mapOf())) provider(AuthProvider.Microsoft(customParameters = mapOf(), tenant = null)) provider(AuthProvider.Yahoo(customParameters = mapOf())) provider(AuthProvider.Apple(customParameters = mapOf(), locale = null)) - provider(AuthProvider.Phone(defaultCountryCode = null, allowedCountries = null)) + provider(AuthProvider.Phone(defaultNumber = null, defaultCountryCode = null, allowedCountries = null)) provider( AuthProvider.Email( actionCodeSettings = null, @@ -304,7 +304,7 @@ class AuthUIConfigurationTest { assertThat(config.providers).hasSize(9) } - @Test(expected = IllegalArgumentException::class) + @Test fun `validation throws for unsupported provider`() { val mockProvider = AuthProvider.GenericOAuth( providerId = "unsupported.provider", @@ -315,73 +315,39 @@ class AuthUIConfigurationTest { buttonColor = null ) - authUIConfiguration { - context = applicationContext - providers { - provider(mockProvider) - } - } - } - - @Test(expected = IllegalStateException::class) - fun `validate throws when only anonymous provider is configured`() { - authUIConfiguration { - context = applicationContext - providers { - provider(AuthProvider.Anonymous) + try { + authUIConfiguration { + context = applicationContext + providers { + provider(mockProvider) + } } + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalArgumentException::class.java) + assertThat(e.message).isEqualTo("Unknown providers: unsupported.provider") } } - @Test(expected = IllegalArgumentException::class) + @Test fun `validate throws for duplicate providers`() { - authUIConfiguration { - context = applicationContext - providers { - provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) - provider( - AuthProvider.Google( - scopes = listOf("email"), - serverClientId = "different" - ) - ) - } - } - } - - @Test(expected = IllegalArgumentException::class) - fun `validate throws for enableEmailLinkSignIn true when actionCodeSettings is null`() { - authUIConfiguration { - context = applicationContext - providers { - provider( - AuthProvider.Email( - isEmailLinkSignInEnabled = true, - actionCodeSettings = null, - passwordValidationRules = listOf() - ) - ) - } - } - } - - @Test(expected = IllegalStateException::class) - fun `validate throws for enableEmailLinkSignIn true when actionCodeSettings canHandleCodeInApp false`() { - val customActionCodeSettings = actionCodeSettings { - url = "https://example.com" - handleCodeInApp = false - } - authUIConfiguration { - context = applicationContext - providers { - provider( - AuthProvider.Email( - isEmailLinkSignInEnabled = true, - actionCodeSettings = customActionCodeSettings, - passwordValidationRules = listOf() + try { + authUIConfiguration { + context = applicationContext + providers { + provider(AuthProvider.Google(scopes = listOf(), serverClientId = "")) + provider( + AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "different" + ) ) - ) + } } + } catch (e: Exception) { + assertThat(e).isInstanceOf(IllegalArgumentException::class.java) + assertThat(e.message).isEqualTo( + "Each provider can only be set once. Duplicates: google.com" + ) } } @@ -397,7 +363,7 @@ class AuthUIConfigurationTest { provider( AuthProvider.Google( scopes = listOf(), - serverClientId = "" + serverClientId = "test_client_id" ) ) }