EnglishBac à sable

Service à service

Toutes les pages précédentes répondent à « qui utilise cette application ». Celle-ci répond à « quel service appelle », qui est l'autre moitié de la plupart des systèmes : un job nocturne qui lit une API, un service qui en appelle un autre, un handler de webhook qui doit réécrire quelque part.

Il n'y a aucun utilisateur dans tout cela, donc rien à rediriger et aucune page de connexion. Le service prouve qui il est avec ses propres credentials.

import { createServiceClient } from '@fluixi/oauth2/service';

const orders = createServiceClient({
  issuer: process.env.OIDC_ISSUER!,
  clientId: 'reports-job',
  clientSecret: process.env.REPORTS_SECRET!,
  scopes: ['orders:read'],
});

const response = await fetch('https://api.example.com/orders', {
  headers: { authorization: `Bearer ${await orders.token()}` },
});

C'est tout. token() renvoie un token valide, en en demandant un quand il le faut et en le réutilisant sinon.

Côté serveur uniquement

Le grant est le client secret. Tout ce qui le détient peut émettre des tokens au nom de ce service : il appartient donc à une variable d'environnement sur un serveur, et jamais à quoi que ce soit livré à un navigateur.

Si vous avez envie de cela dans du code front-end, ce que vous voulez vraiment est OAuth2 : un utilisateur se connecte, et son token porte ce qu'il a le droit de faire.

Le cache, et pourquoi il compte

token() garde le token jusqu'à ce qu'il approche de son expiration, puis en demande un nouveau. refreshSkewMs définit cette marge, 30 secondes par défaut. Un token qui expire en vol fait échouer une requête qui allait bien au départ, et c'est toute la raison d'être de la marge.

Le second comportement est moins évident et compte davantage sous charge. Quand plusieurs appels arrivent sur un cache froid, ils font une demande de token à eux tous plutôt qu'une chacun :

// Une requête au fournisseur, trois appelants servis.
const [a, b, c] = await Promise.all([orders.token(), orders.token(), orders.token()]);

Sans cela, une rafale de travail au démarrage d'un job devient une rafale sur le token endpoint, que les fournisseurs limitent.

Une requête en échec n'est pas mise en cache : un fournisseur brièvement indisponible est réessayé plutôt que retenu comme cassé.

La configuration chez le fournisseur

Le client doit être confidentiel, c'est-à-dire avoir un secret, et le grant client credentials doit être activé. Dans Keycloak c'est un client avec Client authentication activé et Service accounts roles coché. La plupart des fournisseurs nomment cela de façon similaire.

Il vaut la peine de savoir comment se lit le token obtenu : un service est un principal à part entière, pas un principal absent. Keycloak lui donne un service account, donc sub est l'identifiant de ce compte et preferred_username vaut service-account-<client>. Ce n'est pas une personne, mais ce n'est pas anonyme non plus.

Différences entre fournisseurs

Auth0 veut l'API nommée dans audience, et émet un token opaque plutôt qu'un JWT sans cela :

createServiceClient({ issuer, clientId, clientSecret, audience: 'https://api.example.com' });

Credentials dans le corps. Ils partent dans un en-tête Authorization: Basic par défaut, ce qui garde le secret hors du corps de la requête. Certains fournisseurs les attendent dans le formulaire :

createServiceClient({ issuer, clientId, clientSecret, clientAuthMethod: 'body' });

Une clé plutôt qu'un secret. Un client enregistré avec une clé publique s'authentifie par private_key_jwt : passez privateKey (un JWK) et keyId, et aucun secret ne circule. Le serveur d'autorisation de l'org Okta n'accepte rien d'autre pour les client credentials. credentialsFromEnv lit l'une ou l'autre configuration depuis l'environnement et s'étale directement ; voir Vérification des tokens.

import { createServiceClient } from '@fluixi/oauth2/service';
import { credentialsFromEnv } from '@fluixi/oauth2/verify';

const credentials = await credentialsFromEnv({ prefix: 'orders' });
if (!credentials) throw new Error('Set ORDERS_CLIENT_SECRET, or ORDERS_PRIVATE_KEY and ORDERS_KEY_ID.');

export const orders = createServiceClient({ issuer, ...credentials });

Pas de document de discovery. Donnez le token endpoint directement avec tokenEndpoint.

Tout le reste passe par extraParams, fusionné dans la requête de token.

L'autre côté

Un service qui reçoit un de ces tokens le vérifie exactement comme celui d'un utilisateur. Le token nomme le client appelant plutôt qu'une personne, donc le contrôle porte généralement là-dessus :

const verify = createTokenVerifier({
  issuer,
  audience: 'orders-api',
  require: (claims) => String(claims.scope ?? '').includes('orders:read'),
});

Voir Vérification des tokens. Les scopes sont la façon dont un fournisseur dit ce qu'un service a le droit de faire, et les contrôler est la raison d'en émettre d'étroits.

Lire le token courant

current() renvoie ce que le fournisseur a envoyé, pour un appelant qui a besoin de plus que la chaîne :

const { accessToken, expiresAt, scope } = await orders.current();

reset() jette le token en cache, donc l'appel suivant en demande un nouveau. Utile après un 401 qui laisse penser que le token a été révoqué avant son expiration.

Suite : Vérification des tokens.