SDK de Next.js

@keenauth/nextjs añade el inicio de sesión a una aplicación de Next.js con App Router. Tu aplicación es el cliente OAuth y mantiene todos los tokens en el servidor. El navegador solo recibe una cookie de sesión cifrada y HttpOnly, y nunca ve un token de acceso, de actualización ni de ID. Este es el patrón backend-for-frontend (BFF). El paquete no tiene dependencias en tiempo de ejecución. Todavía no se ha publicado en npm.

Antes de empezar

  1. En el panel, crea una aplicación web en tu entorno.
  2. Elige la autenticación de cliente private_key_jwt y registra tu clave pública. También funciona un secreto de cliente.
  3. Añade el URI de redirección https://app.acme.example/auth/callback y el URI de redirección tras cerrar sesión https://app.acme.example/.
  4. Genera un secreto para la cookie de al menos 32 caracteres, por ejemplo con openssl rand -base64 32. Guárdalo en tu almacén de secretos, no en el repositorio.

Crear la instancia

// 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 es la dirección pública de tu aplicación. El SDK construye a partir de ella el URI de redirección y todas las redirecciones, nunca a partir de los encabezados Host o X-Forwarded-* de la solicitud. El http simple solo se acepta para localhost.

Las opciones se comprueban al crear la instancia, así que un error de configuración detiene la aplicación al arrancar.

Añadir las rutas

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

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

Esto sirve tres rutas:

Ruta Qué hace
/auth/login Inicia el inicio de sesión. Un parámetro de consulta opcional returnTo indica a dónde llega el usuario después.
/auth/callback Completa el inicio de sesión y establece la cookie de sesión.
/auth/logout Cierra la sesión en KeenAuth y vuelve a tu aplicación.

El inicio de sesión usa el flujo de código de autorización con PKCE. La solicitud se envía al servidor (PAR) cuando tu entorno lo ofrece. En el callback, el SDK comprueba state, iss y el token de ID, incluido su nonce.

returnTo debe ser una ruta de tu propia aplicación, como /orders?tab=open. Cualquier otro valor, como //evil.example o una URL completa, se ignora y el usuario llega a /.

Proteger páginas y renovar tokens

// 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).*)"],
};

Para una ruta protegida sin sesión:

Cuando el token de acceso caduca en menos de 60 segundos, el proxy lo renueva. Cada renovación rota el token de actualización y la nueva cookie de sesión se establece en la respuesta. requestHeaders pasa la nueva sesión a las páginas que se renderizan en la misma solicitud.

Si el servidor rechaza el token de actualización, la sesión termina. Un error temporal del servidor deja la sesión sin cambios.

Comprueba también la sesión dentro de cada Server Function y route handler. El matcher del proxy puede no cubrir una ruta.

Leer la sesión

En un Server Component, pasa el almacén 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 contiene las claims tipadas del token de ID: subject, email, emailVerified, name, orgId, orgRoles y otras. Todas las claims, incluidas las personalizadas, están en session.user.claims. El objeto de sesión nunca contiene un token.

Un Server Component no puede establecer cookies, así que leer la sesión allí nunca la renueva.

Llamar a tu API

En un Server Component, toma el token de acceso del almacén de cookies:

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

getAccessToken falla con:

En un route handler, updateSession también renueva:

// 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 hace lo mismo en una sola llamada. Lee el token como getAccessToken, añade el encabezado Authorization y, con DPoP, la prueba:

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

No pases el token de acceso a un Client Component. Llama a tu API desde el servidor y envía al navegador solo los datos. Para verificar estos tokens en tu API, consulta Verificar tokens en tu API.

Cerrar sesión

Enlaza a /auth/logout, o envía un POST a esa ruta desde un formulario. El SDK:

  1. elimina la cookie de sesión;
  2. revoca el token de actualización;
  3. envía el navegador al endpoint de cierre de sesión de tu entorno, que lo devuelve a tu aplicación.

Se rechaza una solicitud que el navegador marca como procedente de otro sitio, así que otro sitio no puede cerrar la sesión de tus usuarios. Los navegadores que no envían Sec-Fetch-Site solo pueden cerrar sesión mediante un POST desde tus propias páginas; prefiere un formulario que envíe a /auth/logout.

