Free

Lasciare che Claude costruisca un login SaaS: Google + GitHub OAuth + account linking

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.


La parte da 10 minuti

Amp ha atterrato il boilerplate in un colpo in 91e4f48:

  • Gemfile: omniauth, omniauth-google-oauth2, omniauth-github
  • Model OauthAccount: provider / uid / email / name / avatar_url, con unique index su [provider, uid]
  • Action callback di Auth::OmniauthController
  • Route: /auth/:provider/callback + /auth/failure
  • sessions/new.html.erb con due button_to (Google + GitHub)
  • 72 righe di controller test che coprono 5 scenari
  • Initializer omniauth che configura callback_path

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

Il vero problema: il callback ha tre casi

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:

  1. Questo account Google ha già fatto login? (riga OauthAccount che combacia) → login dell'User già collegato
  2. C'è qualcuno loggato ora? (la session ha un user) → collega Google all'User corrente
  3. Nessuno dei due? (account Google nuovo, nessuna session) → trova per email o crea nuovo User

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

Trappola #1: gli utenti OAuth-only non hanno password

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

: 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_password
  • validates :password, length:, if: password.present?: se l'utente imposta una password, forza min 8; altrimenti non importa

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

Trappola #2: la race condition del find-by-email

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:

  • Unique index a livello DB: add_index :users, :email_address, unique: true
  • A livello app: usa find_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.

Trappola #3: il problema degli orfani dell'account binding

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.

Tranello #1: Turbo si mangia il 302 di Google

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.

Tranello #2: il bottone GitHub si renderizza senza credentials

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é:

  1. I dev che clonano il progetto non vedono un bottone rotto
  2. Se le credentials sparissero accidentalmente in produzione, nessun bottone rotto da cliccare
  3. Quando le credentials locali e prod divergono, la feature degrada invece di dare errore

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.

Amp + Claude Code: due strumenti, un modello

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:

  1. Ammettere che OAuth in sé è boilerplate. Focus sui tre casi del callback.
  2. Utenti OAuth-only: has_secure_password validations: false + lunghezza condizionale. Non buttare via l'auth via password.
  3. Aggiungere unique index DB su email + guard di concorrenza a livello app per il find-by-email. L'agent non lo farà.
  4. Problema degli orfani cross-user dell'account binding: warning UI minimo. Non far finta che non esista.
  5. Ogni button_to OAuth ha bisogno di data-turbo=false. Stessa famiglia di bug di Stripe.
  6. Niente credentials → non renderizzare il bottone OAuth. Copre drift dev e prod.
  7. Scaffold di feature e guard credentials sono due passate. Non chiederle in un solo prompt.

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.