TutorielGuides

Service à service avec client_credentials

Sur cette page

Quand aucun utilisateur n’intervient (un traitement planifié, un microservice qui en appelle un autre), le service s’authentifie lui-même auprès de TOSIAM avec le grant client_credentials : il présente son identifiant et son secret, et reçoit un jeton d’accès à son nom. Ce tutoriel configure deux services de ce type et une API qui valide leurs jetons localement : les jetons sont des JWT signés, que l’API vérifie avec les clés publiques de TOSIAM, sans l’interroger à chaque requête.

Le code est dans le dépôt tosiam-samples, dossier tosiam-client-credentials : une API d’inventaire Spring Boot 3.5 (api) et un service Spring Boot sans serveur web (client) qui l’appelle toutes les 30 secondes au nom de deux clients.

texte
service-commandes ─┐                                       ┌─▶ TOSIAM (royaume ref) : ① jeton (client_credentials)
service-rapports  ─┴─ client (Spring Boot) ────────────────┤
                                                           └─▶ API d'inventaire (localhost:8083) : ② jeton JWT
                       API ──③ clés publiques (jwks_uri), gardées en cache──▶ TOSIAM

Étape 1 : les clients des services#

Chaque service est un client OAuth2 confidentiel du royaume ref : il tourne sur un serveur et peut garder un secret. Dans Clients, créez deux clients :

Faites défiler le tableau
Réglageservice-commandesservice-rapports
Type de clientConfidentialConfidential
Secret (démonstration)service-commandes-secretservice-rapports-secret
Type d’autorisationClient Credentials seulementClient Credentials seulement
Scopesinventaire.lecture, inventaire.ecritureinventaire.lecture
Méthode d’authentification au point d’accès des jetonsclient_secret_basicclient_secret_basic
Émettre les jetons d’accès sous forme de JWT (onglet Avancé)activéactivé

Les scopes de chaque client disent ce que son service a le droit de faire : service-rapports pourra lire l’inventaire, pas le modifier.

Pour service-commandes : dans Clients, Créer un agent ouvre l’assistant. À l’étape Identité, donnez l’identifiant service-commandes ; aucune URI de redirection n’est nécessaire, aucun navigateur n’intervient. À l’étape Sécurité :

Assistant de création d'agent, étape Sécurité : type Confidentiel, mot de passe et méthode client_secret_basic, seul le type d'autorisation Client Credentials coché, scopes inventaire.lecture et inventaire.ecriture Assistant de création d'agent, étape Sécurité : type Confidentiel, mot de passe et méthode client_secret_basic, seul le type d'autorisation Client Credentials coché, scopes inventaire.lecture et inventaire.ecriture
Type de client Confidentiel (1), secret et méthode client_secret_basic (2), seul type d’autorisation Client Credentials (3) et scopes (4).
  • Type de client : Confidentiel.
  • Mot de passe de l’agent : le secret du service. La méthode proposée, client_secret_basic, convient : le service envoie son identifiant et son secret dans l’en-tête Authorization.
  • Types d’autorisation : décochez Authorization Code et Refresh Token, cochez Client Credentials.
  • Scopes : retirez openid et profile, ajoutez inventaire.lecture et inventaire.ecriture.

Créez le client, puis vérifiez sa fiche. Onglet Général, le type de client et les scopes :

Fiche du client service-commandes, onglet Général : type de client Confidentiel, scopes inventaire.lecture et inventaire.ecriture Fiche du client service-commandes, onglet Général : type de client Confidentiel, scopes inventaire.lecture et inventaire.ecriture
Type de client Confidentiel (1) et scopes (2).

Onglet Avancé, activez Émettre les jetons d’accès sous forme de JWT et vérifiez que Client Credentials est le seul type d’autorisation coché :

Fiche du client service-commandes, onglet Avancé : Émettre les jetons d'accès sous forme de JWT activé, seul le type d'autorisation Client Credentials coché Fiche du client service-commandes, onglet Avancé : Émettre les jetons d'accès sous forme de JWT activé, seul le type d'autorisation Client Credentials coché
Jetons d’accès JWT (1) et type d’autorisation Client Credentials seul (2).

Enregistrez, puis créez service-rapports de la même façon, avec le seul scope inventaire.lecture.

