SDK Next.js

@keenauth/nextjs ajoute l'authentification à une application Next.js utilisant l'App Router. Votre application est le client OAuth et conserve tous les jetons côté serveur. Le navigateur ne reçoit qu'un cookie de session chiffré et HttpOnly, et ne voit jamais de jeton d'accès, de jeton de rafraîchissement ou de jeton ID. C'est le modèle backend-for-frontend (BFF). Le package n'a aucune dépendance d'exécution. Il n'est pas encore publié sur npm.

Avant de commencer

  1. Dans le tableau de bord, créez une application web dans votre environnement.
  2. Choisissez l'authentification client private_key_jwt et enregistrez votre clé publique. Un secret client fonctionne également.
  3. Ajoutez l'URI de redirection https://app.acme.example/auth/callback et l'URI de redirection après déconnexion https://app.acme.example/.
  4. Générez un secret de cookie d'au moins 32 caractères, par exemple avec openssl rand -base64 32. Conservez-le dans votre gestionnaire de secrets, et non dans le dépôt.

Créer l'instance

// lib/auth.ts
import { createKeenAuth } from "@keenauth/nextjs";

export const auth = createKeenAuth({
  issuer: "https://acme.eu.keenauth.app", // your environment's issuer, exactly
  clientId: "app_01j9zzzzzzzzzzzzzzzzzzzz",
  clientAuth: { method: "private_key_jwt", privateKey: JSON.parse(process.env.KEENAUTH_CLIENT_KEY!) },
  appBaseUrl: "https://app.acme.example",
  secret: process.env.KEENAUTH_COOKIE_SECRET!,
});

appBaseUrl est l'adresse publique de votre application. Le SDK construit à partir de cette valeur l'URI de redirection et toutes les redirections, jamais à partir des en-têtes Host ou X-Forwarded-* de la requête. Le protocole http n'est accepté que pour localhost.

Les options sont vérifiées à la création de l'instance : une erreur de configuration arrête donc l'application dès le démarrage.

Ajouter les routes

// app/auth/[...keenauth]/route.ts
import { auth } from "@/lib/auth";

export const { GET, POST } = auth.handlers;

Cela expose trois routes :

Route Rôle
/auth/login Démarre la connexion. Le paramètre de requête facultatif returnTo définit la page où l'utilisateur arrive ensuite.
/auth/callback Termine la connexion et définit le cookie de session.
/auth/logout Déconnecte l'utilisateur chez KeenAuth et le renvoie vers votre application.

La connexion utilise le flux d'autorisation par code avec PKCE. La requête est envoyée au serveur (PAR) lorsque votre environnement le propose. Au retour sur le callback, le SDK vérifie state, iss et le jeton ID, y compris son nonce.

returnTo doit être un chemin de votre propre application, par exemple /orders?tab=open. Toute autre valeur, comme //evil.example ou une URL complète, est ignorée et l'utilisateur arrive sur /.

Protéger les pages et renouveler les jetons

// proxy.ts (middleware.ts before Next.js 16)
import { NextResponse, type NextRequest } from "next/server";
import { auth } from "@/lib/auth";

