Free

Laisser Claude construire un login SaaS : Google + GitHub OAuth + account linking

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.


La partie de 10 minutes

Amp a posé le boilerplate d'un seul coup dans 91e4f48 :

  • Gemfile : omniauth, omniauth-google-oauth2, omniauth-github
  • Modèle OauthAccount : provider / uid / email / name / avatar_url, avec unique index sur [provider, uid]
  • Action callback d'Auth::OmniauthController
  • Routes : /auth/:provider/callback + /auth/failure
  • sessions/new.html.erb avec deux button_to (Google + GitHub)
  • 72 lignes de tests controller couvrant 5 scénarios
  • Initializer omniauth configurant callback_path

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

Le vrai problème : le callback a trois cas

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 :

  1. Ce compte Google s'est-il déjà connecté ? (ligne OauthAccount correspondante) → connecter le User déjà lié
  2. Quelqu'un est-il actuellement connecté ? (la session contient un user) → lier Google au User actuel
  3. Ni l'un ni l'autre ? (nouveau compte Google, pas de session) → trouver par email ou créer un nouveau User

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.

Piège #1 : les utilisateurs OAuth-only n'ont pas de mot de passe

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_password
  • validates :password, length:, if: password.present? : si l'utilisateur met un mot de passe, exige un min de 8 ; sinon, on s'en fiche

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

Piège #2 : la race condition du find-by-email

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 :

  • Unique index au niveau DB : add_index :users, :email_address, unique: true
  • Au niveau app : utiliser find_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.

Piège #3 : le problème d'orphelins du account binding

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.

Écueil #1 : Turbo avale le 302 de Google

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.

Écueil #2 : le bouton GitHub s'affiche sans credentials

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 :

  1. Les devs qui clonent le projet ne voient pas de bouton cassé
  2. Si les credentials disparaissent accidentellement en prod, pas de bouton cassé sur lequel cliquer
  3. Quand les credentials local et prod dérivent, la feature se dégrade au lieu d'erreur

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.

Amp + Claude Code : deux outils, un modèle

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 :

  1. Reconnaître qu'OAuth lui-même est du boilerplate. Se concentrer sur les trois cas du callback.
  2. Utilisateurs OAuth-only : has_secure_password validations: false + longueur conditionnelle. Ne pas supprimer l'auth par mot de passe.
  3. Ajouter un unique index DB sur email + garde concurrence côté app pour le find-by-email. L'agent ne le fera pas.
  4. Problème d'orphelins cross-user du account binding : avertissement UI au minimum. Ne pas faire comme si ça n'existait pas.
  5. Chaque button_to OAuth a besoin de data-turbo=false. Même famille de bugs que Stripe.
  6. Pas de credentials → ne pas afficher le bouton OAuth. Couvre la dérive dev et prod.
  7. Scaffold de feature et garde credentials sont deux passes. Ne pas les demander en un seul prompt.

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.