Sans l’option Émettre les jetons d’accès sous forme de JWT (isAccessTokenJwtEnabled), TOSIAM délivre des jetons opaques, que l’API devrait faire vérifier par le point d’introspection (comme dans le tutoriel Angular). Avec elle, le jeton est un JWT signé avec l’algorithme de signature des jetons du fournisseur s’il est asymétrique, et en RS256 s’il est symétrique (HS256, la valeur par défaut) : l’API peut alors le vérifier avec les clés publiques du royaume.

Étape 2 : obtenir un jeton#

Le service appelle le point des jetons du royaume, authentifié par son identifiant et son secret (en-tête HTTP Basic pour client_secret_basic) :

bash
curl -s -u service-rapports:service-rapports-secret \
     -d grant_type=client_credentials -d scope=inventaire.lecture \
     http://localhost:8080/tosiam/oauth2/ref/access_token
json
{"access_token":"eyAidHlwIjogImF0K2p3dCIs…","token_type":"Bearer","expires_in":3599,"scope":"inventaire.lecture"}

Pas de jeton d’identité ni de refresh token : il n’y a pas d’utilisateur, et le service redemandera simplement un jeton à l’expiration du premier. Décodé, le jeton d’accès donne :

json· En-tête
{ "typ": "at+jwt", "token_format": "stateful", "kid": "K2k+448beikyJVPOO8yRpD16QjA=", "alg": "RS256" }
json· Charge utile
{
  "sub": "service-rapports",
  "aud": "service-rapports",
  "client_id": "service-rapports",
  "scope": "inventaire.lecture",
  "iss": "http://localhost:8080/tosiam/oauth2/ref",
  "iat": 1791485669,
  "exp": 1791489269,
  "jti": "c6b996aa-e5ae-471e-9758-23755670fd06"
}
  • typ: at+jwt marque un jeton d’accès JWT (RFC 9068) ; kid désigne la clé de signature, publiée à l’adresse jwks_uri du royaume (/oauth2/ref/connect/jwk_uri).
  • sub, aud et client_id valent tous l’identifiant du client : sans utilisateur, le sujet du jeton est le service lui-même. TOSIAM met l’identifiant du client dans aud, pas celui de l’API.
  • token_format: stateful et jti : le jeton reste enregistré dans TOSIAM, sous l’identifiant jti. Il peut donc être révoqué et introspecté, même si l’API ne le fait pas (voir l’étape 6).

Étape 3 : l’API valide le jeton#

L’API est un serveur de ressources Spring Security en mode JWT. api/src/main/resources/application.yml donne l’émetteur, l’adresse des clés publiques et la liste des clients autorisés :

yaml· api/src/main/resources/application.yml
server:
  port: 8083
app:
  tosiam:
    issuer: http://localhost:8080/tosiam/oauth2/ref
    jwk-set-uri: http://localhost:8080/tosiam/oauth2/ref/connect/jwk_uri
    allowed-clients: service-commandes,service-rapports

SecurityConfig.java construit le décodeur et les règles d’accès :

java· api/src/main/java/org/tosit/samples/inventaire/SecurityConfig.java
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri)
        // Algorithmes asymétriques (par défaut : RS256 seulement)
        .jwsAlgorithms(algorithms -> algorithms.addAll(List.of(
                SignatureAlgorithm.RS256, SignatureAlgorithm.RS384, SignatureAlgorithm.RS512,
                SignatureAlgorithm.ES256, SignatureAlgorithm.ES384, SignatureAlgorithm.ES512,
                SignatureAlgorithm.PS256, SignatureAlgorithm.PS384, SignatureAlgorithm.PS512)))
        // TOSIAM marque ses jetons d'accès JWT du type at+jwt (RFC 9068)
        .jwtProcessorCustomizer(processor -> processor.setJWSTypeVerifier(
                new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt"), JOSEObjectType.JWT, null)))
        .build();
decoder.setJwtValidator(tosiamValidator(issuer, allowedClients));

static OAuth2TokenValidator<Jwt> tosiamValidator(String issuer, List<String> allowedClients) {
    OAuth2TokenValidator<Jwt> allowedClient = new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
            audience -> audience != null && audience.stream().anyMatch(allowedClients::contains));
    return new DelegatingOAuth2TokenValidator<>(JwtValidators.createDefaultWithIssuer(issuer), allowedClient);
}
java
http.authorizeHttpRequests(authz -> authz
        .requestMatchers(HttpMethod.GET, "/inventaire/**").hasAuthority("SCOPE_inventaire.lecture")
        .requestMatchers(HttpMethod.POST, "/inventaire/**").hasAuthority("SCOPE_inventaire.ecriture")
        .anyRequest().denyAll())
    .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));

