Free

Claude ein SaaS-Login bauen lassen: Google + GitHub OAuth + Account Linking

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.


Die 10-Minuten-Arbeit

Amp hat das Boilerplate in 91e4f48 in einem Rutsch abgesetzt:

  • Gemfile: omniauth, omniauth-google-oauth2, omniauth-github
  • OauthAccount-Modell: provider / uid / email / name / avatar_url, mit Unique Index auf [provider, uid]
  • Callback-Action im Auth::OmniauthController
  • Routen: /auth/:provider/callback + /auth/failure
  • sessions/new.html.erb mit zwei button_to (Google + GitHub)
  • 72 Zeilen Controller-Tests, 5 Szenarien abgedeckt
  • omniauth-Initializer mit callback_path

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

Das echte Problem: der Callback hat drei Fälle

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:

  1. Hat sich dieses Google-Konto schon mal eingeloggt? (passende OauthAccount-Zeile) → den bereits verknüpften User einloggen
  2. Ist gerade jemand eingeloggt? (die Session hat einen user) → Google an den aktuellen User binden
  3. Keins von beidem? (Google-Konto neu, keine Session) → per E-Mail suchen oder neuen User anlegen

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

Falle #1: OAuth-only-User haben kein Passwort

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 kommt
  • validates :password, length:, if: password.present?: wenn der User ein Passwort setzt, fordere min 8; sonst ist's egal

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

Falle #2: die Race Condition des find-by-email

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:

  • Unique Index auf DB-Ebene: add_index :users, :email_address, unique: true
  • Auf App-Ebene: find_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.

Falle #3: das Waisenproblem des Account Linkings

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.

Stolperfalle #1: Turbo schluckt Googles 302

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.

Stolperfalle #2: der GitHub-Button rendert ohne Credentials

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:

  1. Devs, die das Projekt klonen, sehen keinen kaputten Button
  2. Wenn Credentials in Produktion versehentlich verschwinden, gibt's keinen kaputten Button zum Klicken
  3. Wenn local und prod Credentials driften, degradiert das Feature statt zu fehlern

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.

Amp + Claude Code: zwei Tools, ein Modell

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:

  1. Anerkenne, dass OAuth selbst Boilerplate ist. Fokus auf die drei Callback-Fälle.
  2. OAuth-only-User: has_secure_password validations: false + bedingte Länge. Die Passwort-Auth nicht entsorgen.
  3. Füge DB-Unique-Index auf email + App-Level-Concurrency-Guard für find-by-email hinzu. Der Agent macht's nicht.
  4. Cross-User-Waisen-Problem des Account-Bindings: UI-Warnung mindestens. Nicht so tun, als existiere es nicht.
  5. Jedes OAuth button_to braucht data-turbo=false. Gleiche Bug-Familie wie Stripe.
  6. Keine Credentials → OAuth-Button nicht rendern. Deckt dev- und prod-Drift ab.
  7. Feature-Scaffold und Credentials-Guard sind zwei Durchgänge. Nicht in einem Prompt verlangen.

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.