OAuth ist Boilerplate; Account Linking ist, wo's beißt. Drei Callback-Fälle, passwortlose User, Race Conditions, Turbo schluckt 302, Credentials-Guard — alles in einem Artikel.
OAuth in how2claude zu verdrahten war nicht wegen OAuth schwer.
omniauth-google-oauth2 und omniauth-github installieren, einen Initializer schreiben, einen Callback schreiben, zwei Buttons hinzufügen — das sind 10 Minuten Boilerplate, die jeder KI-Agent hinkriegt. Schwer ist das Account Linking: Wenn eine E-Mail bereits ein Passwort-Konto hat, fusioniert der Google-Login? Wenn ein User schon Google verknüpft hat und GitHub dazunehmen will, wie läuft das Binding? Wenn zwei Leute gleichzeitig um den ersten Google-Login mit derselben E-Mail wetteifern, kriegt man zwei Users?
Das ist die echte Geschichte. Die initiale Implementierung wurde in Amp (91e4f48) gemacht — Amp läuft unter der Haube auf Claude, mit einem Interaktionsmodell, das sehr geeignet ist, eine große Grundplatte auf einen Schlag zu legen. Tage später hat 0112888 in Claude Code einen Turbo-vs-OAuth-Konflikt gepatcht. Zwei Tools, die sich natürlich den Staffelstab übergeben.
Amp hat das Boilerplate in 91e4f48 in einem Rutsch abgesetzt:
Gemfile: omniauth, omniauth-google-oauth2, omniauth-githubOauthAccount-Modell: provider / uid / email / name / avatar_url, mit Unique Index auf [provider, uid]Auth::OmniauthController/auth/:provider/callback + /auth/failuresessions/new.html.erb mit zwei button_to (Google + GitHub)omniauth-Initializer mit callback_pathDas User-Modell bekam eine Einzeilen-Änderung — und diese Zeile ist der eigentliche Trick (siehe unten).
14 Dateien, 257 Zeilen hinzugefügt. Der mechanische Teil von OAuth endet hier.
Wenn ein User auf "Continue with Google" klickt, landet er am Ende auf /auth/google_oauth2/callback. request.env["omniauth.auth"] enthält provider, uid, email, name, avatar. Du musst entscheiden:
OauthAccount-Zeile) → den bereits verknüpften User einloggenDer Code:
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
Sechsundvierzig Zeilen, die unscheinbar aussehen. Jeder Zweig versteckt eine Falle.
Das Default-has_secure_password in Rails 8 verlangt password_digest. Ein User, der sich mit Google einloggt, hat kein Passwort — und nun?
Mach's nicht so: entferne has_secure_password nicht (bricht den Passwort-Login).
Mach's so: Schalte die Default-Validierung ab und schreib deine eigene bedingte:
class User < ApplicationRecord
has_secure_password validations: false
validates :password, length: { minimum: 8 }, if: -> { password.present? }
# ...
end
validations: false: entfernt die "Passwort erforderlich"-Regel, die mit has_secure_password kommtvalidates :password, length:, if: password.present?: wenn der User ein Passwort setzt, fordere min 8; sonst ist's egalOAuth-only-User werden mit password_digest: "" angelegt. Sie können später ein Passwort ergänzen (solange 8+ Zeichen). Passwort-Auth-User sind völlig unbeeinflusst.
Nebeneffekt: der bestehende "password reset"-Test ist gebrochen — er nutzte ein zu kurzes Passwort. Amp hat ihn im gleichen Commit repariert (test/controllers/passwords_controller_test.rb). Wenn ein Agent ein Feature hinzufügt, wirf einen Blick auf die Testfläche — spart einen Re-Run-Zyklus.
Der dritte Zweig des Callbacks:
user = User.find_by(email_address: auth.info.email) || User.create!(...)
Single-threaded okay. In der Produktion kommen zwei Requests fast gleichzeitig (selten aber passiert) — beide find_by geben nil zurück, beide create!, einer gelingt, der andere schlägt auf den Unique Index auf email_address (sofern du einen hast).
Schlimmer: hast du keinen Unique Index auf email_address, werden zwei Users mit derselben E-Mail erstellt. Danach: Stripe-Customer-Binding mehrdeutig, Subscription-Lookups falsch.
Fix:
add_index :users, :email_address, unique: truefind_or_create_by! nutzen oder mit rescue umhüllen:user = User.find_by(email_address: auth.info.email) ||
User.create_with(password_digest: "").find_or_create_by!(email_address: auth.info.email)
Amps Originalversion hat nicht umhüllt. Wenn ein Agent hochnebenläufigen DB-Code schreibt, musst du aktiv fragen "was passiert unter Nebenläufigkeit?". Agents setzen per Default seriellen Einzelzugriff voraus — ihre Tests decken nur serielle Pfade ab.
Der zweite Zweig — eingeloggter User bindet neuen OAuth-Provider — sieht einfach aus, hat aber einen hässlichen Grenzfall.
User A ist unter [email protected] eingeloggt. Klickt Continue with GitHub. GitHub-Callback gibt [email protected] zurück (As GitHub nutzt die Arbeits-E-Mail).
Jetzt kann die DB enthalten:
- User A: email [email protected]
- User B: email [email protected] (A hat vor Monaten separat mit der Arbeits-E-Mail registriert)
Wenn du Current.user.oauth_accounts.create!(...) ausführst, wie der Code es tut, wird GitHub an A gebunden. Aber User B existiert noch, möglicherweise mit Bs Purchases. B kann immer noch mit Passwort rein, aber sein GitHub gehört jetzt A.
Ein vollständiger Binding-Flow muss "was tun wir mit den verwaisten Daten des anderen Kontos?" behandeln — Agents denken daran nicht per Default. Mindestens UI-Warnung: "Dieses GitHub-Konto ist mit der E-Mail [email protected] verknüpft. Fortfahren verhindert, dass dieses Konto GitHub zum Login nutzen kann."
Die aktuelle Version behandelt das nicht — sie bindet Current.user einfach grob an den OAuth-Datensatz und geht weiter. Das ist eine absichtliche Vereinfachung: patchen, wenn das Problem tatsächlich auftritt. Aber du als Mensch musst wissen, dass das Loch existiert.
OAuth verkabelt, deployed, Continue with Google klicken — nichts passiert. Network-Tab zeigt POST /auth/google_oauth2 → 302 auf accounts.google.com/o/oauth2/auth?.... Browser bewegt sich nicht.
Der vorige Artikel Claude zwei Zahlungswege integrieren lassen: Stripe + x402 hat exakt denselben Mechanismus behandelt: button_to erzeugt ein <form method="post">, Turbo fängt das Form ab, behandelt die Response als TURBO_STREAM, und TURBO_STREAM folgt keinen cross-origin 302s.
Fix ist in 0112888 gelandet (in Claude Code gemacht):
<%= button_to "/auth/google_oauth2", method: :post,
+ form: { data: { turbo: false } },
class: "..." do %>
Continue with Google
<% end %>
Beide OAuth-Buttons brauchen es. Regel: jedes button_to, dessen Ziel mit 302 auf eine externe Domain geht (Stripe Checkout, Stripe Portal, Google OAuth, GitHub OAuth, Apple Sign-In…), braucht data: { turbo: false }. Nicht ein Button — eine ganze Klasse.
Frisch geklontes Repo, keine GitHub-Credentials konfiguriert — die Login-Seite rendert trotzdem "Continue with GitHub". Klicken gibt Invalid client_id.
Derselbe Commit (0112888) fügte einen Guard hinzu:
<% if Rails.application.credentials.dig(:github, :client_id).present? %>
<%= button_to "/auth/github", ... %>
<% end %>
Keine GitHub-Credentials → kein Button. Lohnt sich, weil:
Einen Agent solche "Credentials-Guards" von Anfang an schreiben zu lassen ist nicht automatisch — er nimmt an, dass deine Config immer gefüllt ist. Feature-Scaffold + Credentials-Guard sind zwei Durchgänge. Trenne sie, verlange sie nicht in einem Prompt.
Die OAuth-Arbeit teilte sich in zwei Phasen:
Phase 1 (Amp, 91e4f48): eine Session, die die ganze OAuth-Platte legt — gems, Modell, Migration, Controller, View, Routen, Tests, i18n. 14 Dateien / 257 Zeilen / 20 Minuten. Amps Interaktionsmodell passt zu "ein großes Stück auf einmal vorschieben", mit sauberem linearen Thread-Verlauf.
Phase 2 (Claude Code, 0112888): Tage später, vor dem Go-Live, hat uns Turbo-vs-OAuth erwischt. Das ist Präzisions-Diagnose + Einzeilen-Fix + Regressionstest-Gebiet. Claude Code hat vollen git/session-Kontext im Projektverzeichnis, dazu den eigenen Session-Recording-Hook des Projekts (erfasst automatisch alles in docs/notes/pro/raw.md — siehe Claude Hooks schreiben lassen, die ihn selbst aufzeichnen). Die Debug-Spur wird zu Artikel-Material.
Die beiden Tools kollidieren nicht. Amp ist für Scaffolding von null und für lange Threads offener Diskussion; Claude Code ist für inkrementelle Arbeit in einem bestehenden Projekt und für Engineering-Automatisierung. Gleiches Modell drunter (Claude Opus 4.7, 1M Kontext) — unterschiedliche Interaktionsformen und Toolsets.
Derselbe Ingenieur, mal in der IDE, mal im Terminal. Jedes passt zu anderen Momenten.
Claude (oder Amp) OAuth-Login bauen lassen — vollständige Checkliste:
has_secure_password validations: false + bedingte Länge. Die Passwort-Auth nicht entsorgen.button_to braucht data-turbo=false. Gleiche Bug-Familie wie Stripe.OAuth selbst ist nicht schwer. Schwer ist, die Grenzfälle anzuerkennen, die Agents nicht natürlich ansprechen — Nebenläufigkeit, verwaiste Daten, fehlende Credentials in lokaler Umgebung. Deine Rolle als Mensch: diese Fragen zu stellen: "Was ist mit Nebenläufigkeit? Was ist mit Account-Waisen? Was, wenn Credentials nicht gesetzt sind?"
Du stellst die Fragen, der Agent schreibt die Antworten. Das ist die echte Arbeitsteilung in diesem Workflow.