Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
21 changes: 21 additions & 0 deletions docs/security/keycloak.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
64 changes: 64 additions & 0 deletions keycloak/solr-mcp-realm.json
Original file line number Diff line number Diff line change
@@ -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 } ]
}
]
}