Adios
BlogNext.js SaaS

Next.js SaaS

Comment mettre en oeuvre l'authentification et l'autorisation fondée sur le rôle dans Next.js

Mettez en place la connexion GitHub, les sessions en base et les rôles d’espace de travail avec Better Auth et PostgreSQL. Protégez les pages, Server Actions et routes API.

Équipe AdiosMise à jour 26 septembre 202624 min de lecture

Nous allons créer l’authentification d’un tableau de bord de projets : connexion GitHub, session dans PostgreSQL et suppression des projets réservée aux propriétaires et admins de l’espace, tandis que les viewers ne peuvent que les lire. Les mêmes contrôles de permission protégeront la page, sa Server Action et son API HTTP.

Définir exactement ce qu'un utilisateur connecté peut faire

Supposons que Maya soit propriétaire de Northstar et dispose du rôle viewer dans Acme. Sa connexion est identique dans les deux espaces, mais ses permissions diffèrent. Un seul champ user.role ne peut pas exprimer cela : lui accorder le rôle owner globalement lui donnerait aussi le contrôle d’Acme.

Stockez le rôle sur la relation entre l’utilisateur et l’espace de travail. Une requête n’est autorisée que si la session est valide, la relation active, le rôle permet l’opération et le projet appartient à cet espace. Connaître l’identifiant du projet ne satisfait aucune de ces conditions.

