Authentification
Sessions équipe, liens magic membres, accès organisateurs
3 modes d'authentification
| Acteur | Mécanisme | Implémentation | Persistance |
|---|---|---|---|
| Équipe Reciprok | Email + password | Better Auth (déjà installé) | Session 7 jours, prolongée |
| Membre | Lien magic permanent | Table custom magic_token | Permanent jusqu'à révocation |
| Organisateur | Lien magic ponctuel | Table custom magic_token | Expiration 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ère | Better Auth (équipe) | magic_token (membres/organizers) |
|---|---|---|
| Inscription | Admin only | Non, créé par l'équipe |
| Mot de passe | Oui | Non |
| Session DB | Oui (Better Auth) | Non, token signé / hashé |
| Multi-device | Oui | Oui (un token utilisable depuis n'importe où) |
| Expiration | 7 jours, prolongée | Configurable par token |
| Révocation | Logout / admin | revokedAt sur le token |
| Audit | Sessions Better Auth | Compteur d'usage + lastUsedAt |
| Envoi | Login | Email 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éelLe 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
- Révocation : l'équipe peut révoquer un token et en générer un nouveau (envoyé par email)
- Rotation programmée : option pour rotater automatiquement tous les 6 mois
- Rate limiting par token : si un token reçoit 100+ requêtes/minute, on alerte (forwardé ou bot)
- Audit log : chaque utilisation du token est tracée (IP, user-agent, action)
- 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
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
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.