Trackily Docs

Magic Link Authentication

Authentification passwordless pour les clients finaux. L'acheteur saisit son email, reçoit un lien à usage unique, clique, et accède à /account (historique de commandes, reçus PDF, status d'abonnement). Aucun mot de passe, aucune table users séparée, aucun OAuth.

Concept

Trackily Commerce n'a pas de système de comptes clients au sens classique : pas d'inscription, pas de mot de passe à mémoriser, pas de reset password. À la place :

  1. Le buyer va sur /account (lien dans le footer de la landing, dans le reçu, dans les emails).
  2. Il colle son email dans le formulaire et clique "Get my link".
  3. Trackily génère un token signé HMAC (TTL 30 minutes, single-use), l'enregistre côté DB, et envoie un email contenant https://landing-domain.com/account/auth?token=<token>.
  4. Le buyer clique le lien : /account/auth valide le token, set un cookie de session HMAC-signé (TTL 30 jours), et redirige vers /account.
  5. Le cookie sert d'authentification pour /api/account/me, /api/account/orders/:id/receipt.pdf, et les autres endpoints customer.

Ce flow est stateless côté session : pas de table sessions à maintenir. Le cookie est un blob email|expires.signature signé avec une clé dérivée de SECRETS_MASTER_KEY (HKDF, contexte trackily/session, sel aléatoire preview_token_key en base) une fois SECRETS_CHIFFRER_AU_REPOS=1 posé (temps 2 d'OPERATIONS.md §3 ; avant, signé comme le code d'avant, la dérivée déjà admise) ; un cookie signé avec la clé brute reste accepté 24 h après le démarrage du conteneur — on vérifie la signature à chaque request, on lit l'email signé. Si le timestamp dépasse expires, le cookie est invalide.

Pourquoi ce design

Trois critères justifient l'absence de mot de passe :

  • Volume faible. Un client e-commerce visite /account 1 ou 2 fois par an, max. La friction d'un password reset à chaque visite serait pire que la friction du magic-link.
  • Sécurité. Pas de password = pas de fuite de password = pas de credential stuffing depuis HaveIBeenPwned. Le seul vecteur d'attaque, c'est l'accès au mailbox du buyer — c'est aussi le vecteur de tous les password reset par email, donc on ne perd rien.
  • Pas de surface admin. Pas de page "register / login / forgot", pas de validation de mot de passe complexe, pas de hashing à maintenir. -200 lignes de code vs un système classique.

Endpoints HTTP

Méthode Route Auth Rôle
POST /api/account/magic-link publique génère le token + envoie le mail
GET /account/auth?token=<token> publique consomme le token, set cookie, redirige /account
GET /account publique (renvoie HTML) sert public/account.html
GET /api/account/me cookie retourne { email, total_orders, total_spent, orders[] }
POST /api/account/logout cookie clear cookie
GET /api/account/orders/:id/receipt.pdf cookie + email match génère / sert le PDF du reçu

POST /api/account/magic-link

POST /api/account/magic-link HTTP/1.1
Content-Type: application/json

{ "email": "marie@example.com" }

Réponse 200 { ok: true } que l'email existe ou non en DB (anti-énumération : un attaquant ne peut pas tester quels emails ont des comptes). Une valeur qui n'est pas une seule adresse stricte (liste « a@x,b@y », Nom <a@x>, espaces, guillemets, plus de 254 caractères) reçoit la même réponse, sans jeton ni e-mail.

Limite dédiée (lib/limite-lien-magique.js, en mémoire, bornée à 50 000 seaux), en plus de la rafale pixelRateLimit :

  • 3 demandes / 10 min et 10 / jour par couple (adresse, IP) — l'adresse comptée en minuscules et sans +tag (marie+1@ = marie@) ;
  • 60 / heure par IP — une IPv6 compte par son préfixe /64 ;
  • 20 / jour par adresse, toutes IP — au-delà, réponse silencieuse : 200 { ok: true } sans jeton ni e-mail.

Au-delà d'un seau couple ou IP : 429 { ok: false, error: "Too many requests. Please try again later." } avec Retry-After: 600 — le même corps et le même en-tête quel que soit le seau, et qu'il existe ou non un compte pour l'adresse. Une demande refusée ne crée ni jeton ni e-mail et ne prolonge pas le blocage.

GET /account/auth?token=<token>

GET /account/auth?token=AbCdEfGhIjKlMnOpQrStUvWxYz HTTP/1.1