Tokens con restricción de remitente (DPoP)

Con DPoP (RFC 9449), tu token de acceso queda vinculado a una clave que solo tiene tu servidor. Un token filtrado, por ejemplo desde un registro de la API, no puede usarse sin esa clave.

  1. Genera una clave ES256 (P-256) para la aplicación y guarda la JWK privada en tu almacén de secretos.
  2. Pásala como dpop:
export const auth = createKeenAuth({
  // ...the options above
  dpop: { privateKey: JSON.parse(process.env.KEENAUTH_DPOP_KEY!) },
});

El SDK entonces:

Para llamar a tu API, usa auth.authorizedFetch. Envía Authorization: DPoP <token> y una nueva prueba con ath en cada solicitud, y reintenta una vez cuando tu API responde 401 con error="use_dpop_nonce". Si usas tu propio cliente HTTP, crea la prueba con auth.createDpopProof({ method, url, accessToken, nonce }).

Usa la misma clave en todos los procesos del servidor: tu entorno vincula a ella los tokens y el token de actualización. Si reemplazas la clave, o activas o desactivas DPoP, las sesiones existentes terminan y los usuarios deben iniciar sesión de nuevo.

Sesiones en el servidor

De forma predeterminada, toda la sesión se sella dentro de la cookie. Pasa un sessionStore para guardar la sesión en el servidor. La cookie contendrá entonces solo una referencia cifrada a un identificador de sesión aleatorio.

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,
});

Crea el almacén en memoria una vez por proceso, como arriba. Un memorySessionStore() llamado a nivel de módulo, sin globalThis, da al proxy, a cada route handler y a las páginas su propio almacén vacío: la sesión que guarda la ruta de callback no la encuentran el proxy ni una página, y el usuario aparece como no autenticado. Un almacén que guarda sus datos fuera del proceso (Redis, una base de datos) no tiene este problema.

memorySessionStore guarda las sesiones en la memoria de un proceso y las pierde al reiniciar. Úsalo para desarrollo y servidores de una sola instancia. Con varias instancias o funciones serverless, implementa la interfaz SessionStore sobre un almacén compartido como Redis o tu base de datos:

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 el almacén falla, el SDK lanza un session_store_error en lugar de tratar al usuario como no autenticado. Cerrar sesión sigue eliminando la cookie.

Cierre de sesión de canal trasero

Con un sessionStore, el SDK también sirve /auth/backchannel-logout. Cuando una sesión termina en KeenAuth (por ejemplo, el usuario cierra sesión en otra aplicación o un administrador revoca la sesión), tu entorno envía allí un token de cierre de sesión firmado. El SDK elimina las sesiones correspondientes, de modo que la siguiente solicitud del usuario en tu aplicación no tiene sesión.

  1. Registra https://app.acme.example/auth/backchannel-logout como URI de cierre de sesión de canal trasero de la aplicación.
  2. Asegúrate de que la ruta sea accesible desde internet. El matcher de tu proxy puede incluirla: el proxy la deja pasar.

El SDK comprueba el token de cierre de sesión tal como exige OpenID Connect Back-Channel Logout 1.0: firma, emisor, audiencia, iat y exp, el evento de cierre de sesión, sub o sid, ausencia de nonce y el tipo logout+jwt. Responde 200 si todo es correcto y 400 en caso contrario, y tu entorno reintenta una entrega fallida.

El jti de cada token de cierre de sesión se recuerda hasta que el token caduca, así que un token capturado no puede reutilizarse. Un jti repetido recibe 200 sin volver a actuar. La caché predeterminada vive en un proceso; con varias instancias, pasa un logoutReplayCache compartido que implemente remember(jti, expiresAt) de forma atómica (por ejemplo, SET NX en Redis) y forget(jti).

Para cambiar el secreto, pasa una lista: secret: [newSecret, oldSecret]. El primer secreto cifra y todos descifran. Elimina el antiguo después de sessionMaxAge.

Límites actuales