Vérification des tokens
Tout ce qui précède décide de ce qu'il faut afficher. Ceci décide de ce qui est autorisé, et c'est la seule partie qui protège vraiment quelque chose.
Un client détient un token et l'envoie. Le service qui le reçoit ne doit pas s'y fier : signature, émetteur, audience et expiration, à chaque requête.
import { createTokenVerifier } from '@fluixi/oauth2/verify';
const verify = createTokenVerifier({
issuer: process.env.OIDC_ISSUER!,
audience: 'admin-spa',
});
const claims = await verify.fromRequest(request); // lève si quoi que ce soit ne tient pas
Rien ici n'est spécifique à Fluixi. La fonction prend une Request ou une chaîne et rend
les claims du token, donc un service Express, un worker, une route Hono ou un
gestionnaire Node nu peuvent l'importer sans rien adopter d'autre.
Dans une route d'API
// src/api/orders.ts
import { createTokenVerifier, TokenError } from '@fluixi/oauth2/verify';
const verify = createTokenVerifier({ issuer, audience });
export async function GET(request: Request) {
try {
const claims = await verify.fromRequest(request);
return Response.json(await listOrdersFor(claims.sub));
} catch (cause) {
const code = cause instanceof TokenError ? cause.code : 'invalid_token';
return new Response(JSON.stringify({ error: code }), {
status: 401,
headers: { 'www-authenticate': `Bearer error="${code}"` },
});
}
}
L'en-tête WWW-Authenticate mérite d'être envoyé. C'est ainsi qu'un client distingue un
token expiré d'un endpoint cassé, et c'est ce que lit le client de @fluixi/oauth2
pour décider qu'il doit rafraîchir.
Le garde d'API
Répéter cela dans chaque route, c'est ainsi que l'une d'elles finit sans l'en-tête.
createApiGuard enveloppe plutôt un handler, et il est livré avec le paquet : le format de
l'en-tête et le client qui le lit viennent du même endroit.
// src/services/verify.server.ts
import { createTokenVerifier, createApiGuard } from '@fluixi/oauth2/verify';
import { rolesFrom } from '@fluixi/oauth2/roles';
import { auth } from '../config/env.js';
// The preset knows what this provider's tokens look like: issuer spelling, audience policy.
export const verify = createTokenVerifier(auth.provider.verifier(auth.clientId));
export const guard = createApiGuard({ verify, roles: rolesFrom.keycloakRealm });
// src/api/orders.ts
import { guard } from '../services/verify.server.js';
export const GET = guard(async (_request, claims) => Response.json(await listOrdersFor(String(claims.sub))));
export const DELETE = guard(deleteOrder, { role: 'admin' });
export const POST = guard(deleteOrder, {
scope: 'orders:write',
require: (claims) => claims.tenant === 'acme' || 'wrong tenant',
});
Un token qui échoue reçoit un 401 avec WWW-Authenticate: Bearer error="<code>". Un token
authentique qui ne suffit pas pour la route reçoit un 403. Gardez les deux séparés : un client
qui voit un 401 pour un rôle manquant rafraîchit un token parfaitement bon et redemande, sans
fin. Le 401 porte le code d'erreur et jamais le message, car un refus qui explique pourquoi un
token a échoué dit à un attaquant quelles tentatives s'approchent.
auth.provider.verifier(auth.clientId) construit les options du vérificateur à partir du
preset qu'authFromEnv a choisi (voir OAuth2), donc l'écriture de l'émetteur
et la politique d'audience correspondent au fournisseur.
Le second argument dit ce que la route exige au-delà d'un token valide :
| Exigence | Passe quand |
|---|---|
role |
le token porte l'un des rôles |
allRoles |
il les porte tous |
scope |
son claim scope contient l'un d'eux |
require(claims, request) |
la fonction renvoie true ; une chaîne refuse et dit pourquoi |
Les fournisseurs rangent les rôles à des endroits différents, il n'y a donc pas de valeur par
défaut. Une exigence role sur un garde construit sans roles lève une erreur, plutôt que de
répondre 403 à tout le monde et de ressembler à un problème de permissions. rolesFrom couvre
les formes en usage :
rolesFrom. |
Lit |
|---|---|
keycloakRealm |
realm_access.roles |
keycloakClient(clientId) |
resource_access[clientId].roles |
groups |
groups, tel qu'Okta l'envoie quand un claim de groupes est configuré |
cognito |
cognito:groups |
namespaced(claim) |
un claim personnalisé, tel qu'une action Auth0 l'ajoute |
scope |
le claim scope, pour un fournisseur qui n'émet pas de rôles |
onRefused(refusal, request) répond dans la forme d'erreur de l'application. Un 401 reçoit
quand même l'en-tête WWW-Authenticate sauf si la réponse en porte déjà un, car sans lui le
client ne distingue pas un token expiré d'un endpoint mort.
Pourquoi les options ont cette forme
La cryptographie est celle de jose, délibérément. Un
vérificateur écrit à la main est la façon dont alg: none et la confusion RS256 vers
HS256 finissent livrés, et les deux transforment un contrôle en faille. Ce qui est
ajouté ici est la configuration autour, là où vivent les erreurs courantes.
issuer est obligatoire et n'est jamais pris dans le token. Un token qui nomme son
propre émetteur ne prouve rien.
audience est obligatoire, ou bien un audience: false explicite. L'oublier est
facile et accepte en silence un token émis pour n'importe quel autre client du même
fournisseur.
algorithms vaut ['RS256'] par défaut et n'est jamais lu dans l'en-tête du token.
C'est ce qui empêche un token de basculer en HS256 en étant signé avec la clé publique
comme secret HMAC. none est refusé à la construction du vérificateur.
Le JWKS vient de la discovery, donc un service ne code pas l'URL en dur, et jose
gère le cache, la rotation des clés et le délai entre deux récupérations. Donnez jwksUri
pour sauter la discovery, ou jwks pour fournir les clés directement, ce qui les fige et
empêche la rotation de fonctionner.
jose est une peer dependency optionnelle. Une application qui ne vérifie jamais ne
l'installe jamais.
Les fournisseurs qui omettent aud
Keycloak émet des access tokens sans aud tant qu'un audience mapper n'est pas
configuré, et nomme le client dans azp à la place. Un service devant l'un d'eux veut :
createTokenVerifier({
issuer,
audience: false,
authorizedParty: 'admin-spa',
});
Pas audience: false seul, qui accepte un token émis pour n'importe quel client de
l'émetteur. Cela supprime le contrôle au lieu de le déplacer, et le vérificateur avertit
quand ni l'un ni l'autre n'est renseigné.
Configurer un audience mapper chez le fournisseur et utiliser audience reste le
meilleur état final. authorizedParty est pour quand vous ne contrôlez pas cela.
Un fournisseur qui se nomme de plusieurs façons
Google publie son issuer à la fois comme accounts.google.com et
https://accounts.google.com, et un token peut porter l'un ou l'autre. Un service figé sur
une seule forme rejette l'autre, ce qui ressemble à un échec de signature sans en être un.
issuer accepte une liste pour cela. La discovery utilise la première ; n'importe laquelle
est acceptée sur un token.
createTokenVerifier({
issuer: ['https://accounts.google.com', 'accounts.google.com'],
audience: clientId,
});
Contrôles supplémentaires
require s'exécute une fois le token validé, pour ce qui est propre au service. Renvoyez
true, ou une chaîne à utiliser comme message.
const verify = createTokenVerifier({
issuer,
audience,
require: (claims) =>
String(claims.scope ?? '').includes('billing') || 'Ce point d\'entrée demande le scope billing.',
});
Tokens liés par DPoP
Un token lié par DPoP porte cnf.jkt, l'empreinte de la clé du client, et chaque requête porte
dans l'en-tête DPoP une preuve signée avec cette clé. Sans dpop: true, le vérificateur
accepte un token lié comme s'il s'agissait d'un bearer token, et la liaison ne protège rien.
import { createTokenVerifier } from '@fluixi/oauth2/verify';
export const verify = createTokenVerifier({
issuer: 'https://example.okta.com/oauth2/default',
audience: 'api://orders',
dpop: true,
});
export async function claimsOf(request: Request) {
// `fromRequest`, not `verify(token)`: the proof is a header on the request.
return verify.fromRequest(request);
}
Avec lui, un token sans cnf.jkt est refusé, tout comme un token lié dont la preuve ne
correspond pas : la clé de la preuve doit avoir l'empreinte du token, htm et htu doivent
correspondre à la méthode et à l'URL de cette requête, ath doit être le hash de ce token, et
iat doit tenir dans dpopProofAgeSec (10 secondes sauf indication contraire). Une preuve
absente donne missing_dpop_proof et toute différence invalid_dpop_proof ; createApiGuard
renvoie l'un ou l'autre comme code du 401. Le côté client, dans les deux modes de tokens, est
sur la page OAuth2.
Tokens opaques et introspection
Tous les fournisseurs n'émettent pas des JWT. Un token opaque ne porte rien et ne signifie rien hors du fournisseur, et c'est précisément l'intérêt : il ne fuite rien s'il est intercepté, et le fournisseur reste la seule chose capable de dire ce qu'il représente.
Il n'y a aucun moyen d'en vérifier un localement. Le service demande, via la RFC 7662 :
import { createTokenIntrospector } from '@fluixi/oauth2/verify';
const verify = createTokenIntrospector({
issuer: process.env.OIDC_ISSUER!,
clientId: 'orders-api',
clientSecret: process.env.ORDERS_API_SECRET!,
audience: 'orders-api',
});
const claims = await verify.fromRequest(request); // même forme que le vérificateur JWT
Il renvoie le même TokenVerifier, donc le guard plus haut fonctionne tel quel. Passer
de l'un à l'autre est un changement d'une ligne.
Ce que cela coûte et ce que cela apporte
L'introspection est un appel réseau au fournisseur sur le chemin de chaque requête, là où la vérification JWT est un calcul local contre une clé en cache.
Ce que vous obtenez en échange, une signature ne peut pas le donner. Une signature dit qu'un token était authentique au moment de son émission ; l'introspection dit qu'il est bon maintenant. Un token révoqué il y a cinq minutes se vérifie toujours localement et est refusé ici immédiatement.
cacheMs réutilise une réponse active pendant une fenêtre, et vaut 0 par défaut,
c'est-à-dire demander à chaque fois. Toute valeur supérieure échange exactement la
propriété pour laquelle vous êtes venu, donc gardez-la petite et délibérée. Un refus n'est
jamais mis en cache, car cela maintiendrait dehors un token réactivé.
Les credentials du service sont obligatoires
L'endpoint d'introspection est authentifié, et ce n'est pas accessoire : un endpoint ouvert
laisserait n'importe qui tester si un token volé est encore vivant. Un service a besoin de
ses propres clientId et clientSecret chez le fournisseur, donc c'est un outil côté
serveur. Ne les livrez jamais à un navigateur.
Les credentials partent dans un en-tête Authorization: Basic par défaut.
clientAuthMethod: 'body' les place dans le formulaire pour un fournisseur qui les attend
là.
Les credentials depuis l'environnement
La façon dont un client s'authentifie auprès de l'introspection est le choix du fournisseur,
fait dans sa console : un secret partagé, ou private_key_jwt, une signature par une clé dont
le fournisseur détient la moitié publique. Les deux s'excluent, et un code qui suppose un
secret n'a rien à lire pour un client Okta réglé en clé publique/privée. credentialsFromEnv
lit celle qui est configurée :
// src/api/introspect.ts
import { createTokenIntrospector, credentialsFromEnv, explainCredentials } from '@fluixi/oauth2/verify';
export async function POST(request: Request) {
const credentials = await credentialsFromEnv({ prefix: 'okta' });
if (!credentials) {
return Response.json({ error: explainCredentials({ prefix: 'okta' }) }, { status: 501 });
}
const introspect = createTokenIntrospector({
issuer: 'https://example.okta.com/oauth2/default',
...credentials,
audience: false,
});
return Response.json(await introspect.fromRequest(request));
}
| Variable | Signification |
|---|---|
OKTA_CLIENT_ID |
le client qui demande ; sinon clientId dans les options |
OKTA_CLIENT_SECRET ou OKTA_SECRET |
un secret partagé |
OKTA_PRIVATE_KEY |
chemin du JWK privé ; ~ est développé |
OKTA_KEY_ID |
le kid sous lequel le fournisseur connaît la clé |
OKTA_ASSERTION_ALG |
l'algorithme de l'assertion, quand ce n'est pas celui par défaut |
Le préfixe est le vôtre ; okta ci-dessus. <PREFIX>_INTROSPECT_* est essayé d'abord, pour un
fournisseur qui veut qu'un autre client pose la question que celui avec lequel on se connecte :
une SPA publique chez Okta ne peut pas introspecter, un client API Services le fait donc. Une
clé l'emporte sur un secret. Un fichier de clé nommé mais illisible lève une erreur, car c'est
une configuration cassée et non une configuration absente, et null veut dire que rien n'est
configuré. explainCredentials nomme les deux façons de le régler, ce que le 501 ci-dessus dit
à un opérateur. Serveur uniquement : le fichier de clé est lu avec Node.
Le active: false qui veut dire autre chose
Un fournisseur répond { "active": false } et rien de plus : un token réellement mort et
une erreur de configuration se ressemblent donc exactement.
La cause habituelle du second cas est que le client qui demande n'est pas dans l'audience
du token. Keycloak refuse d'introspecter un token émis pour quelqu'un d'autre et ne dit
que active: false. La correction est un audience mapper sur le client pour que ses tokens
portent aud: <client>, après quoi l'introspection répond normalement.
Lequel utiliser
createTokenVerifier |
createTokenIntrospector |
|
|---|---|---|
| Token | un JWT | opaque, ou un JWT |
| Coût | local, une récupération de clé en cache | une requête au fournisseur, par appel |
| Révocation | invisible jusqu'à expiration | immédiate |
| Credentials du service requis | non | oui |
Utilisez le vérificateur quand les tokens sont des JWT à durée de vie courte. Utilisez l'introspecteur quand ils sont opaques, ou quand une session révoquée doit cesser de fonctionner tout de suite et que vous acceptez de payer un aller-retour pour cela.
Authentique et informatif sont deux choses
Un token peut se vérifier parfaitement et ne presque rien vous apprendre. Les tokens
d'accès légers de Keycloak ne portent pas de sub et peu de claims, et n'importe
quel fournisseur peut être configuré pour en émettre de maigres.
La vérification répond donc à « est-ce authentique et pour moi ». Elle ne répond pas à « qui est-ce », et un service qui a besoin de la seconde réponse doit appeler userinfo ou son propre stockage plutôt que de supposer les claims présents.
La révocation est l'autre limite. Un token à la signature valide pour un compte désactivé il y a cinq minutes se vérifie toujours. Ce sont des durées de vie courtes et un contrôle contre votre propre stockage qui ferment cela, pas la signature.
Ce qui appartient à quoi
| Question | Où |
|---|---|
| Faut-il montrer ce bouton | le client, avec can() et Can |
| Ce token est-il authentique et pour moi | cette page, à l'API |
| Cette personne a-t-elle le droit | votre API, après vérification |
La première est une aide au rendu et un client peut la falsifier. Les deux autres sont la vraie limite.
Suite : Routage.