Free

Niech Claude zbuduje SaaS login: Google + GitHub OAuth + account linking

OAuth to boilerplate; account linking to miejsce gdzie gryzie. Trzy przypadki callback, użytkownicy bez hasła, race condition, Turbo połyka 302, ochrona credentials — wszystko w jednym artykule.


Podpięcie OAuth do how2claude nie było trudne z powodu OAuth.

Instalacja omniauth-google-oauth2 i omniauth-github, napisanie initializera, napisanie callbacka, dodanie dwóch przycisków — to 10 minut boilerplate'u, który każdy AI agent robi poprawnie. Trudne to account linking: jeśli email już ma konto z hasłem, czy login Google łączy? Jeśli użytkownik ma już Google podpięte i chce dodać GitHub, jak działa binding? Jeśli dwie osoby ścigają się w pierwszym loginie Google z tym samym emailem, czy kończymy z dwoma User?

To prawdziwa historia. Początkowa implementacja wykonana w Amp (91e4f48) — Amp działa na Claude pod spodem, z modelem interakcji świetnie nadającym się do położenia dużej podstawy za jednym razem. Dni później 0112888 w Claude Code zaplastrował konflikt Turbo-vs-OAuth. Dwa narzędzia naturalnie przekazujące sobie pałeczkę.


Część na 10 minut

Amp położył boilerplate jednym ciągiem w 91e4f48:

  • Gemfile: omniauth, omniauth-google-oauth2, omniauth-github
  • Model OauthAccount: provider / uid / email / name / avatar_url, z unique index na [provider, uid]
  • Akcja callback w Auth::OmniauthController
  • Trasy: /auth/:provider/callback + /auth/failure
  • sessions/new.html.erb z dwoma button_to (Google + GitHub)
  • 72 linie testów controllera pokrywających 5 scenariuszy
  • Initializer omniauth konfigurujący callback_path

Model User dostał zmianę jednej linii — i ta linia to prawdziwy trik (poniżej).

14 plików, 257 linii dodanych. Mechaniczna część OAuth kończy się tutaj.

Prawdziwy problem: callback obsługuje trzy przypadki

Gdy użytkownik klika "Continue with Google", ostatecznie ląduje na /auth/google_oauth2/callback. request.env["omniauth.auth"] zawiera provider, uid, email, name, avatar. Trzeba zdecydować:

  1. Czy to konto Google już się logowało? (pasujący wiersz OauthAccount) → zaloguj już powiązanego User
  2. Czy ktoś jest aktualnie zalogowany? (session ma user) → powiąż Google z obecnym User
  3. Ani jedno, ani drugie? (nowe konto Google, brak sesji) → znajdź po email lub utwórz nowego User

Kod:

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

Czterdzieści sześć linii wyglądających zwyczajnie. Każda gałąź kryje pułapkę.

Pułapka #1: użytkownicy OAuth-only nie mają hasła

Domyślny has_secure_password w Rails 8 wymaga password_digest. Użytkownik logujący się przez Google nie ma hasła — i co?

Nie usuwaj has_secure_password (zepsujesz login hasłem).

Zrób: wyłącz domyślną walidację i napisz własną warunkową:

class User < ApplicationRecord
  has_secure_password validations: false
  validates :password, length: { minimum: 8 }, if: -> { password.present? }
  # ...
end
  • validations: false: usuwa regułę "hasło wymagane" idącą z has_secure_password
  • validates :password, length:, if: password.present?: jeśli użytkownik ustawi hasło, wymuś min 8; inaczej nie obchodzi

Użytkownicy OAuth-only tworzeni z password_digest: "". Mogą dodać hasło później (jeśli 8+ znaków). Użytkownicy z auth hasłem w ogóle nie dotknięci.

Efekt uboczny: istniejący test "password reset" zepsuł się — używał za krótkiego hasła. Amp naprawił w tym samym commicie (test/controllers/passwords_controller_test.rb). Gdy agent dodaje feature, rzuć okiem na powierzchnię testów — oszczędza jeden cykl re-run.

