Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
9 changes: 6 additions & 3 deletions auth/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,13 @@ dependencies {
implementation(Config.Libs.Androidx.fragment)
implementation(Config.Libs.Androidx.customTabs)
implementation(Config.Libs.Androidx.constraint)

// Google Authentication
implementation(Config.Libs.Androidx.credentials)
implementation("androidx.credentials:credentials-play-services-auth:1.3.0")
implementation(Config.Libs.Androidx.credentialsPlayServices)
implementation(Config.Libs.Misc.googleid)
implementation(Config.Libs.PlayServices.auth)
//api(Config.Libs.PlayServices.auth)

implementation(Config.Libs.Androidx.lifecycleExtensions)
implementation("androidx.core:core-ktx:1.13.1")
Expand All @@ -99,12 +104,10 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("androidx.navigation:navigation-compose:2.8.3")
implementation("com.google.zxing:core:3.5.3")
implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1")
annotationProcessor(Config.Libs.Androidx.lifecycleCompiler)

implementation(platform(Config.Libs.Firebase.bom))
api(Config.Libs.Firebase.auth)
api(Config.Libs.PlayServices.auth)

// Phone number validation
implementation(Config.Libs.Misc.libphonenumber)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package com.firebase.ui.auth.compose

import android.content.Context
import androidx.annotation.RestrictTo
import com.firebase.ui.auth.compose.configuration.auth_provider.signOutFromGoogle
import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration
import com.google.firebase.FirebaseApp
import com.google.firebase.auth.FirebaseAuth
Expand Down Expand Up @@ -341,13 +342,16 @@ class FirebaseAuthUI private constructor(
* @throws AuthException.UnknownException for other errors
* @since 10.0.0
*/
fun signOut(context: Context) {
suspend fun signOut(context: Context) {
try {
// Update state to loading
updateAuthState(AuthState.Loading("Signing out..."))

// Sign out from Firebase Auth
auth.signOut()
.also {
signOutFromGoogle(context)
}

// Update state to idle (user signed out)
updateAuthState(AuthState.Idle)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ import android.app.Activity
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.annotation.RestrictTo
import androidx.annotation.VisibleForTesting
import androidx.compose.ui.graphics.Color
import androidx.core.net.toUri
import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialRequest
import androidx.datastore.preferences.core.stringPreferencesKey
import com.facebook.AccessToken
import com.firebase.ui.auth.R
Expand All @@ -33,6 +33,11 @@ import com.firebase.ui.auth.util.Preconditions
import com.firebase.ui.auth.util.data.ContinueUrlBuilder
import com.firebase.ui.auth.util.data.PhoneNumberUtils
import com.firebase.ui.auth.util.data.ProviderAvailability
import com.google.android.gms.auth.api.identity.AuthorizationRequest
import com.google.android.gms.auth.api.identity.Identity
import com.google.android.gms.common.api.Scope
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.firebase.FirebaseException
import com.google.firebase.auth.ActionCodeSettings
import com.google.firebase.auth.AuthCredential
Expand Down Expand Up @@ -484,16 +489,19 @@ abstract class AuthProvider(open val providerId: String, open val name: String)
)
}
})
activity?.let {
options.setActivity(it)
}
forceResendingToken?.let {
options.setForceResendingToken(it)
}
multiFactorSession?.let {
options.setMultiFactorSession(it)
}
PhoneAuthProvider.verifyPhoneNumber(options.build())
.apply {
activity?.let {
setActivity(it)
}
forceResendingToken?.let {
setForceResendingToken(it)
}
multiFactorSession?.let {
setMultiFactorSession(it)
}
}
.build()
PhoneAuthProvider.verifyPhoneNumber(options)
}
}
}
Expand Down Expand Up @@ -582,6 +590,87 @@ abstract class AuthProvider(open val providerId: String, open val name: String)
)
}
}

/**
* Result container for Google Sign-In credential flow.
* @suppress
*/
internal data class GoogleSignInResult(
val credential: AuthCredential,
val displayName: String?,
val photoUrl: Uri?
)

/**
* An interface to wrap the Authorization API for requesting OAuth scopes.
* @suppress
*/
internal interface AuthorizationProvider {
suspend fun authorize(context: Context, scopes: List<Scope>)
}

