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
- Nella dashboard, crea un'applicazione web nel tuo ambiente.
- Scegli l'autenticazione client
private_key_jwte registra la tua chiave pubblica. Funziona anche un client secret. - Aggiungi il redirect URI
https://app.acme.example/auth/callbacke il redirect URI post-logouthttps://app.acme.example/. - 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:
- una richiesta di pagina (
GEToHEAD) viene reindirizzata a/auth/loginconreturnTo; - una richiesta a
/api/…, o con un altro metodo, riceve401con un documento problem.
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:
refresh_requiredquando il token è vicino alla scadenza e il proxy avrebbe dovuto rinnovarlo;login_requiredquando non c'è una sessione.
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:
- elimina il cookie di sessione;
- revoca il refresh token;
- 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.
- Genera una sola chiave ES256 (P-256) per l'applicazione e conserva la JWK privata nel tuo secret store.
- Passala come
dpop:
export const auth = createKeenAuth({
// ...the options above
dpop: { privateKey: JSON.parse(process.env.KEENAUTH_DPOP_KEY!) },
});
L'SDK quindi:
- lega il codice di autorizzazione alla chiave (
dpop_jkt, inviato con la richiesta di autorizzazione pushed); - invia una prova DPoP con ogni richiesta di token e di refresh, e riprova una volta quando il server chiede un nuovo nonce;
- rifiuta una risposta di token il cui
token_typenon èDPoP.
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 }
valueè comunque cifrato con il tuosecret, quindi una copia dello store non rivela alcun token. Non registrarlo nei log.deleteByLogoutconsidelimina le sessioni con quelsid; con anchesub, entrambi devono corrispondere. Con solosub, elimina ogni sessione di quell'utente.- Elimina un record dopo
expiresAt. - Il repository del pacchetto ha un test di contratto (
clients/nextjs/test/store-contract.ts) che il tuo store dovrebbe superare.
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.
- Registra
https://app.acme.example/auth/backchannel-logoutcome logout URI back-channel dell'applicazione. - 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).
Il cookie di sessione
- Il cookie è cifrato e autenticato con AES-256-GCM, usando una chiave derivata da
secret. Un cookie modificato o copiato viene rifiutato. - Si chiama
__Host-keenauth_session.0e viene impostato conSecure,HttpOnly,SameSite=LaxePath=/. Suhttp://localhostil prefisso__Host-eSecurevengono omessi. - Una sessione dura al massimo 7 giorni (
sessionMaxAge); il rinnovo non la prolunga. - Le sessioni grandi vengono suddivise in fino a tre cookie.
Per cambiare il secret, passa una lista: secret: [newSecret, oldSecret]. Il primo secret cifra e tutti decifrano. Rimuovi quello vecchio dopo sessionMaxAge.
Limiti attuali
- L'accesso con componenti integrati non è ancora disponibile in questo pacchetto.
- I rinnovi sono condivisi solo all'interno di un processo server. Con più istanze, due di esse possono rinnovare la stessa sessione contemporaneamente. L'impostazione dell'applicazione
refresh_reuse_interval_s(predefinita 5 secondi, vedi Aggiorna un'applicazione) permette al secondo rinnovo di riuscire; se la imposti a0, il secondo rinnovo termina la sessione. - Senza un
sessionStore, il logout back-channel non è disponibile; una sessione termina allora sul server quando il suo refresh token viene rifiutato.