From 90530b541d39809478f3ae3179f1270743716c05 Mon Sep 17 00:00:00 2001 From: Aditya Parikh Date: Mon, 31 Aug 2026 09:30:46 -0400 Subject: [PATCH] feat(compose): provision Keycloak with the realm the http profile needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The http profile authenticates against Keycloak, but nothing started one. A developer had to run the container by hand and then script a realm, a client and an audience mapper before the server would boot — and the mapper is easy to miss, because skipping it produces a token that is issued normally and then refused with 401. compose.yaml now defines a keycloak service that imports keycloak/solr-mcp-realm.json, so the realm, both clients and the mapper exist before the server asks for a token. The import covers what the Quick Start created by hand: solr-mcp-service (confidential, service accounts) for machine-to-machine callers, solr-mcp-client (public) for MCP Inspector, and testuser. The credentials in it are development credentials, committed on purpose; a real deployment provisions its own. The healthcheck is load-bearing rather than decoration. The server resolves the issuer while building its JWT decoder and fails to boot if the realm is not yet answering, which is the ordering constraint keycloak.md warns about; declaring a healthcheck makes Spring Boot's compose support wait for the container instead. Keycloak's image ships neither curl nor wget, so the probe goes through bash's /dev/tcp against the management port. Verified by running the service from this compose file: the container reaches healthy, the realm resolves, and both grants return tokens carrying aud http://localhost:8080/mcp — client_credentials for the service client and the password grant for the imported test user. Signed-off-by: Aditya Parikh Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011nUD34DFfoJeyQRTquPy7a --- compose.yaml | 34 +++++++++++++++++++ docs/security/keycloak.md | 21 ++++++++++++ keycloak/solr-mcp-realm.json | 64 ++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 keycloak/solr-mcp-realm.json diff --git a/compose.yaml b/compose.yaml index 75c59201..5d7bea45 100644 --- a/compose.yaml +++ b/compose.yaml @@ -57,6 +57,40 @@ services: # Prevent Spring Boot auto-configuration from trying to manage this service org.springframework.boot.ignore: "true" + # ============================================================================= + # Keycloak - the identity provider the http profile authenticates against + # ============================================================================= + # Started only by the http profile, which sets spring.docker.compose.enabled=true. The realm is + # imported from keycloak/solr-mcp-realm.json, so the clients and — critically — the audience + # protocol mapper exist before the server ever asks for a token. + # + # The mapper is what makes tokens acceptable here. Keycloak does not honour the RFC 8707 + # `resource=` parameter, so without it a token is issued normally and then rejected by + # validateAudienceClaim(true) with a 401. See docs/security/keycloak.md. + # + # The healthcheck is not decoration: the server resolves the issuer at startup and fails to boot + # if the realm is not yet answering, so Spring Boot must wait for this container to be healthy + # before the application context starts. Keycloak's image ships neither curl nor wget, hence + # bash's /dev/tcp. + keycloak: + image: quay.io/keycloak/keycloak:26.0 + ports: + - "8180:8080" + networks: [ search ] + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + command: [ "start-dev", "--import-realm" ] + volumes: + - ./keycloak:/opt/keycloak/data/import:ro + healthcheck: + test: [ "CMD-SHELL", "exec 3<>/dev/tcp/localhost/9000 && echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'" ] + interval: 5s + timeout: 5s + retries: 30 + start_period: 10s + volumes: data: diff --git a/docs/security/keycloak.md b/docs/security/keycloak.md index 1bf36740..be332375 100644 --- a/docs/security/keycloak.md +++ b/docs/security/keycloak.md @@ -59,6 +59,27 @@ User MCP Client Keycloak Solr MCP Ser ## Quick Start +> **The `http` profile now brings its own Keycloak.** `compose.yaml` defines a `keycloak` service +> that imports `keycloak/solr-mcp-realm.json` on startup, so the realm, both clients and the +> audience mapper exist before the server asks for a token — and because the service declares a +> healthcheck, Spring Boot waits for it rather than failing on an unresolvable issuer. Running +> `PROFILES=http ./gradlew bootRun` is enough: +> +> ```bash +> export PROFILES=http +> export OAUTH2_ISSUER_URI=http://localhost:8180/realms/solr-mcp +> ./gradlew bootRun +> ``` +> +> The imported realm provides `solr-mcp-service` (confidential, service accounts, secret +> `dev-only-not-a-secret`) for machine-to-machine callers, `solr-mcp-client` (public) for MCP +> Inspector, and `testuser` / `testpassword`. These are development credentials committed on +> purpose; a real deployment provisions its own. +> +> The manual walkthrough below remains the reference for what that import contains, and for setting +> the same thing up against an existing Keycloak. + + ```bash # 1. Start Keycloak docker run -d --name keycloak \ diff --git a/keycloak/solr-mcp-realm.json b/keycloak/solr-mcp-realm.json new file mode 100644 index 00000000..3fb0cd4b --- /dev/null +++ b/keycloak/solr-mcp-realm.json @@ -0,0 +1,64 @@ +{ + "realm": "solr-mcp", + "enabled": true, + "displayName": "Solr MCP (development)", + "clients": [ + { + "clientId": "solr-mcp-service", + "name": "Machine-to-machine caller", + "description": "Confidential client for services that call the MCP server on their own behalf.", + "enabled": true, + "publicClient": false, + "serviceAccountsEnabled": true, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "secret": "dev-only-not-a-secret", + "protocolMappers": [ + { + "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", + "introspection.token.claim": "true" + } + } + ] + }, + { + "clientId": "solr-mcp-client", + "name": "MCP Inspector and browser clients", + "enabled": true, + "publicClient": true, + "directAccessGrantsEnabled": true, + "redirectUris": [ "http://localhost:6274/*", "http://localhost:*" ], + "webOrigins": [ "http://localhost:6274" ], + "protocolMappers": [ + { + "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", + "introspection.token.claim": "true" + } + } + ] + } + ], + "users": [ + { + "username": "testuser", + "email": "test@example.com", + "firstName": "Test", + "lastName": "User", + "enabled": true, + "emailVerified": true, + "credentials": [ { "type": "password", "value": "testpassword", "temporary": false } ] + } + ] +}