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
 

Reply via email to