Aggiungere l'accesso a un'app React Router
Questa quickstart aggiunge l'accesso KeenAuth a un'app React Router 8 in modalità framework, con il client OpenID Connect standard openid-client. Loader e action vengono eseguiti sul server, quindi la tua app è il client OAuth: conserva ogni token in una sessione lato server, e il browser riceve solo l'HTML e un cookie con id di sessione firmato. Aggiungi le route di accesso, proteggi una pagina, chiami un'API protetta con l'access token dell'utente, disconnettiti e testa l'app offline.
Per una single-page app senza server, vedi Aggiungere l'accesso a una single-page app React.
L'app ha queste route:
| Route | Cosa fa |
|---|---|
GET / |
Home page pubblica: un pulsante di accesso, oppure il nome dell'utente e un pulsante di disconnessione |
GET /dashboard |
Richiede una sessione. Mostra l'utente e i messaggi restituiti dalla tua API |
POST /login |
Avvia l'accesso. Solo da una pagina di questa app |
GET /auth/callback |
Verifica la risposta, scambia il codice e avvia la sessione |
POST /logout |
Termina la sessione qui e presso KeenAuth. Solo da una pagina di questa app |
Prima di iniziare
- Un ambiente KeenAuth. Il suo issuer ha la forma
https://your-environment.eu.keenauth.app. - Un'applicazione web nell'ambiente (Creare un'applicazione) con un client secret (
client_secret_basic). In un ambientedevelopment, registra il redirect URIhttp://localhost/auth/callbacke il post-logout redirect URIhttp://localhost/; un URI di loopback viene registrato senza porta e corrisponde a qualsiasi porta, come5173. - Un'API registrata nell'ambiente (Creare un'API) con lo scope
messages:read. Il suoidentifier, per esempiohttps://api.example.com, è l'audience dei suoi access token. La quickstart Express crea un'API che accetta questi token. - Node.js 22.22 o successivo, e un'app React Router in modalità framework (
npx create-react-router@8.5.0).
1. Installare
npm install --save-exact openid-client@6.8.8
openid-client implementa il relying party OpenID Connect: discovery, flusso authorization code con PKCE, il controllo iss di RFC 9207 e i controlli dell'ID token. L'esempio fissa ogni pacchetto con una versione esatta e installa da package-lock.json con npm ci --ignore-scripts, quindi durante l'installazione non viene eseguito alcuno script dei pacchetti.
2. Configurare
L'app legge la configurazione solo dalle variabili d'ambiente. Inseriscile in .env, che npm run dev e npm start caricano con node --env-file=.env, e non committare mai quel file.
| Variabile | Obbligatoria | Significato |
|---|---|---|
KEENAUTH_ISSUER |
sì | L'issuer del tuo ambiente, https |
KEENAUTH_CLIENT_ID |
sì | Il client_id dell'applicazione web |
KEENAUTH_CLIENT_SECRET |
sì | Il suo client secret |
APP_BASE_URL |
sì | L'URL pubblico dell'app. Il redirect URI viene costruito a partire da esso. http solo per localhost, 127.0.0.1 o [::1] |
SESSION_SECRET |
sì | Almeno 32 caratteri; firma il cookie di sessione. Generane uno con openssl rand -base64 32 |
API_URL |
sì | L'API che l'app chiama, per esempio http://localhost:8080 |
KEENAUTH_API_AUDIENCE |
sì | L'identificatore di quell'API in KeenAuth |
loadConfig in app/lib/config.server.ts controlla questi valori alla prima richiesta. Un valore mancante o non utilizzabile interrompe la richiesta, e nessun messaggio di errore contiene un valore:
const issuer = url("KEENAUTH_ISSUER", false);
const appBaseUrl = url("APP_BASE_URL", true).origin;
const sessionSecret = need("SESSION_SECRET");
if (sessionSecret.length < 32) throw new Error("SESSION_SECRET must be at least 32 characters");
I file che terminano in .server.ts non arrivano mai al bundle del browser; React Router interrompe la build se una pagina ne importa uno per il browser.
3. Creare il client OpenID Connect
app/lib/keenauth.server.ts costruisce il client una sola volta dalla discovery (<issuer>/.well-known/openid-configuration):
export function keenauth(): Promise<client.Configuration> {
discovered ??= client
.discovery(config().issuer, config().clientId, undefined, client.ClientSecretBasic(config().clientSecret), {
// Also check the ID token signature with your environment's keys (jwks_uri), not only the TLS connection.
execute: [client.enableNonRepudiationChecks],
})
Per impostazione predefinita, openid-client considera attendibile un ID token che arriva direttamente dal token endpoint tramite TLS. enableNonRepudiationChecks verifica anche la sua firma con le chiavi pubblicate dal tuo ambiente.
4. Conservare la sessione sul server
La sessione vive in file sul server; il cookie contiene solo un id di sessione firmato:
storage ??= createFileSessionStorage<SessionData>({
dir: ".sessions",
cookie: {
name: "__keenauth_session",
httpOnly: true,
sameSite: "lax",
// Secure whenever the app is served over https; plain http only for localhost.
secure: config().redirectUri.startsWith("https:"),
path: "/",
maxAge: 8 * 60 * 60,
secrets: [config().sessionSecret],
},
});
Uno store su file serve un solo server. Con più server, scrivi uno storage di sessione con createSessionStorage e uno store condiviso, come Redis. Non usare lo storage di sessione in cookie per i token: due token possono superare il limite di 4 KB del cookie, e il browser li conserverebbe.
5. Avviare l'accesso
Le route sono elencate in app/routes.ts:
export default [
index("routes/home.tsx"),
route("dashboard", "routes/dashboard.tsx"),
route("login", "routes/login.ts"),
route("auth/callback", "routes/callback.ts"),
route("logout", "routes/logout.ts"),
] satisfies RouteConfig;
L'accesso è un form che invia un POST a /login:
<Form method="post" action="/login" reloadDocument>
<button type="submit">Sign in</button>
</Form>
L'action crea un nuovo state, nonce e verificatore PKCE per ogni accesso e li conserva nella sessione:
export async function action({ request }: Route.ActionArgs) {
assertSameOrigin(request);
const session = await getSession(request);
const transaction = newTransaction();
session.set("transaction", transaction);
const url = await authorizationUrl(transaction);
return redirect(url.href, { headers: { "Set-Cookie": await sessions().commitSession(session) } });
}
La richiesta di autorizzazione invia la challenge S256 del verificatore e l'identificatore della tua API come resource (RFC 8707). Senza resource, l'access token non è destinato alla tua API e l'API lo rifiuta.
return client.buildAuthorizationUrl(await keenauth(), {
redirect_uri: config().redirectUri,
// messages:read is a scope of your API; the example API requires it on /api/messages.
scope: "openid profile email messages:read",
state: transaction.state,
nonce: transaction.nonce,
// PKCE (RFC 7636): S256 of the verifier that only this session knows.
code_challenge: await client.calculatePKCECodeChallenge(transaction.codeVerifier),
code_challenge_method: "S256",
// RFC 8707: an access token for your API.
resource: config().apiAudience,
});
Accettare il POST solo dalle tue pagine
React Router rifiuta un POST cross-site verso l'action di una pagina, ma /login e /logout sono resource route (un'action senza pagina), e per queste il controllo spetta a te. assertSameOrigin accetta il POST solo quando il suo header Origin (o, in sua assenza, Referer) indica APP_BASE_URL:
const app = new URL(config().redirectUri).origin;
const source = request.headers.get("Origin") ?? request.headers.get("Referer");
6. Gestire il callback
Il callback prende per prima cosa la transazione dalla sessione, così uno state funziona una sola volta:
const old = await getSession(request);
const transaction = old.get("transaction");
// The transaction works once: it leaves the session before anything else happens.
old.unset("transaction");
authorizationCodeGrant verifica state e l'iss che il tuo ambiente invia in ogni risposta di autorizzazione (RFC 9207), scambia il codice con il verificatore PKCE e controlla l'ID token: la sua firma, iss, aud, exp, iat e il nonce di questo accesso. Il redirect URI viene costruito a partire da APP_BASE_URL, mai dall'header Host:
const callbackUrl = new URL(config().redirectUri);
callbackUrl.search = new URL(request.url).search;
return client.authorizationCodeGrant(await keenauth(), callbackUrl, {
pkceCodeVerifier: transaction.codeVerifier,
expectedState: transaction.state,
expectedNonce: transaction.nonce,
idTokenExpected: true,
});
Un controllo non superato risponde con 401 e un codice di errore fisso. Dopo un accesso riuscito, l'app elimina la vecchia sessione e conserva l'utente e i token in una nuova:
// A new session id after sign-in prevents session fixation: the old session is deleted, a new one is created.
await sessions().destroySession(old);
const session = await sessions().getSession(null);
7. Proteggere le pagine e chiamare la tua API
Un loader che richiede un utente chiama requireUser, che reindirizza alla home page senza sessione. L'esempio non ha un refresh token: quando l'access token scade, l'utente accede di nuovo.
export async function loader({ request }: Route.LoaderArgs) {
const { user, accessToken } = await requireUser(request);
const api = await fetchMessages(accessToken);
return { user, api };
}
Il loader chiama la tua API con l'access token come bearer token. Il token resta sul server; la pagina riceve solo l'utente e i messaggi.
response = await fetch(`${config().apiUrl}/api/messages`, {
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
});
8. Disconnettersi
La disconnessione è un POST da una pagina di questa app. L'action termina la sessione locale e manda il browser all'endpoint di logout del tuo ambiente (OpenID Connect RP-Initiated Logout) con id_token_hint, client_id e post_logout_redirect_uri, che lo riporta alla tua app:
const session = await getSession(request);
const idToken = session.get("idToken");
const destroyed = await sessions().destroySession(session);
return redirect((await logoutUrl(idToken)).href, { headers: { "Set-Cookie": destroyed } });
9. Eseguire l'app
Avvia l'API della quickstart Express (o qualsiasi API che accetta i token) ed esegui l'app:
npm run dev
Apri http://localhost:5173, scegli Accedi, accedi su KeenAuth e apri la dashboard.
10. Testare senza rete
I test non richiedono rete né un account KeenAuth. npm test compila l'app ed esegue la build tramite il request handler di React Router. Un fetch finto simula il tuo ambiente (discovery, un set di chiavi creato per l'esecuzione dei test, il token endpoint) e l'API.
test("callback: an unknown or reused state is refused before any token request and starts no session", async () => {
const b = browser();
await startSignIn(b);
const wrong = await returnFromKeenAuth(b, { state: "not-the-state" });
npm test
npm run deps # exact pins, lockfile sources and licences
I test verificano che la configurazione venga validata (e che nessun errore contenga un segreto); che l'accesso invii PKCE S256, state, nonce e resource e venga rifiutato da un altro sito o con una GET; che il codice venga scambiato con il verificatore corrispondente e con l'autenticazione del client; che l'access token venga inviato all'API nell'header Authorization e non arrivi mai al browser; che uno state sconosciuto o riutilizzato, un iss mancante o errato, un errore di autorizzazione e un ID token di un altro client, con un altro nonce o firmato da un'altra chiave vengano rifiutati senza creare una sessione; che l'accesso avvii un nuovo id di sessione; che una pagina senza sessione reindirizzi alla home page; e che la disconnessione venga rifiutata da un altro sito e vada all'endpoint di logout con id_token_hint.
L'accesso completo con un browser reale richiede un ambiente KeenAuth. Non fa parte dei test offline.
Dietro un proxy o un load balancer
Imposta APP_BASE_URL sull'URL pubblico dell'app; il redirect URI e il controllo dell'Origin lo usano, mai l'header Host. Se un proxy termina il TLS, APP_BASE_URL resta l'URL https, quindi il cookie di sessione rimane Secure.
Limiti attuali
- Nessun refresh token e nessun accesso silenzioso: l'utente accede di nuovo quando l'access token scade.
- Lo store di sessione su file conserva i token in file di testo semplice sotto
.sessions/e non elimina i file scaduti. Proteggi quella directory, oppure usa uno store condiviso che cifri e faccia scadere le sessioni. - Il client si autentica con un client secret.
openid-clientsupporta ancheprivate_key_jwt; l'esempio non lo mostra.
Prossimi passi
- Proteggere un'API Express: l'API che questa app chiama.
- Verificare i token: il verificatore sotto il middleware della tua API.