Retour au Blog
SaaS & Side Projects23 novembre 202411 min de lecture

Connexion sociale + authentification sans mot de passe. Leçons apprises

DR

Doug Ribeiro

11 min de lecture

L'authentification est la partie de chaque application que les développeurs sous-estiment, construisent trop vite, puis passent des mois à corriger. Je le savais en commençant. Je me suis dit que je le ferais bien du premier coup. J'ai quand même été brûlé. Pas de manière catastrophique, mais de façons que je veux documenter pour que d'autres développeurs fassent un peu moins les mêmes erreurs.

Mon projet personnel, une application sociale que je construis en local avec Angular et Flask, supporte quatre méthodes d'authentification : connexion sociale Google, connexion sociale Facebook, connexion sociale Bluesky (qui est nettement différente des autres), et lien magique sans mot de passe par courriel. Plus l'option de connecter plusieurs méthodes au même compte. L'architecture qui gère tout cela repose sur Firebase Auth côté client et une couche d'authentification Flask personnalisée côté backend, Firebase se chargeant d'envoyer les courriels de lien magique. C'est un projet personnel, pas un produit. Il tourne sur ma machine, il n'a jamais été déployé, et rien de ce qui suit n'a été dimensionné pour le trafic de quiconque à part le mien.

Cet article détaille comment j'ai construit chaque pièce, les erreurs que j'ai faites, et les considérations de sécurité que j'aurais voulu voir mises en avant dans chaque tutoriel que j'ai lu.

Deux chemins d'architecture

Pour une application qui a besoin de Google, Facebook, d'un lien magique par email et peut-être d'une identité fédérée comme Bluesky, vous avez deux architectures réalistes. Chacune est un choix légitime. Celle que vous retenez dépend de si vous valorisez le temps jusqu'à un login qui marche ou l'évitement de la dépendance à un fournisseur.

