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
- Dans le tableau de bord, créez une application web dans votre environnement.
- Choisissez l'authentification client
private_key_jwtet enregistrez votre clé publique. Un secret client fonctionne également. - Ajoutez l'URI de redirection
https://app.acme.example/auth/callbacket l'URI de redirection après déconnexionhttps://app.acme.example/. - 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 :
- une requête de page (
GETouHEAD) est redirigée vers/auth/loginavecreturnTo; - une requête vers
/api/…, ou utilisant une autre méthode, reçoit401avec un document de problème.
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 :
refresh_requiredlorsque le jeton approche de l'expiration et aurait dû être renouvelé par le proxy ;login_requiredlorsqu'il n'existe aucune session.
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 :
- supprime le cookie de session ;
- révoque le jeton de rafraîchissement ;
- 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é.
- Générez une clé ES256 (P-256) pour l'application et conservez la JWK privée dans votre gestionnaire de secrets.
- Transmettez-la comme
dpop:
export const auth = createKeenAuth({
// ...the options above
dpop: { privateKey: JSON.parse(process.env.KEENAUTH_DPOP_KEY!) },
});
Le SDK :
- lie le code d'autorisation à la clé (
dpop_jkt, envoyé avec la requête d'autorisation transmise au serveur) ; - envoie une preuve DPoP avec chaque requête de jeton et de rafraîchissement, et réessaie une fois lorsque le serveur demande un nouveau nonce ;
- refuse une réponse de jeton dont
token_typen'est pasDPoP.
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 }
valuereste chiffré avec votresecret, donc une copie du magasin ne révèle aucun jeton. Ne le journalisez pas.deleteByLogoutavecsidsupprime les sessions portant cesid; sisubest également fourni, les deux doivent correspondre. Avecsubseul, il supprime toutes les sessions de cet utilisateur.- Supprimez un enregistrement après
expiresAt. - Le dépôt du package contient un test de contrat (
clients/nextjs/test/store-contract.ts) que votre magasin devrait réussir.
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.
- Enregistrez
https://app.acme.example/auth/backchannel-logoutcomme URI de déconnexion back-channel de l'application. - 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).
Le cookie de session
- Le cookie est chiffré et authentifié avec AES-256-GCM, à l'aide d'une clé dérivée de
secret. Un cookie modifié ou copié est rejeté. - Il s'appelle
__Host-keenauth_session.0et est défini avecSecure,HttpOnly,SameSite=LaxetPath=/. Surhttp://localhost, le préfixe__Host-etSecuresont omis. - Une session dure au maximum 7 jours (
sessionMaxAge) ; le renouvellement ne la prolonge pas. - Les sessions volumineuses sont réparties sur jusqu'à trois cookies.
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
- La connexion avec des composants intégrés n'est pas encore disponible dans ce package.
- Les renouvellements ne sont partagés qu'au sein d'un même processus serveur. Avec plusieurs instances, deux d'entre elles peuvent renouveler la même session en même temps. Le paramètre d'application
refresh_reuse_interval_s(5 secondes par défaut, voir Mettre à jour une application) permet au second renouvellement de réussir ; si vous le réglez sur0, le second renouvellement met fin à la session. - Sans
sessionStore, la déconnexion back-channel n'est pas disponible ; une session prend alors fin côté serveur lorsque son jeton de rafraîchissement est refusé.