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ę.
Amp położył boilerplate jednym ciągiem w 91e4f48:
Gemfile: omniauth, omniauth-google-oauth2, omniauth-githubOauthAccount: provider / uid / email / name / avatar_url, z unique index na [provider, uid]Auth::OmniauthController/auth/:provider/callback + /auth/failuresessions/new.html.erb z dwoma button_to (Google + GitHub)omniauth konfigurujący callback_pathModel 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.
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ć:
OauthAccount) → zaloguj już powiązanego UserKod:
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ę.
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_passwordvalidates :password, length:, if: password.present?: jeśli użytkownik ustawi hasło, wymuś min 8; inaczej nie obchodziUż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.
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:
add_index :users, :email_address, unique: truefind_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.
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.
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.
Ś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:
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.
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:
has_secure_password validations: false + warunkowa długość. Nie wywalaj auth hasłem.button_to OAuth potrzebuje data-turbo=false. Ta sama rodzina bugów co Stripe.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.