Aller au contenu

← Tous les articles

Courriels d'authentification : Supabase, Auth0 et Firebase par Duva

Confirmation d'inscription, lien magique, mot de passe oublié, code à usage unique : ces courriels partent souvent du fournisseur d'authentification lui-même, depuis son domaine et avec ses modèles. Pour qu'ils partent plutôt de votre domaine, avec vos textes et un suivi de ce qui leur arrive, chacun des trois grands fournisseurs offre une voie différente. Ce guide les présente côte à côte, avec le code complet pour chacun.

Trois fournisseurs, trois mécanismes

Fournisseur Mécanisme Qui compose le message Où tourne le code
Supabase Send Email hook Votre code Une Edge Function Supabase
Auth0 Fournisseur de courriel personnalisé Auth0, à partir de vos modèles Une Action Auth0
Firebase Lien généré par le SDK Admin Votre code Votre serveur
  • Supabase appelle votre fonction au lieu d'envoyer : vous recevez les données (code, lien) et composez le message.
  • Auth0 compose le message à partir de ses modèles et vous le confie : votre Action n'a qu'à le transmettre.
  • Firebase n'a pas de point d'accroche pour remplacer son envoi : c'est votre serveur qui génère le lien, puis envoie le courriel.

Ce qu'il vous faut, dans tous les cas

  • Un domaine vérifié dans Duva et une clé API créée pour ce domaine (écran « Clés API » du tableau de bord).
  • Un compte Duva ouvert à l'envoi : un compte en bac à sable est limité (voir la FAQ).
  • La clé API de Duva rangée dans les secrets du fournisseur ou de votre serveur. Elle ne doit jamais se trouver dans une application web ou mobile.

Supabase : le Send Email hook

Supabase Auth envoie lui-même les courriels de confirmation d'inscription, de lien magique et de réinitialisation de mot de passe. Avec le Send Email hook, au lieu d'envoyer le courriel, Supabase appelle une URL de votre choix avec tout ce qu'il faut pour le composer, et c'est votre code qui l'envoie. Il vous faut un projet Supabase et la CLI Supabase.

Comment le hook fonctionne

À chaque courriel d'authentification, Supabase envoie à votre URL une requête POST signée, au format ouvert « Standard Webhooks » (le même que celui des webhooks de Duva, voir Vérifier la signature d'un webhook). Le corps contient :

  • user : l'utilisateur concerné, dont user.email.
  • email_data : token (le code à 6 chiffres), token_hash, redirect_to, site_url et email_action_type (signup, magiclink, recovery, invite, email_change ou reauthentication).

Votre fonction vérifie la signature, compose le message, l'envoie, puis répond 200 avec un objet JSON vide, ce que Supabase attend en cas de succès.

La fonction

Créez la fonction avec supabase functions new send-email, puis mettez ceci dans supabase/functions/send-email/index.ts :

import { Webhook } from "npm:[email protected]";

const hookSecret = Deno.env.get("SEND_EMAIL_HOOK_SECRET")!.replace("v1,whsec_", "");
const duvaKey = Deno.env.get("DUVA_API_KEY")!;
const duvaDomain = Deno.env.get("DUVA_DOMAIN")!; // par exemple example.com
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;

const SUBJECTS: Record<string, string> = {
  signup: "Confirmez votre adresse courriel",
  magiclink: "Votre lien de connexion",
  recovery: "Réinitialisation de votre mot de passe",
  invite: "Vous êtes invité",
  email_change: "Confirmez votre nouvelle adresse",
  reauthentication: "Votre code de confirmation",
};

const fail = (status: number, message: string) =>
  Response.json({ error: { http_code: status, message } }, { status });

