Aller au contenu principal
Tous les articles
·4 min de lecture

Authentification multi-tenant avec NextAuth v5

Implémentez un système d'authentification robuste avec rôles, permissions granulaires et isolation multi-tenant pour votre SaaS.

nextauthauthmulti-tenantsecurity

Le défi : auth + multi-tenant

L'authentification d'un SaaS multi-tenant est plus complexe qu'un simple login/password. Vous devez gérer :

  • Organisations — chaque client a son espace isolé
  • Rôles — ADMIN, MANAGER, COLLABORATOR, CLIENT...
  • Permissions granulaires — "facturation:create", "rh:manage_leaves"
  • Invitations — un admin invite ses collaborateurs
  • Session — stocker l'org active dans le token

NextAuth v5 (Auth.js) : le bon choix

NextAuth v5 apporte le support natif de l'Edge Runtime et un système de callbacks puissant.

Configuration de base

// src/server/auth.ts
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import Google from "next-auth/providers/google";
import { PrismaAdapter } from "@auth/prisma-adapter";
 
export const { auth, signIn, signOut, handlers } = NextAuth({
  adapter: PrismaAdapter(db),
  providers: [
    Google({
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    }),
    Credentials({
      async authorize(credentials) {
        // Validation email/password
        const user = await db.user.findUnique({
          where: { email: credentials.email },
          include: { organization: true },
        });
 
        if (!user || !(await verify(credentials.password, user.password))) {
          return null;
        }
 
        return user;
      },
    }),
  ],
  callbacks: {
    async session({ session, token }) {
      // Injecter l'org et le rôle dans la session
      session.user.id = token.sub!;
      session.user.organizationId = token.organizationId;
      session.user.role = token.role;
      return session;
    },
    async jwt({ token, user }) {
      if (user) {
        token.organizationId = user.organizationId;
        token.role = user.role;
      }
      return token;
    },
  },
});

Matrice de permissions RBAC

Le pattern que j'utilise : une matrice statique typée qui est la source unique de vérité.

// src/lib/permissions/matrix.ts
export type Permission =
  | "facturation:read"
  | "facturation:create"
  | "facturation:edit"
  | "clients:read"
  | "clients:create"
  | "rh:manage_leaves";
// ... 50+ permissions
 
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
  ADMIN: [
    "facturation:read",
    "facturation:create",
    "facturation:edit",
    "clients:read",
    "clients:create",
    "rh:manage_leaves",
    // ... toutes les permissions
  ],
  MANAGER: [
    "facturation:read",
    "facturation:create",
    "clients:read",
    // ... permissions limitées
  ],
  COLLABORATOR: [
    "clients:read",
    // ... permissions minimales
  ],
  CLIENT: [], // zéro permission interne
};

Procédure tRPC avec permission

// src/server/api/trpc.ts
export function requirePermission(permission: Permission) {
  return staffProcedure.use(({ ctx, next }) => {
    if (!hasPermission(ctx.session.user.role, permission)) {
      throw new TRPCError({ code: "FORBIDDEN" });
    }
    return next({ ctx });
  });
}
 
// Usage dans un router
export const rhRouter = createTRPCRouter({
  approveLeave: requirePermission("rh:manage_leaves")
    .input(z.object({ leaveId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      // Seuls ADMIN et MANAGER arrivent ici
    }),
});

Isolation multi-tenant : les pièges

Piège 1 : findUnique ne filtre pas par org

// DANGEREUX — pas de filtre organizationId
const invoice = await db.invoice.findUnique({
  where: { id: input.id },
});
 
// CORRECT — findFirst avec double filtre
const invoice = await db.invoice.findFirst({
  where: {
    id: input.id,
    organizationId: ctx.session.user.organizationId,
  },
});

Piège 2 : IDOR — ne jamais renvoyer FORBIDDEN

// MAUVAIS — révèle l'existence de la ressource
if (invoice.organizationId !== ctx.session.user.organizationId) {
  throw new TRPCError({ code: "FORBIDDEN" });
}
 
// CORRECT — NOT_FOUND pour éviter la fuite d'information
const invoice = await db.invoice.findFirst({
  where: { id: input.id, organizationId: ctx.session.user.organizationId },
});
if (!invoice) {
  throw new TRPCError({ code: "NOT_FOUND" });
}

Piège 3 : les sous-entités

// Une ligne de facture n'a pas d'organizationId direct
// → filtrer via le parent
const line = await db.invoiceLine.findFirst({
  where: {
    id: input.lineId,
    invoice: {
      organizationId: ctx.session.user.organizationId,
    },
  },
});

Flow d'invitation

1. Admin crée une invitation → token unique généré
2. Email envoyé avec lien /accept-invite?token=xxx
3. Le nouvel utilisateur crée son compte
4. Il est automatiquement rattaché à l'organisation
5. Le rôle défini par l'admin est assigné

Checklist sécurité auth

  • Tout update/delete a organizationId dans le where
  • findFirst (jamais findUnique) pour les ressources scopées
  • Répondre NOT_FOUND (jamais FORBIDDEN) pour les IDOR
  • HMAC webhook vérifié avec crypto.timingSafeEqual
  • Rate limiting sur le login
  • Mots de passe hashés avec bcrypt/argon2
  • Token d'invitation avec expiration

Conclusion

L'authentification multi-tenant est le fondement de tout SaaS sérieux. Les erreurs ici sont des failles de sécurité critiques — prenez le temps de bien l'implémenter dès le départ.

Le boilerplate HeartCo intègre tout ça out-of-the-box : 50+ permissions, 7 rôles, isolation automatique via Prisma $extends, et invitations par email.