Reciprok Docs

Authentification

Sessions équipe, liens magic membres, accès organisateurs

3 modes d'authentification

ActeurMécanismeImplémentationPersistance
Équipe ReciprokEmail + passwordBetter Auth (déjà installé)Session 7 jours, prolongée
MembreLien magic permanentTable custom magic_tokenPermanent jusqu'à révocation
OrganisateurLien magic ponctuelTable custom magic_tokenExpiration 24h - 7j

Important : on n'utilise pas Better Auth pour les membres et organisateurs. Better Auth est conçu pour des comptes utilisateurs avec session active (équipe interne). Les membres et organisateurs n'ont pas de compte au sens Better Auth, ils n'ont pas de mot de passe, pas d'inscription, pas de session. Ils accèdent à des ressources précises via un lien que l'équipe leur envoie. C'est un cas d'usage différent qui mérite sa propre table et son propre flow.

Pourquoi deux systèmes séparés

CritèreBetter Auth (équipe)magic_token (membres/organizers)
InscriptionAdmin onlyNon, créé par l'équipe
Mot de passeOuiNon
Session DBOui (Better Auth)Non, token signé / hashé
Multi-deviceOuiOui (un token utilisable depuis n'importe où)
Expiration7 jours, prolongéeConfigurable par token
RévocationLogout / adminrevokedAt sur le token
AuditSessions Better AuthCompteur d'usage + lastUsedAt
EnvoiLoginEmail avec URL /m/<code>/<token>

Mélanger les deux dans Better Auth aurait demandé de bidouiller son modèle de User pour des cas auxquels il n'est pas adapté. Plus simple et plus clair de garder Better Auth pour l'équipe et coder un magic_token léger pour le reste.

Better Auth pour l'équipe

Déjà en place dans packages/auth/. Sessions persistées en DB, gérées par Better Auth.

Configuration cible :

  • Email + password obligatoire
  • Pas d'inscription publique (création par admin uniquement)
  • Sessions de 7 jours, prolongées automatiquement
  • Logout = révocation immédiate
  • 2FA TOTP en option (à activer dès qu'on est plus de 3 dans l'équipe)

Liens magic membres (dashboard et KB)

Le problème

Un membre reçoit un lien personnel pour :

  • Accéder à son dashboard
  • Mettre à jour ses disponibilités en langage naturel
  • Enregistrer un audio de présentation
  • Voir ses commissions

Si ce lien est forwardé (à un employé, à un concurrent, par erreur), n'importe qui peut accéder à ses données.

Stratégie

Token persistant en DB, lié au membre :

member.accessTokenHash // hash SHA-256 du token réel

Le token réel est généré une fois (32 bytes random base64url) et envoyé au membre par email à l'onboarding. Jamais stocké en clair, seulement le hash.

Format de l'URL :

https://app.reciprok.com/m/<member_code>/<token>

Garde-fous

  1. Révocation : l'équipe peut révoquer un token et en générer un nouveau (envoyé par email)
  2. Rotation programmée : option pour rotater automatiquement tous les 6 mois
  3. Rate limiting par token : si un token reçoit 100+ requêtes/minute, on alerte (forwardé ou bot)
  4. Audit log : chaque utilisation du token est tracée (IP, user-agent, action)
  5. Pas d'IP whitelist : trop contraignant pour des restaurateurs en mobilité

Ce que le membre peut faire

Avec son token, le membre peut :

  • ✅ Voir SES données (sa fiche, ses dispos, ses commissions)
  • ✅ Modifier ses informations de base
  • ✅ Uploader des audios et photos
  • ✅ Voir les demandes qui le concernent
  • ❌ Voir d'autres membres
  • ❌ Voir des organisateurs
  • ❌ Voir les autres demandes du système

Vérification systématique côté API : req.memberContext.id === resource.memberId.

Liens magic organisateurs

Le problème

Un organisateur reçoit un lien pour :

  • Consulter un catalogue
  • Répondre à une requalification
  • Confirmer des dates

Ces liens doivent être :

  • Courts dans le temps (un catalogue n'a plus d'intérêt 6 mois après)
  • Liés à une action précise (pas un accès général)
  • Révocables si la demande est annulée

Stratégie

Token signé (JWT court ou token DB) avec :

  • requestId (la demande concernée)
  • action (view_catalog | respond_qualification | respond_availability)
  • expiresAt (24h pour requalification, 7 jours pour catalogue)
  • usageLimit (optionnel, un seul usage pour les actions critiques)

Pas de session, pas de persistance côté organisateur. Chaque clic régénère le state.

https://app.reciprok.com/c/<catalog_token>
https://app.reciprok.com/q/<qualification_token>
https://app.reciprok.com/d/<availability_token>

Révocation

Si la demande est marquée perdue ou annulée, tous les tokens liés sont automatiquement invalidés (table magic_token avec revokedAt).

Implémentation

packages/api/src/lib/magic-tokens.ts
import { createHash, randomBytes } from "node:crypto";
import { db } from "@reciprok/db";
import { magicToken } from "@reciprok/db/schema/auth";

export async function createMagicToken(params: {
  scope: "member" | "organizer";
  scopeId: string;
  action?: string;
  expiresIn?: number; // en secondes
  usageLimit?: number;
}) {
  const raw = randomBytes(32).toString("base64url");
  const hash = createHash("sha256").update(raw).digest("hex");

  await db.insert(magicToken).values({
    tokenHash: hash,
    scope: params.scope,
    scopeId: params.scopeId,
    action: params.action ?? null,
    expiresAt: params.expiresIn
      ? new Date(Date.now() + params.expiresIn * 1000)
      : null,
    usageLimit: params.usageLimit ?? null,
    usageCount: 0,
  });

  return raw; // À envoyer au destinataire, jamais stocké
}

export async function verifyMagicToken(raw: string) {
  const hash = createHash("sha256").update(raw).digest("hex");
  const token = await db.query.magicToken.findFirst({
    where: eq(magicToken.tokenHash, hash),
  });

  if (!token) throw new Error("Invalid token");
  if (token.revokedAt) throw new Error("Token revoked");
  if (token.expiresAt && token.expiresAt < new Date())
    throw new Error("Token expired");
  if (token.usageLimit && token.usageCount >= token.usageLimit)
    throw new Error("Token usage exceeded");

  // Increment usage
  await db.update(magicToken)
    .set({ usageCount: sql`usage_count + 1`, lastUsedAt: new Date() })
    .where(eq(magicToken.id, token.id));

  return token;
}

Schema Drizzle

packages/db/src/schema/auth.ts (extension)
export const magicToken = pgTable("magic_token", {
  id: uuid("id").defaultRandom().primaryKey(),
  tokenHash: text("token_hash").notNull().unique(),
  scope: text("scope").notNull(), // "member" | "organizer"
  scopeId: uuid("scope_id").notNull(),
  action: text("action"),
  expiresAt: timestamp("expires_at"),
  usageLimit: integer("usage_limit"),
  usageCount: integer("usage_count").default(0).notNull(),
  lastUsedAt: timestamp("last_used_at"),
  revokedAt: timestamp("revoked_at"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

Index sur tokenHash (unique) et scopeId.

On this page