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

Machine-to-machine access

A program with no user behind it (a nightly job, a backend calling another backend, a CI pipeline) gets an access token with the OAuth 2.0 client_credentials grant: it authenticates as itself and receives a token for one API. By the end of this page a job called orders-job places an order through the example orders API.

This page uses the demo tenant and the orders API from Run rIDM locally and Protect a Rust API:

export RIDM_URL=http://localhost:8090
export RIDM_TOKEN=rpat_...        # ridm:clients:*, ridm:users:*, ridm:roles:read

RIDM_ISSUER=http://localhost:8090/t/demo RIDM_AUDIENCE=https://orders.example \
RIDM_ALLOW_HTTP=true cargo run -p ridm-example-axum-api      # :8081, in another terminal

1. Register a machine client

ridm --tenant demo client create \
  --name "Orders nightly job" \
  --client-id orders-job \
  --type machine \
  --audience https://orders.example \
  --scope orders:read --scope orders:write
Client `orders-job` created (Orders nightly job).
client secret: cs_...
  (shown once — store it now)

--type machine means: token endpoint auth method client_secret_basic, the client_credentials grant only, no redirect URIs, no PKCE, and no scopes unless you list them. --audience limits which resource servers the client may get tokens for; leave it out and any non-built-in resource server of the tenant is allowed, which is rarely what you want.

The secret is shown once. Store it like a database password.

2. Get a token

