OAuth è boilerplate; l'account linking è dove morde. Tre casi di callback, utenti senza password, race condition, Turbo si mangia il 302, guard dei credentials — tutto in un articolo.
Cablare OAuth dentro how2claude non è stato difficile per colpa di OAuth.
Installare omniauth-google-oauth2 e omniauth-github, scrivere un initializer, scrivere un callback, aggiungere due bottoni — sono 10 minuti di boilerplate, qualsiasi AI agent lo fa giusto. Il difficile è l'account linking: se un'email ha già un account con password, il login Google lo fonde? Se un utente ha già Google collegato e vuole aggiungere GitHub, come funziona il binding? Se due persone fanno la gara al primo login Google con la stessa email, si finisce con due User?
Questa è la storia vera. L'implementazione iniziale è stata fatta in Amp (91e4f48) — Amp gira su Claude sotto, con un modello di interazione molto adatto a posare una grande base in un colpo solo. Giorni dopo, 0112888 in Claude Code ha rappezzato un conflitto Turbo-vs-OAuth. Due strumenti che si passano il testimone in modo naturale.
Amp ha atterrato il boilerplate in un colpo in 91e4f48:
Gemfile: omniauth, omniauth-google-oauth2, omniauth-githubOauthAccount: provider / uid / email / name / avatar_url, con unique index su [provider, uid]Auth::OmniauthController/auth/:provider/callback + /auth/failuresessions/new.html.erb con due button_to (Google + GitHub)omniauth che configura callback_pathIl model User ha avuto una modifica di una riga — e quella riga è il vero trucco (sotto).
14 file, 257 righe aggiunte. La parte meccanica di OAuth finisce qui.
Quando un utente clicca "Continue with Google", alla fine atterra su /auth/google_oauth2/callback. request.env["omniauth.auth"] contiene provider, uid, email, name, avatar. Devi decidere:
OauthAccount che combacia) → login dell'User già collegatoIl codice:
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
Quarantasei righe che sembrano banali. Ogni ramo nasconde una trappola.
Il has_secure_password di default di Rails 8 richiede password_digest. Un utente che fa login con Google non ha password — e allora?
Non rimuovere has_secure_password (spacca il login via password).
Sì: spegni la validazione di default e scrivi la tua condizionale:
class User < ApplicationRecord
has_secure_password validations: false
validates :password, length: { minimum: 8 }, if: -> { password.present? }
# ...
end
validations: false: toglie la regola "password obbligatoria" che accompagna has_secure_passwordvalidates :password, length:, if: password.present?: se l'utente imposta una password, forza min 8; altrimenti non importaUtenti OAuth-only vengono creati con password_digest: "". Possono aggiungere una password più tardi (basta 8+ caratteri). Utenti con auth via password non vengono toccati.
Effetto collaterale: il test esistente di "password reset" si è rotto — usava una password troppo corta. Amp l'ha sistemato nello stesso commit (test/controllers/passwords_controller_test.rb). Quando un agent aggiunge una feature, dai un'occhiata all'area di superficie dei test — risparmia un ciclo di re-run.
Il terzo ramo del callback:
user = User.find_by(email_address: auth.info.email) || User.create!(...)
Va bene in single-thread. In produzione, due richieste arrivano quasi simultanee (raro ma succede) — entrambe find_by ritornano nil, entrambe create!, una riesce, l'altra picchia contro l'unique index su email_address (supponendo tu l'abbia).
Peggio: se non hai unique index su email_address, si creano due User con la stessa email. A valle: binding del customer Stripe ambiguo, lookup delle subscription sbagliati.
Fix:
add_index :users, :email_address, unique: truefind_or_create_by! o avvolgi 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 versione originale di Amp non avvolgeva. Quando un agent scrive codice DB ad alta concorrenza, devi chiedere attivamente "cosa succede sotto concorrenza?". Gli agent assumono di default accesso seriale a singolo utente — i loro test coprono solo percorsi seriali.
Il secondo ramo — utente loggato che collega nuovo OAuth provider — sembra semplice, ma ha un caso limite brutto.
Utente A loggato come [email protected]. Clicca Continue with GitHub. Il callback GitHub ritorna [email protected] (il GitHub di A usa l'email di lavoro).
Ora il database può contenere:
- User A: email [email protected]
- User B: email [email protected] (A si è registrato con l'email di lavoro mesi fa)
Se esegui Current.user.oauth_accounts.create!(...) come fa il codice, GitHub viene collegato ad A. Ma User B esiste ancora, possibilmente con Purchases di B. B può ancora loggarsi con password, ma il suo GitHub è ora di A.
Un flusso di binding completo deve gestire "cosa facciamo dei dati orfani dell'account?" — gli agent non ci pensano di default. Come minimo, un warning UI: "Questo account GitHub è collegato all'email [email protected]. Continuare impedirà a quell'account di usare GitHub per il login."
La versione attuale non gestisce questo — collega semplicemente Current.user al record OAuth e va avanti. È una semplificazione intenzionale: patcharla quando il problema emerge davvero. Ma tu come umano devi sapere che il buco esiste.
OAuth cablato, deployato, clicca Continue with Google — non succede niente. La tab network mostra POST /auth/google_oauth2 → 302 verso accounts.google.com/o/oauth2/auth?.... Il browser non si muove.
L'articolo precedente Lasciare che Claude integri due pagamenti: Stripe + x402 ha coperto esattamente lo stesso meccanismo: button_to genera un <form method="post">, Turbo intercetta la form, tratta la risposta come TURBO_STREAM, e TURBO_STREAM non segue 302 cross-origin.
Il fix è atterrato in 0112888 (fatto in Claude Code):
<%= button_to "/auth/google_oauth2", method: :post,
+ form: { data: { turbo: false } },
class: "..." do %>
Continue with Google
<% end %>
Entrambi i bottoni OAuth ne hanno bisogno. Regola: qualsiasi button_to il cui target fa 302 verso un dominio esterno (Stripe Checkout, Stripe Portal, Google OAuth, GitHub OAuth, Apple Sign-In…) ha bisogno di data: { turbo: false }. Non un bottone — un'intera classe.
Repo appena clonato, nessuna credentials GitHub configurata — la pagina di login renderizza comunque "Continue with GitHub". Cliccare dà Invalid client_id.
Lo stesso commit (0112888) ha aggiunto una guard:
<% if Rails.application.credentials.dig(:github, :client_id).present? %>
<%= button_to "/auth/github", ... %>
<% end %>
Niente credentials GitHub → niente bottone. Vale la pena farlo perché:
Far scrivere a un agent questo tipo di "guard credentials" fin da subito non è automatico — assume che la config sia sempre popolata. Scaffold di feature + guard credentials sono due passate. Separale, non chiederle in un solo prompt.
Il lavoro OAuth si è diviso in due fasi:
Fase 1 (Amp, 91e4f48): una sessione che posa l'intera lastra OAuth — gem, model, migration, controller, view, route, test, i18n. 14 file / 257 righe / 20 minuti. Il modello di interazione di Amp si adatta a "spingere un grosso pezzo alla volta", con storia thread lineare e pulita.
Fase 2 (Claude Code, 0112888): giorni dopo, prima del go-live, Turbo-vs-OAuth ci ha beccato. Territorio di diagnosi precisa + fix di una riga + test di regressione. Claude Code ha il contesto git/session completo dentro la directory del progetto, più il proprio hook di registrazione sessione del progetto (cattura automaticamente tutto in docs/notes/pro/raw.md — vedi Lasciare che Claude scriva hook che registrano se stesso). La scia di debug diventa materiale per l'articolo.
I due strumenti non si pestano i piedi. Amp per scaffolding da zero e per thread lunghi di discussione aperta; Claude Code per lavoro incrementale dentro un progetto esistente e per automazione di engineering. Stesso modello sotto (Claude Opus 4.7, 1M contesto) — forme di interazione e toolset diversi.
Stesso ingegnere, a volte nell'IDE, a volte nel terminale. Ciascuno si adatta a momenti diversi.
Lasciare che Claude (o Amp) costruisca un login OAuth — checklist completa:
has_secure_password validations: false + lunghezza condizionale. Non buttare via l'auth via password.button_to OAuth ha bisogno di data-turbo=false. Stessa famiglia di bug di Stripe.OAuth in sé non è difficile. Il difficile è ammettere i casi limite che gli agent non fanno emergere naturalmente — concorrenza, dati orfani, credentials mancanti in ambiente locale. Il tuo ruolo come umano è fare quelle domande: "E la concorrenza? E gli orfani di account? E se le credentials non sono impostate?"
Tu fai le domande, l'agent scrive le risposte. Questa è la vera divisione del lavoro in questo workflow.