OAuth2 Mal Implementado: Por Que um ID Token Válido Ainda Pode Logar a Pessoa Errada

OAuth2 Mal Implementado: Por Que um ID Token Válido Ainda Pode Logar a Pessoa Errada

Django Security Series — Post 17 | Série III: Autenticação e Sessão
OWASP A07:2025 — Authentication Failures | Tempo de leitura: ~15 min

🧪 Rode você mesmo. Este ataque vem como um laboratório executável no django-security-lab, com um provedor de identidade próprio. Dê à conta do atacante no provedor o e-mail da vítima, entre com ela, e um callback abre a conta da vítima enquanto o outro responde 403.

"Entrar com o Google" é uma delegação. Um app Django que oferece esse botão deixa outra pessoa conferir a senha, um provedor de identidade como Google, GitHub ou Microsoft, e recebe de volta uma resposta sobre quem entrou. Dois padrões fazem essa troca. O OAuth 2.0 (RFC 6749) foi criado para deixar um app usar algo seu, como os seus repositórios no GitHub, sem nunca ver a sua senha; ele responde à pergunta "o que este app pode fazer?", e não "quem é esta pessoa?". O OpenID Connect (OIDC) é uma camada fina por cima do OAuth que acrescenta a segunda resposta: junto com o acesso, o provedor envia um ID token, um JWT (Post 16) que ele assina para dizer quem é o usuário. O login do Google e o da Microsoft são OIDC; alguns provedores, o GitHub entre eles, usam OAuth puro e deixam o app perguntar à API deles quem é o usuário. A maioria dos projetos Django recebe tudo isso do django-allauth ou do social-auth-app-django.

A vantagem é fácil de ver. Não há tabela de senhas para proteger, nem fluxo de redefinição para construir (Post 14), e muitas vezes já existe um segundo fator que o provedor exige. O custo é que o login passa a ter duas metades, e só uma delas pertence ao provedor. O provedor autentica a pessoa. A view de callback, a URL do seu site para onde o provedor devolve o usuário, decide qual conta do seu banco essa pessoa recebe, e todas as verificações por trás dessa decisão rodam no seu código ou na biblioteca que você configurou. Quando uma delas falta, a metade do provedor continua funcionando perfeitamente, e o atacante continua indo parar na conta de outra pessoa.

Este post acompanha uma dessas decisões: qual parte da resposta do provedor o callback usa para encontrar a conta.


O Ataque: O Que É e Como Funciona

Quase todo botão "Entrar com…" de um site Django renderizado no servidor roda o que o OAuth chama de fluxo de código de autorização (authorization code flow). Quando o usuário clica, o seu app manda o navegador para o provedor com alguns dados na URL: qual app está pedindo (client_id), para onde devolver o usuário depois (redirect_uri, a sua view de callback) e a que o app quer ter acesso (os escopos). O usuário entra no provedor e aprova, e o provedor manda o navegador de volta ao seu callback com um code de uso único. O seu servidor então troca esse code, junto com o client secret do app, por tokens: um access token e, com OIDC, um ID token, a afirmação assinada do provedor sobre quem acabou de entrar.

Essa afirmação é um conjunto de claims. O iss diz qual provedor a emitiu. O sub, o subject, é o identificador que o próprio provedor dá à pessoa: um valor opaco como 248289761001, que nunca muda e nunca é entregue a mais ninguém. Ao lado deles vêm as claims de perfil, como email, name e preferred_username. O callback agora precisa escolher a claim que encontra a conta no seu banco, e o e-mail é a candidata óbvia, porque as suas contas locais já são indexadas por ele. É também a escolha errada. A especificação do OpenID Connect diz isso com todas as letras (Core §5.7): email, phone_number, preferred_username e name "MUST NOT be used as unique identifiers", e a combinação de iss e sub é "the only guaranteed unique identifier". Uma claim de perfil descreve a conta no provedor. Ela pode mudar, pode ser reatribuída, e alguns provedores deixam o titular da conta digitar um endereço de e-mail que ninguém nunca confere.

