Comunidade

Modern Auth: Integrando Firebase com o Novo Gerenciador de Credenciais do Android

O artigo explora como o Android's Credential Manager API simplifica o processo de autenticação, integrando-se com o Firebase Authentication para oferecer uma experiência mais fluida e unificada. A abordagem envolve solicitar um único tipo de credencial que pode ser usada tanto para contas Google quanto outras fontes, mantendo a lógica do aplicativo separada da implementação técnica.

Compartilhar
Medium

Modern Auth: Integrando Firebase com o Novo Gerenciador de Credenciais do Android

Pressione Enter ou clique para ver a imagem em tamanho completo

O Google Sign-In no Android costumava significar a importação da antiga GoogleSignInClient, lidando com contratos de resultado de atividade e esperando que a versão do play-services no dispositivo correspondesse àquela esperada pelo aplicativo. O API Gerenciador de Credenciais do Android substitui isso por uma superfície unificada para senhas, passkeys e entrada federada — e combiná-lo com o Firebase Authentication acabou sendo mais um problema sobre "qual camada é responsável por quê" do que um desafio técnico difícil.

Aqui está como a entrada realmente funciona no aplicativo, do início ao fim.

Por que o Gerenciador de Credenciais?

O Gerenciador de Credenciais fica entre o seu aplicativo e todas as fontes de credencial no dispositivo — senhas salvas, passkeys e a identidade do Google One Tap — por meio de uma única API: CredentialManager.getCredential(). Em vez de o aplicativo saber especificamente sobre o Google Sign-In, ele solicita uma credencial e recebe aquilo que o usuário tem e escolhe usar. Essa é toda a proposta: um formato único para solicitação, vários tipos de credenciais e nenhum fluxo específico do SDK para cuidar separadamente.

Para este aplicativo, apenas a entrada federada do Google está conectada até agora, mas a mesma GetCredentialRequest poderia adicionar uma GetPasswordOption ou uma opção de passkey mais tarde sem tocar na lógica de entrada circundante.

Onde as Peças Estão Localizadas

Este é um bom exemplo de fronteiras entre módulos realmente importarem. A chamada do Gerenciador de Credenciais tem que acontecer a partir de um contexto Activity — ela precisa iniciar uma interface do sistema —, então vive em MainActivity.kt, dentro de :app. O Firebase em si e o estado da sessão resultante vivem um nível abaixo em :auth e :session. O AuthViewModel de :auth não sabe nada sobre Gerenciador de Credenciais; ele só sabe como pegar um token ID e passá-lo para o Firebase.

Essa divisão mantém a preocupação "como obtemos uma credencial do SO" (um detalhe da plataforma Android) separada da preocupação "como transformamos uma credencial em um usuário Firebase conectado" (um detalhe de lógica do aplicativo).

O Fluxo de Entrada

MainActivity cria uma única instância de CredentialManager e constrói um GetGoogleIdOption — isso é o que informa ao Gerenciador de Credenciais para realmente se comunicar com o Google como a fonte de identidade:

val credentialManager = CredentialManager.create(context)

Quando a pessoa toca em “Entrar com Google”, este pedido é feito:

val googleIdOption: GetGoogleIdOption =
GetGoogleIdOption.Builder()
.setFilterByAuthorizedAccounts(false)
.setServerClientId(getString(R.string.default_web_client_id))
.build()

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

setFilterByAuthorizedAccounts(false) significa que o Credential Manager mostrará todas as contas Google no dispositivo, não apenas aquelas que já fizeram login neste aplicativo — a chamada correta para um botão de login que também precisa atender aos usuários de primeira viagem. setServerClientId é o ID do cliente OAuth do projeto Firebase, o que permite que o token ID retornado pelo Credential Manager seja verificado como legítimo por Firebase posteriormente.

A chamada real é uma função suspensa, então ela é lançada de um escopo de coroutine vinculado ao composable:

try {
val result =
credentialManager.getCredential(
context = this@MainActivity,
request = request,
)
val credential = result.credential
val googleIdTokenCredential =
GoogleIdTokenCredential.createFrom(credential.data)
val idToken = googleIdTokenCredential.idToken

authViewModel.signInWithGoogle(idToken) {}
} catch (e: GetCredentialException) {
val msg = getString(R.string.google_sign_in_failed, e.message)
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}

Dois pontos importantes aqui. Primeiro, GetCredentialException é um único tipo de exceção que cobre tudo que pode dar errado — o usuário cancela, nenhuma credencial está disponível, a rede falha — o que é uma superfície muito menor para lidar do que ramificações antigas baseadas em resultados de atividade. Segundo, assim que o Credential Manager retorna uma credencial, MainActivity sai imediatamente da questão de identidade: o token ID bruto é entregue diretamente ao ViewModel e nada sobre como esse token foi obtido se espalha mais para baixo.

Passando para Firebase

AuthViewModel — abaixo em :auth, sem conhecimento do Credential Manager — toma a partir daí:

fun signInWithGoogle(
idToken: String,
onSuccess: () -> Unit,
) {
viewModelScope.launch {
_loading.value = true
_error.value = null
try {
val credential = GoogleAuthProvider.getCredential(idToken, null)
auth.signInWithCredential(credential).await()
sessionManager.updateSession()
onSuccess()
} catch (e: Exception) {
_error.value = e.message ?: "Google sign in failed"
} finally {
_loading.value = false
}
}
}

