Programação Por Equipe DevBras

Capa do artigo: Validar ID token do Google: testes com token falso

Validar ID token do Google: testes com token falso

Teste a validação do ID token do Google com chaves falsas no pytest: assinatura, aud, iss, exp e hd, e a armadilha do audience=None no google-auth.

Validar o ID token do Google costuma ser umas cinco linhas de código. O problema é que essas cinco linhas continuam "funcionando" quando você esquece a audiência, quando o relógio do servidor está adiantado ou quando alguém troca a biblioteca numa atualização. Login que aceita qualquer coisa não dá erro nenhum. Por isso este guia trata a validação como código que precisa de teste: um módulo pequeno em Python com a biblioteca oficial google-auth e uma bateria de testes com pytest que fabrica tokens falsos e confirma que cada um é recusado.

Tudo aqui roda sem internet e sem conta no Google Cloud: os testes geram um par de chaves que faz o papel do Google e apontam a biblioteca para um servidor local de certificados. O fluxo do botão, do formulário que chega ao servidor até a sessão, está no post sobre login com Google no servidor: token, CSRF e conta.

O que a biblioteca confere e o que fica com você

A documentação do Google Identity Services lista as condições de um ID token válido: assinatura feita por uma chave pública do Google, aud igual a um dos client IDs do seu app, iss igual a accounts.google.com ou https://accounts.google.com, e exp no futuro. Se você restringe o acesso a uma empresa no Google Workspace, ainda tem o campo hd.

A função verify_oauth2_token do google-auth baixa os certificados públicos do Google e faz as quatro primeiras checagens. O hd fica por sua conta. E tem uma pegadinha que aparece mais adiante nos testes: a audiência só é conferida se você passar o parâmetro.

O módulo de validação ficou assim:

from google.oauth2 import id_token
from google.auth.transport import requests

CLIENT_IDS = ["1234567890-web.apps.googleusercontent.com",
              "1234567890-android.apps.googleusercontent.com"]
_req = requests.Request()

def conferir(token: str, dominio: str | None = None) -> dict:
    # assinatura, iss, exp e aud (contra a lista) ficam por conta da biblioteca
    info = id_token.verify_oauth2_token(token, _req, CLIENT_IDS,
                                        clock_skew_in_seconds=10)
    if dominio and info.get("hd") != dominio:
        raise ValueError("conta fora do domínio da empresa")
    return info

Três decisões estão nesse arquivo:

  • Lista de client IDs. Quem tem web e Android costuma ter um client ID para cada um. O parâmetro audience aceita lista, então não é preciso tentar um por um.
  • Folga de relógio de 10 segundos. Sem ela, um servidor com o relógio um pouco à frente recusa tokens recém-emitidos. Folga grande demais estende a vida de token vencido; se o erro for de minutos, o problema é o NTP, assunto do post sobre token recusado por relógio fora de hora.
  • Domínio opcional. Para um painel interno da empresa, passe dominio="minhaloja.com.br". Conferir só o final do e-mail não basta, porque conta Google comum pode usar endereço de qualquer domínio. O hd só existe em conta Workspace.

Um Google de mentira para os testes

Para testar recusa, você precisa de tokens que só um emissor consegue assinar. A saída é virar esse emissor dentro do teste: gerar uma chave RSA, embrulhar a parte pública num certificado X.509 (o formato que o endpoint de certificados do Google entrega) e servir esse JSON em 127.0.0.1. Uma segunda chave faz o papel de quem tenta forjar.

def par_de_chaves():
    k = rsa.generate_private_key(public_exponent=65537, key_size=2048)
    nome = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "teste")])
    agora = datetime.datetime.now(datetime.timezone.utc)
    cert = (x509.CertificateBuilder().subject_name(nome).issuer_name(nome)
            .public_key(k.public_key()).serial_number(1)
            .not_valid_before(agora - datetime.timedelta(minutes=1))
            .not_valid_after(agora + datetime.timedelta(days=1))
            .sign(k, hashes.SHA256()))
    pem = k.private_bytes(serialization.Encoding.PEM,
                          serialization.PrivateFormat.PKCS8,
                          serialization.NoEncryption()).decode()
    return pem, cert.public_bytes(serialization.Encoding.PEM).decode()

CHAVE_TESTE, CERT_TESTE = par_de_chaves()   # faz o papel do Google
CHAVE_INTRUSA, _ = par_de_chaves()          # chave de quem quer forjar

A fixture abaixo sobe o servidor numa porta livre e troca a URL de certificados da biblioteca só durante os testes. É um atributo interno (_GOOGLE_OAUTH2_CERTS_URL), então pode mudar de nome numa versão futura. Se isso acontecer, o teste do token bom quebra na hora e você fica sabendo, o que é melhor do que um teste que passa sem conferir nada.