Firebase Auth (ce que j'ai construit)Heures jusqu'à un Google + lien…SDK des fournisseurs gérés pour…Le couplage de comptes est conf…Dépend de la disponibilité de F…Le niveau gratuit couvre une ve…Auto-hébergé (Keycloak / Authentik)Jours à semaines pour configure…Flux OAuth2 que vous possédez d…Pleine propriété des données, s…Dépend de votre maturité opérat…Gratuit si vous opérez déjà l'i…VS

Si vous ne voulez pas d'un fournisseur dans votre chemin d'authentification, la voie gratuite est de l'opérer vous-même : Keycloak est le choix mature par défaut, Authentik est le plus léger et le plus moderne, et les deux sont open source et ne coûtent rien de plus que la machine sur laquelle vous les posez. Une chose à vérifier avant de vous engager : le passwordless intégré de Keycloak, c'est WebAuthn, donc les liens magiques par courriel nécessitent une extension. J'ai quand même choisi Firebase pour ce projet, parce que le temps jusqu'à un login qui marche se mesurait en heures et que j'étais seul développeur à construire une première version. Le reste de cet article détaille le chemin Firebase que j'ai implémenté, et les options auto-hébergées sont comparées en détail à la fin.

Choisir Firebase : les compromis que j'ai faits

La question que la plupart des développeurs posent est : pourquoi ne pas implémenter les flux OAuth2 directement ? Pour la connexion Google et Facebook, je le pourrais absolument. Le flux OAuth2 est bien documenté, et il existe de bonnes bibliothèques Python pour le gérer. Mais implémenter OAuth2 correctement pour plusieurs fournisseurs signifie gérer l'échange de tokens, les endpoints d'informations utilisateur spécifiques à chaque fournisseur, différents formats de tokens et différentes réponses d'erreur pour chaque fournisseur. Vous prenez aussi la responsabilité de gérer de manière sécurisée les paramètres d'état OAuth2 pour prévenir les attaques CSRF.

Firebase Auth gère tout cela. Le SDK JavaScript côté Angular gère le flux de redirection ou de popup OAuth2, reçoit le token du fournisseur, l'échange avec Firebase, et retourne un ID token Firebase. Mon backend Flask ne voit jamais que des ID tokens Firebase. Je ne touche jamais à un token OAuth2 brut. La complexité spécifique au fournisseur est cachée dans Firebase.

Le compromis est une dépendance à Firebase pour l'authentification. Si Firebase Auth tombe en panne, les utilisateurs ne peuvent pas se connecter. C'est un risque réel. Mais pour un projet personnel développé en solo à ses débuts, les économies de temps de développement justifient la dépendance. Je pourrai remplacer Firebase Auth par une implémentation directe plus tard quand le projet justifiera l'investissement en ingénierie.

Le service d'authentification Angular

Le service d'authentification Angular encapsule les appels du SDK Firebase et produit un état d'authentification interne propre que le reste de l'application utilise. L'application Angular ne communique jamais directement avec Firebase sauf à travers ce service.

typescript
// Angular auth service wrapping Firebase + Flask
@Injectable({ providedIn: 'root' })
export class AuthService {
  private auth = getAuth();
  private readonly apiService = inject(ApiService);

  // Internal state
  private _currentUser = signal<AppUser | null>(null);
  private _authState = signal<AuthState>('loading');

  readonly currentUser = this._currentUser.asReadonly();
  readonly authState = this._authState.asReadonly();
  readonly isAuthenticated = computed(() => this._currentUser() !== null);

  constructor() {
    // Listen to Firebase auth state changes
    onAuthStateChanged(this.auth, async (firebaseUser) => {
      if (!firebaseUser) {
        this._currentUser.set(null);
        this._authState.set('unauthenticated');
        return;
      }

      try {
        const idToken = await firebaseUser.getIdToken();
        const appUser = await this.exchangeTokenWithBackend(idToken);
        this._currentUser.set(appUser);
        this._authState.set('authenticated');
      } catch (error) {
        await signOut(this.auth);
        this._authState.set('error');
      }
    });
  }

  async signInWithGoogle(): Promise<void> {
    const provider = new GoogleAuthProvider();
    await signInWithPopup(this.auth, provider);
    // onAuthStateChanged handles the rest
  }

  async signInWithFacebook(): Promise<void> {
    const provider = new FacebookAuthProvider();
    await signInWithPopup(this.auth, provider);
  }

  private async exchangeTokenWithBackend(idToken: string): Promise<AppUser> {
    return firstValueFrom(
      this.apiService.post<AppUser>('/auth/verify', { id_token: idToken })
    );
  }
}

La couche de vérification des tokens Flask

Chaque requête nécessitant une authentification passe par un décorateur Flask qui vérifie l'ID token Firebase. Je ne stocke pas les tokens Firebase. Je les vérifie à la volée en utilisant le SDK Admin Firebase, qui vérifie la signature du token contre les clés publiques de Firebase et valide l'expiration, l'audience et l'émetteur.

python
import firebase_admin
from firebase_admin import auth as firebase_auth
from functools import wraps
from flask import request, g
from app.models.user import User
from app import db
from sqlalchemy import select

def firebase_auth_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        auth_header = request.headers.get('Authorization', '')
        if not auth_header.startswith('Bearer '):
            return error_response([{'code': 'MISSING_TOKEN', 'message': 'Authorization header required'}], 401)

        id_token = auth_header.split('Bearer ')[1]

        try:
            decoded = firebase_auth.verify_id_token(id_token)
        except firebase_auth.ExpiredIdTokenError:
            return error_response([{'code': 'TOKEN_EXPIRED', 'message': 'Token has expired'}], 401)
        except firebase_auth.InvalidIdTokenError:
            return error_response([{'code': 'INVALID_TOKEN', 'message': 'Invalid token'}], 401)

        firebase_uid = decoded['uid']
        user = db.session.execute(
            select(User).where(User.firebase_uid == firebase_uid)
        ).scalar_one_or_none()

        if not user:
            return error_response([{'code': 'USER_NOT_FOUND', 'message': 'User not found'}], 404)

        g.current_user = user
        return f(*args, **kwargs)
    return decorated

Un endpoint ne peut pas utiliser ce décorateur : /auth/verify, celui que le service Angular appelle à chaque changement d'état d'authentification Firebase. C'est le seul endroit où un token Firebase valide peut légitimement arriver sans ligne utilisateur derrière lui, donc il vérifie le token lui-même et provisionne l'utilisateur local à la première connexion, à partir des claims vérifiés et de rien d'autre. C'est pour ça que le décorateur peut traiter une ligne manquante comme un 404 au lieu d'en créer une en silence : au moment où n'importe quel autre endpoint s'exécute, la ligne existe.

python
# /auth/verify: the only endpoint that runs before a local user row exists
@auth_bp.route('/verify', methods=['POST'])
def verify():
    id_token = (request.get_json(silent=True) or {}).get('id_token', '')

    try:
        decoded = firebase_auth.verify_id_token(id_token)
    except firebase_auth.ExpiredIdTokenError:
        return error_response([{'code': 'TOKEN_EXPIRED', 'message': 'Token has expired'}], 401)
    except firebase_auth.InvalidIdTokenError:
        return error_response([{'code': 'INVALID_TOKEN', 'message': 'Invalid token'}], 401)

    user = db.session.execute(
        select(User).where(User.firebase_uid == decoded['uid'])
    ).scalar_one_or_none()

    if user is None:
        # First sign-in. Trust the verified claims only, never the request body
        user = User(
            firebase_uid=decoded['uid'],
            email=decoded.get('email'),
            email_verified=decoded.get('email_verified', False),
            display_name=decoded.get('name'),
        )
        db.session.add(user)

    # sign_in_provider is what the login page uses to show the last method used
    user.last_sign_in_provider = decoded.get('firebase', {}).get('sign_in_provider')
    db.session.commit()

    return success_response(user_schema.dump(user))

Gérer plusieurs fournisseurs pour le même utilisateur

C'est là où la plupart des tutoriels d'authentification s'arrêtent et où la plupart des bugs vivent. Un utilisateur s'inscrit avec Google en utilisant son adresse gmail.com. Plus tard, il essaie de se connecter avec un lien magique au même email. Est-ce le même utilisateur ? Ça devrait l'être. Mais si vous créez naïvement un nouvel enregistrement utilisateur pour chaque méthode d'authentification, ils deviennent deux comptes différents.

Firebase vous donne une partie de la réponse. Avec le réglage « Lier les comptes qui utilisent le même courriel » (un seul compte par adresse courriel), Firebase ne créera pas un second compte pour un courriel qu'il connaît déjà. Il ne fusionne rien tout seul pour autant : se connecter avec un autre fournisseur pour ce courriel échoue avec auth/account-exists-with-different-credential, et c'est à l'application de connecter l'utilisateur avec la méthode d'origine puis d'appeler linkWithCredential. L'exception, c'est Google sur son propre domaine de courriel, que Firebase considère comme faisant autorité, donc un utilisateur gmail.com qui se connecte avec Google ne rencontre jamais cette erreur. J'active le réglage pour les méthodes basées sur le courriel, et avec cette erreur gérée, la connexion Google et le lien magique sur le même courriel partagent un seul UID Firebase.

Mais Facebook est différent. Facebook ne garantit pas que l'adresse email fournie par un utilisateur est la même que son email Google. Un utilisateur peut utiliser des emails différents sur différentes plateformes sociales. Pour Facebook, je ne peux pas coupler automatiquement les comptes par email car je ne peux pas être sûr que les emails correspondent à l'intention de l'utilisateur.

Ma solution : après l'authentification d'un utilisateur, je lui montre ses fournisseurs connectés dans les paramètres de son compte et je le laisse coupler manuellement des fournisseurs supplémentaires pendant qu'il est connecté. Le couplage pendant l'authentification est sûr. Le SDK Firebase gère le flux de couplage, et mon backend stocke la liste des fournisseurs couplés. Les utilisateurs qui veulent fusionner des comptes doivent le faire explicitement.

Implémentation du lien magique

L'authentification par lien magique sans mot de passe est gérée par l'Email Link authentication de Firebase. Le flux est : l'utilisateur entre son email, Firebase envoie un lien de connexion à cet email, l'utilisateur clique sur le lien, le SDK Firebase côté Angular intercepte l'URL et complète la connexion, produisant un ID token Firebase. Le même flux de vérification de token que la connexion sociale prend le relais à partir de là.

Les parties délicates sont dans l'UX, pas dans l'implémentation technique. Les liens de connexion par courriel de Firebase expirent six heures après leur envoi, et le SDK client n'expose aucun réglage pour changer ça. Compléter la connexion exige à nouveau l'adresse courriel (Firebase la demande pour prévenir la fixation de session), et le flux standard la lit dans le localStorage du navigateur où l'utilisateur l'a tapée. Sur mobile, un lien touché peut s'ouvrir dans un navigateur différent de celui où le courriel a été entré, et l'authentification échoue sans raison évidente. Le deep linking vers les applications mobiles ajoute une autre couche de complexité.

Mes solutions : la fenêtre de six heures est fixe, donc le travail d'UX se concentre sur le chemin d'échec. Je détecte le scénario de navigateur croisé et affiche un message d'erreur spécifique avec des instructions pour copier-coller le lien dans le navigateur souhaité. Je n'ai pas encore implémenté le deep linking, donc sur mobile le repli est de terminer la connexion dans le navigateur.

Connexion Bluesky : l'AT Protocol est différent

Bluesky est notable car il ne rentre pas dans le moule du social login standard. L'AT Protocol, qui sous-tend Bluesky, a son propre modèle d'identité basé sur les DIDs (Identifiants Décentralisés) et la résolution de handles, et le profil OAuth qu'il a publié en septembre 2024 est OAuth 2.1 avec PKCE, PAR et DPoP obligatoires, ce qu'une configuration générique de client OAuth ne couvre pas d'office. Firebase Auth n'a pas de fournisseur Bluesky intégré.

Mon implémentation : j'authentifie directement contre l'API Bluesky depuis Flask en utilisant le handle Bluesky de l'utilisateur et son mot de passe d'application (pas son mot de passe principal. Les mots de passe d'application Bluesky sont des identifiants à portée limitée). En cas d'authentification Bluesky réussie, je reçois un DID Bluesky et une session. Je crée ou recherche ensuite un utilisateur dans ma base de données par ce DID et j'émets mon propre token de session. Je ne suis pas passé par Firebase pour Bluesky car il n'y avait pas de point d'intégration naturel. Les app passwords sont toutefois le chemin hérité : depuis cette publication de septembre 2024, Bluesky demande aux nouveaux projets d'utiliser OAuth à la place, donc traitez ma version comme la version rapide, pas comme celle à copier.

Cela signifie que les utilisateurs authentifiés par Bluesky utilisent un type de token différent de ceux authentifiés par Firebase. Mon décorateur d'authentification backend gère les deux : il vérifie d'abord un Bearer token Firebase, et si cela échoue, vérifie mon token de session Bluesky personnalisé. C'est plus complexe qu'un chemin d'authentification unique, mais l'AT Protocol de Bluesky est suffisamment convaincant en tant que plateforme pour que je veuille le supporter malgré la surcharge d'intégration.

Firebase (Google/Magic Link)Mature SDK with Angular supportToken refresh handled automatic…Email magic link out of the boxVerified on Flask with ID tokenBluesky (AT Protocol)DID-based decentralized identityOAuth 2.1 profile (PKCE, PAR, D…More intégration overheadNo official Angular SDK. Manual…VS
python
# Bluesky authentication endpoint
from atproto import Client as BlueskyClient

@auth_bp.route('/bluesky', methods=['POST'])
def bluesky_login():
    data = request.get_json()
    handle = data.get('handle', '').strip()
    app_password = data.get('app_password', '')

    if not handle or not app_password:
        return validation_error('credentials', 'Handle and app password required')

    try:
        client = BlueskyClient()
        profile = client.login(handle, app_password)
        did = profile.did
    except Exception:
        return error_response(
            [{'code': 'BLUESKY_AUTH_FAILED', 'message': 'Invalid handle or app password'}],
            status=401
        )

    # Find or create user by Bluesky DID
    user = UserService.find_or_create_bluesky_user(
        did=did,
        handle=handle,
        display_name=profile.display_name
    )

    # Issue internal session token
    session_token = SessionTokenService.create(user.id)
    return success_response({'session_token': session_token, 'user': user_schema.dump(user)})

Considérations de sécurité que j'ai failli manquer

Les tokens de lien magique doivent être à usage unique. Après qu'un lien magique est cliqué et que l'authentification est complétée, ce lien doit être invalidé. Firebase gère cela automatiquement. Les liens magiques Firebase sont à usage unique. Mais si vous construisez un système de lien magique personnalisé sans Firebase, c'est une exigence critique facile à oublier.

Le rafraîchissement des tokens doit être géré de manière transparente. Les ID tokens Firebase expirent après une heure. Je ne stocke pas le token moi-même. L'intercepteur HTTP qui l'attache aux requêtes API demande à Firebase le token courant à chaque requête, et getIdToken() retourne le token en cache tant qu'il est encore valide et le rafraîchit quand il est sur le point d'expirer.

typescript
// HTTP interceptor that always gets a fresh Firebase token
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  private auth = getAuth();

  intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    // Only attach auth header to our API requests
    if (!req.url.includes('/api') && !req.url.includes('/auth')) {
      return next.handle(req);
    }

    return from(this.getToken()).pipe(
      switchMap(token => {
        if (!token) return next.handle(req);
        const authReq = req.clone({
          setHeaders: { Authorization: `Bearer ${token}` }
        });
        return next.handle(authReq);
      })
    );
  }

  private async getToken(): Promise<string | null> {
    const user = this.auth.currentUser;
    if (!user) return null;
    // getIdToken() returns cached token if still valid (default); getIdToken(true) forces refresh
    return user.getIdToken(false);
  }
}

La limitation de débit sur les endpoints d'authentification est non négociable. Les endpoints de lien magique, en particulier, peuvent être exploités pour inonder la boîte de réception d'un utilisateur avec des courriels de connexion s'il n'y a pas de limitation de débit. Dans ce projet, les courriels de lien magique passent par Firebase, qui applique ses propres quotas d'envoi et limites par IP ; un système de lien magique maison a besoin de ses propres limites par courriel et par IP. Pour les endpoints que je contrôle directement, comme l'endpoint d'authentification Bluesky, j'ai implémenté la limitation de débit avec Flask-Limiter.

Les leçons UX

La partie technique de l'authentification était difficile. L'UX de l'authentification était plus difficile. Les utilisateurs ne pensent pas en termes de fournisseurs d'authentification. Ils pensent en termes de comment ils se sont connectés la dernière fois. Si un utilisateur s'est inscrit avec Google puis essaie d'utiliser un lien magique avec le même email, il s'attend à atterrir dans le même compte. Quand ce n'est pas le cas, il pense que le produit est cassé. Concevoir les règles de couplage, et trouver un texte d'interface qui rend la règle évidente sans un paragraphe d'explication, a demandé plus d'itérations que n'importe quelle pièce technique que j'ai construite.

L'autre leçon UX : toujours indiquer à l'utilisateur quelle méthode d'authentification il a utilisée quand il revient. Ma page de connexion se souvient de la dernière méthode utilisée dans localStorage et l'affiche en évidence. L'hypothèse pour laquelle j'ai conçu, c'est que personne ne se souvient s'il a cliqué sur Google ou utilisé un lien magique il y a six semaines. L'afficher supprime la frustration d'essayer trois méthodes d'authentification avant de trouver la bonne.

L'authentification est la première expérience que les utilisateurs ont avec votre produit. Investir le temps pour la rendre impeccable, techniquement et du point de vue UX, rapporte à chaque fois qu'un utilisateur revient sans friction.

Le parcours auto-heberge en détail

Firebase Auth était le choix pragmatique ici parce que le temps jusqu'à un login qui marche se mesurait en heures, pas en semaines. C'est une raison légitime. Mais si vous voulez auto-héberger ou éviter le vendor lock-in, la stack est assez mature pour que monter un équivalent fonctionnel prenne des jours, pas un projet de recherche.

Les options que je choisirais : Keycloak (mature, complet, le choix par défaut pour OIDC auto-hébergé). Authentik (plus léger, moderne, bonne expérience développeur). Lucia (l'authentification comme du code à l'intérieur de votre propre back end JavaScript ; son auteur a annoncé en octobre 2024 que le paquet serait déprécié en mars 2025 et survivrait comme un guide pour écrire ce code vous-même). Supabase Auth (auto-hébergeable si vous hébergez Supabase, managé sinon). Ory (orienté développeur, plus de pièces mobiles). Les quatre services vous donnent des sessions et de la connexion sociale avec des compromis opérationnels différents ; avec Lucia, vous écrivez cette partie vous-même. C'est sur le passwordless qu'ils diffèrent le plus : le passwordless intégré de Keycloak, c'est WebAuthn, et les liens magiques par courriel nécessitent une extension, donc vérifiez ce point avant de vous engager.

Si vous démarrez aujourd'hui et que vous savez que vous voulez de l'auto-hébergé, Keycloak est ma recommandation par défaut pour la plupart des équipes. Authentik si vous voulez quelque chose de plus léger et plus moderne. Les guides Lucia si vous êtes à l'aise de garder l'authentification comme du code plutôt qu'un service. Entre Keycloak et Authentik, les compromis sont opérationnels, pas de capacité : les deux couvrent la surface OIDC et connexion sociale que Firebase couvre, et pour les liens magiques par courriel, vérifiez ce que chacun supporte avant de choisir.

Références

AuthenticationAngularFlaskFirebase