Programação Por Equipe DevBras

Capa do artigo: AppAuth no Android: o login abre no navegador e não volta para o app

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.

  1. Cadastre um client por variante que você distribui. No Keycloak, crie loja-android e loja-android-debug, ambos públicos (sem client secret) e com o fluxo Authorization Code habilitado.
  2. Registre o redirect exato em cada client. br.com.lojaexemplo:/oauth2redirect no de release e br.com.lojaexemplo.debug:/oauth2redirect no de debug. Sem curinga.
  3. Declare o esquema por build type no Gradle. O placeholder precisa acompanhar o pacote.
  4. Coloque client id e redirect em recursos por source set, para o debug nunca carregar o id do release e vice-versa.
  5. Trate três resultados: sucesso, cancelamento pela pessoa e erro. Cancelamento não é erro e não deve reabrir o navegador sozinho.
  6. 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_uri da mesma variante.
  • O esquema do placeholder appAuthRedirectScheme é o mesmo do redirect, em minúsculas.
  • O comando adb shell am start com o redirect abre a RedirectUriReceiverActivity.
  • 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?

Avalie este artigo

Referência: https://openid.github.io/AppAuth-Android/