Test service-to-service auth with client credentials
When one microservice calls another, there is no user: the calling service gets a token with the OAuth 2.0 client credentials grant and sends it as a bearer token. The receiving service has to check three things: that the token is genuine, that it was issued for this service, and that the caller has the scope for the operation. The second check is the one most often missing, and the one a test should prove.
NanoIDP issues these tokens locally, with per-client scopes and per-service audiences (RFC 8707 resource indicators), so you can test both what a service accepts and what it must refuse. This page sets up two services calling two APIs, and a Spring Boot resource server that enforces the three checks.
Everything here is in
examples/microservices-client-credentials/,
and the repository's CI builds the API and runs the tests below against it.
1. Declare the services, their scopes and their audiences
pip install nanoidp requests pytest # requests and pytest for the tests below
mkdir -p config
base=https://raw.githubusercontent.com/cdelmonte-zg/nanoidp/main/examples/microservices-client-credentials
curl -fsSL -o config/settings.yaml "$base/settings.yaml"
curl -fsSL -o config/users.yaml "$base/users.yaml"
python -m nanoidp --config ./config
# NanoIDP Settings - Microservices Client Credentials
# Copy this file to your config directory
# Guide: https://cdelmonte-zg.github.io/nanoidp/use-cases/service-to-service-client-credentials.html
server:
host: "127.0.0.1"
# host: "0.0.0.0" # LAN access is intentionally opt-in (GHSA-2473-px8h-rvg6)
port: 8000
oauth:
issuer: "http://localhost:8000"
# The aud of a token requested without a resource: accepted by no service
audience: "microservices"
token_expiry_minutes: 60
# Every scope a client may ask for must be in the vocabulary
scopes_supported:
- openid
- profile
- email
- offline_access
- orders:read
- inventory:read
- inventory:reserve
clients:
# Calls the inventory API: reads stock, reserves items
- client_id: "order-service"
client_secret: "order-service-secret"
description: "Order management microservice"
allowed_scopes: [inventory:read, inventory:reserve]
allowed_resources: ["https://inventory.internal"]
# Calls the orders API: reads orders to notify about them
- client_id: "notification-service"
client_secret: "notification-service-secret"
description: "Notification delivery microservice"
allowed_scopes: [orders:read]
allowed_resources: ["https://orders.internal"]
scopes_supportedis the vocabulary: NanoIDP refuses a scope outside it withinvalid_scope, for every client.allowed_scopesnarrows what one client may ask for.allowed_resourceslists the APIs a client may get a token for. A token requested withresource=https://inventory.internalhas that as itsaud; without aresource, itsaudisoauth.audience, which no API here accepts.
2. Get a token as a service
curl -X POST http://localhost:8000/token \
-u 'order-service:order-service-secret' \
-d 'grant_type=client_credentials' \
-d 'scope=inventory:read' \
-d 'resource=https://inventory.internal'
The access token's claims include:
{
"iss": "http://localhost:8000",
"aud": "https://inventory.internal",
"client_id": "order-service",
"scope": "inventory:read",
"sub": "order-service"
}
No refresh token: the service asks again when the token expires.
The token is the service's own: sub is the calling client, as RFC 9068
§2.2 asks for grants with no user, and no user's roles or attributes come
with it. Authorize services on their scopes and identify them by
client_id or sub.
In releases up to 3.3.0, a client credentials token was issued for the
default_userofusers.yaml, with that user's roles (#445):subwas that user's name, not the service.default_useris deprecated since, and this preset no longer needs a user for its services.
3. Enforce the checks in the receiving service
A Spring Boot 4 resource server needs one starter:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
</dependencies>
The issuer URI gives Spring NanoIDP's discovery document and JWKS, so the
signature and iss are checked. The audience is not checked unless you
say so: without the audiences line, the inventory API accepts a token
issued for any other service.
server:
port: 8081
spring:
security:
oauth2:
resourceserver:
jwt:
# Discovery and JWKS come from NanoIDP; iss is checked against it
issuer-uri: http://localhost:8000
# Spring does not check aud unless told to: without this line a
# token issued for any other service is accepted here
audiences: https://inventory.internal
Spring turns the scope claim into SCOPE_ authorities, one per scope:
package example;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// Spring turns the token's "scope" claim into SCOPE_ authorities.
// Authorize a service on its scopes; its sub is its client_id, and a
// client_credentials token carries no roles
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/stock/**").hasAuthority("SCOPE_inventory:read")
.requestMatchers(HttpMethod.POST, "/reservations").hasAuthority("SCOPE_inventory:reserve")
.anyRequest().denyAll())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
}
4. Test what is accepted and what is refused
These tests play order-service and notification-service against the
inventory API on :8081, and ask NanoIDP for tokens it must refuse:
"""order-service calls the inventory API with a client_credentials token.
Needs NanoIDP on :8000 with this preset and the inventory API
(inventory-api/) on :8081.
"""
import jwt
import requests
IDP = "http://localhost:8000"
INVENTORY_API = "http://localhost:8081"
INVENTORY = "https://inventory.internal" # the inventory API's audience
TIMEOUT = 10 # seconds: a stuck server fails the test instead of hanging it
def token_request(client_id, secret, scope=None, resource=None):
data = {"grant_type": "client_credentials"}
if scope:
data["scope"] = scope
if resource:
data["resource"] = resource # RFC 8707: the token's aud becomes this
return requests.post(f"{IDP}/token", auth=(client_id, secret), data=data,
timeout=TIMEOUT)
def token(client_id, secret, scope=None, resource=None):
response = token_request(client_id, secret, scope, resource)
assert response.status_code == 200, response.text
return response.json()["access_token"]
def call(method, path, access_token=None):
headers = {"Authorization": f"Bearer {access_token}"} if access_token else {}
return requests.request(method, INVENTORY_API + path, headers=headers,
timeout=TIMEOUT)
ORDER_SERVICE = ("order-service", "order-service-secret")
# What the inventory API accepts
def test_order_service_reads_stock():
access = token(*ORDER_SERVICE, scope="inventory:read", resource=INVENTORY)
response = call("GET", "/stock/sku-1", access)
assert response.status_code == 200
assert response.json()["caller"] == "order-service"
def test_reserving_needs_the_reserve_scope():
read_only = token(*ORDER_SERVICE, scope="inventory:read", resource=INVENTORY)
assert call("POST", "/reservations", read_only).status_code == 403
both = token(*ORDER_SERVICE, scope="inventory:read inventory:reserve",
resource=INVENTORY)
assert call("POST", "/reservations", both).status_code == 200
# What the inventory API refuses
def test_no_token_is_refused():
assert call("GET", "/stock/sku-1").status_code == 401
def test_a_token_without_a_resource_is_refused():
# aud is oauth.audience ("microservices"), not this API's audience
access = token(*ORDER_SERVICE, scope="inventory:read")
assert call("GET", "/stock/sku-1", access).status_code == 401
def test_a_token_for_another_service_is_refused():
access = token("notification-service", "notification-service-secret",
scope="orders:read", resource="https://orders.internal")
assert call("GET", "/stock/sku-1", access).status_code == 401
# What NanoIDP refuses to issue
def test_a_scope_the_client_may_not_have():
response = token_request(*ORDER_SERVICE, scope="orders:read", resource=INVENTORY)
assert response.json()["error"] == "invalid_scope"
def test_a_resource_the_client_may_not_target():
response = token_request(*ORDER_SERVICE, scope="inventory:read",
resource="https://orders.internal")
assert response.json()["error"] == "invalid_target"
def test_a_wrong_secret():
response = token_request("order-service", "wrong", scope="inventory:read")
assert response.status_code == 401
assert response.json()["error"] == "invalid_client"
# What the token says about the caller
def test_the_caller_is_the_client_id_and_carries_no_user_privileges():
access = token(*ORDER_SERVICE, scope="inventory:read", resource=INVENTORY)
claims = jwt.decode(access, options={"verify_signature": False})
assert claims["client_id"] == "order-service"
assert claims["aud"] == INVENTORY
# The token is the client's own (RFC 9068 §2.2): its subject is the
# client, and no user's roles or attributes come with it
assert claims["sub"] == "order-service"
for user_claim in ("roles", "authorities", "tenant", "entitlements", "groups", "attributes"):
assert user_claim not in claims
| Case | Answer |
|---|---|
order-service, inventory:read, audience https://inventory.internal, GET /stock | 200, the caller is order-service |
the same token on POST /reservations | 403: the scope is missing |
| no token | 401 |
a token requested without resource (aud is microservices) | 401 |
notification-service's token for https://orders.internal | 401: issued for another API |
order-service asks for orders:read | NanoIDP: invalid_scope |
order-service asks for https://orders.internal | NanoIDP: invalid_target |
| a wrong client secret | NanoIDP: 401 invalid_client |
Remove the audiences line from the API and the two "issued for another
API" cases turn into accepted calls: the tests catch it.
Takeaways
- Give every API its own audience and have clients request it with
resource. A shared audience means any service's token works everywhere. - Spring checks the signature and the issuer by itself, the audience only when configured. Test that a token for another service is refused.
- Authorize a service on its scopes, and identify it by its
sub, which is itsclient_id. A client credentials token carries no user's roles. - Declare scopes in
scopes_supportedand per client inallowed_scopes: a scope NanoIDP does not know is refused, not silently dropped.