Pułapka #2: race condition find-by-email

Trzecia gałąź callbacka:

user = User.find_by(email_address: auth.info.email) || User.create!(...)

Ok w single-thread. W produkcji dwa requesty przychodzą prawie jednocześnie (rzadko, ale się zdarza) — oba find_by zwracają nil, oba create!, jeden udaje się, drugi uderza w unique index na email_address (zakładając, że masz).

Gorzej: jeśli nie masz unique index na email_address, tworzą się dwa User z tym samym email. Dalej: binding Stripe customer niejednoznaczny, lookupy subscription błędne.

Fix:

  • Unique index na poziomie DB: add_index :users, :email_address, unique: true
  • Na poziomie app: użyj find_or_create_by! lub opakuj rescue:
user = User.find_by(email_address: auth.info.email) ||
       User.create_with(password_digest: "").find_or_create_by!(email_address: auth.info.email)

Oryginalna wersja Amp nie opakowała. Gdy agent pisze kod DB o wysokiej konkurencji, musisz aktywnie pytać "co się tu dzieje przy konkurencji?". Agenci domyślnie zakładają sekwencyjny dostęp jednego użytkownika — ich testy pokrywają tylko ścieżki sekwencyjne.

Pułapka #3: problem sierot przy account binding

Druga gałąź — zalogowany użytkownik podpina nowy OAuth provider — wygląda prosto, ale ma brzydki przypadek brzegowy.

Użytkownik A zalogowany jako [email protected]. Klika Continue with GitHub. Callback GitHub zwraca [email protected] (GitHub A używa emaila służbowego).

Teraz DB może zawierać:
- User A: email [email protected]
- User B: email [email protected] (A zarejestrował się z emailem służbowym miesiące temu)

Jeśli uruchomisz Current.user.oauth_accounts.create!(...) jak robi kod, GitHub zostaje podpięty do A. Ale User B nadal istnieje, możliwe że z Purchases B. B nadal loguje się hasłem, ale jego GitHub jest teraz u A.

Pełny flow binding musi obsłużyć "co robimy z osieroconymi danymi cudzego konta?" — agenci o tym domyślnie nie myślą. Co najmniej ostrzeżenie UI: "To konto GitHub jest powiązane z emailem [email protected]. Kontynuacja uniemożliwi temu kontu używanie GitHub do logowania."

Obecna wersja tego nie obsługuje — po prostu gruboskórnie podpina Current.user do rekordu OAuth i leci dalej. To celowe uproszczenie: łatać gdy problem naprawdę wystąpi. Ale ty jako człowiek musisz wiedzieć, że dziura istnieje.

Dołek #1: Turbo połyka 302 Google'a

OAuth podpięte, wdrożone, klik Continue with Google — nic się nie dzieje. Zakładka network pokazuje POST /auth/google_oauth2 → 302 na accounts.google.com/o/oauth2/auth?.... Przeglądarka się nie rusza.

Poprzedni artykuł Niech Claude zintegruje dwa systemy płatności: Stripe + x402 pokrył dokładnie ten sam mechanizm: button_to generuje <form method="post">, Turbo przechwytuje form, traktuje odpowiedź jak TURBO_STREAM, a TURBO_STREAM nie podąża za cross-origin 302.

Fix wylądował w 0112888 (zrobiony w Claude Code):

 <%= button_to "/auth/google_oauth2", method: :post,
+      form: { data: { turbo: false } },
       class: "..." do %>
   Continue with Google
 <% end %>

Oba przyciski OAuth go potrzebują. Reguła: każdy button_to, którego cel robi 302 na zewnętrzną domenę (Stripe Checkout, Stripe Portal, Google OAuth, GitHub OAuth, Apple Sign-In…), potrzebuje data: { turbo: false }. Nie jeden przycisk — cała klasa.

