SDK per Next.js

@keenauth/nextjs aggiunge l'accesso a un'applicazione Next.js con App Router. La tua app è il client OAuth e conserva tutti i token sul server. Il browser riceve solo un cookie di sessione cifrato e HttpOnly, e non vede mai un access token, un refresh token o un ID token. Questo è il pattern backend-for-frontend (BFF). Il pacchetto non ha dipendenze di runtime. Non è ancora pubblicato su npm.

Prima di iniziare

  1. Nella dashboard, crea un'applicazione web nel tuo ambiente.
  2. Scegli l'autenticazione client private_key_jwt e registra la tua chiave pubblica. Funziona anche un client secret.
  3. Aggiungi il redirect URI https://app.acme.example/auth/callback e il redirect URI post-logout https://app.acme.example/.
  4. Genera un secret per il cookie di almeno 32 caratteri, per esempio con openssl rand -base64 32. Conservalo nel tuo secret store, non nel repository.

Crea l'istanza

// 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 è l'indirizzo pubblico della tua app. L'SDK costruisce da esso il redirect URI e tutti i redirect, mai dagli header Host o X-Forwarded-* della richiesta. Il protocollo http è accettato solo per localhost.

Le opzioni vengono verificate al momento della creazione dell'istanza, quindi un errore di configurazione blocca l'app all'avvio.

Aggiungi le route

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

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

Questo serve tre route:

Route Cosa fa
/auth/login Avvia l'accesso. Il parametro di query opzionale returnTo imposta dove l'utente arriva dopo.
/auth/callback Completa l'accesso e imposta il cookie di sessione.
/auth/logout Disconnette l'utente in KeenAuth e riporta alla tua app.

L'accesso usa il flusso authorization code con PKCE. La richiesta viene inviata al server (PAR) quando il tuo ambiente la supporta. Nel callback l'SDK verifica state, iss e l'ID token, incluso il suo nonce.

returnTo deve essere un percorso della tua app, per esempio /orders?tab=open. Qualsiasi altro valore, come //evil.example o un URL completo, viene ignorato e l'utente arriva su /.

Proteggi le pagine e rinnova i token

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

Per un percorso protetto senza sessione:

Quando l'access token scade entro 60 secondi, il proxy lo rinnova. Ogni rinnovo ruota il refresh token e il nuovo cookie di sessione viene impostato nella risposta. requestHeaders passa la nuova sessione alle pagine che renderizzano la stessa richiesta.

Se il server rifiuta il refresh token, la sessione termina. Un errore temporaneo del server lascia la sessione invariata.

Verifica inoltre la sessione in ogni Server Function e route handler. Il matcher del proxy può non coprire un percorso.

Leggi la sessione

In un Server Component, passa il cookie store:

// 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 le claim tipizzate dell'ID token: subject, email, emailVerified, name, orgId, orgRoles e altre. Tutte le claim, incluse quelle personalizzate, sono in session.user.claims. L'oggetto sessione non contiene mai un token.

Un Server Component non può impostare cookie, quindi leggere la sessione lì non la rinnova mai.

Chiama la tua API

In un Server Component, prendi l'access token dal cookie store:

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

getAccessToken fallisce con:

In un route handler, anche updateSession rinnova:

// 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 fa lo stesso in una sola chiamata. Legge il token come getAccessToken, aggiunge l'header Authorization e, con DPoP, la prova:

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

Non passare l'access token a un Client Component. Chiama la tua API dal server e invia al browser solo i dati. Per verificare questi token nella tua API, vedi Verifica dei token nella tua API.

Disconnetti

Crea un link a /auth/logout, oppure invia una POST a quella route da un form. L'SDK:

  1. elimina il cookie di sessione;
  2. revoca il refresh token;
  3. invia il browser all'endpoint di logout del tuo ambiente, che lo riporta alla tua app.