Veja o que isso significa para uma usuária. A Alice tem conta no seu site há anos, com o endereço alice@example.com e uma senha. Você acrescenta o botão "Entrar com" um provedor e, para facilitar a transição, o callback procura uma conta local com o mesmo e-mail do ID token e faz o login nela. Agora o atacante só precisa de uma coisa: um provedor que assine um token dizendo email: alice@example.com para uma conta que o atacante controla. Um provedor que deixa o titular da conta, ou o administrador de uma organização que ele mesmo gerencia, definir um endereço sem provar que é dono dele faz exatamente isso. Então o atacante abre uma conta lá, define o e-mail dela como o endereço da Alice e clica em "Entrar com…" no seu site. O provedor autentica o atacante corretamente, com a senha do próprio atacante, e assina um token legítimo sobre a conta do atacante, levando o endereço da Alice. O seu callback lê o e-mail, encontra a linha da Alice e faz o login do atacante nela. A Alice talvez nunca tenha ouvido falar desse provedor. Nenhuma senha foi adivinhada e nenhum token foi forjado.

Nada nessa cadeia está quebrado, a não ser a última consulta.

O callback pode errar de outras duas formas. Um callback que não confere o state, o valor aleatório que o app envia com o login e espera receber de volta, pode ser levado a concluir um login que o atacante iniciou; numa página de "conecte sua conta", isso vincula a conta do atacante no provedor à conta da vítima. E um app que aceita um ID token vindo do navegador sem conferir o aud, o cliente para quem o token foi emitido, aceita também tokens que o mesmo provedor emitiu para qualquer outro app. Os padrões do allauth cuidam das duas.


Incidentes Reais

nOAuth — a claim de e-mail do Azure AD (divulgado pela Descope e pela Microsoft em 20 de junho de 2023)

A Descope, uma empresa de identidade, descobriu que o Azure AD (hoje Microsoft Entra ID) colocava nos seus tokens uma claim email que ninguém tinha verificado. O comunicado da Microsoft explica o motivo: "AAD users without a provisioned mailbox can have any email address set for their Mail (Primary SMTP) attribute." Qualquer pessoa pode criar um tenant, e o administrador de um tenant controla esse atributo para os seus usuários. Então o atacante cria um tenant, define o e-mail do próprio usuário como o endereço da vítima e clica em "Entrar com a Microsoft" num app que aceita contas de qualquer tenant e identifica os usuários pelo e-mail. A Microsoft assina um token perfeitamente legítimo dizendo que o e-mail do usuário é o da vítima. Se o app então junta esse login à conta local que já existe com o mesmo endereço, o atacante está dentro da conta da vítima, e, como a Descope observou, a vítima nem precisa ter uma conta da Microsoft. A Descope informou a Microsoft em 11 de abril de 2023, avisou as organizações afetadas naquele mês e alertou dois provedores de autenticação que juntavam contas dessa forma; os dois confirmaram e corrigiram em poucos dias, no início de maio. Em 20 de junho de 2023, Microsoft e Descope divulgaram o problema juntas. A Microsoft implantou mitigações para "omit token claims from unverified domain owners for most applications", e a orientação dela aos desenvolvedores cabe numa linha: as aplicações "should never use the email claim for authorization due to its mutability and non-uniqueness."

A classe não se limita a provedores grandes, nem a apps que escrevem o próprio callback. Nas notas de versão do próprio django-allauth, os provedores Okta e NetIQ "were using preferred_username as the identifier for third-party provider accounts", um valor que "may be mutable and should therefore be avoided for authorization decisions"; a 65.13.0 (31 de outubro de 2025) passou os dois para o sub, e a correção foi publicada como CVE-2025-65431 (GHSA-8m3c-c723-h4p4, classificada como moderada). Onze meses depois, a 65.19.6 (30 de setembro de 2026) corrigiu a mesma coisa nos provedores Bitbucket e Pinterest, com a consequência por extenso ("If a username changed and was then claimed by another user, that user could log in to the original owner's account"), e a 65.19.7, no dia seguinte, nos dois provedores do Tumblr. São cinco provedores em três versões da biblioteca que a maioria dos projetos Django usa justamente para isso. Para quem desenvolve em Django, a lição é mais estreita do que "mantenha o allauth atualizado", embora isso importe: o identificador é uma decisão que alguém precisa tomar para cada provedor, e a resposta segura é aquela que o provedor nunca reatribui: o sub com o seu iss no OpenID Connect, o ID permanente da conta no provedor no OAuth puro, como escolheram as correções do Bitbucket, do Pinterest e do Tumblr.

