Free

Fazendo o Claude construir login SaaS: Google + GitHub OAuth + account linking

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.


A parte de 10 minutos

Amp aterrissou o boilerplate de uma tacada só no 91e4f48:

  • Gemfile: omniauth, omniauth-google-oauth2, omniauth-github
  • Modelo OauthAccount: provider / uid / email / name / avatar_url, com unique index em [provider, uid]
  • Action callback do Auth::OmniauthController
  • Rotas: /auth/:provider/callback + /auth/failure
  • sessions/new.html.erb com dois button_to (Google + GitHub)
  • 72 linhas de testes de controller cobrindo 5 cenários
  • Initializer do omniauth configurando callback_path

O 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.

O problema real: o callback tem três casos

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:

  1. Essa conta Google já entrou antes? (linha OauthAccount correspondente) → login no User já ligado
  2. Tem alguém logado agora? (a session tem um user) → ligar Google ao User atual
  3. Nem um nem outro? (conta Google nova, sem sessão) → achar por email ou criar User novo

O 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.

Armadilha #1: usuários OAuth-only não têm senha

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_password
  • validates :password, length:, if: password.present?: se o usuário setar senha, exige min 8; se não, tanto faz

Usuá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.

Armadilha #2: a race do find-by-email

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:

  • Unique index no nível DB: add_index :users, :email_address, unique: true
  • Nível app: usar find_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.

Armadilha #3: o problema de órfãos do account binding

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.

Cilada #1: Turbo engole o 302 do Google

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.

Cilada #2: o botão do GitHub renderiza sem credentials

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:

  1. Devs clonando o projeto não veem botão quebrado
  2. Se as credentials acidentalmente sumirem em produção, nenhum botão quebrado pra clicar
  3. Quando as credentials local e prod divergem, a feature degrada ao invés de dar erro

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ó.

Amp + Claude Code: duas ferramentas, um modelo

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:

  1. Reconhecer que OAuth em si é boilerplate. Focar nos três casos do callback.
  2. Usuários OAuth-only: has_secure_password validations: false + comprimento condicional. Não tire a auth por senha.
  3. Adicionar unique index DB em email + guard de concorrência na app pro find-by-email. O agent não vai.
  4. Problema de órfãos cross-user do account binding: aviso UI no mínimo. Não finge que não existe.
  5. Todo button_to OAuth precisa de data-turbo=false. Mesma família de bugs que Stripe.
  6. Sem credentials → não renderizar o botão OAuth. Cobre drift dev e prod.
  7. Scaffold de feature e guard de credentials são duas passadas. Não pede numa prompt só.

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.