Pour chaque requête, l’API vérifie, sans appeler TOSIAM :

  1. la signature, avec la clé kid du jeu de clés publié par TOSIAM et un algorithme asymétrique. Le jeu de clés est téléchargé à la première requête puis gardé en cache cinq minutes ; une clé inconnue (nouvelle clé de signature) provoque un nouveau téléchargement. Par défaut, Spring Security n’accepte que RS256 : l’exemple accepte aussi les autres algorithmes asymétriques (RS384, ES256, PS256…), pour le cas où le fournisseur signe avec l’un d’eux ;
  2. le type at+jwt : le vérificateur par défaut de Nimbus n’accepte que JWT ou l’absence de type, d’où le réglage explicite ;
  3. l’émetteur (iss) et l’expiration (exp) ;
  4. le client appelant : comme TOSIAM met l’identifiant du client dans aud, l’API compare aud à la liste des clients autorisés. Un jeton délivré à un autre client du royaume, par exemple l’application Angular, est refusé (401) ;
  5. le scope : Spring Security transforme la revendication scope en autorisations SCOPE_…. Un jeton sans le bon scope donne 403.

Étape 4 : le service client#

Le service utilise le client OAuth2 de Spring Security. client/src/main/resources/application.yml déclare les deux enregistrements :

yaml· client/src/main/resources/application.yml
spring:
  main:
    web-application-type: none
  security:
    oauth2:
      client:
        registration:
          service-commandes:
            provider: tosiam
            client-id: service-commandes
            client-secret: ${SERVICE_COMMANDES_SECRET:service-commandes-secret}
            client-authentication-method: client_secret_basic
            authorization-grant-type: client_credentials
            scope: inventaire.lecture,inventaire.ecriture
          service-rapports:
            # … même forme, scope inventaire.lecture
        provider:
          tosiam:
            token-uri: http://localhost:8080/tosiam/oauth2/ref/access_token

Seule l’adresse du point des jetons est donnée (token-uri), sans découverte au démarrage : le service démarre même si TOSIAM est arrêté, et réessaie au cycle suivant.

OAuth2ClientConfig.java crée le gestionnaire des jetons et le RestClient de l’API :

java· client/src/main/java/org/tosit/samples/serviceclient/OAuth2ClientConfig.java
var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, authorizedClients);
manager.setAuthorizedClientProvider(OAuth2AuthorizedClientProviderBuilder.builder().clientCredentials().build());

RestClient inventaire = builder.baseUrl(apiBaseUrl)
        .requestInterceptor(new OAuth2ClientHttpRequestInterceptor(manager))
        .build();

Chaque appel désigne le client à utiliser ; l’intercepteur obtient le jeton (ou reprend celui en cache) et ajoute l’en-tête Authorization: Bearer … :

java· client/src/main/java/org/tosit/samples/serviceclient/InventaireCalls.java
List<Map<String, Object>> articles = inventaire.get().uri("/inventaire/articles")
        .attributes(clientRegistrationId("service-rapports"))
        .retrieve()
        .body(new ParameterizedTypeReference<>() { });

Le gestionnaire garde le jeton de chaque client et en redemande un une minute avant son expiration (marge d’horloge par défaut de Spring Security) : le service n’appelle pas TOSIAM à chaque requête.

Étape 5 : lancer et tester#

Dans deux terminaux, l’API puis le service :

bash
cd tosiam-samples/tosiam-client-credentials/api
./mvnw spring-boot:run   # http://localhost:8083
bash
cd tosiam-samples/tosiam-client-credentials/client
./mvnw spring-boot:run

Le journal du service montre les deux clients :

texte
service-commandes : nouveau jeton d'accès de TOSIAM (scopes [inventaire.lecture, inventaire.ecriture], expire à 22:00:27)
service-commandes : 2 articles en stock
service-commandes : article ajouté {id=3, nom=Câble USB-C, quantite=10}
service-rapports : nouveau jeton d'accès de TOSIAM (scopes [inventaire.lecture], expire à 22:00:27)
service-rapports : 3 articles en stock
service-rapports : l'API répond 403 Forbidden

Trente secondes plus tard, les appels recommencent sans nouvelle ligne « nouveau jeton » : chaque service réutilise son jeton. Le message vient du gestionnaire des jetons, qui ne l’écrit que lorsque TOSIAM en délivre un :