Deno.serve(async (req) => {
  if (req.method !== "POST") return new Response("not allowed", { status: 405 });

  let user: { id: string; email: string };
  let data: { token: string; token_hash: string; redirect_to: string; email_action_type: string };
  try {
    const payload = await req.text();
    ({ user, email_data: data } = new Webhook(hookSecret).verify(
      payload,
      Object.fromEntries(req.headers),
    ) as { user: typeof user; email_data: typeof data });
  } catch {
    return fail(401, "invalid signature");
  }

  const link =
    `${supabaseUrl}/auth/v1/verify?token=${data.token_hash}` +
    `&type=${data.email_action_type}&redirect_to=${encodeURIComponent(data.redirect_to)}`;
  const text =
    data.email_action_type === "reauthentication"
      ? `Votre code : ${data.token}`
      : `Cliquez sur ce lien : ${link}\n\nOu saisissez ce code : ${data.token}`;

  const response = await fetch(`https://api.duva.ca/v1/${duvaDomain}/messages`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${duvaKey}`,
      "Content-Type": "application/json",
      // Une relance de Supabase pour le même courriel n'en crée jamais un second.
      "Idempotency-Key": `supabase-${user.id}-${data.email_action_type}-${data.token_hash || data.token}`,
    },
    body: JSON.stringify({
      from: `Example <comptes@${duvaDomain}>`,
      to: [user.email],
      subject: SUBJECTS[data.email_action_type] ?? "Notification",
      text,
    }),
  });
  if (!response.ok) return fail(500, `Duva a refusé le message (HTTP ${response.status})`);

  return Response.json({});
});

Déployer et brancher

  1. Déployez sans vérification du JWT, puisque c'est Supabase Auth qui appelle la fonction, pas un utilisateur : supabase functions deploy send-email --no-verify-jwt.
  2. Dans le tableau de bord Supabase, ouvrez Authentication > Hooks, créez un « Send Email hook », choisissez HTTPS, collez l'adresse de la fonction et cliquez sur Generate Secret.
  3. Enregistrez les secrets de la fonction : supabase secrets set SEND_EMAIL_HOOK_SECRET="v1,whsec_..." DUVA_API_KEY="dv_..." DUVA_DOMAIN="example.com".
  4. Créez un compte de test : le courriel de confirmation part maintenant par Duva.

Propre à Supabase

  • Le texte du message est à vous. Cet exemple envoie du texte brut. Ajoutez un champ html pour une mise en page, et adaptez les sujets et le texte à votre langue et à votre ton.
  • Changement d'adresse avec double confirmation. Quand Supabase confirme à la fois l'ancienne et la nouvelle adresse, il fournit aussi token_new et token_hash_new : composez alors deux messages, un par adresse. La documentation de Supabase décrit ce cas.

Auth0 : le fournisseur de courriel personnalisé

Auth0 envoie les courriels de vérification d'adresse, de réinitialisation de mot de passe et de code à usage unique (connexion sans mot de passe). Avec un fournisseur de courriel personnalisé, vous écrivez une Action, une petite fonction Node.js, qu'Auth0 appelle à chaque courriel à livrer. Elle reçoit le message déjà composé (sujet, HTML, texte, destinataire) et n'a qu'à l'envoyer. Il vous faut un compte Auth0 avec accès au tableau de bord.

L'Action

Le message est composé par Auth0 à partir de vos modèles de courriel : l'Action ne fait que le transmettre à Duva. L'objet event.notification contient to, from, subject, html et text.

/**
 * Livre un courriel d'Auth0 par l'API de Duva.
 * Secrets à définir dans l'éditeur : DUVA_API_KEY et DUVA_DOMAIN.
 */
exports.onExecuteCustomEmailProvider = async (event, api) => {
  const { to, from, subject, html, text } = event.notification;

  const body = { from, to: [to], subject };
  if (html) body.html = html;
  if (text) body.text = text;

  let response;
  try {
    response = await fetch(`https://api.duva.ca/v1/${event.secrets.DUVA_DOMAIN}/messages`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${event.secrets.DUVA_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });
  } catch (error) {
    // Réseau indisponible : Auth0 peut réessayer.
    api.notification.retry(`Duva est injoignable : ${error.message}`);
    return;
  }

  if (response.status === 429 || response.status >= 500) {
    // Limite de débit ou panne passagère : on réessaie.
    api.notification.retry(`Duva a répondu HTTP ${response.status}`);
  } else if (!response.ok) {
    // Refus définitif (adresse invalide, clé refusée...) : inutile de réessayer.
    api.notification.drop(`Duva a refusé le message (HTTP ${response.status})`);
  }
};

Deux choix à connaître :

  • api.notification.retry() demande à Auth0 de réessayer plus tard : on l'utilise pour une limite de débit (429) ou une panne passagère (5xx), qui peuvent se résoudre seules.
  • api.notification.drop() abandonne le courriel : on l'utilise pour un refus définitif, qu'une nouvelle tentative ne changerait pas. Une erreur de validation (422) ou une clé refusée en sont des exemples.

Brancher l'Action

  1. Dans le tableau de bord Auth0, ouvrez Branding > Email Provider.
  2. Activez Use my own email provider et choisissez Custom Provider.
  3. Dans From, indiquez l'adresse d'envoi : son domaine doit être celui de votre domaine Duva, par exemple [email protected].
  4. Collez le code de l'Action dans l'éditeur. Avec l'icône de clé du menu de gauche, ajoutez les secrets DUVA_API_KEY et DUVA_DOMAIN.
  5. Cliquez sur Save pour déployer, puis sur Send Test Email.

Propre à Auth0

  • Un doublon est possible après une panne. Si le réseau coupe après que Duva a accepté le message mais avant qu'Auth0 reçoive la réponse, la nouvelle tentative envoie un second courriel. C'est rare et sans gravité pour un code à usage unique ou un lien de réinitialisation, qui restent valides.
  • Les modèles restent dans Auth0. Pour changer le texte, le sujet ou la langue d'un courriel, modifiez les modèles dans Auth0 (Branding > Email Templates) : l'Action envoie ce qu'ils produisent.

Firebase : générer le lien avec le SDK Admin

Firebase Authentication peut envoyer des courriels de vérification d'adresse, de réinitialisation de mot de passe et de connexion par lien. Quand c'est le SDK client qui les demande, c'est Google qui les envoie, avec un modèle dont la personnalisation est limitée. Firebase n'offre pas de point d'accroche pour remplacer cet envoi : la voie prévue est de générer le lien côté serveur avec le SDK Admin, puis d'envoyer vous-même le courriel. Il vous faut un serveur où s'exécute le SDK Admin de Firebase : une fonction Cloud, un serveur Node.js, etc.

Générer le lien, puis l'envoyer

Le SDK Admin expose generatePasswordResetLink, generateEmailVerificationLink et generateSignInWithEmailLink. Chacune renvoie une adresse que vous placez dans votre courriel. Voici le mot de passe oublié, en Node.js :

import { createHash } from "node:crypto";
import { getAuth } from "firebase-admin/auth";

const DUVA_DOMAIN = process.env.DUVA_DOMAIN; // par exemple example.com

// L'adresse vers laquelle l'utilisateur revient ; son domaine doit être autorisé dans la console Firebase.
const actionCodeSettings = { url: "https://app.example.com/connexion" };

async function sendWithDuva({ to, subject, text, html, idempotencyKey }) {
  const response = await fetch(`https://api.duva.ca/v1/${DUVA_DOMAIN}/messages`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.DUVA_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify({ from: `Example <comptes@${DUVA_DOMAIN}>`, to: [to], subject, text, html }),
  });
  if (!response.ok) throw new Error(`Duva a refusé le message (HTTP ${response.status})`);
}

