Programação Por Equipe DevBras

Capa do artigo: Client secret dentro do APK não é segredo: OAuth em app nativo com PKCE

Client secret dentro do APK não é segredo: OAuth em app nativo com PKCE

Qualquer pessoa extrai o client secret de um app instalado. Veja como achar o vazamento no seu build, trocar para cliente público com PKCE e rotacionar o segredo que já saiu.

Faça a experiência com o APK de produção do seu app: descompacte e procure pelo prefixo do client secret. Em menos de um minuto, num app de agenda que atendemos, apareceu o mesmo secret usado pelo backend web, copiado do exemplo de servidor para o código Android porque "precisava para trocar o código pelo token". Esse secret estava em cada aparelho que instalou o app nos últimos dois anos.

A resposta curta: um app instalado é um cliente público, porque qualquer pessoa pode abrir o pacote e ler o que tem dentro. Ele não deve carregar client secret. O fluxo certo é Authorization Code com PKCE, no navegador do sistema, com um cliente OAuth do tipo aplicativo nativo e um redirect que só o seu app recebe. Se um secret já foi embutido, trate como vazado: troque, atualize o backend e só depois limpe o histórico.

Por que não existe segredo dentro do app

O APK e o IPA são arquivos compactados. Strings do código, recursos e configurações ficam legíveis com ferramentas gratuitas, e ofuscação só atrasa quem procura. Não importa se o secret está numa constante, num arquivo de recurso ou dividido em pedaços: o app precisa remontá-lo para usar, e quem controla o aparelho vê o resultado.

O PKCE resolve o problema que o secret tentava resolver. O app gera um valor aleatório, o code_verifier, e manda ao provedor apenas o hash dele, o code_challenge, no início do login. Na hora de trocar o código pelo token, apresenta o verificador original. Um app malicioso que intercepte o código de autorização não consegue trocá-lo, porque não tem o verificador, que nunca saiu da memória do app legítimo. É uma prova criada a cada login, em vez de um segredo fixo compartilhado por todas as instalações.

O guia do Google para apps nativos, que é a sourceUrl deste post, e a RFC 8252, que define boas práticas de OAuth para apps nativos, apontam na mesma direção: navegador do sistema, PKCE e nenhuma dependência de secret. Para tipos de cliente instalado em que o Google ainda entrega um secret, a própria documentação avisa que ele não é tratado como confidencial.

Como corrigir, passo a passo

O exemplo é o app Agenda Clínica, Android em Kotlin, com um provedor OIDC próprio e um backend web que também usa OAuth.

  1. Procure o secret no build de produção, do jeito que um atacante faria.
  2. Crie um cliente OAuth separado para o app, do tipo aplicativo nativo ou público, sem secret.
  3. Use um redirect que só o app recebe: um App Link HTTPS verificado ou um esquema privado baseado no domínio invertido.
  4. Implemente o fluxo com PKCE, de preferência com a biblioteca AppAuth, que já trata os detalhes.
  5. Remova o secret do código do app e publique a versão nova.
  6. Rotacione o secret vazado no cliente do backend, atualizando o servidor antes de desativar o antigo.
  7. Limpe o histórico do repositório e coloque um detector de segredos no CI.

Achar o vazamento no seu próprio build

unzip -q -o app-release.apk -d apk-extraido
grep -r -a -l "GOCSPX-" apk-extraido

Os secrets mais recentes do Google costumam começar com GOCSPX-; para outros provedores, procure por um trecho do secret real. O grep encontra strings que ficam legíveis no bytecode; para uma busca completa, inclusive em recursos, abra o APK num descompilador como o jadx. Na Agenda Clínica, o resultado apontou para classes.dex, e o descompilador mostrou a constante com o nome do campo.

Gerar verificador e desafio

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

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

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

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

Abrir o login no navegador do sistema

val verifier = gerarCodeVerifier()
val state = gerarCodeVerifier()
// guardar verifier e state para conferir no retorno

val authUri = Uri.parse("https://sso.agendaclinica.example/authorize").buildUpon()
    .appendQueryParameter("client_id", "agenda-android")
    .appendQueryParameter("redirect_uri", "br.example.agendaclinica:/oauth2redirect")
    .appendQueryParameter("response_type", "code")
    .appendQueryParameter("scope", "openid profile")
    .appendQueryParameter("code_challenge", gerarCodeChallenge(verifier))
    .appendQueryParameter("code_challenge_method", "S256")
    .appendQueryParameter("state", state)
    .build()

CustomTabsIntent.Builder().build().launchUrl(context, authUri)

No retorno, o app confere se o state é o mesmo que gerou e troca o código no endpoint de token com grant_type=authorization_code, code, redirect_uri, client_id e code_verifier. Nenhum secret. O código acima mostra o mecanismo; em produção, a biblioteca AppAuth faz esse fluxo, a conferência do state e a troca do código com menos chance de erro. No Google, as bibliotecas oficiais de login para Android e iOS são o caminho recomendado, e as regras de redirect por tipo de cliente mudaram ao longo do tempo, então confira o guia atual antes de cadastrar.

O redirect do exemplo usa um esquema privado no formato de domínio invertido, como sugere a RFC 8252. Um App Link HTTPS verificado é ainda melhor, porque o sistema garante que só o seu app abre aquele endereço. Em .NET MAUI, o mesmo vai-e-volta está detalhado em WebAuthenticator no .NET MAUI.

Rotacionar o que já vazou

Remover o secret do app novo não resolve: as versões antigas continuam instaladas e o secret está na loja, em espelhos de APK e no histórico do Git. Como era o mesmo secret do backend web, a Agenda Clínica criou um novo secret no provedor, atualizou o backend, confirmou que os logins web funcionavam e só então desativou o antigo. A ordem importa, e está descrita em rotação que tem dois passos. Depois, o histórico foi limpo e um detector de segredos entrou no CI, seguindo segredo no Git se troca antes de limpar. O backend, por sua vez, continua usando secret, porque ali ele fica no servidor; veja OAuth de servidor guarda o segredo lá.

Erros comuns

  • Copiar o exemplo de servidor web para o app. O secret vai para todos os aparelhos e para quem baixar o APK.
  • Reaproveitar o cliente OAuth do backend no app. Um vazamento no app compromete também o login web.
  • Confiar em ofuscação. O secret continua extraível; só demora um pouco mais.
  • Usar WebView para a página de login. O provedor pode bloquear, e o app passa a ter acesso ao que a pessoa digita.
  • Esquecer o state. O app aceita um retorno que não iniciou, abrindo espaço para falsificação de login.
  • Limpar o histórico sem rotacionar. O secret antigo segue válido nas cópias que já circulam.

Checklist

  • A busca no APK ou IPA de produção não encontra nenhum client secret.
  • O app tem cliente OAuth próprio, público, separado do backend.
  • O fluxo usa Authorization Code com PKCE e code_challenge_method=S256.
  • O login abre no navegador do sistema, não numa WebView.
  • O redirect é um App Link verificado ou um esquema privado de domínio invertido.
  • O state é gerado, guardado e conferido no retorno.
  • Qualquer secret que já esteve no app foi rotacionado.
  • Um detector de segredos roda no CI do repositório do app.

Baixe o pacote que está na loja agora e procure nele: o seu app carrega um client secret?

Avalie este artigo

Referência: https://developers.google.com/identity/protocols/oauth2/native-app?authuser=1&hl=pt_BR