
# 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

```ts
// 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

```ts
// 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

```ts
// 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 (`GET` ou `HEAD`) est redirigée vers `/auth/login` avec `returnTo` ;
- une requête vers `/api/…`, ou utilisant une autre méthode, reçoit `401` avec 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 :

```tsx
// 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 :

```ts
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_required` lorsque le jeton approche de l'expiration et aurait dû être renouvelé par le proxy ;
- `login_required` lorsqu'il n'existe aucune session.

Dans une route handler, `updateSession` effectue aussi le renouvellement :

```ts
// 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 :

```ts
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](/docs/sdk/token-verification).

## 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` :

```ts
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_type` n'est pas `DPoP`.

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.

```ts
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 :

```ts
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 }
```

- `value` reste chiffré avec votre `secret`, donc une copie du magasin ne révèle aucun jeton. Ne le journalisez pas.
- `deleteByLogout` avec `sid` supprime les sessions portant ce `sid` ; si `sub` est également fourni, les deux doivent correspondre. Avec `sub` seul, 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.

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)`.

## 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.0` et est défini avec `Secure`, `HttpOnly`, `SameSite=Lax` et `Path=/`. Sur `http://localhost`, le préfixe `__Host-` et `Secure` sont 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](/docs/api/management/applications/update)) permet au second renouvellement de réussir ; si vous le réglez sur `0`, 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é.