A técnica do MITRE ATT&CK que mais se aproxima é a T1078 — Valid Accounts: o atacante acaba operando uma conta legítima, depois de entrar pela porta da frente. O contraste com o Post 16 ensina. Lá, o atacante reenviava um token roubado do dono (T1550.001). Aqui nada é roubado nem forjado; o provedor de identidade assinou uma afirmação verdadeira, e o app tirou dela a conclusão errada. Por isso também é difícil enxergar depois. O próprio log do app registra um login social bem-sucedido da vítima.

Fontes: Descope — nOAuth: How Microsoft OAuth Misconfiguration Can Lead to Full Account Takeover (20 de junho de 2023) · MSRC — Potential Risk of Privilege Escalation in Azure AD Applications (20 de junho de 2023) · GHSA-8m3c-c723-h4p4 — django-allauth's Okta and NetIQ implementations used a mutable identifier for authorization decisions


Proteções Padrão do Django

O Django não tem cliente OAuth próprio, então os padrões que importam são os da biblioteca. Tudo o que está abaixo foi conferido com o django-allauth 65.20.1 e o social-auth-app-django 7.2.0, no Django 5.2.

As duas bibliotecas encontram as contas pelo identificador do provedor, e não pelo e-mail. O allauth vincula um usuário local a uma linha de SocialAccount com o provedor e o seu uid, e o provedor de OpenID Connect dele tira esse uid de uma configuração, uid_field, cujo padrão é sub. O social-auth faz o mesmo com a tabela UserSocialAuth, única por provedor e uid, e o backend de OpenID Connect dele também usa o sub. Um primeiro login social sem linha correspondente é uma pessoa nova, para qualquer das duas.

O caso interessante é um primeiro login social cujo e-mail já pertence a uma conta local, e as duas bibliotecas se recusam a juntá-los por padrão. O allauth não faz o login. Ele manda o usuário para o seu formulário de cadastro social, e o formulário recusa o endereço: "An account already exists with this email address. Please sign in to that account first, then connect your … account." O comentário no código de cadastro dele explica o motivo, e descreve o ataque deste post quase palavra por palavra: o endereço "may not be verified, meaning, the user may be a hacker that has added your email address to their account in the hope that you fall in their trap." O social-auth tem uma etapa de pipeline que junta contas pelo e-mail, associate_by_email, e a deixa fora do pipeline padrão, com uma docstring que avisa que ela "is not 100% secure unless you know that the providers enabled enforce email verification on their side".

Então o comportamento vulnerável é algo que o desenvolvedor precisa ligar, e o allauth faz dessa ligação uma decisão consciente. A configuração é SOCIALACCOUNT_EMAIL_AUTHENTICATION, desligada por padrão, e a docstring dela diz que ligá-la "implies that an untrustworthy provider can login to any local account by fabricating social account data". Mesmo assim, o allauth só compara endereços que o provedor marcou como verificados (no OpenID Connect, a claim email_verified), a menos que uma configuração VERIFIED_EMAIL mande tratar como verificado todo endereço vindo daquele provedor.


O Padrão Vulnerável e a Correção

O primeiro padrão é o callback escrito à mão, aquele que uma equipe escreve para um provedor que a biblioteca não suporta, ou para um servidor de identidade interno. Ele troca o code e verifica o ID token corretamente, e depois entrega a conta na consulta. As linhas - são esse callback, do views_vulnerable.py do laboratório; as linhas + são a correção, do views_secure.py:

     tokens = idp.exchange_code(request.GET["code"], redirect_uri=CALLBACK)
     claims = idp.verify_id_token(tokens["id_token"])
