Programação Por Equipe DevBras

Capa do artigo: OAuth no app móvel: como impedir que outro aplicativo receba o seu código de login

OAuth no app móvel: como impedir que outro aplicativo receba o seu código de login

Registre um redirect que só o seu app pode receber, ligue o PKCE e teste com um app impostor. Assim um código interceptado não vira token.

Você toca em "Entrar com Google", o navegador abre, a pessoa confirma e o celular pergunta: "Abrir com Agenda Clínica ou com Lanterna Turbo?". Esse menu de escolha é o sintoma de um registro de retorno frouxo: outro aplicativo no mesmo aparelho se declarou dono do endereço para onde o provedor manda o código de autorização. A resposta curta é usar um redirect que só o seu app consegue receber (link HTTPS verificado ou, no mínimo, um esquema próprio em domínio reverso), ligar o PKCE para que um código roubado não sirva para nada e manter a lista de redirects do provedor com uma entrada por plataforma.

O exemplo que atravessa este guia é o app Android Agenda Clínica, que usa o login do Google para identificar a recepcionista e chamar a API da própria clínica em api.agendaclinica.com.br.

Por que o retorno é o ponto fraco do app nativo

No fluxo Authorization Code, o provedor não entrega o token direto. Ele devolve um código de uso único num redirect, e o app troca esse código por tokens. Num site, o redirect é uma URL HTTPS do seu domínio, e só o seu servidor responde por ela. No celular, o redirect é entregue pelo sistema operacional a qualquer app que se registrou para aquele endereço.

Com um esquema personalizado como agenda://callback, nada impede que outro app declare o mesmo esquema. O sistema não tem como saber qual é o legítimo. Se o código cair no app errado e não houver mais nenhuma proteção, ele pode ser trocado por tokens em nome da pessoa.

Some a isso o fato de que app móvel é cliente público: qualquer segredo empacotado no APK pode ser extraído. O provedor não consegue confirmar a identidade do app por um segredo, então a proteção precisa vir de outro lugar. É o assunto de por que o app nativo não carrega o client secret; aqui o foco é o caminho de volta.

