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 SECRETS_MASTER_KEY — 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 toujours 200 { ok: true }, même si l'email n'existe pas en DB. C'est volontaire (anti-énumération) : un attaquant ne doit pas pouvoir tester quels emails ont des comptes en spamming l'endpoint.

Rate-limited via pixelRateLimit (par défaut ~10/minute par IP).

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.

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