From 5ba7d5095ea47b350e0583f004bcbc46f17a66b6 Mon Sep 17 00:00:00 2001 From: Aditya Parikh Date: Mon, 31 Aug 2026 09:24:44 -0400 Subject: [PATCH] docs(security): document client credentials for machine-to-machine callers Every token example in keycloak.md uses the password grant with a test user, so a service calling this server on its own behalf -- an agent, a scheduled job, another application -- has no documented path. The confidential client it needs is already described under Creating Clients; what was missing is the grant that uses it. The gap that actually bites is the audience mapper. The Quick Start attaches it to the public client used by MCP Inspector, so a service client created by following Creating Clients has none of its own, and its tokens are issued normally and then rejected with 401 by validateAudienceClaim(true). The new section says where the mapper belongs, shows how to add it to the service client, and shows how to confirm the claim landed. Also fixes the audience check under Troubleshooting. A JWT payload is base64url without padding, so `base64 -d` truncates it and jq reports "Unfinished string at EOF" instead of the claim -- the failure looks like a malformed token when the token is fine. Replaced with a jq-only decode that tolerates the missing padding. Every command in the new section was run verbatim against Keycloak 26 and a running server, and produces the output shown. Signed-off-by: Aditya Parikh Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011nUD34DFfoJeyQRTquPy7a --- docs/security/keycloak.md | 70 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/docs/security/keycloak.md b/docs/security/keycloak.md index 1bf36740..fa5af1d8 100644 --- a/docs/security/keycloak.md +++ b/docs/security/keycloak.md @@ -15,6 +15,9 @@ This guide covers setting up [Keycloak](https://www.keycloak.org/) as an OAuth2/ - [Spring Boot Configuration](#spring-boot-configuration) - [Running the Server](#running-the-server) - [Testing Authentication](#testing-authentication) + - [Obtain a Token](#obtain-a-token) + - [Machine-to-Machine Tokens](#machine-to-machine-tokens) + - [Call the MCP Server](#call-the-mcp-server) - [User Management Options](#user-management-options) - [Manual User Creation](#manual-user-creation) - [Identity Brokering (GitHub, Google, etc.)](#identity-brokering-github-google-etc) @@ -221,6 +224,71 @@ Response: } ``` +### Machine-to-Machine Tokens + +The password grant above assumes a human. A service that calls this MCP server on its own behalf -- +an agent, a scheduled job, another application -- has no user to authenticate, and uses the +**client credentials** grant with the confidential client created in +[Creating Clients](#creating-clients). That client must have **Client authentication: ON** and +**Service accounts roles** enabled; a public client cannot use this grant at all. + +```bash +KC=http://localhost:8180 + +ADMIN_TOKEN=$(curl -s -X POST "$KC/realms/master/protocol/openid-connect/token" \ + -d client_id=admin-cli -d username=admin -d password=admin \ + -d grant_type=password | jq -r .access_token) + +CLIENT_UUID=$(curl -s "$KC/admin/realms/solr-mcp/clients?clientId=solr-mcp-server" \ + -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') + +TOKEN=$(curl -s -X POST "$KC/realms/solr-mcp/protocol/openid-connect/token" \ + -d "grant_type=client_credentials" \ + -d "client_id=solr-mcp-server" \ + -d "client_secret=$CLIENT_SECRET" | jq -r '.access_token') +``` + +#### The audience mapper belongs on whichever client obtains the token + +The [Quick Start](#quick-start) adds the audience mapper to the public client used by MCP Inspector, +so a service client created by following [Creating Clients](#creating-clients) has none of its own. +Its tokens are issued normally and then rejected by the server with `401`, because +`validateAudienceClaim(true)` finds no matching `aud`. Add the mapper to the service client too -- +see [Configuring the Audience Claim](#configuring-the-audience-claim) for what each field means: + +```bash +curl -s -X POST "$KC/admin/realms/solr-mcp/clients/$CLIENT_UUID/protocol-mappers/models" \ + -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ + -d '{ + "name": "mcp-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.custom.audience": "http://localhost:8080/mcp", + "access.token.claim": "true", + "id.token.claim": "false" + } + }' +``` + +Request a fresh token afterwards -- the mapper applies at issue time -- and confirm the claim before +pointing an application at it, since it is the whole difference between a working token and a `401`: + +```bash +echo "$TOKEN" | jq -R 'split(".")[1] | @base64d | fromjson | .aud' +# ["http://localhost:8080/mcp", "account"] +``` + +`account` comes from Keycloak's own `audience resolve` mapper and is harmless: the server checks that +its resource URI is present, not that it is the only entry. Confirm the URI it expects with: + +```bash +curl -s http://localhost:8080/.well-known/oauth-protected-resource | jq -r .resource +``` + ### Call the MCP Server ```bash @@ -511,7 +579,7 @@ public String getSchema(String collection) { ... } ```bash # Decode token (without verification) -echo $TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq +echo "$TOKEN" | jq -R 'split(".")[1] | @base64d | fromjson' ``` **Check Keycloak OpenID configuration:**