Dołek #2: przycisk GitHub renderuje się bez credentials

Świeżo sklonowane repo, brak skonfigurowanych credentials GitHub — strona logowania nadal renderuje "Continue with GitHub". Kliknięcie daje Invalid client_id.

Ten sam commit (0112888) dodał strażnika:

<% if Rails.application.credentials.dig(:github, :client_id).present? %>
  <%= button_to "/auth/github", ... %>
<% end %>

Brak credentials GitHub → brak przycisku. Warto robić, bo:

  1. Devowie klonujący projekt nie widzą zepsutego przycisku
  2. Jeśli credentials przypadkowo znikną na produkcji, nie ma zepsutego przycisku do kliknięcia
  3. Gdy credentials local i prod rozjeżdżają się, feature degraduje zamiast się wysypywać

Zmuszenie agenta do pisania takich "strażników credentials" od razu nie przychodzi automatycznie — zakłada, że twój config jest zawsze wypełniony. Scaffold feature + strażnik credentials to dwa przejścia. Rozdziel je, nie proś w jednym promptcie.

Amp + Claude Code: dwa narzędzia, jeden model

Praca OAuth podzieliła się na dwie fazy:

Faza 1 (Amp, 91e4f48): jedna sesja kładąca całą płytę OAuth — gems, model, migracja, controller, view, routes, tests, i18n. 14 plików / 257 linii / 20 minut. Model interakcji Amp pasuje do "pchania jednego dużego kawałka naraz", z czystą liniową historią thread.

Faza 2 (Claude Code, 0112888): dni później, przed go-live, Turbo-vs-OAuth nas złapał. Terytorium precyzyjnej diagnozy + fixa jednej linii + testu regresyjnego. Claude Code ma pełny kontekst git/session w katalogu projektu, plus własny hook nagrywania sesji projektu (automatycznie łapie wszystko do docs/notes/pro/raw.md — zobacz Niech Claude pisze hooki nagrywające siebie). Ślad debugowania staje się materiałem artykułu.

Te dwa narzędzia się nie gryzą. Amp do scaffoldingu od zera i długich thread-ów otwartej dyskusji; Claude Code do pracy inkrementalnej wewnątrz istniejącego projektu i automatyzacji engineeringowej. Ten sam model pod spodem (Claude Opus 4.7, 1M kontekstu) — inne formy interakcji i zestawy narzędzi.

Ten sam inżynier, czasem w IDE, czasem w terminalu. Każde pasuje do innych momentów.


Niech Claude (albo Amp) zbuduje login OAuth — pełna checklista:

  1. Uznać, że sam OAuth to boilerplate. Skupić się na trzech przypadkach callback.
  2. Użytkownicy OAuth-only: has_secure_password validations: false + warunkowa długość. Nie wywalaj auth hasłem.
  3. Dodaj unique index DB na email + strażnika konkurencji na poziomie app dla find-by-email. Agent nie zrobi.
  4. Problem cross-user sierot przy account binding: ostrzeżenie UI minimum. Nie udawaj, że go nie ma.
  5. Każdy button_to OAuth potrzebuje data-turbo=false. Ta sama rodzina bugów co Stripe.
  6. Brak credentials → nie renderuj przycisku OAuth. Pokrywa drift dev i prod.
  7. Scaffold feature i strażnik credentials to dwa przejścia. Nie proś w jednym promptcie.

Sam OAuth nie jest trudny. Trudne to uznanie przypadków brzegowych, których agenci naturalnie nie wynoszą na wierzch — konkurencja, dane sieroty, brakujące credentials w środowisku lokalnym. Twoja rola jako człowieka to zadawać te pytania: "A konkurencja? A sieroty konta? A jeśli credentials nie są ustawione?"

Ty zadajesz pytania, agent pisze odpowiedzi. To prawdziwy podział pracy w tym workflow.