From bed06fcf731becb5779b793f2541f10becbe3f1e Mon Sep 17 00:00:00 2001 From: Aditya Parikh Date: Fri, 11 Sep 2026 09:55:27 -0400 Subject: [PATCH] docs(security): show how a service client obtains its token 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 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NxXQyT9mLgFV8TPUU4imwp --- 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 226d0091..bafaf220 100644 --- a/docs/security/keycloak.md +++ b/docs/security/keycloak.md @@ -513,8 +513,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