export async function sendPasswordReset(email) {
  let link;
  try {
    link = await getAuth().generatePasswordResetLink(email, actionCodeSettings);
  } catch (error) {
    // Aucun compte pour cette adresse : on ne le révèle pas (voir plus bas).
    if (error.code === "auth/user-not-found") return;
    throw error;
  }
  await sendWithDuva({
    to: email,
    subject: "Réinitialisation de votre mot de passe",
    text: `Pour choisir un nouveau mot de passe, ouvrez ce lien : ${link}`,
    html: `<p>Pour choisir un nouveau mot de passe, <a href="${link.replaceAll("&", "&amp;")}">cliquez ici</a>.</p>`,
    // Le lien est unique : une relance de ce même envoi n'en crée pas un second.
    idempotencyKey: `firebase-${createHash("sha256").update(link).digest("hex")}`,
  });
}

Les deux autres cas suivent le même schéma : generateEmailVerificationLink(email, actionCodeSettings) pour confirmer une adresse, et generateSignInWithEmailLink(email, actionCodeSettings) pour la connexion par lien (qui exige handleCodeInApp: true dans actionCodeSettings, et que la connexion par lien soit activée dans Firebase).

Faire appeler votre serveur plutôt que le SDK client

Pour que Duva remplace l'envoi de Google, l'application ne doit plus appeler sendPasswordResetEmail() ni sendEmailVerification() du SDK client : elle appelle votre fonction (une fonction appelable, un point d'accès HTTP), qui génère le lien et envoie le courriel comme ci-dessus. Tant que le client appelle ces méthodes, Google envoie son propre courriel.

Propre à Firebase

  • Ne révélez pas si un compte existe. Pour le mot de passe oublié, répondez exactement la même chose que l'adresse ait un compte ou non. C'est pourquoi l'exemple ignore l'erreur auth/user-not-found : sinon, n'importe qui pourrait tester des adresses pour savoir lesquelles sont inscrites. Limitez aussi le nombre de demandes par adresse et par IP.
  • Les liens expirent. Les liens de Firebase ont une durée limitée ; ne les conservez pas et n'en générez un qu'au moment d'envoyer.

À savoir, quel que soit le fournisseur

  • Envoyé ne veut pas dire reçu. Duva répond queued : le message est accepté pour envoi, pas encore livré. Pour savoir ce qui est arrivé à un courriel, lisez ses événements ou recevez-les par webhook ; le guide Courriel non reçu montre comment.
  • L'adresse d'envoi doit être sur votre domaine Duva. Le domaine du champ from doit être exactement celui de la clé API et de l'adresse appelée (/v1/<domaine>/messages) ; un sous-domaine est refusé.
  • Vos modèles, votre langue. Le texte et la mise en page sont à vous (dans votre code pour Supabase et Firebase, dans les modèles d'Auth0) : adaptez-les à la langue de l'utilisateur et ajoutez votre marque.

La référence complète de l'API est dans la documentation.