Cette implémentation utilise App Router de Next.js 16, TypeScript, Better Auth 1.7 et PostgreSQL dans l’environnement Node.js. Partez d’une application TypeScript existante avec l’alias d’import @/*, une base PostgreSQL et Node.js 24. L’exemple couvre la lecture et la suppression des projets ; les invitations d’espace et la modification des rôles nécessitent leurs propres opérations protégées.

Autorisations dans un même espace de travail
RôleLire les projetsSupprimer les projetsModifier les appartenances
ResponsableOuiOuiOui
AdministrateurOuiOuiNon
LecteurOuiNonNon
Aucune appartenance activeNonNonNon

One protected request

Browser cookie
  → Better Auth verifies the session
  → application gets the verified user ID
  → PostgreSQL loads membership for the requested workspace
  → permission check + workspace-scoped project query
  → return only the fields the page needs

Configurer un vrai fournisseur d'authentification

Better Auth gérera le callback GitHub, les cookies de session et ses tables d’authentification. Notre application gérera les tables des espaces de travail et les décisions d’autorisation. Séparer ces responsabilités permet de changer le rôle dans un espace sans modifier l’identité de l’utilisateur.

Installez les paquets dans l’application et versionnez le fichier de verrouillage obtenu. Générez BETTER_AUTH_SECRET avec openssl rand -base64 32. Créez une application OAuth GitHub avec le callback local http://localhost:3000/api/auth/callback/github ; utilisez une application OAuth distincte et un callback HTTPS en production.

Install dependencies

npm install better-auth@1.7.6 pg zod server-only
npm install -D @types/pg

Définir la configuration du serveur

Ajoutez ces valeurs à un fichier .env ignoré. Remplacez les exemples par vos identifiants de base de données et OAuth. Aucune de ces variables ne nécessite le préfixe NEXT_PUBLIC_.

.env

DATABASE_URL=postgresql://USER:PASSWORD@localhost:5432/auth_demo
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=REPLACE_WITH_A_GENERATED_SECRET
GITHUB_CLIENT_ID=REPLACE_WITH_YOUR_CLIENT_ID
GITHUB_CLIENT_SECRET=REPLACE_WITH_YOUR_CLIENT_SECRET

Réutiliser un pool de connexions par processus Node

Créer un Pool dans chaque requête donnerait à chacune un nouveau budget de connexions. Ce module conserve un seul pool, y compris lors des rechargements en développement. Avec quatre répliques et max: 10, l’application peut utiliser jusqu’à 40 connexions ; les migrations et les autres services ont aussi besoin de capacité.

lib/db.ts

import { Pool } from "pg";

const globalDb = globalThis as unknown as { authDemoPool?: Pool };

export const db = globalDb.authDemoPool ?? new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
  connectionTimeoutMillis: 5_000,
  idleTimeoutMillis: 30_000,
});

if (process.env.NODE_ENV !== "production") globalDb.authDemoPool = db;

Utiliser des sessions en base avec une expiration fixe

Nous choisissons une durée absolue de sept jours et désactivons le renouvellement des sessions dans cet exemple. Le cache des cookies reste désactivé : chaque recherche de session consulte donc la base. Ces politiques sont délibérées : un cookie volé peut être révoqué centralement, et un navigateur actif doit se reconnecter après sept jours.

Un jeton signé autonome peut réduire les lectures en base, mais un rôle ou une session copié dans ce jeton reste valide jusqu’à l’expiration, sauf si vous ajoutez un contrôle de révocation. Les sessions en base conviennent à ce tableau de bord, car les changements de rôle et la déconnexion d’un appareil perdu doivent prendre effet lors des contrôles suivants.

lib/auth.ts

import { betterAuth } from "better-auth";
import { db } from "@/lib/db";

export const auth = betterAuth({
  database: db,
  baseURL: process.env.BETTER_AUTH_URL!,
  secret: process.env.BETTER_AUTH_SECRET!,
  trustedOrigins: [process.env.BETTER_AUTH_URL!],
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    },
  },
  session: {
    expiresIn: 60 * 60 * 24 * 7,
    disableSessionRefresh: true,
    cookieCache: { enabled: false },
  },
});

Monter les endpoints d’authentification

La route catch-all traite les requêtes de connexion, callback, session et déconnexion. Générez le schéma de la bibliothèque après la configuration, examinez le SQL et appliquez-le à votre base de développement. La commande CLI est npx auth@latest generate ; conservez la migration générée dans l’historique des migrations de l’application. Utilisez la version CLI compatible avec votre version verrouillée de Better Auth.

app/api/auth/[...all]/route.ts

import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";

export const runtime = "nodejs";
export const { GET, POST } = toNextJsHandler(auth);

Suivez la connexion du navigateur à la session

Le navigateur lance la connexion par notre route d’authentification, puis est redirigé vers GitHub. Le callback renvoie un code d’autorisation. Better Auth valide le parcours OAuth, échange ce code sur le serveur, associe l’identité du prestataire à un utilisateur local et crée une session en base. Le navigateur reçoit un cookie de session, puis revient sur notre site.

Le jeton d’accès GitHub et la session applicative ont des rôles différents. Le jeton du prestataire sert à communiquer avec GitHub. Notre tableau de bord utilise la session applicative pour identifier l’appelant. Ni une adresse e-mail fournie par le client ni un nom d’utilisateur GitHub ne prouve l’appartenance à un espace de travail.

Utilisez le client ci-dessous dans app/sign-in/page.tsx. Le callback revient à la page d’accueil existante ; après avoir créé l’espace d’exemple dans la section suivante, ouvrez l’URL de son projet.

lib/auth-client.ts

import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient();

Se connecter et gérer une requête échouée

La connexion GitHub crée l’utilisateur local lors de sa première utilisation. Un nouvel utilisateur n’accède à aucun espace de travail tant que le parcours d’accueil n’en crée pas un ou qu’une invitation autorisée ne lui accorde pas une appartenance.

app/sign-in/page.tsx

"use client";

import { useState } from "react";
import { authClient } from "@/lib/auth-client";

export default function SignInPage() {
  const [pending, setPending] = useState(false);
  const [error, setError] = useState("");

  async function signIn() {
    setPending(true);
    setError("");
    try {
      const result = await authClient.signIn.social({
        provider: "github",
        callbackURL: "/",
      });
      if (result.error) {
        setError("Sign-in failed. Please try again.");
        setPending(false);
      }
    } catch {
      setError("Could not reach the sign-in service.");
      setPending(false);
    }
  }

  return (
    <main>
      <button onClick={signIn} disabled={pending}>
        {pending ? "Redirecting…" : "Continue with GitHub"}
      </button>
      {error && <p role="alert">{error}</p>}
    </main>
  );
}

Stocker les rôles sur les appartenances aux espaces de travail

Appliquez cette migration applicative après celle générée par Better Auth. La bibliothèque d’authentification gère les tables user, account, session et verification. Les tables ci-dessous référencent sa table PostgreSQL user par défaut sans y ajouter de rôle global.

La clé composite d’appartenance autorise un rôle par utilisateur et par espace. workspace_id est obligatoire sur le projet. Ces contraintes empêchent les appartenances en double et les propriétaires orphelins, mais n’effectuent pas automatiquement les contrôles d’autorisation. Chaque requête de projet doit toujours respecter le périmètre de l’espace.

migrations/002_workspaces.sql

CREATE TABLE app_workspace (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name text NOT NULL
);

CREATE TABLE app_membership (
  workspace_id uuid NOT NULL REFERENCES app_workspace(id) ON DELETE CASCADE,
  user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
  role text NOT NULL CHECK (role IN ('owner', 'admin', 'viewer')),
  status text NOT NULL DEFAULT 'active'
    CHECK (status IN ('active', 'suspended')),
  PRIMARY KEY (workspace_id, user_id)
);

CREATE TABLE app_project (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id uuid NOT NULL REFERENCES app_workspace(id) ON DELETE CASCADE,
  name text NOT NULL
);

CREATE INDEX app_project_workspace_idx ON app_project(workspace_id);

CREATE TABLE app_audit_event (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  workspace_id uuid NOT NULL REFERENCES app_workspace(id),
  actor_id text NOT NULL,
  operation text NOT NULL,
  resource_id uuid NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

Créer un espace de travail pour le tutoriel local

Connectez-vous une fois, puis trouvez votre identifiant utilisateur local dans la base d’authentification. Dans psql, définissez demo_user_id avec cet identifiant et exécutez les instructions ci-dessous. Il s’agit de données d’initialisation de développement exécutées par un opérateur, pas d’un endpoint acceptant un identifiant ou un rôle depuis le navigateur.

En production, le parcours d’accueil doit créer l’espace de travail et son propriétaire initial dans une transaction, en déduisant l’identifiant du propriétaire de la session vérifiée. Accepter une invitation doit la lier à l’identité visée et consommer un jeton à usage unique avec expiration. Un utilisateur connecté ne doit jamais pouvoir choisir un espace existant et s’y accorder le rôle de propriétaire.

Development seed — run in psql after setting demo_user_id

-- First: SELECT id, email FROM "user";
-- Then:  \set demo_user_id 'PASTE_YOUR_LOCAL_USER_ID'

BEGIN;
INSERT INTO app_workspace (id, name)
VALUES ('11111111-1111-4111-8111-111111111111', 'Northstar');

INSERT INTO app_membership (workspace_id, user_id, role)
VALUES ('11111111-1111-4111-8111-111111111111', :'demo_user_id', 'owner');

INSERT INTO app_project (id, workspace_id, name)
VALUES (
  '22222222-2222-4222-8222-222222222222',
  '11111111-1111-4111-8111-111111111111',
  'Website'
);
COMMIT;

-- Open /workspaces/11111111-1111-4111-8111-111111111111/projects/22222222-2222-4222-8222-222222222222

Définir les permissions une fois et refuser les rôles inconnus

Nommez l’opération dans le code applicatif : project:read ou project:delete. Convertissez les rôles en permissions dans un module unique. La page et le serveur peuvent utiliser cette même correspondance, mais seule la recherche actuelle d’appartenance sur le serveur peut autoriser une requête.

Cette politique renvoie false pour un rôle inconnu. Cela compte pendant les migrations et lorsqu’une valeur inattendue arrive dans l’application. N’interprétez pas un rôle absent ou inconnu comme la valeur par défaut la plus permissive.

lib/permissions.ts

export type Permission =
  | "project:read"
  | "project:delete"
  | "membership:manage";

const permissions: Record<string, readonly Permission[]> = {
  owner: ["project:read", "project:delete", "membership:manage"],
  admin: ["project:read", "project:delete"],
  viewer: ["project:read"],
};

export function can(role: string, permission: Permission): boolean {
  return Object.hasOwn(permissions, role)
    && permissions[role].includes(permission);
}

Où le RBAC s'arrête

Le rôle détermine si un membre peut supprimer des projets dans un espace. workspace_id détermine quels projets sont concernés par cette règle. Ce sont deux conditions distinctes, qui doivent toutes deux être satisfaites.

Si le produit autorise ensuite les membres à supprimer seulement leurs propres projets, ajoutez un contrôle created_by à la requête protégée. Le rôle seul ne suffit pas à exprimer cette règle de propriété. De même, le rôle d’admin d’un espace ne doit pas accorder implicitement l’accès à une console d’assistance interne couvrant tous les clients.

Vérifier la session dans l’accès protégé aux données

Une redirection dans un layout peut améliorer la navigation, mais un Route Handler ou une Server Action peut être appelé directement. Placez la recherche d’identité dans la fonction qui lit les données protégées. Tous les appelants bénéficient alors des mêmes contrôles.

requireUserId vérifie le cookie entrant via Better Auth. Elle ne renvoie que l’identifiant utilisateur et lève une erreur 401 s’il n’existe aucune session valide. Une erreur de base ou de prestataire doit faire échouer la requête, sans se rabattre sur un utilisateur anonyme ou précédemment privilégié.

La requête de projet joint l’appartenance de l’appelant à l’espace demandé. Un projet Acme ne peut pas être récupéré en plaçant son identifiant dans une URL Northstar. Une appartenance manquante et un projet manquant renvoient tous deux 404 ici : l’endpoint ne révèle donc pas l’existence d’un projet d’un autre espace.

lib/access.ts

import "server-only";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";

export class AccessError extends Error {
  readonly status: 401 | 403 | 404;
  constructor(status: 401 | 403 | 404) {
    super(status === 401 ? "Sign in required"
      : status === 403 ? "Forbidden" : "Not found");
    this.status = status;
  }
}

export async function requireUserId() {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) throw new AccessError(401);
  return session.user.id;
}

Limiter la lecture et renvoyer un résultat réduit

Zod valide la structure des identifiants ; la jointure d’appartenance établit l’accès. Un UUID syntaxiquement valide reste non fiable. Les paramètres SQL gardent ces valeurs séparées du texte de la requête.

Renvoyez les champs d’affichage du projet et un indicateur canDelete. La page n’a besoin ni du jeton de session, ni du jeton du prestataire, ni de la ligne utilisateur, ni de l’appartenance complète. Cela limite aussi les dommages d’une sérialisation accidentelle vers un Client Component.

lib/projects.ts — read operation

import "server-only";
import { z } from "zod";
import { db } from "@/lib/db";
import { AccessError, requireUserId } from "@/lib/access";
import { can } from "@/lib/permissions";

export const projectInput = z.object({
  workspaceId: z.string().uuid(),
  projectId: z.string().uuid(),
});

export async function getProject(input: unknown) {
  const userId = await requireUserId();
  const { workspaceId, projectId } = projectInput.parse(input);
  const result = await db.query<{
    id: string; name: string; role: string;
  }>(
    `SELECT p.id, p.name, m.role
       FROM app_project p
       JOIN app_membership m ON m.workspace_id = p.workspace_id
      WHERE p.id = $1 AND p.workspace_id = $2
        AND m.user_id = $3 AND m.status = 'active'`,
    [projectId, workspaceId, userId],
  );

  const row = result.rows[0];
  if (!row || !can(row.role, "project:read")) throw new AccessError(404);
  return {
    id: row.id,
    name: row.name,
    canDelete: can(row.role, "project:delete"),
  };
}

Protéger la page et faire refléter les permissions dans l’interface

Appelez getProject depuis le Server Component. Un visiteur anonyme est envoyé à la connexion ; un projet inaccessible affiche la page introuvable. Le formulaire de suppression n’apparaît que si le rôle actuel autorise cette action.

Les champs cachés transportent les identifiants cibles, ce qui facilite le branchement du formulaire. Ils ne donnent aucune autorité. Un viewer peut ajouter le formulaire dans les outils du navigateur ou envoyer lui-même la requête ; la mutation de la section suivante doit la rejeter.

canDelete décrit l’accès au moment du rendu de la page. Si un propriétaire rétrograde l’utilisateur dans un autre onglet, l’ancienne page peut encore afficher Supprimer. La garantie vient de la nouvelle vérification d’appartenance au moment de l’écriture.

app/workspaces/[workspaceId]/projects/[projectId]/page.tsx

import { notFound, redirect } from "next/navigation";
import { ZodError } from "zod";
import { AccessError } from "@/lib/access";
import { getProject } from "@/lib/projects";
import { deleteProjectAction } from "./actions";

export const runtime = "nodejs";

export default async function ProjectPage({
  params,
}: {
  params: Promise<{ workspaceId: string; projectId: string }>;
}) {
  const ids = await params;
  const project = await getProject(ids).catch((error: unknown) => {
    if (error instanceof AccessError && error.status === 401) {
      redirect("/sign-in");
    }
    if (error instanceof AccessError || error instanceof ZodError) notFound();
    throw error;
  });

  return (
    <main>
      <h1>{project.name}</h1>
      {project.canDelete && (
        <form action={deleteProjectAction}>
          <input type="hidden" name="workspaceId" value={ids.workspaceId} />
          <input type="hidden" name="projectId" value={ids.projectId} />
          <button type="submit">Delete project</button>
        </form>
      )}
    </main>
  );
}

Autoriser l'écriture dans la même transaction

Supprimer un projet exige une nouvelle décision d’appartenance. Réservez une seule connexion PostgreSQL pour toute la transaction. Chargez et verrouillez l’appartenance de l’appelant, vérifiez project:delete et supprimez selon l’identifiant du projet et celui de l’espace de travail. Enregistrez l’opération réussie avant le commit.

FOR SHARE permet des lectures concurrentes, mais bloque UPDATE ou DELETE sur cette ligne d’appartenance jusqu’à la fin de la transaction. Cela ferme la fenêtre où le rôle pourrait changer entre sa vérification et l’écriture. Une transaction sans verrou adapté laisserait cette fenêtre ouverte.

L’insertion d’audit et la suppression du projet sont soit validées ensemble, soit annulées ensemble. actor_id de l’audit provient de la session vérifiée. Nous le conservons volontairement comme texte historique pour que la suppression ultérieure d’un utilisateur d’authentification n’efface pas l’auteur de l’opération.

Append to lib/projects.ts

export async function deleteProject(input: unknown) {
  const userId = await requireUserId();
  const { workspaceId, projectId } = projectInput.parse(input);
  const client = await db.connect();

  try {
    await client.query("BEGIN");
    await client.query("SET LOCAL lock_timeout = '3s'");
    await client.query("SET LOCAL statement_timeout = '5s'");

    const membership = await client.query<{ role: string }>(
      `SELECT role FROM app_membership
        WHERE workspace_id = $1 AND user_id = $2 AND status = 'active'
        FOR SHARE`,
      [workspaceId, userId],
    );
    const role = membership.rows[0]?.role;
    if (!role) throw new AccessError(404);
    if (!can(role, "project:delete")) throw new AccessError(403);

    const deleted = await client.query<{ id: string }>(
      `DELETE FROM app_project
        WHERE id = $1 AND workspace_id = $2
        RETURNING id`,
      [projectId, workspaceId],
    );
    if (deleted.rowCount !== 1) throw new AccessError(404);

    await client.query(
      `INSERT INTO app_audit_event
        (workspace_id, actor_id, operation, resource_id)
        VALUES ($1, $2, 'project.delete', $3)`,
      [workspaceId, userId, projectId],
    );
    await client.query("COMMIT");
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release();
  }
}

Appeler l’opération protégée depuis une Server Action

L’action transmet les champs de formulaire non fiables à deleteProject, qui récupère elle-même l’identité et les permissions. Aucun rôle, identifiant de propriétaire ou champ canDelete du navigateur n’est accepté.

Gardez les redirections hors du bloc catch de la mutation réussie, car les redirections Next.js lèvent des exceptions en interne. Pour un envoi refusé ou invalide, ce petit formulaire navigue vers une page d’erreur fixe. Un formulaire plus riche peut renvoyer des erreurs typées avec useActionState sans changer l’opération en base.

app/workspaces/[workspaceId]/projects/[projectId]/actions.ts

"use server";

import { redirect } from "next/navigation";
import { ZodError } from "zod";
import { AccessError } from "@/lib/access";
import { deleteProject } from "@/lib/projects";

export async function deleteProjectAction(formData: FormData) {
  let destination = "/";
  try {
    await deleteProject({
      workspaceId: formData.get("workspaceId"),
      projectId: formData.get("projectId"),
    });
  } catch (error) {
    if (error instanceof AccessError && error.status === 401) {
      destination = "/sign-in";
    } else if (error instanceof AccessError || error instanceof ZodError) {
      destination = "/access-denied";
    } else {
      throw error;
    }
  }
  redirect(destination);
}

Donner une destination claire aux formulaires refusés

Cette page signale l'échec de l'opération sans confirmer l'existence du projet soumis.

app/access-denied/page.tsx

export default function AccessDeniedPage() {
  return <p>The project could not be changed. Check your access and try again.</p>;
}

Réutiliser les contrôles dans une API HTTP

L’API importe les mêmes fonctions getProject et deleteProject. Un deuxième point d’entrée ne peut donc pas omettre accidentellement les contrôles de permission ou d’espace de travail. Utilisez 401 pour une session invalide, 403 pour un membre actif sans la permission requise et 404 pour une ressource inaccessible.

L’authentification par cookie nécessite aussi une protection CSRF. Better Auth protège ses propres endpoints, pas ce gestionnaire DELETE personnalisé. Cette API navigateur de même origine exige un en-tête Origin exactement égal à l’origine applicative configurée. Les origines absentes, null ou étrangères sont rejetées avant la mutation. Les clients machine ont besoin d’un parcours d’authentification distinct, plutôt que d’une exception qui affaiblirait cet endpoint à cookie.

Next.js ajoute des contrôles Origin/Host pour les Server Actions. Gardez-les activés et limitez précisément les origines de proxy de confiance. CORS détermine quels scripts navigateur peuvent lire une réponse ; ce n’est pas le contrôle d’autorisation d’une écriture authentifiée par cookie.

app/api/workspaces/[workspaceId]/projects/[projectId]/route.ts

import { ZodError } from "zod";
import { AccessError } from "@/lib/access";
import { getProject, deleteProject } from "@/lib/projects";

export const runtime = "nodejs";
const privateHeaders = { "Cache-Control": "private, no-store" };
type Context = {
  params: Promise<{ workspaceId: string; projectId: string }>;
};

function errorResponse(error: unknown) {
  if (error instanceof AccessError) {
    return Response.json({ error: error.message }, {
      status: error.status, headers: privateHeaders,
    });
  }
  if (error instanceof ZodError) {
    return Response.json({ error: "Invalid project identifiers" }, {
      status: 400, headers: privateHeaders,
    });
  }
  // Send unexpected failures to server monitoring; do not expose SQL/errors.
  return Response.json({ error: "Request failed" }, {
    status: 500, headers: privateHeaders,
  });
}

export async function GET(_request: Request, { params }: Context) {
  try {
    return Response.json(await getProject(await params), {
      headers: privateHeaders,
    });
  } catch (error) {
    return errorResponse(error);
  }
}

export async function DELETE(request: Request, { params }: Context) {
  const expectedOrigin = new URL(process.env.BETTER_AUTH_URL!).origin;
  if (request.headers.get("origin") !== expectedOrigin) {
    return Response.json({ error: "Forbidden origin" }, {
      status: 403, headers: privateHeaders,
    });
  }
  try {
    await deleteProject(await params);
    return new Response(null, { status: 204, headers: privateHeaders });
  } catch (error) {
    return errorResponse(error);
  }
}

Rendre prévisibles la révocation et les requêtes concurrentes

La session indique qui appelle. L’appartenance à l’espace de travail indique ce que cette personne peut faire maintenant. Chaque opération protégée relisant cette appartenance, rétrograder un admin en viewer affecte le prochain contrôle d’autorisation même si sa session de sept jours reste valide. Supprimer l’appartenance bloque l’accès à cet espace tout en préservant l’accès aux autres.

Une requête déjà autorisée est un autre cas. Si la suppression verrouille d’abord l’appartenance, elle peut être validée avant la fin d’une rétrogradation concurrente. Si la rétrogradation prend le verrou d’abord, la suppression attend puis vérifie le rôle mis à jour. La garantie est une décision ordonnée en base, pas l’annulation du travail déjà autorisé.

La révocation de session a une limite similaire : invalider une session empêche les contrôles suivants de réussir. Cela ne rappelle pas une réponse HTTP déjà envoyée, n’efface pas les données du navigateur et n’annule pas automatiquement une opération ayant déjà passé requireUserId. Les actions financières ou destructrices à l’échelle du compte peuvent nécessiter une nouvelle authentification et une limite transactionnelle plus forte.

Protégez également l'opération de changement de rôle

Un futur endpoint de gestion des rôles doit charger l’appartenance de l’utilisateur qui agit, exiger membership:manage et limiter l’appartenance cible au même espace de travail. Il ne doit jamais accepter un rôle de l’acteur simplement affirmé dans le JSON. Appliquez la même règle à la création d’invitations, au retrait des utilisateurs et à leur suspension.

Empêchez le retrait du dernier propriétaire. Deux requêtes concurrentes peuvent chacune voir un autre propriétaire et supprimer les deux, sauf si le contrôle est sérialisé. Une approche pratique consiste à verrouiller la ligne de l’espace avec FOR UPDATE, vérifier l’acteur et le nombre de propriétaires, puis modifier les appartenances dans une même transaction. Tous les chemins qui modifient la propriété doivent prendre ce verrou dans le même ordre.

Garder les décisions d’autorisation hors des caches partagés

Les exemples ne mettent pas en cache les sessions, appartenances ou réponses de projet entre les requêtes. Ne placez pas ces fonctions dans un wrapper partagé use cache ou unstable_cache. Mettre en cache une autorisation par le seul identifiant de projet pourrait permettre au prochain utilisateur de la réutiliser ; même une décision en cache propre à l’utilisateur peut survivre à une révocation.

Si les recherches répétées deviennent coûteuses pendant le rendu d’un Server Component, React cache peut les dédupliquer dans cette requête. Sa durée de vie diffère d’un cache de données Next.js persistant. Les mutations doivent toujours obtenir une décision fraîche dans leur transaction. Les réponses de route ci-dessus utilisent private, no-store ; configurez le CDN pour les respecter et exclure le HTML authentifié et les réponses RSC du cache public.

Un fichier proxy.ts facultatif peut rediriger les visiteurs sans cookie de session. La présence d’un cookie ne prouve pas sa validité. Gardez les contrôles de la DAL même si l’application utilise aussi les redirections Proxy, les layouts protégés et les gardes de routes côté client.

Tester les opérations refusées avec deux espaces de travail

Une connexion réussie du propriétaire prouve peu de choses sur les autorisations. Créez Northstar et Acme dans une base jetable. Donnez au même utilisateur le rôle admin dans Northstar et viewer dans Acme, puis créez un projet dans chacun. Créez aussi un second utilisateur sans appartenance.

Commencez par un test exécutable de la politique, puis testez les chemins en base et HTTP. Pour un refus, l’assertion critique porte à la fois sur la réponse et l’état inchangé : le projet doit toujours exister et aucun événement d’audit de suppression réussie ne doit avoir été ajouté.

Scénarios d'intégration et résultats escomptés
Requête ou changementRésultat escomptéCe que cela prouve
Aucune session ou session révoquée401 de l’API ; la page redirige vers la connexionChaque point d'entrée vérifie l'identité.
Un admin de Northstar supprime un projet Northstar204 ; un projet supprimé ; un événement d’auditLe parcours autorisé est validé en une seule opération.
Le même utilisateur tente de supprimer dans Acme avec le rôle viewer403 ; le projet est conservéLes rôles sont limités à chaque appartenance.
URL Northstar avec l’identifiant d’un projet Acme404 ; le projet Acme est conservéL'écriture utilise à la fois l'espace de travail et les identifiants de projet.
Un non-membre demande un identifiant de projet connu404 sans champs de projetConnaître un identifiant ne donne pas accès.
Rétrograder l’admin après l’affichage du formulaire de suppressionProchain envoi refusé ; le projet est conservéLe serveur ignore les permissions périmées de l’interface.
DELETE avec Origin absent ou étranger403 ; le projet est conservéL’endpoint personnalisé à cookie vérifie l’origine contre le CSRF.
L’insertion d’audit échoue après l’instruction DELETETransaction annulée ; le projet est conservéUn échec ne peut pas valider la moitié de l’opération.

tests/permissions.test.mjs — run with node --test tests/permissions.test.mjs on Node.js 24

import test from "node:test";
import assert from "node:assert/strict";
import { can } from "../lib/permissions.ts";

test("workspace roles grant only their declared permissions", () => {
  const cases = [
    ["owner", "project:delete", true],
    ["owner", "membership:manage", true],
    ["admin", "project:delete", true],
    ["admin", "membership:manage", false],
    ["viewer", "project:read", true],
    ["viewer", "project:delete", false],
    ["unknown", "project:read", false],
    ["constructor", "project:read", false],
  ];
  for (const [role, permission, expected] of cases) {
    assert.equal(can(role, permission), expected, role + ": " + permission);
  }
});

Tester la concurrence plutôt que supposer que le verrou fonctionne

Utilisez deux connexions en base. Sur la première, démarrez une transaction et passez le rôle du membre à viewer sans commit. Lancez la suppression sur la seconde : elle doit attendre à SELECT … FOR SHARE. Validez la rétrogradation ; la suppression doit reprendre, constater viewer et refuser l’opération.

Répétez en faisant d’abord verrouiller l’appartenance par la suppression. La rétrogradation doit attendre le commit de la suppression. Gardez le délai inférieur aux trois secondes de délai limite de verrouillage de l’exemple. Ce test indique précisément quelle opération gagne et vérifie une concurrence que le test unitaire des permissions ne peut pas couvrir.

Appelez aussi directement la Server Action à partir de sa requête navigateur capturée après le changement de rôle. Ne tester que les boutons visibles manquerait un point d’entrée qu’un attaquant peut toujours invoquer.

Exploiter le système d’authentification après le lancement

Toutes les répliques ont besoin du même secret d’authentification, de la même URL applicative, de la même base et de la même configuration OAuth. Gardez-les dans les secrets d’exécution, utilisez HTTPS et enregistrez l’URL de callback exacte de production. Une session créée sur la réplique A doit être vérifiable sur la réplique B. Déployez les changements de schéma avec des migrations examinées pour permettre la coexistence des anciennes et nouvelles versions pendant le déploiement.

Limitez le débit du trafic d’authentification à un point commun lorsque plusieurs répliques tournent ; des compteurs mémoire indépendants multiplient l’autorisation effective. Configurez le nœud périphérique pour écraser les en-têtes d’IP client de confiance et empêchez l’accès direct permettant de les falsifier. Surveillez les échecs de callback, les erreurs de recherche de session, les opérations refusées et l’épuisement du pool sans journaliser les cookies ni les jetons des prestataires.

Ce tutoriel utilise la connexion GitHub : la vérification des mots de passe et la récupération de compte commencent donc chez ce prestataire. Si vous activez ensuite la connexion par e-mail et mot de passe, ajoutez l’envoi vérifié d’e-mails, des jetons de réinitialisation à usage unique avec expiration, des limites de tentatives et la révocation des sessions après un changement sensible d’identifiants. Un champ de mot de passe seul ne fournit pas de système de récupération.

Pour les exports longs et les tâches en arrière-plan, enregistrez l’utilisateur initiateur et l’espace de travail, puis définissez si l’autorisation est revérifiée à l’exécution et au téléchargement du résultat. Une requête autorisée aujourd’hui ne doit pas créer un lien de téléchargement sans restriction qui fonctionne encore après le départ de l’utilisateur de l’espace.

Le résultat est un parcours d’autorisation réduit et explicite : session vérifiée, appartenance actuelle, opération permise, requête limitée au périmètre. Chaque endpoint ajouté doit appeler ce parcours, et chaque permission ajoutée doit avoir un test de refus.

Tous les articles