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

  1. Nella dashboard, crea un'applicazione web nel tuo ambiente.
  2. Scegli l'autenticazione del client: un client secret (client_secret_basic) oppure private_key_jwt con una coppia di chiavi la cui chiave pubblica registri sull'applicazione. private_key_jwt è consigliato, perché nessun segreto condiviso esce dal tuo server.
  3. Aggiungi il redirect URI https://app.acme.example/auth/callback e il post-logout redirect URI https://app.acme.example/.
  4. 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:

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:

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:

  1. elimina il cookie di sessione (e la sessione memorizzata, se usi uno store);
  2. revoca il refresh token;
  3. 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