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
audienceaceita 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. Ohdsó 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.
Referência: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token