Files

422 lines
18 KiB
Bash

#!/bin/bash
# Validate the Keycloak MCP OAuth2 resource-server path end-to-end against a real Keycloak.
# Runs every check (no set -e) and exits non-zero if anything failed.
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
KEYCLOAK_HOST="${KEYCLOAK_HOST:-kubernetes.docker.internal}"
REALM="stirling-mcp"
CLIENT_ID="mcp-test-client"
CLIENT_SECRET="mcp-test-secret"
MCP_URL="http://localhost:8080/mcp"
PRM_URL="http://localhost:8080/.well-known/oauth-protected-resource"
PASS=0
FAIL=0
pass() { echo -e "${GREEN}${NC} $1"; PASS=$((PASS + 1)); }
fail() { echo -e "${RED}$1${NC}"; FAIL=$((FAIL + 1)); }
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Validating MCP Test Environment ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
echo ""
# Pick a Keycloak base URL that resolves from this host (both share KC_HOSTNAME issuer).
KC_BASE="http://${KEYCLOAK_HOST}:9080"
if ! curl -sf --max-time 3 "$KC_BASE/realms/$REALM" >/dev/null 2>&1; then
KC_BASE="http://localhost:9080"
fi
TOKEN_URL="$KC_BASE/realms/$REALM/protocol/openid-connect/token"
echo -e "${BLUE}Using Keycloak base:${NC} $KC_BASE"
echo ""
# liveness
echo -e "${YELLOW}[0] Liveness${NC}"
if curl -sf "$KC_BASE/realms/$REALM" > /dev/null 2>&1; then
pass "Keycloak realm '$REALM' reachable"
else
fail "Keycloak realm '$REALM' not reachable - is the stack up?"
fi
if curl -sf "$KC_BASE/realms/$REALM/.well-known/openid-configuration" > /dev/null 2>&1; then
pass "Keycloak OIDC discovery reachable"
else
fail "Keycloak OIDC discovery not reachable"
fi
if curl -sf http://localhost:8080/api/v1/info/status 2>/dev/null | grep -q "UP"; then
pass "Stirling PDF is UP"
else
fail "Stirling PDF is not UP"
fi
echo ""
# protected-resource metadata (RFC 9728)
echo -e "${YELLOW}[1] Protected-resource metadata (RFC 9728)${NC}"
PRM=$(curl -s -o /tmp/mcp_prm.json -w "%{http_code}" "$PRM_URL")
PRM_BODY=$(cat /tmp/mcp_prm.json 2>/dev/null)
if [ "$PRM" = "200" ]; then
pass "GET $PRM_URL -> 200 (publicly discoverable)"
else
fail "GET $PRM_URL -> $PRM (expected 200)"
fi
if echo "$PRM_BODY" | grep -q '"resource"'; then
pass "metadata advertises a 'resource' id"
else
fail "metadata missing 'resource' id"
fi
if echo "$PRM_BODY" | grep -q "realms/$REALM"; then
pass "metadata points clients at the Keycloak authorization server"
else
fail "metadata missing the Keycloak authorization server (realms/$REALM)"
fi
if echo "$PRM_BODY" | grep -q "mcp.tools"; then
pass "metadata advertises mcp.tools scopes"
else
fail "metadata missing mcp.tools scopes"
fi
# RFC 9728 path-inserted form: clients derive {origin}/.well-known/oauth-protected-resource/mcp
# for a resource at /mcp. This must serve the SAME customized metadata; a default document here
# (no authorization_servers) makes clients fall back to treating Stirling as its own AS.
PRM_SUB=$(curl -s -o /tmp/mcp_prm_sub.json -w "%{http_code}" "${PRM_URL}/mcp")
PRM_SUB_BODY=$(cat /tmp/mcp_prm_sub.json 2>/dev/null)
if [ "$PRM_SUB" = "200" ]; then
pass "GET ${PRM_URL}/mcp -> 200 (RFC 9728 path-inserted form)"
else
fail "GET ${PRM_URL}/mcp -> $PRM_SUB (expected 200)"
fi
if echo "$PRM_SUB_BODY" | grep -q "realms/$REALM"; then
pass "path-inserted metadata advertises the Keycloak authorization server"
else
fail "path-inserted metadata missing authorization_servers (filter-chain fall-through regression)"
fi
WWW_AUTH=$(curl -s -o /dev/null -D - -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}' | grep -i '^WWW-Authenticate:')
if echo "$WWW_AUTH" | grep -q 'oauth-protected-resource/mcp'; then
pass "401 WWW-Authenticate advertises the path-inserted metadata URL"
else
fail "401 WWW-Authenticate lacks the path-inserted metadata URL: $WWW_AUTH"
fi
echo ""
# unauthenticated access is rejected
echo -e "${YELLOW}[2] Unauthenticated requests are rejected${NC}"
RPC_LIST='{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
NO_TOKEN=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" -d "$RPC_LIST")
if [ "$NO_TOKEN" = "401" ]; then
pass "POST /mcp with no token -> 401"
else
fail "POST /mcp with no token -> $NO_TOKEN (expected 401)"
fi
BAD_TOKEN=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer not-a-real-jwt" -d "$RPC_LIST")
if [ "$BAD_TOKEN" = "401" ]; then
pass "POST /mcp with garbage token -> 401"
else
fail "POST /mcp with garbage token -> $BAD_TOKEN (expected 401)"
fi
echo ""
# fetch a real user token from Keycloak (password grant)
echo -e "${YELLOW}[3] Fetch a real access token from Keycloak (mcpuser)${NC}"
get_token() {
# $1 username, $2 password -> access_token (empty on failure)
curl -s -X POST "$TOKEN_URL" \
--data-urlencode "grant_type=password" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "client_secret=$CLIENT_SECRET" \
--data-urlencode "username=$1" \
--data-urlencode "password=$2" \
--data-urlencode "scope=openid email mcp.tools.read mcp.tools.write" \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p'
}
USER_TOKEN=$(get_token "[email protected]" "mcppassword")
if [ -n "$USER_TOKEN" ]; then
pass "Obtained access token for [email protected]"
else
fail "Could not obtain access token for [email protected] (check Keycloak/client)"
fi
echo ""
# authenticated tools/list (account-binding succeeds)
echo -e "${YELLOW}[4] Authenticated MCP access (provisioned user)${NC}"
if [ -n "$USER_TOKEN" ]; then
LIST_CODE=$(curl -s -o /tmp/mcp_list.json -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" -d "$RPC_LIST")
LIST_BODY=$(cat /tmp/mcp_list.json 2>/dev/null)
if [ "$LIST_CODE" = "200" ]; then
pass "POST /mcp tools/list with valid token -> 200"
else
fail "POST /mcp tools/list with valid token -> $LIST_CODE (expected 200)"
fi
for t in stirling_describe_operation stirling_convert stirling_pages stirling_misc stirling_security; do
if echo "$LIST_BODY" | grep -q "\"$t\""; then
pass "tools/list advertises $t"
else
fail "tools/list missing $t"
fi
done
else
fail "Skipping authenticated checks - no user token"
fi
echo ""
# tools/call stirling_describe_operation
echo -e "${YELLOW}[5] Deep schema via stirling_describe_operation${NC}"
if [ -n "$USER_TOKEN" ]; then
OP=$(echo "$LIST_BODY" | grep -oE '"enum":\[[^]]+\]' | head -1 | grep -oE '"[^"]+"' | sed -n '2p' | tr -d '"')
if [ -n "$OP" ]; then
echo -e " ${BLUE}using operation:${NC} $OP"
DESC_RPC="{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"stirling_describe_operation\",\"arguments\":{\"operation\":\"$OP\"}}}"
DESC_CODE=$(curl -s -o /tmp/mcp_desc.json -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" -d "$DESC_RPC")
DESC_BODY=$(cat /tmp/mcp_desc.json 2>/dev/null)
if [ "$DESC_CODE" = "200" ] && echo "$DESC_BODY" | grep -q "parametersSchema"; then
pass "describe_operation('$OP') returned a parameters schema"
else
fail "describe_operation('$OP') -> $DESC_CODE (no parametersSchema in body)"
fi
else
fail "Could not extract an operation id from tools/list (no enabled ops?)"
fi
else
fail "Skipping describe check - no user token"
fi
echo ""
# account-binding: valid Keycloak user with no Stirling account -> 403
echo -e "${YELLOW}[6] Account-binding rejects users without a Stirling account${NC}"
GHOST_TOKEN=$(get_token "[email protected]" "ghostpassword")
if [ -n "$GHOST_TOKEN" ]; then
pass "Obtained a valid Keycloak token for [email protected]"
GHOST_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $GHOST_TOKEN" -d "$RPC_LIST")
if [ "$GHOST_CODE" = "403" ]; then
pass "Valid token, no Stirling account -> 403 (account-binding enforced)"
else
fail "ghost user -> $GHOST_CODE (expected 403)"
fi
else
fail "Could not obtain a token for [email protected] (cannot test account-binding)"
fi
echo ""
# hardening: bad tokens & endpoint isolation
echo -e "${YELLOW}[7] Hardening - bad tokens & endpoint isolation${NC}"
b64url() { printf '%s' "$1" | base64 | tr '+/' '-_' | tr -d '='; }
# unsigned alg:none token must be rejected
NONE_JWT="$(b64url '{"alg":"none","typ":"JWT"}').$(b64url '{"sub":"mcpuser","iss":"http://kubernetes.docker.internal:9080/realms/stirling-mcp","aud":"http://localhost:8080/mcp","email":"[email protected]","scope":"mcp.tools.read mcp.tools.write","exp":9999999999}')."
NONE_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" -H "Authorization: Bearer $NONE_JWT" -d "$RPC_LIST")
if [ "$NONE_CODE" = "401" ] || [ "$NONE_CODE" = "400" ]; then
pass "Unsigned alg:none token -> $NONE_CODE (rejected; a real signature is required)"
else
fail "alg:none token -> $NONE_CODE (expected 400/401)"
fi
# valid token minted for a different audience must be rejected (RFC 8707)
WRONG_AUD=$(curl -s -X POST "$TOKEN_URL" \
--data-urlencode "grant_type=password" \
--data-urlencode "client_id=other-client" \
--data-urlencode "[email protected]" \
--data-urlencode "password=mcppassword" \
--data-urlencode "scope=openid email" \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
if [ -n "$WRONG_AUD" ]; then
WA_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" -H "Authorization: Bearer $WRONG_AUD" -d "$RPC_LIST")
if [ "$WA_CODE" = "401" ]; then
pass "Valid token, wrong audience -> 401 (RFC 8707 audience binding)"
else
fail "wrong-audience token -> $WA_CODE (expected 401)"
fi
else
fail "Could not mint a wrong-audience token from other-client"
fi
if [ -n "$USER_TOKEN" ]; then
# the /mcp guard covers every method: GET without a token -> 401
GET_NOAUTH=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$MCP_URL")
if [ "$GET_NOAUTH" = "401" ]; then
pass "GET /mcp with no token -> 401 (chain guards all methods, not just POST)"
else
fail "GET /mcp no token -> $GET_NOAUTH (expected 401)"
fi
# unknown JSON-RPC method is refused with -32601
UNK=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" \
-d '{"jsonrpc":"2.0","id":9,"method":"admin/deleteEverything"}')
if echo "$UNK" | grep -q "32601"; then
pass "Unknown JSON-RPC method -> error -32601 (not dispatched)"
else
fail "Unknown method not rejected as expected: $UNK"
fi
# isolation: the MCP token must not unlock admin endpoints
ADM_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET \
"http://localhost:8080/api/v1/admin/settings" \
-H "Authorization: Bearer $USER_TOKEN")
if [ "$ADM_CODE" = "401" ] || [ "$ADM_CODE" = "403" ] || [ "$ADM_CODE" = "302" ]; then
pass "MCP token on /api/v1/admin/settings -> $ADM_CODE (no cross-endpoint access)"
else
fail "MCP token reached an admin endpoint -> $ADM_CODE (expected 401/403/302)"
fi
else
fail "Skipping method/isolation checks - no user token"
fi
echo ""
# regression checks
echo -e "${YELLOW}[8] Regression checks${NC}"
if [ -n "$USER_TOKEN" ]; then
# a PDF category tool must return an honest error (isError), not a fake success
CAT=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" \
-d '{"jsonrpc":"2.0","id":81,"method":"tools/call","params":{"name":"stirling_security","arguments":{"operation":"add-password"}}}')
if echo "$CAT" | grep -q '"isError":true'; then
pass "category tool returns isError, not a fake success"
else
fail "category tool did not return isError: $CAT"
fi
# malformed JSON -> JSON-RPC parse error (-32700) envelope
MAL=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" -d '{ not valid json ')
if echo "$MAL" | grep -q "32700"; then
pass "malformed JSON -> -32700 envelope"
else
fail "malformed JSON not enveloped as -32700: $MAL"
fi
# valid JSON but wrong shape -> invalid request (-32600)
WS=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" -d '{"hello":"world"}')
if echo "$WS" | grep -q "32600"; then
pass "wrong-shape JSON -> -32600"
else
fail "wrong-shape JSON wrong error code: $WS"
fi
# initialize echoes the client protocolVersion (negotiation)
INIT=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" \
-d '{"jsonrpc":"2.0","id":82,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}')
if echo "$INIT" | grep -q '"protocolVersion":"2025-03-26"'; then
pass "initialize negotiates the client's protocolVersion"
else
fail "initialize did not echo client protocolVersion: $INIT"
fi
else
fail "Skipping regression checks - no user token"
fi
# chunked body (no Content-Length) over the cap -> 413, pre-auth
CHUNK=$( { printf '{"jsonrpc":"2.0","id":1,"method":"x","params":"'; head -c 300000 /dev/zero | tr '\0' A; printf '"}'; } \
| curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
-H "Content-Type: application/json" -H "Transfer-Encoding: chunked" --data-binary @- )
if [ "$CHUNK" = "413" ]; then
pass "chunked oversized body -> 413"
else
fail "chunked oversized body -> $CHUNK (expected 413)"
fi
# behind a proxy, the 401 resource_metadata URL must honour X-Forwarded-*
WWW=$(curl -s -D - -o /dev/null -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: mcp.example.com" -d "$RPC_LIST" \
| tr -d '\r' | grep -i "^www-authenticate:")
if echo "$WWW" | grep -q "https://mcp.example.com/.well-known/oauth-protected-resource"; then
pass "401 resource_metadata honours X-Forwarded-*"
else
fail "resource_metadata ignored forwarded headers: $WWW"
fi
echo ""
# CORS for browser-based MCP clients
echo -e "${YELLOW}[9] CORS (browser MCP clients)${NC}"
ORIGIN="http://127.0.0.1:6274"
PF=$(curl -s -D - -o /dev/null -X OPTIONS "$MCP_URL" \
-H "Origin: $ORIGIN" -H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type" | tr -d '\r')
PF_CODE=$(printf '%s' "$PF" | sed -n 's#^HTTP/[0-9.]* \([0-9][0-9][0-9]\).*#\1#p' | head -1)
if printf '%s' "$PF_CODE" | grep -qE '^2'; then
pass "OPTIONS /mcp preflight -> $PF_CODE (not 401 - browsers can call /mcp)"
else
fail "OPTIONS /mcp preflight -> $PF_CODE (expected 2xx)"
fi
if printf '%s' "$PF" | grep -qi "access-control-allow-origin"; then
pass "preflight advertises Access-Control-Allow-Origin"
else
fail "preflight missing Access-Control-Allow-Origin"
fi
if curl -s -D - -o /dev/null -H "Origin: $ORIGIN" "$PRM_URL" | tr -d '\r' | grep -qi "access-control-allow-origin"; then
pass "protected-resource metadata sends Access-Control-Allow-Origin (browser-readable)"
else
fail "metadata missing Access-Control-Allow-Origin (browser can't read discovery)"
fi
echo ""
# real MCP client (official @modelcontextprotocol/sdk)
echo -e "${YELLOW}[10] Real MCP SDK client (not just curl)${NC}"
CHECK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/mcp-client-check"
if ! command -v node >/dev/null 2>&1; then
echo -e " ${YELLOW}(node not installed - skipping real-client check)${NC}"
elif [ -z "$USER_TOKEN" ]; then
fail "skipping real-client check - no user token"
else
if [ ! -d "$CHECK_DIR/node_modules" ]; then
echo " installing MCP SDK (first run)..."
(cd "$CHECK_DIR" && npm install --silent --no-fund --no-audit >/dev/null 2>&1)
fi
if MODE=oauth MCP_URL="$MCP_URL" MCP_BEARER="$USER_TOKEN" node "$CHECK_DIR/check.mjs"; then
pass "official MCP SDK client connected + validated over streamable HTTP (oauth)"
else
fail "official MCP SDK client could not validate the server (oauth)"
fi
fi
echo ""
# real operation execution (rotate a real PDF, inline)
echo -e "${YELLOW}[11] Real operation execution (rotate-pdf, inline file)${NC}"
SAMPLE_PDF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../app/common/src/test/resources/example.pdf"
if [ -z "$USER_TOKEN" ]; then
fail "skipping execution check - no user token"
elif [ ! -f "$SAMPLE_PDF" ]; then
fail "sample PDF not found at $SAMPLE_PDF"
else
B64=$(base64 -w0 "$SAMPLE_PDF" 2>/dev/null || base64 "$SAMPLE_PDF" | tr -d '\n')
EXEC_RPC=$(printf '{"jsonrpc":"2.0","id":91,"method":"tools/call","params":{"name":"stirling_pages","arguments":{"operation":"rotate-pdf","fileName":"example.pdf","parameters":{"angle":90},"file":"%s"}}}' "$B64")
EXEC=$(curl -s -X POST "$MCP_URL" -H "Content-Type: application/json" \
-H "Authorization: Bearer $USER_TOKEN" -d "$EXEC_RPC")
if echo "$EXEC" | grep -q '"isError":true'; then
fail "rotate-pdf returned isError: $(printf '%s' "$EXEC" | head -c 300)"
elif echo "$EXEC" | grep -q 'fileId='; then
pass "rotate-pdf processed a real PDF over MCP and returned a result fileId"
else
fail "rotate-pdf returned no result fileId: $(printf '%s' "$EXEC" | head -c 300)"
fi
fi
echo ""
# summary
rm -f /tmp/mcp_prm.json /tmp/mcp_list.json /tmp/mcp_desc.json 2>/dev/null
echo -e "${BLUE}────────────────────────────────────────────────────${NC}"
if [ "$FAIL" -eq 0 ]; then
echo -e "${GREEN}All MCP environment checks passed! ($PASS passed)${NC}"
exit 0
else
echo -e "${RED}MCP validation failed: $FAIL failed, $PASS passed.${NC}"
exit 1
fi