AppAuth no Android: o login abre no navegador e não volta para o app
Descubra por que o navegador não devolve o login ao app com AppAuth e alinhe client id, esquema e redirect entre o build e o console do provedor.
Terça, 15h. O sample do AppAuth compila, o botão "Entrar" abre o navegador, a pessoa digita a senha, o provedor aceita e... a aba fica parada, ou mostra "redirect_uri inválido", ou volta para uma tela em branco. A equipe passa a tarde depurando a activity de callback. O problema quase nunca está nela.
A resposta curta: o AppAuth só executa o fluxo; quem decide se o navegador consegue voltar é o cadastro. Três coisas precisam ser idênticas, caractere por caractere: o client id que o app envia, o redirect URI que ele pede e o redirect registrado no provedor. E o esquema desse redirect precisa estar declarado no manifesto do pacote que está instalado no aparelho, não no do sample.
Por que o navegador não acha o caminho de volta
O fluxo nativo funciona assim: o app monta uma requisição de autorização com client id, redirect URI, escopo e um desafio PKCE (o AppAuth gera o PKCE sozinho). Abre um Custom Tab. O provedor autentica a pessoa e responde com um redirecionamento para o redirect URI, levando o code e o state.
A partir daí são dois porteiros. O primeiro é o provedor: ele compara o redirect pedido com a lista cadastrada para aquele client id. Se não bate, ele para ali e mostra erro, ou nem mostra, dependendo do provedor. O segundo é o Android: ao ver uma URL como br.com.lojaexemplo:/oauth2redirect, ele procura uma activity com intent filter para o esquema br.com.lojaexemplo. No AppAuth, essa activity é a RedirectUriReceiverActivity, e o esquema dela vem de um placeholder de manifesto chamado appAuthRedirectScheme.
O sample traz o esquema do pacote dele. Você troca o client id, esquece o placeholder, e o Android não tem ninguém para entregar a URL. Ou acerta o placeholder do release e instala o build de debug, que tem outro applicationId. A biblioteca fez a parte dela em todos esses casos.
Como alinhar o app e o provedor, passo a passo
O exemplo que atravessa este guia: o app da Loja Exemplo, pacote br.com.lojaexemplo, com um build de debug br.com.lojaexemplo.debug, autenticando num Keycloak em https://auth.lojaexemplo.com.br/realms/loja. O raciocínio vale para qualquer provedor OpenID Connect.
- Cadastre um client por variante que você distribui. No Keycloak, crie
loja-androideloja-android-debug, ambos públicos (sem client secret) e com o fluxo Authorization Code habilitado. - Registre o redirect exato em cada client.
br.com.lojaexemplo:/oauth2redirectno de release ebr.com.lojaexemplo.debug:/oauth2redirectno de debug. Sem curinga. - Declare o esquema por build type no Gradle. O placeholder precisa acompanhar o pacote.
- Coloque client id e redirect em recursos por source set, para o debug nunca carregar o id do release e vice-versa.
- Trate três resultados: sucesso, cancelamento pela pessoa e erro. Cancelamento não é erro e não deve reabrir o navegador sozinho.
- Teste o app instalado, aberto pelo ícone, sem o depurador anexado.
O Gradle com o esquema certo para cada variante
// app/build.gradle.kts
android {
defaultConfig {
applicationId = "br.com.lojaexemplo"
manifestPlaceholders["appAuthRedirectScheme"] = "br.com.lojaexemplo"
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
manifestPlaceholders["appAuthRedirectScheme"] = "br.com.lojaexemplo.debug"
}
}
}
dependencies {
implementation("net.openid:appauth:0.11.1")
}
Confira a versão mais recente da biblioteca no repositório oficial antes de fixar a sua. Use o esquema em minúsculas: o casamento de esquema nos intent filters do Android diferencia maiúsculas, e um "LojaExemplo" no console contra "lojaexemplo" no manifesto é uma tarde perdida.
Os valores por variante, fora do código
<!-- app/src/main/res/values/auth.xml (release) -->
<resources>
<string name="auth_issuer">https://auth.lojaexemplo.com.br/realms/loja</string>
<string name="auth_client_id">loja-android</string>
<string name="auth_redirect_uri">br.com.lojaexemplo:/oauth2redirect</string>
</resources>
<!-- app/src/debug/res/values/auth.xml (sobrescreve no debug) -->
<resources>
<string name="auth_client_id">loja-android-debug</string>
<string name="auth_redirect_uri">br.com.lojaexemplo.debug:/oauth2redirect</string>
</resources>
A activity que inicia o login e trata cancelamento
class LoginActivity : AppCompatActivity() {
private lateinit var authService: AuthorizationService
private val authLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val data = result.data
val response = data?.let { AuthorizationResponse.fromIntent(it) }
val error = data?.let { AuthorizationException.fromIntent(it) }
val cancelou = data == null ||
(error?.type == AuthorizationException.TYPE_GENERAL_ERROR &&
error?.code == AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW.code)
when {
response != null -> trocarCodigo(response)
cancelou -> mostrarBotaoEntrar()
else -> mostrarErro("Falha no login: ${error?.errorDescription ?: error?.error}")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
authService = AuthorizationService(this)
findViewById<Button>(R.id.botao_entrar).setOnClickListener { iniciarLogin() }
}
private fun iniciarLogin() {
AuthorizationServiceConfiguration.fetchFromIssuer(
Uri.parse(getString(R.string.auth_issuer))
) { config, ex ->
if (config == null) {
mostrarErro("Provedor indisponível: ${ex?.message}")
return@fetchFromIssuer
}
val request = AuthorizationRequest.Builder(
config,
getString(R.string.auth_client_id),
ResponseTypeValues.CODE,
Uri.parse(getString(R.string.auth_redirect_uri))
).setScope("openid profile email").build()
authLauncher.launch(authService.getAuthorizationRequestIntent(request))
}
}
private fun trocarCodigo(response: AuthorizationResponse) {
authService.performTokenRequest(response.createTokenExchangeRequest()) { tokens, ex ->
if (tokens == null) {
mostrarErro("Não foi possível concluir o login: ${ex?.errorDescription}")
return@performTokenRequest
}
salvarTokensComSeguranca(tokens)
abrirTelaInicial()
}
}
override fun onDestroy() {
authService.dispose()
super.onDestroy()
}
}
Repare no que o código não faz: não loga o code, não loga o token, não reabre o navegador quando a pessoa cancela. As funções mostrarErro, salvarTokensComSeguranca e abrirTelaInicial são suas; guarde tokens em armazenamento protegido pelo sistema, nunca em SharedPreferences sem criptografia.
Como provar que o Android entrega o redirect
Antes de mexer em código, pergunte ao próprio sistema quem atende o esquema. Com o app instalado:
adb shell am start -W -a android.intent.action.VIEW \
-d "br.com.lojaexemplo.debug:/oauth2redirect?code=teste&state=teste"
Se o Android responder que não há activity para tratar a intent, o problema é o manifesto: placeholder errado, ou você instalou outra variante. Se a RedirectUriReceiverActivity abrir e reclamar de state, o lado Android está certo e a dúvida volta para o provedor. Essa separação vale mais do que qualquer breakpoint.
Erros comuns
- Manter o esquema do sample no placeholder. O provedor redireciona certo, mas nenhum app do aparelho atende a URL, e a aba fica parada.
- Usar o client id de debug no release. O app publicado depende de um cadastro que alguém vai apagar numa limpeza do console, e o login de produção cai sem nenhuma alteração de código.
- Embutir client secret no app. Qualquer pessoa extrai o APK e lê o valor. App nativo é cliente público; quem protege o fluxo é o PKCE. Veja por que app nativo não carrega o segredo do cliente.
- Tratar cancelamento como erro e tentar de novo. A pessoa fecha a aba, o app reabre, ela fecha de novo. Vira um loop que parece travamento.
- Logar o authorization code ou o token "para depurar". O log vai para ferramentas de crash e de suporte, que viram cópia da credencial. Veja log sem senha.
- Testar só com o depurador. Rodar pelo Android Studio pode mascarar a variante instalada e o estado da activity; o comportamento real do esquema aparece no app aberto pelo ícone.
Checklist
- Cada variante distribuída tem o próprio client cadastrado no provedor.
- O redirect cadastrado é idêntico ao do recurso
auth_redirect_urida mesma variante. - O esquema do placeholder
appAuthRedirectSchemeé o mesmo do redirect, em minúsculas. - O comando
adb shell am startcom o redirect abre aRedirectUriReceiverActivity. - Nenhum client secret está no APK.
- Cancelar o login volta para a tela com o botão "Entrar", sem reabrir o navegador.
- Code e tokens não aparecem em log nem em relatório de crash.
- O login foi testado no build de release instalado, aberto pelo ícone.
Se o fluxo for dentro de WebView, o problema é outro e mais sério: leia por que login OAuth no WebView não é o navegador. Agora abra o manifesto mesclado do build que está no seu aparelho: o esquema de redirect que aparece ali é exatamente o mesmo cadastrado no provedor para aquele client id?
Referência: https://openid.github.io/AppAuth-Android/