GoogleAuthProvider.getCredential(idToken, null) converte o token de nível de plataforma em uma credencial no formato do Firebase, e auth.signInWithCredential() é a chamada que efetivamente cria ou faz login no usuário do Firebase. A partir daqui, o fluxo retorna ao mesmo caminho que a autenticação por email/senha — sessionManager.updateSession() é exatamente a mesma chamada feita por signInWithEmail e signUpWithEmail, então o código downstream nunca precisa saber ou se importar com qual método de identidade o usuário escolheu.

A instância FirebaseAuth em si é injetada em vez de ser construída inline — ela é fornecida como um vinculo singleton a partir do módulo Hilt de :session, então há exatamente uma única instância de FirebaseAuth compartilhada entre login por email, Google e restauração da sessão:

@Provides
@Singleton
fun provideFirebaseAuth(): FirebaseAuth = FirebaseAuth.getInstance()

O Desafio: Prevenir um Flash na Inicialização

O Firebase persiste o usuário autenticado localmente, então em uma inicialização fria auth.currentUser geralmente já está preenchido — mas não é preenchido imediatamente. Há um breve período enquanto o Firebase restaura esse estado do disco. Se a UI simplesmente verificar user == null e navegar no momento da criação do ViewModel, um usuário retornando é empurrado para a tela de login por um ou dois frames antes que o Firebase se recupere e a verdadeira navegação comece. Esse flash da tela errada é o tipo de coisa que faz com que um aplicativo pareça quebrado mesmo que nada tenha realmente falhado.

A solução ingênua — simplesmente adiar a navegação por um número fixo de milissegundos — é frágil. Demasiado curto e você não resolveu nada; demais longo e cada inicialização parece lenta, incluindo para usuários que estão deslogados e devem ver a tela de login imediatamente.

A Solução: Um isCheckingSession StateFlow

Em vez de adivinhar um atraso, AuthViewModel expõe uma terceira parte do estado que fica ao lado de user e loading: uma bandeira que é true até a verificação da sessão realmente terminar, por quanto tempo for necessário.

private val _isCheckingSession = MutableStateFlow(true)
val isCheckingSession: StateFlow<Boolean> = _isCheckingSession

init {
checkSession()
}

private fun checkSession() {
viewModelScope.launch {
// Deixe o Firebase um momento para restaurar a sessão se necessário
if (auth.currentUser != null) {
sessionManager.updateSession()
}
_isCheckingSession.value = false
}
}

Ele começa true por padrão, e checkSession() é executado automaticamente a partir de init no momento em que o ViewModel é criado — antes que qualquer tela tenha tido a chance de renderizar uma decisão com base no usuário. Se o Firebase já tem um usuário atual quando esta rotina corrotine começa, a sessão é recarregada via sessionManager.updateSession(); de qualquer forma, _isCheckingSession só muda para false uma vez que a verificação é realmente concluída, não em um temporizador.

MainActivity usa esta bandeira para bloquear toda a decisão de navegação atrás de um estado de carregamento em vez de competir com ela:

val isCheckingSession by authViewModel.isCheckingSession.collectAsStateWithLifecycle()
...
if (isCheckingSession) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else {
LaunchedEffect(user) {
if (user != null) {
navController.navigate("main") { popUpTo(0) { inclusive = true } }
} else {
navController.navigate("login") { popUpTo(0) { inclusive = true } }
}
}
NavHost(/* ... */)
}

Enquanto isCheckingSession é true, o aplicativo exibe um único indicador de carregamento deliberado — nada mais. O NavHost (e o LaunchedEffect que decide para onde enviar o usuário) nem mesmo é composto ainda, então não há tela de login sentada embaixo esperando para piscar. Apenas uma vez que a verificação completa, a UI se compromete com um destino e por isso user reflete o estado real restaurado do Firebase ao invés de um temporariamente vazio.

Reagindo ao Resultado

Uma vez que isCheckingSession é limpo, a navegação em si permanece simples, porque está reagindo ao estado em vez de uma única chamada de retorno:

kotlin

val user by authViewModel.user.collectAsStateWithLifecycle()

Seja qual for o método de login do usuário (e-mail, Google) ou se ele foi restaurado silenciosamente de uma sessão persistida ao iniciar o aplicativo, a lógica de navegação subsequente é a mesma. O AuthViewModel.user é apenas um sessionManager.user exposto como um StateFlow<FirebaseUser?> — uma única fonte de verdade que o Gerenciador de Credenciais, autenticação por e-mail/senha e restauração da sessão alimentam do mesmo modo, e a porta isCheckingSession garante que nada leia antes que seja confiável.

O Que Vem Próximo

O Gerenciador de Credenciais e o Firebase fazem com que o usuário entre, mas uma sessão assinada não é a mesma coisa que uma segura. O próximo post aborda o que acontece após o login — como o DataStore criptografado em :session rastreia atividades e como um tempo de inatividade de 2 minutos realmente bloqueia o aplicativo ao invés de apenas mostrar a tela de login novamente.

Fonte original

Conteúdo traduzido e adaptado pela redação do Notícias Mobile. Confira também a matéria na fonte original.

Leia a matéria completa