-    user = User.objects.filter(email__iexact=claims["email"]).first()
-    if user is None:
-        user = User.objects.create_user(username=claims["sub"], email=claims["email"])
+    identity = Identity.objects.filter(issuer=claims["iss"], subject=claims["sub"]).first()
+    if identity is None:
+        return HttpResponseForbidden("No account is linked to this provider account.\n")
+    user = identity.user
     login(request, user, backend="django.contrib.auth.backends.ModelBackend")

A versão - faz o login de quem quer que o provedor diga ter aquele endereço. A versão + só encontra uma conta por meio de uma linha de Identity, indexada por iss e sub, e o único jeito de criar uma é uma página de "conecte sua conta" que pede a senha de novo a um usuário logado antes de vincular a identidade do provedor à conta dele. Um primeiro login social sem identidade vinculada é recusado aqui; num projeto real ele vai para o cadastro e, se o e-mail pertencer a uma conta existente, para uma página que pede ao usuário que entre do jeito antigo e faça a conexão de lá, como faz o allauth. Isso custa um passo a alguns usuários. Trocar esse passo por uma verificação de email_verified é tentador, e mais difícil de defender do que parece: a pergunta passa a ser se cada provedor que você habilita define essa claim com verdade para cada conta, hoje e depois da próxima mudança dele, e essa é uma afirmação sobre o código de outra pessoa.

O segundo padrão é o mesmo erro no allauth, onde ele cabe em duas linhas de configuração em vez de código:

 SOCIALACCOUNT_PROVIDERS = {
     "openid_connect": {
-        "EMAIL_AUTHENTICATION": True,   # log in to the local account with the same email
-        "VERIFIED_EMAIL": True,         # and treat every address from this provider as verified
         "APPS": [...],
     },
 }

Cada linha sozinha é uma decisão defensável para um único provedor em que você confia por completo. Juntas, são o lado - do primeiro diff. O mesmo vale para um app de OpenID Connect cujo uid_field foi trocado de sub para email ou preferred_username, e para o social_core.pipeline.social_auth.associate_by_email no SOCIAL_AUTH_PIPELINE. Procure as quatro nas suas configurações; cada uma deveria vir com um comentário dizendo em qual provedor ela confia e por quê.

Para um projeto que já encontra os logins sociais pelo e-mail, a migração tentadora é gravar iss e sub na conta que o e-mail encontrar, no próximo login social de cada usuário. Isso registra o próprio bug: um atacante que entrar durante a migração fica com o sub dele vinculado à conta da vítima para sempre. Troque a consulta primeiro e depois deixe cada usuário existente vincular a conta do provedor pela página de conexão, depois de entrar com a senha ou com um link enviado ao endereço que já está na conta, e use o e-mail só para exibição.


Detectando Automaticamente

Testando Sua Defesa

O teste que importa entra como o atacante. O bob_claims_alices_email() define o e-mail da conta do bob no provedor como o endereço da alice, na página de perfil do provedor do laboratório, e o sign_in_with_provider() segue os redirecionamentos do login como um navegador faria:

def test_secure_refuses_bobs_provider_account(self):
    self.bob_claims_alices_email()
    bob = Client()
    resp = self.sign_in_with_provider(bob, SECURE_LOGIN, "bob")
    self.assertEqual(resp.status_code, 403)
    self.assertEqual(bob.get(ACCOUNT).status_code, 302)  # not signed in

Faça o callback seguro procurar o usuário pelo e-mail e ele falha, porque o bob volta logado como alice. O tests.py do laboratório tem o resto, inclusive a metade mais rigorosa da correção (a própria conta da alice no provedor, com endereço verificado, não abre nada até ela conectá-la) e a prova de que ela ainda consegue conectar a dela e entrar com ela. No seu projeto, a linha a copiar é a primeira: dê a uma conta no provedor o e-mail de outra pessoa e depois entre com ela.

A metade das configurações pede uma busca, não um teste:

grep -rnE "EMAIL_AUTHENTICATION|VERIFIED_EMAIL|uid_field|associate_by_email" --include=*.py .