/**
* The default implementation of [AuthorizationProvider].
* @suppress
*/
internal class DefaultAuthorizationProvider : AuthorizationProvider {
override suspend fun authorize(context: Context, scopes: List<Scope>) {
val authorizationRequest = AuthorizationRequest.builder()
.setRequestedScopes(scopes)
.build()

Identity.getAuthorizationClient(context)
.authorize(authorizationRequest)
.await()
}
}

/**
* An interface to wrap the Credential Manager flow for Google Sign-In.
* @suppress
*/
internal interface CredentialManagerProvider {
suspend fun getGoogleCredential(
context: Context,
serverClientId: String,
filterByAuthorizedAccounts: Boolean,
autoSelectEnabled: Boolean
): GoogleSignInResult
}

/**
* The default implementation of [CredentialManagerProvider].
* @suppress
*/
internal class DefaultCredentialManagerProvider : CredentialManagerProvider {
override suspend fun getGoogleCredential(
context: Context,
serverClientId: String,
filterByAuthorizedAccounts: Boolean,
autoSelectEnabled: Boolean
): GoogleSignInResult {
val credentialManager = CredentialManager.create(context)
val googleIdOption = GetGoogleIdOption.Builder()
.setServerClientId(serverClientId)
.setFilterByAuthorizedAccounts(filterByAuthorizedAccounts)
.setAutoSelectEnabled(autoSelectEnabled)
.build()

val request = GetCredentialRequest.Builder()
.addCredentialOption(googleIdOption)
.build()

val result = credentialManager.getCredential(context, request)
val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(result.credential.data)
val credential = GoogleAuthProvider.getCredential(googleIdTokenCredential.idToken, null)

return GoogleSignInResult(
credential = credential,
displayName = googleIdTokenCredential.displayName,
photoUrl = googleIdTokenCredential.profilePictureUri
)
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package com.firebase.ui.auth.compose.configuration.auth_provider

import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.credentials.ClearCredentialStateRequest
import androidx.credentials.CredentialManager
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.NoCredentialException
import com.firebase.ui.auth.compose.AuthException
import com.firebase.ui.auth.compose.AuthState
import com.firebase.ui.auth.compose.FirebaseAuthUI
import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration
import com.google.android.gms.common.api.Scope
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch

/**
* Creates a remembered callback for Google Sign-In that can be invoked from UI components.
*
* This Composable function returns a lambda that, when invoked, initiates the Google Sign-In
* flow using [signInWithGoogle]. The callback is stable across recompositions and automatically
* handles coroutine scoping and error state management.
*
* **Usage:**
* ```kotlin
* val onSignInWithGoogle = authUI.rememberGoogleSignInHandler(
* context = context,
* config = configuration,
* provider = googleProvider
* )
*
* Button(onClick = onSignInWithGoogle) {
* Text("Sign in with Google")
* }
* ```
*
* **Error Handling:**
* - Catches all exceptions and converts them to [AuthException]
* - Automatically updates [AuthState.Error] on failures
* - Logs errors for debugging purposes
*
* @param context Android context for Credential Manager
* @param config Authentication UI configuration
* @param provider Google provider configuration with server client ID and optional scopes
* @return A callback function that initiates Google Sign-In when invoked
*
* @see signInWithGoogle
* @see AuthProvider.Google
*/
@Composable
internal fun FirebaseAuthUI.rememberGoogleSignInHandler(
context: Context,
config: AuthUIConfiguration,
provider: AuthProvider.Google,
): () -> Unit {
val coroutineScope = rememberCoroutineScope()
return remember(this) {
{
coroutineScope.launch {
try {
signInWithGoogle(context, config, provider)
} catch (e: AuthException) {
// Log.d("rememberGoogleSignInHandler", "exception: $e")
Copy link
Contributor

Choose a reason for hiding this comment

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

We can remove this

updateAuthState(AuthState.Error(e))
} catch (e: Exception) {
val authException = AuthException.from(e)
updateAuthState(AuthState.Error(authException))
}
}
}
}
}

/**
* Signs in with Google using Credential Manager and optionally requests OAuth scopes.
*
* This function implements Google Sign-In using Android's Credential Manager API with
* comprehensive error handling.
*
* **Flow:**
* 1. If [AuthProvider.Google.scopes] are specified, requests OAuth authorization first
* 2. Attempts sign-in using Credential Manager
* 3. Creates Firebase credential and calls [signInAndLinkWithCredential]
*
* **Scopes Behavior:**
* - If [AuthProvider.Google.scopes] is not empty, requests OAuth authorization before sign-in
* - Basic profile, email, and ID token are always included automatically
* - Scopes are requested using the AuthorizationClient API
*
* **Error Handling:**
* - [GoogleIdTokenParsingException]: Library version mismatch
* - [NoCredentialException]: No Google accounts on device
* - [GetCredentialException]: User cancellation, configuration errors, or no credentials
* - Configuration errors trigger detailed developer guidance logs
*
* @param context Android context for Credential Manager
* @param config Authentication UI configuration
* @param provider Google provider configuration with optional scopes
* @param authorizationProvider Provider for OAuth scopes authorization (for testing)
* @param credentialManagerProvider Provider for Credential Manager flow (for testing)
*
* @throws AuthException.InvalidCredentialsException if token parsing fails
* @throws AuthException.AuthCancelledException if user cancels or no accounts found
* @throws AuthException if sign-in or linking fails
*
* @see AuthProvider.Google
* @see signInAndLinkWithCredential
*/
internal suspend fun FirebaseAuthUI.signInWithGoogle(
context: Context,
config: AuthUIConfiguration,
provider: AuthProvider.Google,
authorizationProvider: AuthProvider.Google.AuthorizationProvider = AuthProvider.Google.DefaultAuthorizationProvider(),
credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider = AuthProvider.Google.DefaultCredentialManagerProvider(),
) {
try {
updateAuthState(AuthState.Loading("Signing in with google..."))

// Request OAuth scopes if specified (before sign-in)
if (provider.scopes.isNotEmpty()) {
try {
val requestedScopes = provider.scopes.map { Scope(it) }
authorizationProvider.authorize(context, requestedScopes)
// Log.d("GoogleSignIn", "Successfully authorized scopes: ${provider.scopes}")
} catch (e: Exception) {
// Log.w("GoogleSignIn", "Failed to authorize scopes: ${provider.scopes}", e)
Copy link
Contributor

Choose a reason for hiding this comment

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

same here

// Continue with sign-in even if scope authorization fails
val authException = AuthException.from(e)
updateAuthState(AuthState.Error(authException))
}
}

val result = credentialManagerProvider.getGoogleCredential(
context = context,
serverClientId = provider.serverClientId!!,
filterByAuthorizedAccounts = true,
autoSelectEnabled = false
)

signInAndLinkWithCredential(
config = config,
credential = result.credential,
provider = provider,
displayName = result.displayName,
photoUrl = result.photoUrl,
)
} catch (e: CancellationException) {
val cancelledException = AuthException.AuthCancelledException(
message = "Sign in with google was cancelled",
cause = e
)
updateAuthState(AuthState.Error(cancelledException))
throw cancelledException

} catch (e: AuthException) {
updateAuthState(AuthState.Error(e))
throw e

} catch (e: Exception) {
val authException = AuthException.from(e)
updateAuthState(AuthState.Error(authException))
throw authException
}
}

/**
* Signs out from Google and clears credential state.
*
* This function clears the cached Google credentials, ensuring that the account picker
* will be shown on the next sign-in attempt instead of automatically signing in with
* the previously used account.
*
* **When to call:**
* - After user explicitly signs out
* - Before allowing user to select a different Google account
* - When switching between accounts
*
* **Note:** This does not sign out from Firebase Auth itself. Call [FirebaseAuth.signOut]
* separately if you need to sign out from Firebase.
*
* @param context Android context for Credential Manager
*/
suspend fun FirebaseAuthUI.signOutFromGoogle(context: Context) {
try {
val credentialManager = CredentialManager.create(context)
credentialManager.clearCredentialState(
ClearCredentialStateRequest()
)
// Log.d("GoogleSignIn", "Cleared Google credential state")
} catch (e: Exception) {
// Log.w("GoogleSignIn", "Failed to clear credential state", e)
Copy link
Contributor

Choose a reason for hiding this comment

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

Same

}
}
Loading