diff --git a/compose.yaml b/compose.yaml index 75c59201..3b5b6cc8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -57,6 +57,45 @@ 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 + # ============================================================================= + # Gated behind the `http` compose profile so a plain `docker compose up -d` (STDIO users) does not + # start it; the http Spring profile activates it via spring.docker.compose.profiles.active. 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 26's minimal image (UBI 9) intentionally omits curl and wget, and health endpoints are + # exposed on the dedicated management port 9000. This probe follows Keycloak's official Containerfile + # HEALTHCHECK recommendation (https://www.keycloak.org/observability/health#_healthcheck) using + # bash's built-in /dev/tcp socket redirection. + keycloak: + profiles: [ http ] + 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", "{ printf 'HEAD /health/ready HTTP/1.0\\r\\n\\r\\n' >&0; grep 'HTTP/1.0 200'; } 0<>/dev/tcp/localhost/9000" ] + interval: 5s + timeout: 5s + retries: 30 + start_period: 10s + volumes: data: diff --git a/docs/security/keycloak.md b/docs/security/keycloak.md index 3befbf2c..36e76a82 100644 --- a/docs/security/keycloak.md +++ b/docs/security/keycloak.md @@ -77,11 +77,36 @@ The Quick Start below is ordered to satisfy both. You do **not** need to start Solr yourself. `application-http.properties` sets `spring.docker.compose.enabled=true`, so `./gradlew bootRun` starts the Solr, -ZooKeeper and LGTM containers from `compose.yaml` before the application -context comes up. +ZooKeeper, LGTM and Keycloak containers from `compose.yaml` before the +application context comes up. ## 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. Point the +> server at the imported realm and start it; Spring Boot's Docker Compose support brings Keycloak up +> (it sits behind the `http` compose profile, so start it by hand with +> `docker compose --profile http up -d` if you are not using `bootRun`): +> +> ```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. With this realm, skip the client-creation steps +> below and use `solr-mcp-service` / `dev-only-not-a-secret` wherever a confidential client is needed. +> +> The manual walkthrough below remains the reference for what that import contains, and for setting +> the same thing up against an existing Keycloak. It binds its own container to the same port, +> 8180, so use one or the other: if you follow it, start the server with +> `SPRING_DOCKER_COMPOSE_ENABLED=false` so Boot does not bring up a second Keycloak. + This block is runnable end to end — copy the whole thing. It waits for Keycloak, creates the realm, client, audience mapper and test user, verifies the realm resolves, and only then starts the server. 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 } ] + } + ] +} diff --git a/src/main/resources/application-http.properties b/src/main/resources/application-http.properties index 77578a3c..d6a2cd18 100644 --- a/src/main/resources/application-http.properties +++ b/src/main/resources/application-http.properties @@ -21,6 +21,8 @@ spring.ai.mcp.server.stdio=false # Docker Compose integration: automatically start Solr and other services on bootRun. # Spring Boot detects compose.yaml and starts declared services before the application context. spring.docker.compose.enabled=true +# Keycloak sits behind the `http` compose profile so STDIO users' `docker compose up -d` skips it. +spring.docker.compose.profiles.active=http # OAuth2 Security Configuration # Configure the issuer URI for your OAuth2 authorization server # For Auth0: https:///.well-known/openid-configuration