Nem todo resultado é um problema, já que "uid_field": "sub" e uma configuração desligada também aparecem. Os que merecem leitura são a autenticação por e-mail ou o associate_by_email ligados, o VERIFIED_EMAIL definido e um uid_field diferente de sub: cada um é um lugar onde um e-mail ou um nome de usuário pode abrir uma conta existente, e cada um deveria dizer num comentário em qual provedor confia.

Fazendo a Varredura

O único redirecionamento de que os scanners reclamaram é o único redirecionamento deste laboratório que é seguro. Os pacotes curados do Semgrep devolvem dois achados, os dois no provedor de identidade do laboratório:

semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_17_oauth2/
# Ran 156 rules on 18 files: 2 findings.

O open-redirect dispara na view de autorização do provedor, que recusa qualquer redirect_uri fora de uma lista exata antes de redirecionar para qualquer lugar. A definição da regra mostra o porquê: ela só isenta uma função que chama is_safe_url() ou url_has_allowed_host_and_scheme(), e conferir se a URI está num conjunto de URIs registradas não é nenhuma das duas coisas. O outro achado, jwt-python-hardcoded-secret, é justo, já que o client secret do laboratório está no repositório. A camada do registro (r/python.django, r/python) acrescenta direct-use-of-httpresponse cinco vezes, nos dois callbacks igualmente, somando 7 achados em 372 regras, e o Bandit aponta dois B105 de senha fixa no código do provedor, um deles a chave de dicionário "token_type" ao lado de "Bearer".

Nenhum deles toca a linha 46 do views_vulnerable.py, User.objects.filter(email__iexact=claims["email"]), nem a linha que a substitui. O bug é uma consulta comum ao ORM, e enxergá-lo exige saber que uma claim de um token verificado corretamente não pode identificar uma pessoa. Procurar nas definições das cinco configurações do Semgrep as palavras que uma regra sobre isso mais provavelmente usaria (email_verified, ["email"], email__iexact, preferred_username, allauth, associate_by_email, id_token e mais algumas) devolve 0 em todas, e as 75 verificações do Bandit não têm nada sobre e-mail ou OAuth.

Como nenhuma camada aponta a consulta, o laboratório traz uma regra própria, a rules/oauth2.yaml. É uma regra de taint: as fontes são o jwt.decode() e um helper verify_id_token(), e o sink é uma consulta de conta pelo campo de e-mail, então ela acompanha a claim tanto por uma variável local quanto direto na chamada.

semgrep scan --config rules/oauth2.yaml labs/post_17_oauth2/views_vulnerable.py
# Ran 1 rule on 1 file: 1 finding.
semgrep scan --config rules/oauth2.yaml labs/post_17_oauth2/views_secure.py
# Ran 1 rule on 1 file: 0 findings.

Ela dispara na linha 46, fica em silêncio na consulta por (iss, sub), e o CI do repositório confere essa separação. A fixture dela registra os seus limites. Ela aponta uma consulta protegida por uma verificação de email_verified, de propósito, e aponta também consultas inofensivas, como uma checagem de cadastro que só pergunta se o endereço já está em uso, porque casa onde a claim encontra a consulta, e não se alguém acaba logado. Ela deixa passar o mesmo erro indexado por preferred_username, claims lidas de um endpoint de userinfo em vez de um ID token, e o e-mail dentro de um Q() ou de uma lista email__in. E ela não enxerga as configurações do allauth e do social-auth, e é por isso que o grep acima fica ao lado dela.


O hábito a levar deste post é uma única pergunta para cada botão "Entrar com…": qual claim o callback lê para encontrar a conta? A resposta que se sustenta é um identificador que o provedor nunca reatribui, iss e sub no OpenID Connect, por meio de um vínculo que o usuário criou enquanto estava logado; um e-mail ou um nome de usuário é um campo que o software de outra pessoa deixa outra pessoa editar. O Post 18 abre a Série IV com o hashing fraco de senhas, para as contas que continuam entrando com senha.

Leitura Complementar

← Voltar para a série