Sessioni Fastify
@keenauth/node/bff aggiunge l'accesso a un'applicazione Fastify 5. Il tuo server è il client OAuth e conserva tutti i token lato 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 importa Fastify, quindi non aggiunge dipendenze. Non è ancora pubblicato su npm.
Per proteggere invece un'API che riceve bearer token, vedi Proteggere la tua API.
Prima di iniziare
- Nella dashboard, crea un'applicazione web nel tuo ambiente.
- Scegli l'autenticazione del client: un client secret (
client_secret_basic) oppureprivate_key_jwtcon una coppia di chiavi la cui chiave pubblica registri sull'applicazione.private_key_jwtè consigliato, perché nessun segreto condiviso esce dal tuo server. - Aggiungi il redirect URI
https://app.acme.example/auth/callbacke il post-logout redirect URIhttps://app.acme.example/. - Genera un cookie secret di almeno 32 caratteri, per esempio con
openssl rand -base64 32. Conservalo nel tuo secret store, non nel repository.
Configurazione
import Fastify from "fastify";
import { createBff, fastifyRequireAuth, fastifySession } from "@keenauth/node/bff";
const bff = createBff({
issuer: "https://acme.eu.keenauth.app", // your environment's issuer, exactly
clientId: "app_01j9zzzzzzzzzzzzzzzzzzzz",
clientAuth: { method: "client_secret_basic", clientSecret: process.env.KEENAUTH_CLIENT_SECRET! },
appBaseUrl: "https://app.acme.example",
secret: process.env.KEENAUTH_COOKIE_SECRET!,
});
const app = Fastify();
fastifySession(app, bff); // on the root instance, so it applies to every route
app.get("/orders", { preHandler: fastifyRequireAuth(bff) }, async (request) => {
return `Signed in as ${request.keenauth!.session!.user.email}`;
});
Con private_key_jwt, passa la chiave privata al posto del secret: clientAuth: { method: "private_key_jwt", privateKey: readFileSync("bff-key.pem", "utf8") }. L'SDK firma un nuovo client assertion per ogni richiesta agli endpoint di token e PAR. Se la chiave si trova in un KMS o in un HSM, passa clientAuth: { method: "private_key_jwt", assertion }, dove assertion() restituisce un nuovo client assertion firmato ogni volta. La pagina Express descrive l'assertion e gli algoritmi supportati.
Chiama fastifySession sull'istanza root. All'interno di un plugin incapsulato si applica solo alle route di quel plugin.
appBaseUrl è l'indirizzo pubblico della tua app. L'SDK costruisce da qui il redirect URI e tutti i redirect, mai dagli header Host o X-Forwarded-* della richiesta, quindi l'opzione trustProxy non ha effetto su di essi. Il protocollo http è accettato solo per localhost.
Le opzioni vengono verificate quando viene eseguito createBff, quindi un errore di configurazione blocca l'app all'avvio.
Route
fastifySession aggiunge tre route e legge la sessione in un hook onRequest su ogni altra richiesta:
| Route | Cosa fa |
|---|---|
GET /auth/login |
Avvia l'accesso. Parametri query opzionali: returnTo (dove atterra l'utente) e organization (accedi a un'organizzazione). |
GET /auth/callback |
Completa l'accesso e imposta il cookie di sessione. |
GET o POST /auth/logout |
Disconnette da KeenAuth e torna alla tua app. |
L'accesso usa il flusso authorization code con PKCE (S256). La richiesta viene inviata al server tramite PAR quando il tuo ambiente lo supporta. Nel callback l'SDK verifica state, iss e l'ID token, incluso il suo nonce. Ogni accesso riuscito crea una nuova sessione e quella precedente viene eliminata.
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 atterra su /.
Proteggere le route
fastifyRequireAuth(bff) è un preHandler che lascia passare una richiesta solo con una sessione:
- una richiesta di pagina (
GEToHEAD) senza sessione viene reindirizzata a/auth/loginconreturnTo; - una richiesta a
/api/…, oppure con un altro metodo, riceve401con un documento problem. Passa{ api: true }per rispondere401su qualsiasi percorso.
Per POST, PUT, PATCH, DELETE e gli altri metodi non sicuri, il browser deve indicare che la richiesta arriva dalla tua app: Sec-Fetch-Site: same-origin, oppure un header Origin uguale all'origine della tua app. Le altre richieste ricevono 403. Questo protegge dalle richieste cross-site forgery in aggiunta a SameSite=Lax.
Usare la sessione e chiamare la tua API
request.keenauth contiene:
session:null, oppure l'utente autenticato.session.userha claim tipizzati dell'ID token comesubject,email,name,orgIdeorgRoles; tutti i claim sono insession.user.claims. L'oggetto sessione non contiene mai un token.accessToken: un access token valido per chiamate lato server, oppurenull.
app.get("/api/orders", { preHandler: fastifyRequireAuth(bff) }, async (request, reply) => {
const upstream = await fetch("https://api.acme.example/v1/orders", {
headers: { Authorization: `Bearer ${request.keenauth!.accessToken}` },
});
return reply.code(upstream.status).send(await upstream.json());
});
In TypeScript, dichiara la proprietà una sola volta:
import type { SessionContext } from "@keenauth/node/bff";
declare module "fastify" {
interface FastifyRequest {
keenauth?: SessionContext | null;
}
}
Quando l'access token scade entro 60 secondi, l'SDK lo rinnova prima che venga eseguito il tuo handler. Ogni rinnovo ruota il refresh token e imposta il nuovo cookie di sessione nella risposta. Se il server rifiuta il refresh token, la sessione termina. Un errore temporaneo del server lascia la sessione invariata.
Non inviare l'access token al browser.
Cambiare organizzazione
bff.switchOrganizationPath("org_01j9…", "/dashboard") restituisce un link a /auth/login che fa riaccedere l'utente in quell'organizzazione. KeenAuth verifica l'appartenenza. Dopo l'accesso, session.user.orgId contiene l'organizzazione.
Disconnettersi
Invia una richiesta POST a /auth/logout da un form. L'SDK:
- elimina il cookie di sessione (e la sessione memorizzata, se usi uno store);
- revoca il refresh token;
- porta il browser all'endpoint di logout del tuo ambiente, che lo riporta alla tua app.
Una richiesta che il browser segnala come proveniente da un altro sito viene rifiutata. I browser che non inviano Sec-Fetch-Site possono disconnettere solo con un POST dalle tue pagine.
Archiviazione della sessione
Di default l'intera sessione si trova nel cookie, cifrata e autenticata con AES-256-GCM usando una chiave derivata da secret. Un cookie modificato o copiato viene rifiutato. Il cookie si chiama __Host-keenauth_session.0 e viene impostato con Secure, HttpOnly, SameSite=Lax e Path=/. Su http://localhost il prefisso __Host- e Secure vengono omessi. Le sessioni grandi vengono suddivise in fino a tre cookie.
Per conservare i token sul server, passa uno store:
import { createBff, type SessionStore } from "@keenauth/node/bff";
const store: SessionStore = {
get: async (id) => JSON.parse((await redis.get(`sess:${id}`)) ?? "null"),
set: async (id, session, ttl) => void (await redis.set(`sess:${id}`, JSON.stringify(session), { EX: ttl })),
delete: async (id) => void (await redis.del(`sess:${id}`)),
};
const bff = createBff({ /* … */ store });
Il cookie contiene quindi solo un ID di sessione casuale e cifrato. Ogni accesso crea un nuovo ID ed elimina il record precedente. Lo store contiene i token, quindi limitane l'accesso. MemorySessionStore è solo per sviluppo e test.
Una sessione dura al massimo 7 giorni (sessionMaxAge); il rinnovo non la estende. Per cambiare il secret, passa una lista: secret: [newSecret, oldSecret]. Rimuovi quello vecchio dopo sessionMaxAge.
Limiti attuali
- I token vincolati con DPoP e il back-channel logout non sono ancora gestiti.
- I rinnovi sono condivisi solo all'interno di un singolo processo server. Con più istanze, l'impostazione dell'applicazione
refresh_reuse_interval_s(default 5 secondi) permette a un rinnovo parallelo di riuscire; se la imposti a0, il secondo rinnovo termina la sessione.