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 } ] + } + ] +}