This is an automated email from the ASF dual-hosted git repository.
epugh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-mcp.git
The following commit(s) were added to refs/heads/main by this push:
new 102625ec docs(security): show how a service client obtains its token
(#191)
102625ec is described below
commit 102625ece3c7be1efe8957f1d02517c26cb8d03e
Author: Aditya Parikh <[email protected]>
AuthorDate: Tue Oct 6 14:43:28 2026 -0400
docs(security): show how a service client obtains its token (#191)
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.
Claude-Session: https://claude.ai/code/session_01NxXQyT9mLgFV8TPUU4imwp
Signed-off-by: Aditya Parikh <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
docs/security/keycloak.md | 41 +++++++++++++++++++++++++++++++++++++++--
1 file changed, 39 insertions(+), 2 deletions(-)
diff --git a/docs/security/keycloak.md b/docs/security/keycloak.md
index 36e76a82..ddf1eca7 100644
--- a/docs/security/keycloak.md
+++ b/docs/security/keycloak.md
@@ -538,8 +538,45 @@ curl -s -X POST "$KC/admin/realms/solr-mcp/clients" \
The audience mapper is **not** optional here either — a service-account token
without it is rejected with `The aud claim is not valid`, exactly like a user
-token. Retrieve the generated secret from **Clients** → `spring-ai-app` →
-**Credentials**.
+token. The generated secret is at **Clients** → `spring-ai-app` →
+**Credentials** in the console, or over the Admin API:
+
+```bash
+CLIENT_UUID=$(curl -s
"$KC/admin/realms/solr-mcp/clients?clientId=spring-ai-app" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id')
+
+CLIENT_SECRET=$(curl -s
"$KC/admin/realms/solr-mcp/clients/$CLIENT_UUID/client-secret" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.value')
+```
+
+Requesting a token by hand — the same `client_credentials` grant the Spring
+client will use — confirms the registration before any application code is
+involved, and populates the `$TOKEN` that [step 4](#4-verify) checks:
+
+```bash
+TOKEN=$(curl -s -X POST "$KC/realms/solr-mcp/protocol/openid-connect/token" \
+ -d grant_type=client_credentials \
+ -d client_id=spring-ai-app \
+ -d "client_secret=$CLIENT_SECRET" | jq -r .access_token)
+
+jwt_payload "$TOKEN" | jq .aud
+# [ "http://localhost:8080/mcp", "account" ]
+```
+
+If `aud` is only `"account"`, the `protocolMappers` block above did not take —
+re-check it before moving on, because the failure surfaces much later as a
+`401` from the transport.
+
+Both tokens expire in 300s by default, `$ADMIN_TOKEN` included, and carrying
+this `$TOKEN` as far as [step 4](#4-verify) will usually outlive it. An expired
+token is rejected with the same `401` as a misconfigured one, so read the
reason
+before suspecting the mapper:
+
+```bash
+curl -s -i -H "Authorization: Bearer $TOKEN"
http://localhost:8080/actuator/metrics \
+ | grep -i www-authenticate
+# ... error_description="... Jwt expired at ..." -> just request a new token
+```
### 2. Configure the transport