java· client/src/main/java/org/tosit/samples/serviceclient/OAuth2ClientConfig.java
manager.setAuthorizationSuccessHandler((client, principal, attributes) -> {
    log.info("{} : nouveau jeton d'accès de TOSIAM (scopes {}, expire à {})", …);
    authorizedClients.saveAuthorizedClient(client, principal);   // comportement par défaut : garder le jeton
});

Les mêmes vérifications à la main, avec le jeton de l’étape 2 dans la variable TOKEN :

Faites défiler le tableau
RequêteRéponse
GET /inventaire/articles sans jeton401
GET /inventaire/articles avec le jeton de service-rapports200 et la liste des articles
POST /inventaire/articles avec le jeton de service-rapports403 (pas de scope inventaire.ecriture)
POST /inventaire/articles avec un jeton de service-commandes201 et l’article créé
Jeton altéré (un caractère de la signature changé)401

Étape 6 : révocation et validation locale#

Valider localement a un prix : l’API ne sait pas qu’un jeton a été révoqué. Sur la QuickStart :

bash
curl -s -u service-rapports:service-rapports-secret -d "token=$TOKEN" \
     http://localhost:8080/tosiam/oauth2/ref/token/revoke                 # 200 : jeton révoqué

curl -s -u service-rapports:service-rapports-secret -d "token=$TOKEN" \
     http://localhost:8080/tosiam/oauth2/ref/introspect                   # {"active":false}

curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" \
     http://localhost:8083/inventaire/articles                           # 200 : toujours accepté

TOSIAM sait que le jeton n’est plus valide, mais l’API, qui ne lui pose pas la question, l’accepte jusqu’à son expiration, plus la minute de tolérance d’horloge de Spring Security. Deux façons de réduire ce délai :

  • des jetons courts : réglez la durée de vie des jetons d’accès du fournisseur (une heure par défaut) au délai de révocation acceptable ; les services redemandent des jetons plus souvent ;
  • l’introspection pour les opérations sensibles : l’API interroge TOSIAM (/oauth2/ref/introspect) et obtient l’état réel du jeton, au prix d’un appel par requête (voir le tutoriel Angular).

En cas de problème#

Faites défiler le tableau
SymptômeCause probable
invalid_client au point des jetonsIdentifiant ou secret faux, client absent du royaume ref, ou méthode d’authentification du client différente de client_secret_basic
L’API répond 401 avec un jeton tout juste obtenuJeton opaque (option Émettre les jetons d’accès sous forme de JWT désactivée), client absent de allowed-clients, ou émetteur différent de app.tosiam.issuer
L’API répond 401, l’en-tête WWW-Authenticate contient « JOSE header typ (type) at+jwt not allowed »Vérificateur de type par défaut de Nimbus : at+jwt n’est pas accepté sans le réglage de l’étape 3
L’API répond 403Jeton sans le scope de l’opération : inventaire.lecture pour lire, inventaire.ecriture pour ajouter
[invalid_token_response] … Connection refused dans le journal du serviceTOSIAM arrêté : le service réessaie au cycle suivant
I/O error on GET request for "http://localhost:8083/…" dans le journal du serviceAPI arrêtée : le service réessaie au cycle suivant
L’API répond 401 et son journal contient une erreur d’entrée-sortie sur jwk_uriTOSIAM injoignable : passé les cinq minutes de cache, l’API ne peut plus relire les clés publiques. Elle reprend seule quand TOSIAM revient
L’API répond 401 : « Another algorithm expected »Algorithme de signature absent de la liste du décodeur (étape 3)
Un jeton révoqué est encore acceptéComportement attendu de la validation locale (étape 6)

En production#

  • Secrets : passez-les par des variables d’environnement ou un coffre de secrets, jamais dans le dépôt. Pour aller plus loin, remplacez le secret par une assertion signée (private_key_jwt) ou par un certificat client (mTLS), voir Enregistrer un client.
  • Un client par service, avec les seuls scopes dont il a besoin : un secret volé ne donne que les droits de ce service.
  • Durée de vie des jetons choisie en fonction du délai de révocation acceptable (étape 6).
  • HTTPS entre les services, l’API et TOSIAM.
  • Rotation des clés : l’API recharge le jeu de clés quand elle rencontre un kid inconnu ; gardez l’ancienne clé publiée le temps que les jetons qu’elle a signés expirent.

Pour aller plus loin#

Mis à jour le