Skip to content

docs(security): show how a service client obtains its token - #191

Open
adityamparikh wants to merge 1 commit into
apache:mainfrom
adityamparikh:docs/keycloak-client-credentials
Open

docs(security): show how a service client obtains its token#191
adityamparikh wants to merge 1 commit into
apache:mainfrom
adityamparikh:docs/keycloak-client-credentials

Conversation

@adityamparikh

@adityamparikh adityamparikh commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

The gap

Configuring a Spring AI MCP Client registers a confidential client in step 1 and verifies a gated tool call with $TOKEN in step 4, but nothing between them obtains that token. The client_credentials request appears only Spring-side, as properties and an OAuth2AuthorizedClientManager bean, so the first time a reader learns whether the registration is right is after writing application code, and a protocolMappers block that did not take surfaces as an opaque 401 rather than a missing aud claim.

The change

Appended to step 1: the two Admin API calls that read back the generated secret, the client_credentials request that exchanges it, and an aud assertion using the existing jwt_payload helper. A note on the 300 s token lifetime tells the reader to re-request before step 4, and the WWW-Authenticate check distinguishes an expired token from a misconfigured mapper (both are a 401). The console click-path to the secret is kept alongside the API calls.

Verification

Every command run verbatim against Keycloak 26.0.8, Solr 9.10.1, and the server on the http profile with HTTP_SECURITY_ENABLED=true:

Step Result
Register spring-ai-app (§1 curl) 201
Read back CLIENT_UUID / CLIENT_SECRET over Admin API UUID + 32-char secret
client_credentials grant token issued
jwt_payload "$TOKEN" | jq .aud ["http://localhost:8080/mcp", "account"]
/actuator/metrics with token / without 200 / 401
§4 check-health tool call with token {"isHealthy":true,...}
§4 check-health tool call without token {"content":[{"text":"Access Denied"}],"isError":true}
Expired-token WWW-Authenticate error_description="... Jwt expired at ..."

Docs only; no code or test changes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CiUHyyXLTo9ATdgg8eRFZJ

@adityamparikh
adityamparikh force-pushed the docs/keycloak-client-credentials branch from 5ba7d50 to a2cd8bf Compare September 11, 2026 13:55
@adityamparikh adityamparikh changed the title docs(security): document client credentials for machine-to-machine callers docs(security): show how a service client obtains its token Sep 11, 2026
The Spring AI MCP client section registers a confidential client in step 1 and
then verifies a tool call with `$TOKEN` in step 4, but nothing between the two
ever obtains that token by hand. The client_credentials request only appears
Spring-side, as properties and an OAuth2AuthorizedClientManager bean, so the
first time a reader finds out whether the registration is correct is after
writing application code -- and a misapplied audience mapper surfaces there as
an opaque 401 from the transport rather than as a missing `aud` claim.

Adds the two Admin API calls that read back the generated secret and the
client_credentials request that exchanges it, immediately after the
registration they check. Reuses the `jwt_payload` helper already defined under
Configuring the Audience Claim to assert the `aud` claim landed, which is the
single check that separates a working service client from one that will fail
later.

Also warns about the 300s token lifetime here specifically. Section 3 mentions
it as a reason not to hand-roll a token cache, and section 4 warns about an
unset `$TOKEN`, but neither covers a *stale* one -- and this text is what hands
the reader a `$TOKEN` to carry forward several minutes of reading. Both tokens
in play expire, `$ADMIN_TOKEN` included, and an expired token is rejected with
the same 401 as a misconfigured mapper. Writing this section produced that
exact confusion twice, so the WWW-Authenticate check that disambiguates them is
included.

The console path to the secret is kept alongside the API calls; the rest of the
section is scripted, so only having a click-path there was the odd one out.

Verified against Keycloak 26.0.8, Solr 9.10.1 and the server on the http
profile with HTTP_SECURITY_ENABLED=true. Every command was run verbatim:
the client registration returned 201, the secret read back over the Admin API,
the client_credentials grant issued a token whose `aud` was
["http://localhost:8080/mcp", "account"], /actuator/metrics answered 200 with
that token and 401 without it, and the step 4 check-health tool call returned
{"isHealthy":true,...} with the token and "Access Denied" without.

Signed-off-by: Aditya Parikh <aditya.m.parikh@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxXQyT9mLgFV8TPUU4imwp
@adityamparikh
adityamparikh force-pushed the docs/keycloak-client-credentials branch from a2cd8bf to bed06fc Compare September 11, 2026 14:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant