OAuth é boilerplate; o account linking é onde morde. Três casos de callback, usuários sem senha, race conditions, Turbo engole o 302, guarda de credentials — tudo num artigo.
Plugar OAuth no how2claude não foi difícil por causa do OAuth em si.
Instalar omniauth-google-oauth2 e omniauth-github, escrever um initializer, escrever um callback, adicionar dois botões — é uma pilha de boilerplate de 10 minutos que qualquer AI agent acerta. O difícil é o account linking: se um email já tem conta com senha, o login do Google funde? Se um usuário já tem Google ligado e quer adicionar GitHub, como funciona o binding? Se duas pessoas correm pro primeiro login do Google com o mesmo email, você acaba com dois Users?
Essa é a história real. A implementação inicial foi feita no Amp (91e4f48) — o Amp roda sobre o Claude por baixo, com um modelo de interação bem adequado pra botar uma grande base de uma vez. Dias depois, 0112888 no Claude Code emendou um conflito Turbo-vs-OAuth. Duas ferramentas que se passam o bastão naturalmente.
Amp aterrissou o boilerplate de uma tacada só no 91e4f48:
Gemfile: omniauth, omniauth-google-oauth2, omniauth-githubOauthAccount: provider / uid / email / name / avatar_url, com unique index em [provider, uid]Auth::OmniauthController/auth/:provider/callback + /auth/failuresessions/new.html.erb com dois button_to (Google + GitHub)omniauth configurando callback_pathO modelo User ganhou mudança de uma linha — e essa linha é o truque de verdade (abaixo).
14 arquivos, 257 linhas adicionadas. A parte mecânica do OAuth termina aqui.
Quando um usuário clica em "Continue with Google," ele eventualmente aterrissa em /auth/google_oauth2/callback. request.env["omniauth.auth"] tem provider, uid, email, name, avatar. Você precisa decidir:
OauthAccount correspondente) → login no User já ligadoO código:
class Auth::OmniauthController < ApplicationController
allow_unauthenticated_access only: [:callback, :failure]
skip_forgery_protection only: :callback
def callback
auth = request.env["omniauth.auth"]
oauth_account = OauthAccount.find_by(provider: auth.provider, uid: auth.uid)
if oauth_account
start_new_session_for(oauth_account.user)
elsif (resume_session; Current.user)
Current.user.oauth_accounts.create!(oauth_params(auth))
redirect_to root_path, notice: I18n.t("auth.oauth_linked", provider: auth.provider.titleize) and return
else
user = User.find_by(email_address: auth.info.email) || User.create!(
email_address: auth.info.email,
password_digest: ""
)
user.oauth_accounts.create!(oauth_params(auth))
start_new_session_for(user)
end
redirect_to after_authentication_url
end
private
def oauth_params(auth)
{
provider: auth.provider,
uid: auth.uid,
email: auth.info.email,
name: auth.info.name,
avatar_url: auth.info.image
}
end
end
Quarenta e seis linhas que parecem comuns. Cada branch esconde uma armadilha.
O has_secure_password padrão do Rails 8 exige password_digest. Usuário entrando com Google não tem senha — e aí?
Não tire o has_secure_password (quebra o login por senha).
Sim: desligue a validação padrão e escreva a sua condicional:
class User < ApplicationRecord
has_secure_password validations: false
validates :password, length: { minimum: 8 }, if: -> { password.present? }
# ...
end
validations: false: tira a regra "senha obrigatória" que vem com has_secure_passwordvalidates :password, length:, if: password.present?: se o usuário setar senha, exige min 8; se não, tanto fazUsuários OAuth-only são criados com password_digest: "". Podem adicionar senha depois (desde que tenha 8+ chars). Usuários de auth por senha não são afetados.
Efeito colateral: o teste existente de "password reset" quebrou — estava usando uma senha muito curta. Amp corrigiu no mesmo commit (test/controllers/passwords_controller_test.rb). Quando um agent adiciona feature, dá uma olhada na área de superfície dos testes — economiza um ciclo de re-run.
O terceiro branch do callback:
user = User.find_by(email_address: auth.info.email) || User.create!(...)
Tranquilo single-thread. Em produção, duas requisições chegam quase simultaneamente (raro mas acontece) — ambos find_by retornam nil, ambos create!, um sucesso, o outro bate no unique index de email_address (assumindo que você tem).
Pior: se você não tem unique index em email_address, dois Users com mesmo email são criados. Daí pra frente: binding de customer Stripe ambíguo, lookups de subscription errados.
Fix:
add_index :users, :email_address, unique: truefind_or_create_by! ou envolver com rescue:user = User.find_by(email_address: auth.info.email) ||
User.create_with(password_digest: "").find_or_create_by!(email_address: auth.info.email)
A versão original do Amp não envolveu. Quando um agent escreve código DB de alta concorrência, você precisa perguntar ativamente "o que acontece sob concorrência?". Os agents assumem por padrão acesso serial de um usuário só — seus testes só cobrem caminhos seriais.
O segundo branch — usuário logado ligando novo OAuth provider — parece simples, mas tem um caso borda feio.
Usuário A está logado como [email protected]. Clica Continue with GitHub. Callback do GitHub retorna [email protected] (o GitHub do A usa o email de trabalho).
Agora o banco pode conter:
- User A: email [email protected]
- User B: email [email protected] (A registrou com email de trabalho meses atrás)
Se você roda Current.user.oauth_accounts.create!(...) como o código faz, GitHub liga no A. Mas User B ainda existe, possivelmente com Purchases do B. B ainda loga com senha, mas seu GitHub agora é do A.
Um fluxo completo de binding tem que lidar com "o que fazer com os dados órfãos da conta?" — agents não pensam nisso por padrão. No mínimo, um aviso UI: "Essa conta GitHub está ligada ao email [email protected]. Continuar impedirá aquela conta de usar GitHub pra entrar."
A versão atual não lida com isso — simplesmente liga Current.user ao registro OAuth e segue. É uma simplificação intencional: emenda quando o problema surgir mesmo. Mas você como humano precisa saber que o buraco existe.
OAuth ligado, deployado, clica Continue with Google — nada acontece. Aba network mostra POST /auth/google_oauth2 → 302 pra accounts.google.com/o/oauth2/auth?.... Navegador não se mexe.
O artigo anterior Fazendo o Claude integrar duas formas de pagamento: Stripe + x402 cobriu exatamente o mesmo mecanismo: button_to gera um <form method="post">, Turbo intercepta o form, trata a resposta como TURBO_STREAM, e TURBO_STREAM não segue 302s cross-origin.
Fix aterrissou em 0112888 (feito no Claude Code):
<%= button_to "/auth/google_oauth2", method: :post,
+ form: { data: { turbo: false } },
class: "..." do %>
Continue with Google
<% end %>
Ambos os botões OAuth precisam. Regra: qualquer button_to cujo alvo faça 302 pra domínio externo (Stripe Checkout, Stripe Portal, Google OAuth, GitHub OAuth, Apple Sign-In…) precisa de data: { turbo: false }. Não um botão — uma classe inteira.
Repo recém clonado, sem credentials do GitHub configuradas — a página de login ainda renderiza "Continue with GitHub." Clicar dá Invalid client_id.
Mesmo commit (0112888) adicionou um guard:
<% if Rails.application.credentials.dig(:github, :client_id).present? %>
<%= button_to "/auth/github", ... %>
<% end %>
Sem credentials do GitHub → sem botão. Vale fazer porque:
Fazer um agent escrever esse tipo de "guard de credentials" de cara não é automático — ele assume que sua config sempre tá populada. Scaffold de feature + guard de credentials são duas passadas. Separa, não pede numa prompt só.
O trabalho de OAuth se dividiu em duas fases:
Fase 1 (Amp, 91e4f48): uma sessão colocando toda a laje do OAuth — gems, modelo, migration, controller, view, rotas, testes, i18n. 14 arquivos / 257 linhas / 20 minutos. O modelo de interação do Amp casa com "empurrar um grande pedaço de cada vez", com histórico de thread linear e limpo.
Fase 2 (Claude Code, 0112888): dias depois, antes de subir, o Turbo-vs-OAuth pegou a gente. Aqui é território de diagnóstico preciso + fix de uma linha + teste de regressão. Claude Code tem contexto completo de git/session dentro do diretório do projeto, mais o próprio hook do projeto pra gravar sessão (captura tudo automaticamente pra docs/notes/pro/raw.md — ver Deixando o Claude escrever hooks que gravam ele mesmo). A trilha de debug vira material de artigo.
As duas ferramentas não batem. Amp é pra scaffolding do zero e pra threads longos de discussão aberta; Claude Code é pra trabalho incremental dentro de projeto existente e pra automação de engenharia. Mesmo modelo embaixo (Claude Opus 4.7, 1M contexto) — formas de interação e toolsets diferentes.
Mesmo engenheiro, às vezes na IDE, às vezes no terminal. Cada um encaixa em momentos diferentes.
Fazer o Claude (ou o Amp) construir login OAuth — checklist completa:
has_secure_password validations: false + comprimento condicional. Não tire a auth por senha.button_to OAuth precisa de data-turbo=false. Mesma família de bugs que Stripe.OAuth em si não é difícil. O difícil é reconhecer os casos borda que os agents não levantam naturalmente — concorrência, dados órfãos, credentials faltando em ambiente local. Seu papel como humano é fazer essas perguntas: "E a concorrência? E os órfãos de conta? E se as credentials não estiverem setadas?"
Você faz as perguntas, o agent escreve as respostas. Essa é a divisão real de trabalho nesse workflow.