OAuth est du boilerplate ; l'account linking, c'est là que ça mord. Trois cas de callback, utilisateurs sans mot de passe, race conditions, Turbo avale le 302, garde credentials — tout dans un seul article.
Câbler OAuth dans how2claude n'a pas été difficile à cause d'OAuth.
Installer omniauth-google-oauth2 et omniauth-github, écrire un initializer, écrire un callback, ajouter deux boutons — c'est 10 minutes de boilerplate que n'importe quel agent IA gère correctement. Ce qui est difficile, c'est l'account linking : si un email a déjà un compte avec mot de passe, le login Google le fusionne-t-il ? Si un utilisateur a déjà Google lié et veut ajouter GitHub, comment fonctionne le binding ? Si deux personnes se précipitent pour le premier login Google avec le même email, finit-on avec deux Users ?
Voici la vraie histoire. L'implémentation initiale a été faite dans Amp (91e4f48) — Amp tourne sur Claude en dessous, avec un modèle d'interaction bien adapté pour poser une grosse base d'un seul coup. Quelques jours plus tard, 0112888 dans Claude Code a corrigé un conflit Turbo-vs-OAuth. Deux outils qui se passent le relais naturellement.
Amp a posé le boilerplate d'un seul coup dans 91e4f48 :
Gemfile : omniauth, omniauth-google-oauth2, omniauth-githubOauthAccount : provider / uid / email / name / avatar_url, avec unique index sur [provider, uid]Auth::OmniauthController/auth/:provider/callback + /auth/failuresessions/new.html.erb avec deux button_to (Google + GitHub)omniauth configurant callback_pathLe modèle User a reçu une modification d'une ligne — et cette ligne est la vraie astuce (ci-dessous).
14 fichiers, 257 lignes ajoutées. La partie mécanique d'OAuth s'arrête ici.
Quand un utilisateur clique sur "Continue with Google", il atterrit finalement sur /auth/google_oauth2/callback. request.env["omniauth.auth"] contient provider, uid, email, name, avatar. Il faut décider :
OauthAccount correspondante) → connecter le User déjà liéLe 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
Quarante-six lignes qui ont l'air ordinaires. Chaque branche cache un piège.
Le has_secure_password par défaut de Rails 8 exige password_digest. Un utilisateur qui se connecte avec Google n'a pas de mot de passe — et alors ?
Ne retirez pas has_secure_password (ça casse le login par mot de passe).
Faites : désactiver la validation par défaut et écrire la vôtre conditionnellement :
class User < ApplicationRecord
has_secure_password validations: false
validates :password, length: { minimum: 8 }, if: -> { password.present? }
# ...
end
validations: false : retire la règle "mot de passe requis" qui accompagne has_secure_passwordvalidates :password, length:, if: password.present? : si l'utilisateur met un mot de passe, exige un min de 8 ; sinon, on s'en ficheLes utilisateurs OAuth-only sont créés avec password_digest: "". Ils peuvent ajouter un mot de passe plus tard (tant qu'il fait 8+ caractères). Les utilisateurs d'auth par mot de passe ne sont pas affectés.
Effet de bord : le test existant de "password reset" a cassé — il utilisait un mot de passe trop court. Amp l'a corrigé dans le même commit (test/controllers/passwords_controller_test.rb). Quand un agent ajoute une feature, jetez un œil à la surface des tests — ça économise un cycle de re-run.
La troisième branche du callback :
user = User.find_by(email_address: auth.info.email) || User.create!(...)
Très bien en single-thread. En production, deux requêtes arrivent presque simultanément (rare mais ça arrive) — les deux find_by renvoient nil, les deux create!, une réussit, l'autre cogne contre l'unique index sur email_address (en supposant que vous l'avez).
Pire : si vous n'avez pas d'unique index sur email_address, deux Users avec le même email sont créés. En aval : binding du customer Stripe ambigu, lookups de subscription erronés.
Fix :
add_index :users, :email_address, unique: truefind_or_create_by! ou envelopper avec 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 version originale d'Amp n'enveloppait pas. Quand un agent écrit du code DB haute concurrence, il faut demander activement "que se passe-t-il en concurrence ?". Les agents supposent par défaut un accès sériel mono-utilisateur — leurs tests ne couvrent que les chemins sériels.
La deuxième branche — utilisateur connecté liant un nouveau provider OAuth — semble simple, mais a un cas limite moche.
Utilisateur A connecté sous [email protected]. Clique sur Continue with GitHub. Le callback GitHub renvoie [email protected] (le GitHub d'A utilise son email pro).
Maintenant la DB peut contenir :
- User A : email [email protected]
- User B : email [email protected] (A s'est inscrit avec son email pro il y a des mois)
Si vous exécutez Current.user.oauth_accounts.create!(...) comme le code fait, GitHub est lié à A. Mais User B existe toujours, possiblement avec les Purchases de B. B peut toujours se connecter par mot de passe, mais son GitHub est maintenant à A.
Un flux de binding complet doit gérer "que fait-on des données orphelines du compte ?" — les agents n'y pensent pas par défaut. Au minimum, un avertissement UI : "Ce compte GitHub est lié à l'email [email protected]. Continuer empêchera ce compte d'utiliser GitHub pour se connecter."
La version actuelle ne gère pas ça — elle lie simplement Current.user à l'enregistrement OAuth et passe. C'est une simplification intentionnelle : à patcher quand le problème se présentera vraiment. Mais vous, en tant qu'humain, devez savoir que le trou existe.
OAuth câblé, déployé, clic sur Continue with Google — rien ne se passe. L'onglet network montre POST /auth/google_oauth2 → 302 vers accounts.google.com/o/oauth2/auth?.... Le navigateur ne bouge pas.
L'article précédent Laisser Claude intégrer deux paiements : Stripe + x402 a couvert exactement le même mécanisme : button_to génère un <form method="post">, Turbo intercepte le form, traite la réponse comme TURBO_STREAM, et TURBO_STREAM ne suit pas les 302 cross-origin.
Fix atterri dans 0112888 (fait dans Claude Code) :
<%= button_to "/auth/google_oauth2", method: :post,
+ form: { data: { turbo: false } },
class: "..." do %>
Continue with Google
<% end %>
Les deux boutons OAuth en ont besoin. Règle : tout button_to dont la cible fait un 302 vers un domaine externe (Stripe Checkout, Stripe Portal, Google OAuth, GitHub OAuth, Apple Sign-In…) a besoin de data: { turbo: false }. Pas un bouton — une classe entière.
Dépôt fraîchement cloné, pas de credentials GitHub configurées — la page de login affiche quand même "Continue with GitHub". Cliquer donne Invalid client_id.
Le même commit (0112888) a ajouté un garde :
<% if Rails.application.credentials.dig(:github, :client_id).present? %>
<%= button_to "/auth/github", ... %>
<% end %>
Pas de credentials GitHub → pas de bouton. Ça vaut le coup parce que :
Faire écrire à un agent ce genre de "garde credentials" d'emblée ne vient pas automatiquement — il suppose que votre config est toujours peuplée. Scaffold de feature + garde credentials sont deux passes. Séparez-les, ne les demandez pas en un seul prompt.
Le travail OAuth s'est divisé en deux phases :
Phase 1 (Amp, 91e4f48) : une session posant toute la dalle OAuth — gems, modèle, migration, controller, view, routes, tests, i18n. 14 fichiers / 257 lignes / 20 minutes. Le modèle d'interaction d'Amp s'accorde avec "pousser un gros morceau à la fois", avec un historique de thread linéaire et propre.
Phase 2 (Claude Code, 0112888) : des jours plus tard, avant la mise en prod, Turbo-vs-OAuth nous a pincés. C'est le territoire du diagnostic précis + fix d'une ligne + test de régression. Claude Code a le contexte git/session complet dans le répertoire du projet, plus le propre hook d'enregistrement de session du projet (capture automatiquement tout vers docs/notes/pro/raw.md — voir Laisser Claude écrire des hooks qui s'enregistrent eux-mêmes). La trace de debug devient matière d'article.
Les deux outils ne se gênent pas. Amp pour le scaffolding depuis zéro et les longs threads de discussion ouverte ; Claude Code pour le travail incrémental dans un projet existant et pour l'automatisation d'ingénierie. Même modèle en dessous (Claude Opus 4.7, 1M de contexte) — formes d'interaction et toolsets différents.
Même ingénieur, parfois dans l'IDE, parfois dans le terminal. Chacun colle à des moments différents.
Laisser Claude (ou Amp) construire un login OAuth — checklist complète :
has_secure_password validations: false + longueur conditionnelle. Ne pas supprimer l'auth par mot de passe.button_to OAuth a besoin de data-turbo=false. Même famille de bugs que Stripe.OAuth lui-même n'est pas difficile. Ce qui est difficile, c'est de reconnaître les cas limites que les agents ne remontent pas naturellement — concurrence, données orphelines, credentials manquantes dans l'environnement local. Votre rôle d'humain est de poser ces questions : "Et la concurrence ? Et les orphelins de compte ? Et si les credentials ne sont pas configurées ?"
Vous posez les questions, l'agent écrit les réponses. C'est la vraie division du travail dans ce workflow.