Una richiesta che il browser contrassegna come proveniente da un altro sito viene rifiutata, così un altro sito non può disconnettere i tuoi utenti. I browser che non inviano Sec-Fetch-Site possono disconnettere solo con una POST dalle tue pagine; preferisci un form che invia a /auth/logout.

Token vincolati al mittente (DPoP)

Con DPoP (RFC 9449), il tuo access token è legato a una chiave che solo il tuo server possiede. Un token trapelato, per esempio da un log delle API, non può essere usato senza quella chiave.

  1. Genera una sola chiave ES256 (P-256) per l'applicazione e conserva la JWK privata nel tuo secret store.
  2. Passala come dpop:
export const auth = createKeenAuth({
  // ...the options above
  dpop: { privateKey: JSON.parse(process.env.KEENAUTH_DPOP_KEY!) },
});

L'SDK quindi:

Per chiamare la tua API, usa auth.authorizedFetch. Invia Authorization: DPoP <token> e una nuova prova con ath per ogni richiesta, e riprova una volta quando la tua API risponde 401 con error="use_dpop_nonce". Se usi un tuo client HTTP, crea la prova con auth.createDpopProof({ method, url, accessToken, nonce }).

Usa la stessa chiave in ogni processo server: il tuo ambiente lega a essa i token e il refresh token. Se sostituisci la chiave, o attivi o disattivi DPoP, le sessioni esistenti terminano e gli utenti devono accedere di nuovo.

Sessioni lato server

Di default l'intera sessione è sigillata nel cookie. Passa un sessionStore per mantenere la sessione sul server. Il cookie contiene allora solo un riferimento cifrato a un session id casuale.

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 lo store in memoria una sola volta per processo, come sopra. Un memorySessionStore() chiamato a livello di modulo, senza globalThis, dà al proxy, a ogni route handler e alle pagine un proprio store vuoto: la sessione salvata dalla route di callback non viene trovata dal proxy o da una pagina, e l'utente risulta disconnesso. Uno store che mantiene i dati fuori dal processo (Redis, un database) non ha questo problema.

memorySessionStore mantiene le sessioni nella memoria di un processo e le perde al riavvio. Usalo per lo sviluppo e per server a istanza singola. Con più istanze o funzioni serverless, implementa l'interfaccia SessionStore su uno store condiviso come Redis o il tuo database:

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 }

Se lo store fallisce, l'SDK lancia un session_store_error invece di considerare l'utente disconnesso. Disconnettersi elimina comunque il cookie.

Logout back-channel

Con un sessionStore, l'SDK serve anche /auth/backchannel-logout. Quando una sessione termina in KeenAuth (per esempio, l'utente si disconnette da un'altra applicazione o un amministratore revoca la sessione), il tuo ambiente invia lì un logout token firmato. L'SDK elimina le sessioni corrispondenti, quindi la richiesta successiva dell'utente nella tua app non ha più una sessione.

  1. Registra https://app.acme.example/auth/backchannel-logout come logout URI back-channel dell'applicazione.
  2. Assicurati che la route sia raggiungibile da internet. Il matcher del proxy potrebbe includerla: il proxy la lascia passare.

L'SDK verifica il logout token come richiesto da OpenID Connect Back-Channel Logout 1.0: firma, issuer, audience, iat ed exp, l'evento di logout, sub o sid, assenza di nonce e il tipo logout+jwt. Risponde 200 in caso di successo e 400 altrimenti, e il tuo ambiente riprova una consegna non riuscita.

Il jti di ogni logout token viene ricordato fino alla scadenza del token, così un token catturato non può essere riprodotto. Un jti ripetuto riceve 200 senza agire di nuovo. La cache predefinita vive in un processo; con più istanze, passa una logoutReplayCache condivisa che implementi in modo atomico remember(jti, expiresAt) (per esempio Redis SET NX) e forget(jti).

Per cambiare il secret, passa una lista: secret: [newSecret, oldSecret]. Il primo secret cifra e tutti decifrano. Rimuovi quello vecchio dopo sessionMaxAge.

Limiti attuali