Si le token est valide et non-expiré :

  • Consomme le token (marqué consumed_at = NOW() côté DB — single-use)
  • Set le cookie tly_customer_session (httpOnly, sameSite=Lax, secure si HTTPS)
  • Redirige 302 vers /account

Si invalide / expiré :

<p>This sign-in link is invalid or has expired.
<a href="/account">Request a new one</a>.</p>

GET /api/account/me

Retourne le profil + lifetime stats si cookie valide :

{
  "ok": true,
  "email": "marie@example.com",
  "total_orders": 4,
  "total_spent": "187.50",
  "orders": [
    {
      "id": 142,
      "order_number": "TR-000142",
      "total": "59.00",
      "currency": "EUR",
      "payment_status": "paid",
      "created_at": "2026-04-22T14:22:11.421Z"
    }
  ]
}

Si pas de cookie / cookie invalide : 401 { ok: false, error: "not_authenticated" }.

SMTP utilisé

Le magic-link envoie via le SMTP server marqué is_default=true (ou le premier is_active=true si aucun n'est default). C'est intentionnellement séparé du SMTP "marketing" : tu peux brancher Postmark pour les magic-links (excellente délivrabilité transactionnelle) et Mailgun pour les sequences marketing.

L'envoi est noté sur ce serveur (lib/email-transactionnel.js, depuis le 2026-09-30), en envoi anonyme : la route est publique et l'adresse est libre, c'est donc un visiteur qui choisit le rythme et le destinataire. Un succès note last_success_at sans compter dans les plafonds — compté, une seule IP épuisait le plafond horaire du serveur par défaut en deux minutes et arrêtait toute la file e-mail (post-achat, relances, double opt-in) jusqu'à minuit UTC. Seule une erreur survenue avant l'enveloppe (connexion, identifiant) se note dans last_error* et fait rougir le verdict « Peut envoyer ? » : un refus à l'étape RCPT TO recopie l'adresse saisie, qui ne doit rien pouvoir écrire sur le serveur. AVANT, un lien magique qui échouait ne laissait qu'une ligne dans les logs. Voir SMTP.

Pour vérifier la config :

{ "name": "list_email_smtp_servers", "arguments": {} }

Si aucun SMTP n'est configuré, l'endpoint loggue le lien dans la console serveur (dev fallback) au lieu de l'envoyer par mail :

[customer-magic-link] No SMTP configured. Link for marie@example.com: https://brand.com/account/auth?token=AbCd...

Le template d'email

Hard-coded dans server.js (volontairement simple, pas de templating loader pour réduire le risque d'injection) :

<p>Hi,</p>
<p>Click the link below to access your order history.
   This link expires in 30 minutes and can only be used once.</p>
<p><a href="<LINK>" style="display:inline-block;padding:10px 18px;
   background:#6366f1;color:#fff;text-decoration:none;border-radius:6px;
   font-weight:600">Open my account</a></p>
<p style="color:#94a3b8;font-size:12px">
   If you didn't request this, just ignore this email.</p>

Subject : Your sign-in link. From / Reply-To : pris du SMTP server utilisé.

La page /account

Servie depuis public/account.html — un seul fichier, autonomous, qui :

  • Si pas de cookie : affiche le form "Enter your email" qui POST sur /api/account/magic-link
  • Si cookie valide : appelle GET /api/account/me, affiche la liste des orders avec lien "Download PDF" par order

Pas de framework JS, juste du fetch + vanilla. Compatible avec n'importe quelle landing brandée — le branding (brand.color, brand.logo_url) est récupéré via un endpoint séparé.

Domaines custom

Si l'opérateur a configuré un domaine custom pour la landing (example-shop.com), le buyer arrive sur https://example-shop.com/account. Le magic-link envoie un lien sur le même domaine (la route /api/account/magic-link lit req.get('origin') ou req.get('host')) — pas de cross-domain weirdness.

Conséquence : le cookie est par-domaine. Un buyer qui achète via shop-fr.com puis via shop-de.com aura deux sessions séparées.

Sécurité

Vecteur Mitigation
Token guessing 32 bytes random base64 → 256 bits d'entropie
Token replay consumed_at côté DB → single-use enforcé
Token expiration TTL 30 minutes hard-coded
Cookie tampering HMAC-SHA256 sur `email
Cookie theft via XSS httpOnly cookie → JS ne peut pas le lire
MITM secure cookie quand req.protocol === 'https'
CSRF sur logout OK : POST simple, cookie sent → server clear cookie. Pas d'action critique sur logout.
Email enumeration endpoint retourne toujours 200

Voir aussi