export async function proxy(request: NextRequest) {
  const result = await auth.proxy(request, {
    protect: (path) => path.startsWith("/orders") || path.startsWith("/api/"),
  });
  if (result.response) return result.response;
  const response = NextResponse.next({ request: { headers: result.requestHeaders } });
  result.apply(response);
  return response;
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

Pour un chemin protégé sans session :

Lorsque le jeton d'accès expire dans moins de 60 secondes, le proxy le renouvelle. Chaque renouvellement remplace le jeton de rafraîchissement, et le nouveau cookie de session est défini dans la réponse. requestHeaders transmet la nouvelle session aux pages qui traitent la même requête.

Si le serveur refuse le jeton de rafraîchissement, la session prend fin. Une erreur serveur temporaire laisse la session inchangée.

Vérifiez également la session dans chaque Server Function et chaque route handler. Le matcher du proxy peut manquer un chemin.

Lire la session

Dans un Server Component, transmettez le magasin de cookies :

// app/orders/page.tsx
import { cookies } from "next/headers";
import { auth } from "@/lib/auth";

export default async function Orders() {
  const session = await auth.getSession(await cookies());
  if (!session) return <a href="/auth/login?returnTo=/orders">Sign in</a>;
  return <p>Signed in as {session.user.email}</p>;
}

session.user contient les revendications typées du jeton ID : subject, email, emailVerified, name, orgId, orgRoles et d'autres. Toutes les revendications, y compris les personnalisées, figurent dans session.user.claims. L'objet de session ne contient jamais de jeton.

Un Server Component ne peut pas définir de cookies : lire la session à cet endroit ne la renouvelle donc jamais.

Appeler votre API

Dans un Server Component, récupérez le jeton d'accès depuis le magasin de cookies :

const token = await auth.getAccessToken(await cookies());
const res = await fetch("https://api.acme.example/v1/orders", {
  headers: { Authorization: `Bearer ${token}` },
});

getAccessToken échoue avec :

Dans une route handler, updateSession effectue aussi le renouvellement :

// app/api/orders/route.ts
import { auth } from "@/lib/auth";

export async function GET(request: Request) {
  const { accessToken, apply } = await auth.updateSession(request);
  if (!accessToken) return new Response(null, { status: 401 });
  const upstream = await fetch("https://api.acme.example/v1/orders", {
    headers: { Authorization: `Bearer ${accessToken}` },
  });
  const response = new Response(upstream.body, { status: upstream.status });
  apply(response);
  return response;
}

auth.authorizedFetch fait la même chose en un seul appel. Il lit le jeton comme getAccessToken, ajoute l'en-tête Authorization et, avec DPoP, la preuve :

const res = await auth.authorizedFetch(await cookies(), "https://api.acme.example/v1/orders");

Ne transmettez pas le jeton d'accès à un Client Component. Appelez votre API depuis le serveur et n'envoyez au navigateur que les données. Pour vérifier ces jetons dans votre API, consultez Vérifier les jetons dans votre API.

Se déconnecter

Créez un lien vers /auth/logout, ou envoyez une requête POST vers cette route depuis un formulaire. Le SDK :

  1. supprime le cookie de session ;
  2. révoque le jeton de rafraîchissement ;
  3. envoie le navigateur vers le point de terminaison de déconnexion de votre environnement, qui le renvoie vers votre application.

Une requête que le navigateur marque comme provenant d'un autre site est refusée, afin qu'un autre site ne puisse pas déconnecter vos utilisateurs. Les navigateurs qui n'envoient pas Sec-Fetch-Site ne peuvent se déconnecter que par un POST depuis vos propres pages ; privilégiez un formulaire qui envoie une requête à /auth/logout.

Jetons liés à l'expéditeur (DPoP)

Avec DPoP (RFC 9449), votre jeton d'accès est lié à une clé que seul votre serveur détient. Un jeton divulgué, par exemple depuis un journal d'API, ne peut pas être utilisé sans cette clé.

  1. Générez une clé ES256 (P-256) pour l'application et conservez la JWK privée dans votre gestionnaire de secrets.
  2. Transmettez-la comme dpop :
export const auth = createKeenAuth({
  // ...the options above
  dpop: { privateKey: JSON.parse(process.env.KEENAUTH_DPOP_KEY!) },
});

Le SDK :

Pour appeler votre API, utilisez auth.authorizedFetch. Il envoie Authorization: DPoP <token> ainsi qu'une nouvelle preuve avec ath pour chaque requête, et réessaie une fois lorsque votre API répond 401 avec error="use_dpop_nonce". Si vous utilisez votre propre client HTTP, créez la preuve avec auth.createDpopProof({ method, url, accessToken, nonce }).

Utilisez la même clé dans chaque processus serveur : votre environnement lie les jetons et le jeton de rafraîchissement à cette clé. Si vous remplacez la clé, ou si vous activez ou désactivez DPoP, les sessions existantes prennent fin et les utilisateurs doivent se reconnecter.

Sessions côté serveur

Par défaut, la session entière est scellée dans le cookie. Transmettez un sessionStore pour conserver la session sur le serveur. Le cookie ne contient alors qu'une référence chiffrée vers un identifiant de session aléatoire.

import { createKeenAuth, memorySessionStore, type SessionStore } from "@keenauth/nextjs";

// Next.js bundles proxy.ts, route handlers and pages separately, and each bundle runs its own copy of this module.
// Keep the store on globalThis so that they all share one store in the server process.
const shared = globalThis as { keenauthSessions?: SessionStore };
const sessionStore = (shared.keenauthSessions ??= memorySessionStore());

export const auth = createKeenAuth({
  // ...the options above
  sessionStore,
});

Créez le magasin en mémoire une seule fois par processus, comme ci-dessus. Un memorySessionStore() appelé au niveau du module, sans globalThis, donne au proxy, à chaque route handler et aux pages leur propre magasin vide : la session que la route de callback enregistre n'est alors pas trouvée par le proxy ni par une page, et l'utilisateur apparaît déconnecté. Un magasin qui conserve ses données hors du processus (Redis, une base de données) ne pose pas ce problème.

memorySessionStore conserve les sessions dans la mémoire d'un processus et les perd au redémarrage. Utilisez-le pour le développement et pour les serveurs à instance unique. Avec plusieurs instances ou des fonctions serverless, implémentez l'interface SessionStore sur un magasin partagé, comme Redis ou votre base de données :

interface SessionStore {
  get(id: string): Promise<SessionRecord | null>;
  set(id: string, record: SessionRecord): Promise<void>;
  delete(id: string): Promise<void>;
  deleteByLogout(target: { sub?: string; sid?: string }): Promise<void>;
}
// SessionRecord: { value: string; sub: string; sid?: string; expiresAt: number }

Si le magasin échoue, le SDK lève une erreur session_store_error au lieu de considérer l'utilisateur comme déconnecté. La déconnexion supprime toujours le cookie.

Déconnexion back-channel

Avec un sessionStore, le SDK expose aussi /auth/backchannel-logout. Lorsqu'une session prend fin dans KeenAuth (par exemple, si l'utilisateur se déconnecte d'une autre application ou si un administrateur révoque la session), votre environnement envoie à cette route un jeton de déconnexion signé. Le SDK supprime les sessions correspondantes, de sorte que la requête suivante de l'utilisateur dans votre application ne contient plus de session.

  1. Enregistrez https://app.acme.example/auth/backchannel-logout comme URI de déconnexion back-channel de l'application.
  2. Vérifiez que la route est accessible depuis Internet. Le matcher de votre proxy peut l'inclure : le proxy la laisse passer.

Le SDK vérifie le jeton de déconnexion comme l'exige OpenID Connect Back-Channel Logout 1.0 : signature, émetteur, audience, iat et exp, événement de déconnexion, sub ou sid, absence de nonce, et type logout+jwt. Il répond 200 en cas de succès et 400 sinon, et votre environnement réessaie une livraison échouée.

Le jti de chaque jeton de déconnexion est mémorisé jusqu'à l'expiration du jeton, afin qu'un jeton intercepté ne puisse pas être rejoué. Un jti déjà reçu reçoit la réponse 200 sans nouvelle action. Le cache par défaut réside dans un seul processus ; avec plusieurs instances, transmettez un logoutReplayCache partagé qui implémente remember(jti, expiresAt) de façon atomique (par exemple Redis SET NX) et forget(jti).

Pour changer le secret, transmettez une liste : secret: [newSecret, oldSecret]. Le premier secret chiffre, et tous déchiffrent. Retirez l'ancien après sessionMaxAge.

Limites actuelles