@pytest.fixture(autouse=True, scope="session")
def certificados_locais():
    corpo = json.dumps({"k1": CERT_TESTE}).encode()
    class H(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.end_headers(); self.wfile.write(corpo)
        def log_message(self, *a): pass
    srv = http.server.HTTPServer(("127.0.0.1", 0), H)
    threading.Thread(target=srv.serve_forever, daemon=True).start()
    original = id_token._GOOGLE_OAUTH2_CERTS_URL
    id_token._GOOGLE_OAUTH2_CERTS_URL = f"http://127.0.0.1:{srv.server_port}/certs"
    yield
    id_token._GOOGLE_OAUTH2_CERTS_URL = original
    srv.shutdown()

A fábrica de tokens monta um payload igual ao do Google e deixa cada teste mudar um campo:

def token(chave=CHAVE_TESTE, **campos):
    agora = int(time.time())
    p = {"iss": "https://accounts.google.com",
         "aud": validar.CLIENT_IDS[0], "sub": "110248495921238986420",
         "email": "cliente@gmail.com", "email_verified": True,
         "iat": agora, "exp": agora + 3600}
    p.update(campos)
    return jwt.encode(crypt.RSASigner.from_string(chave, "k1"), p).decode()

Os testes que provam cada checagem

Primeiro o que precisa passar. Se só existir teste de recusa, uma validação que recusa tudo também fica verde.

def test_token_bom_passa():
    assert validar.conferir(token())["sub"] == "110248495921238986420"

def test_segundo_client_id_da_lista_passa():
    assert validar.conferir(token(aud=validar.CLIENT_IDS[1]))

def test_emissor_sem_https_tambem_e_aceito():
    assert validar.conferir(token(iss="accounts.google.com"))

def test_vencido_ha_5s_passa_com_folga_de_10s():
    agora = int(time.time())
    assert validar.conferir(token(iat=agora - 3605, exp=agora - 5))

Depois, um teste para cada porta que precisa estar fechada: token de outro app, vencido, emissor falso, assinatura com outra chave e conteúdo alterado depois da assinatura.

@pytest.mark.parametrize("tk", [
    pytest.param(lambda: token(aud="999-outroapp.apps.googleusercontent.com"), id="outro-app"),
    pytest.param(lambda: token(iat=int(time.time()) - 7200,
                               exp=int(time.time()) - 3600), id="vencido"),
    pytest.param(lambda: token(iss="https://login.falso.com.br"), id="emissor-falso"),
    pytest.param(lambda: token(chave=CHAVE_INTRUSA), id="chave-intrusa"),
])
def test_recusa(tk):
    with pytest.raises(Exception):
        validar.conferir(tk())

def test_conteudo_trocado_quebra_a_assinatura():
    cab, corpo, ass = token().split(".")
    dados = json.loads(base64.urlsafe_b64decode(corpo + "=="))
    dados["sub"] = "1"
    corpo = base64.urlsafe_b64encode(json.dumps(dados).encode()).rstrip(b"=").decode()
    with pytest.raises(Exception):
        validar.conferir(".".join([cab, corpo, ass]))

O teste do conteúdo trocado mostra por que decodificar não é validar: o JWT continua legível, só que agora diz que a pessoa é o usuário 1. Quem lê o payload com um decodificador qualquer acredita. A biblioteca recalcula a assinatura e recusa.

A armadilha do audience=None

O último teste não protege o seu código, ele documenta um comportamento da biblioteca. Na versão 2.62 do google-auth, que é a testada aqui, audience tem None como valor padrão. Chamada assim, a função confere assinatura, emissor e prazo, mas não confere para qual app o token foi emitido:

def test_sem_audience_a_biblioteca_nao_confere_aud():
    # a armadilha: audience=None aceita token emitido para outro app
    alheio = token(aud="999-outroapp.apps.googleusercontent.com")
    info = id_token.verify_oauth2_token(alheio, validar._req, None)
    assert info["aud"].startswith("999-")

Na prática, qualquer site que use "Fazer login com o Google" recebe tokens legítimos dos próprios visitantes. Se o seu backend não confere o aud, o dono desse outro site pode reaproveitar o token de um visitante que também é seu cliente. Ninguém percebe, porque o login funciona. Procure no código chamadas com um argumento só, verify_oauth2_token(token, req), e trate como bug.

A saída da bateria completa, rodando com Python 3 e google-auth 2.62:

test_validar.py::test_token_bom_passa PASSED
test_validar.py::test_segundo_client_id_da_lista_passa PASSED
test_validar.py::test_emissor_sem_https_tambem_e_aceito PASSED
test_validar.py::test_vencido_ha_5s_passa_com_folga_de_10s PASSED
test_validar.py::test_recusa[outro-app] PASSED
test_validar.py::test_recusa[vencido] PASSED
test_validar.py::test_recusa[emissor-falso] PASSED
test_validar.py::test_recusa[chave-intrusa] PASSED
test_validar.py::test_conteudo_trocado_quebra_a_assinatura PASSED
test_validar.py::test_dominio_da_empresa PASSED
test_validar.py::test_sem_audience_a_biblioteca_nao_confere_aud PASSED
============================== 11 passed in 1.19s ==============================

Se o seu backend é Node ou PHP

A mesma ideia vale para as bibliotecas oficiais de outras linguagens. No Node, o OAuth2Client.verifyIdToken da google-auth-library recebe a audiência no objeto de opções; no PHP, o Google_Client do pacote google/apiclient usa o client ID passado no construtor. Os exemplos das duas estão na página de verificação do Google citada acima. O que não muda é a bateria: token de outro app, vencido, emissor falso, chave intrusa e payload alterado. Se o seu framework não deixa trocar a URL dos certificados, isole a chamada da biblioteca numa função sua e teste o resto com ela substituída. O teste do token bom passa a ser de integração, com um token real gerado no ambiente de homologação.

Depois do token válido

Um token que passou por todos esses testes diz quem é a pessoa, não o que ela pode fazer. O sub é o identificador da conta Google e é ele que liga ao seu usuário; papéis e permissões vêm do seu banco, como explica o post ID token não é autorização. E o que fazer quando o e-mail do token já existe com senha está em entrar com Google sem tomar a conta de ninguém.

Vale deixar a bateria no CI. Ela roda em pouco mais de um segundo e pega exatamente o tipo de regressão que não aparece em teste manual: alguém "simplifica" a chamada, tira a lista de client IDs e o login continua funcionando para todo mundo, inclusive para quem não deveria.

Avalie este artigo

Referência: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token