CLIENT_SECRET=cs_...
curl -s -u "orders-job:$CLIENT_SECRET" \
  -d grant_type=client_credentials \
  -d resource=https://orders.example \
  -d scope="orders:read orders:write" \
  http://localhost:8090/t/demo/token | jq
{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6…",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "orders:read orders:write"
}
ParameterMeaning
-u client_id:secretclient_secret_basic: the credentials in the Authorization header. rIDM accepts only the method registered for the client.
grant_type=client_credentialsrefused for public clients (unauthorized_client)
resourceRFC 8707: the resource server the token is for. It becomes aud. Repeat it for several. It must be an absolute URI naming a resource server of the tenant that is in the client’s allowed_audiences; otherwise invalid_target.
scopeoptional; every scope must exist in the tenant and be allowed for the client (invalid_scope otherwise). openid and offline_access are refused: there is no user to identify and nothing to refresh. Without scope, the token gets the tenant’s default scopes (is_default) that the client is allowed, other than those two, which is often none. A scope bound to a resource server (as orders:read is to https://orders.example in the demo tenant) also adds that resource server to aud.

Without resource, the token is minted for every audience in the client’s allowed_audiences; with that list empty too, aud is the client’s own client_id, which no API accepts. Send resource every time.

There is no refresh token: when the access token expires (five minutes by default), ask for a new one. Cache the token and reuse it until shortly before expires_in runs out rather than requesting one per call. The token endpoint is rate-limited per tenant and per client address; see Rate limits, IP rules and CAPTCHA.

3. Call the API, and be refused

TOKEN=$(curl -s -u "orders-job:$CLIENT_SECRET" \
  -d grant_type=client_credentials -d resource=https://orders.example \
  http://localhost:8090/t/demo/token | jq -r .access_token)

curl -s -H "Authorization: Bearer $TOKEN" localhost:8081/whoami | jq
curl -i -H "Authorization: Bearer $TOKEN" localhost:8081/orders

/whoami answers, because the token is valid for https://orders.example:

{
  "subject": "orders-job",
  "tenant": "…",
  "client": "orders-job",
  "scopes": [],
  "roles": [],
  "permissions": [],
  "client_only": true
}

GET /orders answers 403 insufficient_scope: the token is missing permission orders:read. The subject is the client itself (sub equals client_id), and a client on its own holds no roles, so the token carries no permissions.

Scopes are a different matter. An API can gate on the scopes a client was allowed (Guard::scope("orders:read") in ridm-auth), and a token requested with scope=orders:read carries it. The orders API checks permissions, which come from roles, and roles belong to users. That is what a service account is for.

4. Give the client a service account

A service account is a user of the tenant, named svc-{client_id}, that the client’s client_credentials tokens are issued for. It holds roles and groups like any other user, so the token carries their permissions.

SVC=$(curl -s -X PUT -H "Authorization: Bearer $RIDM_TOKEN" \
  "$RIDM_URL/admin/tenants/demo/clients/orders-job/service-account" | jq -r .service_account.id)

ROLE=$(curl -s -H "Authorization: Bearer $RIDM_TOKEN" \
  "$RIDM_URL/admin/tenants/demo/roles" | jq -r '.[] | select(.name=="orders-manager") | .id')

curl -s -X PUT -H "Authorization: Bearer $RIDM_TOKEN" \
  "$RIDM_URL/admin/tenants/demo/users/$SVC/roles/$ROLE" | jq .effective
RequestEffect
PUT /admin/tenants/{slug}/clients/{client}/service-accountcreates the svc-orders-job user and links it to the client; returns the client and service_account. Idempotent. Needs ridm:clients:write, and the client must allow client_credentials.
DELETE …/service-accountunlinks and deletes that user
PUT /admin/tenants/{slug}/users/{user}/roles/{role_id}assigns a role; assigning one twice is not an error. Needs ridm:users:write, and an administrator can grant only permissions it holds.

In a configuration document the same client reads "service_account": true; role assignments are not part of the document, so they stay an admin API (or console) step. See Configuration as code.

Request a new token (the old one keeps what it was minted with) and try again:

TOKEN=$(curl -s -u "orders-job:$CLIENT_SECRET" \
  -d grant_type=client_credentials -d resource=https://orders.example \
  http://localhost:8090/t/demo/token | jq -r .access_token)

curl -s -H "Authorization: Bearer $TOKEN" localhost:8081/whoami | jq '{subject, roles, permissions, client_only}'
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"item":"Anvil","quantity":2}' localhost:8081/orders

Now subject is the service account’s user id, roles is ["orders-manager"], permissions is ["orders:read", "orders:write"], client_only is false, and the order is placed (201). In ridm-auth, Claims::is_client_only() tells a client acting on its own from one with a service account.

Administering rIDM from a machine client

The admin API is itself a resource server, urn:ridm:admin. It is built in, so a client must list it in allowed_audiences explicitly; an empty list does not imply it. With that audience, a service account holding administrator roles in master or the tenant, and a secret, the CLI runs non-interactively:

printf '%s' "$CI_CLIENT_SECRET" |
  ridm login --url https://id.example.com --tenant acme --client-id ci-bot --client-secret-stdin

See The ridm command line and Administrator access.

Stronger client authentication: private_key_jwt

A client secret is a shared secret: rIDM stores only its hash, but whoever holds the secret can use it, and it has to travel to every place the job runs. With private_key_jwt (RFC 7523) the client instead holds a private key that never leaves it, and rIDM holds the public key. Each token request carries a short signed assertion in place of a secret.

ridm client create has no flag for the client’s keys, so register this kind of client through the admin API, with either an inline jwks or a jwks_uri the client publishes (rIDM refetches a jwks_uri when an assertion names a key it has not seen, so the client can rotate its own keys):

curl -s -X POST "$RIDM_URL/admin/tenants/demo/clients" \
  -H "Authorization: Bearer $RIDM_TOKEN" -H 'content-type: application/json' \
  -d '{
        "client_id": "orders-sync",
        "name": "Orders sync service",
        "client_type": "machine",
        "token_endpoint_auth_method": "private_key_jwt",
        "jwks_uri": "https://sync.acme.example/.well-known/jwks.json",
        "allowed_audiences": ["https://orders.example"]
      }'

No secret is minted. The assertion is a JWT the client signs with its private key:

Claim or headerValue
algan asymmetric algorithm; HMAC (HS256 …) is refused
kidthe key’s id in the client’s key set (may be omitted when the set has exactly one key)
iss, subboth the client_id
audthe tenant’s token endpoint URL, or its issuer
expat most 600 seconds after iat
jtiunique; a reused jti is refused for the assertion’s lifetime
curl -s \
  -d grant_type=client_credentials \
  -d resource=https://orders.example \
  -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
  -d client_assertion="$ASSERTION" \
  https://id.example.com/t/demo/token

Everything after authentication (audience, scopes, service account, permissions) works exactly as with a secret. The confidential web app in Sign in from a server-side web app can use private_key_jwt the same way.

Troubleshooting

Answer from /tokenCause
401 invalid_clientwrong secret, a secret sent with a method other than the registered one, a retired secret, or a bad assertion
400 unauthorized_clientthe client is public, or not allowed client_credentials
400 invalid_scopea scope the tenant does not define, one the client is not allowed, one bound to a resource server the client may not target, or openid / offline_access
400 invalid_targetresource is not an absolute URI, names no resource server, or names one outside allowed_audiences

Every error code is listed in Errors; the claims in these tokens are in Token claims.