As três formas de redirect para apps nativos

  • Link HTTPS verificado (App Links no Android, Universal Links no iOS). O sistema só entrega a URL ao app se o domínio publicar um arquivo dizendo qual app, assinado com qual certificado, pode recebê-la. Outro app não consegue provar que é dono do seu domínio.
  • Esquema privado em domínio reverso, como br.com.agendaclinica:/oauth2redirect. Melhor que agenda:// porque reduz colisão acidental, mas não impede um app malicioso que copie o esquema de propósito.
  • Loopback (http://127.0.0.1:porta), usado em aplicativos de desktop, que abrem uma porta local temporária para receber o código.

O padrão que descreve isso é o RFC 8252, "OAuth 2.0 for Native Apps". Os provedores aplicam regras próprias por cima dele: alguns, inclusive o Google, restringem certos tipos de redirect em certas plataformas. Confira a página do seu provedor antes de escolher, porque o que ele aceita para Android, iOS e desktop pode ser diferente.

Como o PKCE salva o código interceptado

PKCE (Proof Key for Code Exchange) amarra o código a quem iniciou o login. Antes de abrir o navegador, o app gera um valor aleatório secreto, o code_verifier, e manda ao provedor apenas o hash dele, o code_challenge. Na troca do código por token, o app precisa apresentar o verifier original. Quem interceptou só o código não tem o verifier, e o provedor recusa a troca.

Gerar o par no Android é curto:

import android.util.Base64
import java.security.MessageDigest
import java.security.SecureRandom

private val FLAGS = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP

fun novoVerifier(): String {
    val bytes = ByteArray(32)
    SecureRandom().nextBytes(bytes)
    return Base64.encodeToString(bytes, FLAGS)
}

fun challengeDe(verifier: String): String {
    val digest = MessageDigest.getInstance("SHA-256")
        .digest(verifier.toByteArray(Charsets.US_ASCII))
    return Base64.encodeToString(digest, FLAGS)
}

Na URL de autorização vão code_challenge e code_challenge_method=S256. O verifier fica só na memória do app até a troca. Na prática, prefira uma biblioteca mantida, como AppAuth para Android e iOS, que já faz PKCE, state e abre o navegador do sistema do jeito certo. O código acima serve para você entender o que ela está fazendo.

Passo a passo para fechar o retorno

  1. Cadastre o cliente com o tipo de app nativo. No console do provedor, escolha Android, iOS ou desktop, não "aplicativo web". O tipo define que não há segredo e quais redirects valem.
  2. Use um link HTTPS verificado quando a plataforma e o provedor permitirem. No Android, declare o filtro com verificação automática e publique o arquivo de associação no domínio.
  3. Publique a prova de posse do domínio. No Android, o arquivo fica em https://login.agendaclinica.com.br/.well-known/assetlinks.json, com o nome do pacote e a impressão digital SHA-256 do certificado de assinatura do app de produção.
  4. Ligue o PKCE com S256 em toda requisição de autorização, mesmo que o provedor diga que é opcional.
  5. Gere e confira o state. Ao receber o retorno, recuse se o valor não for o que você gerou nesta tentativa.
  6. Abra o navegador do sistema (Custom Tabs no Android, ASWebAuthenticationSession no iOS), nunca uma WebView sua. Veja por que o login dentro de WebView não é a mesma coisa.
  7. Guarde os tokens no cofre do sistema (Keystore no Android, Keychain no iOS) e peça só os escopos que a tela usa.

O filtro do Android para o link verificado fica assim:

<activity android:name=".LoginCallbackActivity" android:exported="true">
  <intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="https"
          android:host="login.agendaclinica.com.br"
          android:pathPrefix="/oauth/callback" />
  </intent-filter>
</activity>

Se a verificação falhar (arquivo ausente, impressão digital do certificado de debug em vez do de produção), o Android trata o link como um link comum e pode abrir no navegador ou mostrar o menu de escolha. Esse é o primeiro lugar para olhar quando o retorno "às vezes" não volta para o app.

A API valida de novo

O login no app prova algo ao app. A API da clínica precisa conferir por conta própria. Se o app manda um ID token do Google, o servidor verifica assinatura, emissor, audiência (o client ID esperado) e validade antes de aceitar. Um token que veio de outro app, emitido para outro client ID, tem de ser recusado. O raciocínio é o mesmo de conferir o token do Google no servidor.

Como testar com um app impostor

Crie um segundo app de teste, com outro nome de pacote, que declare o mesmo esquema ou o mesmo host de retorno. Instale os dois no aparelho de teste e faça o login pelo app verdadeiro.

  • Com link HTTPS verificado e funcionando, o impostor nunca aparece: o sistema entrega direto ao app dono do domínio.
  • Com esquema personalizado, o impostor provavelmente aparece no menu de escolha. Escolha-o, capture o código e tente trocá-lo por token sem o verifier. O provedor deve recusar. Se aceitar, o PKCE não está ativo de verdade.

Erros comuns

  • Esquema genérico como app:// ou myapp://. Colide com outros apps por acidente e convida cópia proposital; o código vai parar onde não devia.
  • Client secret dentro do APK. Qualquer um extrai com ferramentas comuns; o segredo deixa de ser segredo e dá falsa sensação de proteção.
  • PKCE com método plain. O challenge passa a ser o próprio verifier; quem vê a URL de autorização já tem o que precisa para a troca.
  • Lista de redirects com entradas de teste esquecidas. Um localhost ou um domínio antigo vira porta de retorno que você não controla mais.
  • Fluxo de copiar e colar código. O Google descontinuou o fluxo "fora de banda" (OOB) por motivo de phishing; ensinar a pessoa a colar códigos de outra tela é ensinar o golpe.
  • Login dentro de WebView. O app enxerga a senha digitada e muitos provedores bloqueiam esse caminho; o login quebra ou fica inseguro.

Checklist

  • O cliente está cadastrado como Android, iOS ou desktop, sem segredo.
  • A lista de redirects tem uma entrada por plataforma, e todas são suas.
  • O link HTTPS verificado passa na verificação com o certificado de produção.
  • Toda autorização envia code_challenge_method=S256.
  • O state é gerado por tentativa e conferido no retorno.
  • O login abre no navegador do sistema, não em WebView.
  • Tokens ficam no Keystore ou Keychain, nunca em arquivo de preferências aberto.
  • A API confere audiência e assinatura do token que recebe.

Abra agora o console do seu provedor e conte as entradas de redirect do cliente móvel. Cada uma delas aponta para um endereço que só o seu app consegue receber?

Avalie este artigo

Referência: https://developers.google.com/identity/protocols/oauth2/native-app