OAuth es boilerplate; el account linking es donde muerde. Tres casos de callback, usuarios sin contraseña, race conditions, Turbo se come el 302, protección de credenciales — todo en un artículo.
Conectar OAuth a how2claude no fue difícil por OAuth mismo.
Instalar omniauth-google-oauth2 y omniauth-github, escribir un initializer, escribir un callback, añadir dos botones — eso es un montón de boilerplate de 10 minutos que cualquier AI agent hace bien. Lo difícil es el account linking: si un email ya tiene cuenta con contraseña, ¿el login de Google se fusiona? Si un usuario ya tiene Google y quiere añadir GitHub, ¿cómo funciona el binding? Si dos personas compiten por el primer login de Google con el mismo email, ¿terminas con dos Users?
Esta es la historia real. La implementación inicial se hizo en Amp (91e4f48) — Amp corre sobre Claude por debajo, con un modelo de interacción muy adecuado para poner una gran base de un tirón. Días después, 0112888 en Claude Code parcheó un conflicto Turbo-vs-OAuth. Dos herramientas que se pasan el testigo naturalmente.
Amp aterrizó el boilerplate de una sola en 91e4f48:
Gemfile: omniauth, omniauth-google-oauth2, omniauth-githubOauthAccount: provider / uid / email / name / avatar_url, con unique index en [provider, uid]Auth::OmniauthController/auth/:provider/callback + /auth/failuresessions/new.html.erb con dos button_to (Google + GitHub)omniauth configurando callback_pathEl modelo User recibió un cambio de una línea — y esa línea es el truco real (abajo).
14 archivos, 257 líneas añadidas. La parte mecánica de OAuth termina aquí.
Cuando un usuario clica "Continue with Google," eventualmente aterriza en /auth/google_oauth2/callback. request.env["omniauth.auth"] contiene provider, uid, email, name, avatar. Hay que decidir:
OauthAccount que coincide) → iniciar sesión del User ya vinculadoEl 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
Cuarenta y seis líneas que parecen ordinarias. Cada rama esconde una trampa.
El has_secure_password por defecto de Rails 8 requiere password_digest. Un usuario entrando con Google no tiene contraseña — ¿y?
No quites has_secure_password (rompes el login de contraseña).
Sí: apaga la validación por defecto y escribe la tuya condicional:
class User < ApplicationRecord
has_secure_password validations: false
validates :password, length: { minimum: 8 }, if: -> { password.present? }
# ...
end
validations: false: quita la regla "contraseña requerida" que viene con has_secure_passwordvalidates :password, length:, if: password.present?: si el usuario pone contraseña, exige min 8; si no, da igualUsuarios OAuth-only se crean con password_digest: "". Pueden añadir contraseña después (si tiene 8+ caracteres). Los usuarios con auth por contraseña no se ven afectados.
Efecto colateral: el test existente de "password reset" se rompió — usaba una contraseña muy corta. Amp lo arregló en el mismo commit (test/controllers/passwords_controller_test.rb). Cuando un agent añade una feature, echa un vistazo al área de superficie de tests — ahorra un ciclo de re-run.
La tercera rama del callback:
user = User.find_by(email_address: auth.info.email) || User.create!(...)
Bien single-threaded. En producción, dos requests llegan casi simultáneamente (raro pero ocurre) — ambos find_by devuelven nil, ambos create!, uno tiene éxito, el otro choca con el unique index sobre email_address (asumiendo que lo tienes).
Peor: si no tienes unique index en email_address, se crean dos Users con el mismo email. Aguas abajo: el binding de customer de Stripe es ambiguo, las búsquedas de subscription están mal.
Fix:
add_index :users, :email_address, unique: truefind_or_create_by! o envolver con rescue:user = User.find_by(email_address: auth.info.email) ||
User.create_with(password_digest: "").find_or_create_by!(email_address: auth.info.email)
La versión original de Amp no envolvió. Cuando un agent escribe código DB de alta concurrencia, tienes que preguntar activamente "¿qué pasa con concurrencia?". Los agents asumen por defecto acceso serial de un solo usuario — sus tests sólo cubren caminos seriales.
La segunda rama — un usuario con sesión vinculando nuevo OAuth provider — parece simple pero tiene un caso borde feo.
Usuario A está con sesión como [email protected]. Clica Continue with GitHub. El callback de GitHub devuelve [email protected] (el GitHub de A usa su email de trabajo).
Ahora la base de datos puede contener:
- User A: email [email protected]
- User B: email [email protected] (A registró con email de trabajo hace meses)
Si ejecutas Current.user.oauth_accounts.create!(...) como hace el código, GitHub se vincula a A. Pero User B sigue existiendo, posiblemente con Purchases de B. B todavía puede entrar con contraseña, pero su GitHub es ahora de A.
Un flujo de binding completo tiene que manejar "¿qué hacemos con los datos huérfanos de la cuenta?" — los agents no piensan en esto por defecto. Como mínimo, una advertencia UI: "Esta cuenta GitHub está vinculada al email [email protected]. Continuar evitará que esa cuenta pueda entrar con GitHub."
La versión actual no maneja esto — simplemente vincula Current.user al registro OAuth y sigue. Es una simplificación intencional: parchéalo cuando el problema surja de verdad. Pero tú como humano necesitas saber que el hueco existe.
OAuth cableado, desplegado, clica Continue with Google — no pasa nada. La pestaña network muestra POST /auth/google_oauth2 → 302 a accounts.google.com/o/oauth2/auth?.... El navegador no se mueve.
El artículo anterior Hacer que Claude integre dos pasarelas de pago: Stripe + x402 cubre exactamente el mismo mecanismo: button_to genera un <form method="post">, Turbo intercepta la form, trata la respuesta como TURBO_STREAM, y TURBO_STREAM no sigue 302s cross-origin.
Fix aterrizó en 0112888 (hecho en Claude Code):
<%= button_to "/auth/google_oauth2", method: :post,
+ form: { data: { turbo: false } },
class: "..." do %>
Continue with Google
<% end %>
Ambos botones OAuth lo necesitan. Regla: cualquier button_to cuyo objetivo haga 302 a un dominio externo (Stripe Checkout, Stripe Portal, Google OAuth, GitHub OAuth, Apple Sign-In…) necesita data: { turbo: false }. No un botón — toda una clase.
Repo recién clonado, sin credentials de GitHub configuradas — la página de login aún renderiza "Continue with GitHub." Clicar da Invalid client_id.
El mismo commit (0112888) añadió un guard:
<% if Rails.application.credentials.dig(:github, :client_id).present? %>
<%= button_to "/auth/github", ... %>
<% end %>
Sin credentials de GitHub → sin botón. Vale la pena hacerlo porque:
Conseguir que un agent escriba este tipo de "guard de credentials" de entrada no es automático — asume que tu config siempre está poblada. Scaffold de feature + guard de credentials son dos pasos. Sepáralos, no los pidas en un solo prompt.
El trabajo de OAuth se dividió en dos fases:
Fase 1 (Amp, 91e4f48): una sesión poniendo toda la base de OAuth — gems, modelo, migration, controller, view, rutas, tests, i18n. 14 archivos / 257 líneas / 20 minutos. El modelo de interacción de Amp encaja con "empujar un gran pedazo a la vez", con historia de thread lineal y limpia.
Fase 2 (Claude Code, 0112888): días después, antes de salir en vivo, Turbo-vs-OAuth nos pilló. Esto es territorio de diagnóstico preciso + fix de una línea + test de regresión. Claude Code tiene contexto completo de git/session dentro del directorio del proyecto, más el hook propio del proyecto para grabar sesiones (captura automáticamente todo a docs/notes/pro/raw.md — ver Dejar que Claude escriba hooks que se graben a sí mismo). El rastro de depuración se convierte en material de artículo.
Las dos herramientas no chocan. Amp es para scaffolding desde cero y para threads largos de discusión abierta; Claude Code es para trabajo incremental dentro de un proyecto existente y para automatización de ingeniería. Mismo modelo debajo (Claude Opus 4.7, 1M contexto) — diferentes formas de interacción y toolsets.
Mismo ingeniero, a veces en el IDE, a veces en la terminal. Cada cual encaja en momentos diferentes.
Hacer que Claude (o Amp) construya login OAuth — checklist completa:
has_secure_password validations: false + longitud condicional. No tires la auth por contraseña.button_to OAuth necesita data-turbo=false. Misma familia de bugs que Stripe.OAuth mismo no es difícil. Lo difícil es reconocer los casos borde que los agents no sacan naturalmente — concurrencia, datos huérfanos, credentials faltantes en entorno local. Tu rol como humano es hacer esas preguntas: "¿Qué pasa con concurrencia? ¿Qué pasa con huérfanos de cuenta? ¿Qué pasa si no están las credentials?"
Tú haces las preguntas, el agent escribe las respuestas. Esa es la división de labor real en este workflow.