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 queagenda://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
- 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.
- 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.
- 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. - Ligue o PKCE com S256 em toda requisição de autorização, mesmo que o provedor diga que é opcional.
- Gere e confira o
state. Ao receber o retorno, recuse se o valor não for o que você gerou nesta tentativa. - Abra o navegador do sistema (Custom Tabs no Android,
ASWebAuthenticationSessionno iOS), nunca uma WebView sua. Veja por que o login dentro de WebView não é a mesma coisa. - 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://oumyapp://. 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
localhostou 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?
Referência: https://developers.google.com/identity/protocols/oauth2/native-app