Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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_supported is the vocabulary: NanoIDP refuses a scope outside it with invalid_scope, for every client.
  • allowed_scopes narrows what one client may ask for.
  • allowed_resources lists the APIs a client may get a token for. A token requested with resource=https://inventory.internal has that as its aud; without a resource, its aud is oauth.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_user of users.yaml, with that user's roles (#445): sub was that user's name, not the service. default_user is 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
CaseAnswer
order-service, inventory:read, audience https://inventory.internal, GET /stock200, the caller is order-service
the same token on POST /reservations403: the scope is missing
no token401
a token requested without resource (aud is microservices)401
notification-service's token for https://orders.internal401: issued for another API
order-service asks for orders:readNanoIDP: invalid_scope
order-service asks for https://orders.internalNanoIDP: invalid_target
a wrong client secretNanoIDP: 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 its client_id. A client credentials token carries no user's roles.
  • Declare scopes in scopes_supported and per client in allowed_scopes: a scope NanoIDP does not know is refused, not silently dropped.