mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
MCP OAuth discovery fix + Supabase consent page (#6608)
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# Overlay for docker-compose-saas.yml: switches on the MCP server inside the
|
||||
# saas-flavor backend so the two-filter-chain interplay (Supabase chain at
|
||||
# @Order(1) + MCP chain at @Order(0)) can be validated locally.
|
||||
#
|
||||
# The Supabase chain registers Spring Security 7's default RFC 9728 metadata
|
||||
# filter for /.well-known/oauth-protected-resource/**. Without the MCP chain
|
||||
# claiming those subpaths, the path-inserted discovery URL is answered by that
|
||||
# default filter with a document missing authorization_servers, which makes MCP
|
||||
# clients fall back to treating Stirling itself as the authorization server.
|
||||
#
|
||||
# Usage:
|
||||
# docker-compose -f docker-compose-saas.yml -f docker-compose-saas-mcp.override.yml up -d --build
|
||||
# MCP_AUTH_ISSUERURI=<your issuer> bash validate-mcp-saas-chain.sh
|
||||
services:
|
||||
stirling-pdf-saas:
|
||||
environment:
|
||||
# Base compose pins this to "disabled" (Supabase JWT enforcement off) for the payg
|
||||
# cucumber tests. For full-stack MCP/consent testing against a real Supabase
|
||||
# project, pass the project ref so the Supabase chain validates real tokens.
|
||||
SAAS_DB_PROJECT_REF: "${SAAS_DB_PROJECT_REF:-disabled}"
|
||||
MCP_ENABLED: "true"
|
||||
MCP_AUTH_MODE: "oauth"
|
||||
# Discovery-interplay checks only need a resolvable issuer URL; no tokens
|
||||
# are minted against it. Point this at your real IdP to go further.
|
||||
MCP_AUTH_ISSUERURI: "${MCP_AUTH_ISSUERURI:-https://auth.example.com}"
|
||||
MCP_AUTH_RESOURCEID: "http://localhost:8080/mcp"
|
||||
# Supabase's OAuth server cannot mint RFC 8707 resource audiences; its tokens always
|
||||
# carry aud=authenticated. Accept it in addition to the resource id.
|
||||
MCP_AUTH_ACCEPTEDAUDIENCES: "${MCP_AUTH_ACCEPTEDAUDIENCES:-}"
|
||||
MCP_AUTH_USERNAMECLAIM: "${MCP_AUTH_USERNAMECLAIM:-email}"
|
||||
MCP_AUTH_REQUIREEXISTINGACCOUNT: "${MCP_AUTH_REQUIREEXISTINGACCOUNT:-true}"
|
||||
# Supabase only issues openid/profile/email/phone scopes, never mcp.tools.*; SaaS-style
|
||||
# deployments therefore run with scope enforcement off.
|
||||
MCP_SCOPESENABLED: "${MCP_SCOPESENABLED:-true}"
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
# Validate MCP OAuth discovery when the saas profile is active, i.e. when the
|
||||
# Supabase security chain coexists with the MCP chain. Guards the regression
|
||||
# where /.well-known/oauth-protected-resource/<subpath> fell through to the
|
||||
# Supabase chain's default Spring Security metadata filter and was served
|
||||
# WITHOUT authorization_servers, sending MCP clients to Stirling for OAuth.
|
||||
#
|
||||
# Prereq: docker-compose -f docker-compose-saas.yml -f docker-compose-saas-mcp.override.yml up
|
||||
# Env: MCP_AUTH_ISSUERURI must match the issuer the stack was started with.
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:8080}"
|
||||
ISSUER="${MCP_AUTH_ISSUERURI:-https://auth.example.com}"
|
||||
PRM_URL="$BASE_URL/.well-known/oauth-protected-resource"
|
||||
MCP_URL="$BASE_URL/mcp"
|
||||
|
||||
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}Validating MCP discovery with the saas profile (two-chain interplay)${NC}"
|
||||
echo -e "${BLUE}Issuer:${NC} $ISSUER"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}[0] Liveness${NC}"
|
||||
if curl -sf "$BASE_URL/api/v1/info/status" 2>/dev/null | grep -q "UP"; then
|
||||
pass "Stirling PDF (saas flavor) is UP"
|
||||
else
|
||||
fail "Stirling PDF is not UP at $BASE_URL"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}[1] Root metadata document${NC}"
|
||||
ROOT_CODE=$(curl -s -o /tmp/saas_prm_root.json -w "%{http_code}" "$PRM_URL")
|
||||
ROOT_BODY=$(cat /tmp/saas_prm_root.json 2>/dev/null)
|
||||
[ "$ROOT_CODE" = "200" ] && pass "GET $PRM_URL -> 200" || fail "GET $PRM_URL -> $ROOT_CODE"
|
||||
if echo "$ROOT_BODY" | grep -q "\"authorization_servers\"" && echo "$ROOT_BODY" | grep -qF "$ISSUER"; then
|
||||
pass "root metadata advertises the configured authorization server"
|
||||
else
|
||||
fail "root metadata missing authorization_servers/issuer: $ROOT_BODY"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}[2] Path-inserted metadata document (the regression)${NC}"
|
||||
SUB_CODE=$(curl -s -o /tmp/saas_prm_sub.json -w "%{http_code}" "$PRM_URL/mcp")
|
||||
SUB_BODY=$(cat /tmp/saas_prm_sub.json 2>/dev/null)
|
||||
[ "$SUB_CODE" = "200" ] && pass "GET $PRM_URL/mcp -> 200" || fail "GET $PRM_URL/mcp -> $SUB_CODE"
|
||||
if echo "$SUB_BODY" | grep -q "\"authorization_servers\"" && echo "$SUB_BODY" | grep -qF "$ISSUER"; then
|
||||
pass "path-inserted metadata served by the MCP chain (authorization_servers present)"
|
||||
else
|
||||
fail "path-inserted metadata fell through to the Supabase chain default filter: $SUB_BODY"
|
||||
fi
|
||||
if echo "$SUB_BODY" | grep -q "mcp.tools.read"; then
|
||||
pass "path-inserted metadata advertises mcp.tools scopes"
|
||||
else
|
||||
fail "path-inserted metadata missing mcp.tools scopes"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}[3] 401 challenge points at the path-inserted metadata URL${NC}"
|
||||
HDRS=$(curl -s -o /dev/null -D - -X POST "$MCP_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}')
|
||||
CODE=$(echo "$HDRS" | head -1 | grep -o '[0-9][0-9][0-9]')
|
||||
[ "$CODE" = "401" ] && pass "POST /mcp with no token -> 401" || fail "POST /mcp no token -> $CODE (expected 401)"
|
||||
if echo "$HDRS" | grep -i '^WWW-Authenticate:' | grep -q 'oauth-protected-resource/mcp'; then
|
||||
pass "WWW-Authenticate advertises resource_metadata at the path-inserted URL"
|
||||
else
|
||||
fail "WWW-Authenticate header wrong: $(echo "$HDRS" | grep -i '^WWW-Authenticate:')"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}[4] Chain isolation${NC}"
|
||||
API_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/api/v1/user/me")
|
||||
if [ "$API_CODE" = "401" ] || [ "$API_CODE" = "403" ] || [ "$API_CODE" = "404" ]; then
|
||||
pass "Supabase chain still guards regular API routes ($API_CODE)"
|
||||
else
|
||||
fail "Unexpected status on regular API route: $API_CODE"
|
||||
fi
|
||||
GARBAGE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer not-a-jwt" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}')
|
||||
[ "$GARBAGE" = "401" ] && pass "garbage bearer token at /mcp -> 401" || fail "garbage token -> $GARBAGE"
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE}Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
@@ -77,6 +77,30 @@ if echo "$PRM_BODY" | grep -q "mcp.tools"; then
|
||||
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
|
||||
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/bin/bash
|
||||
# Walk the MCP OAuth discovery chain exactly like a spec-compliant client:
|
||||
# 1. POST the MCP endpoint unauthenticated -> 401 + WWW-Authenticate resource_metadata
|
||||
# 2. Fetch the advertised metadata URL AND the client-derived RFC 9728
|
||||
# path-inserted URL (clients use either; both must serve the real document)
|
||||
# 3. Pick authorization_servers[0] and resolve its metadata via RFC 8414
|
||||
# path-aware discovery, falling back to OIDC discovery
|
||||
# 4. Report authorize/token/registration endpoints; flag missing DCR support
|
||||
#
|
||||
# Usage: MCP_URL=https://host/mcp bash walk-mcp-discovery.sh
|
||||
# Exits non-zero if discovery would strand a client (the fall-back-to-origin bug).
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||
MCP_URL="${MCP_URL:-http://localhost:8080/mcp}"
|
||||
|
||||
PASS=0; FAIL=0; WARN=0
|
||||
pass() { echo -e "${GREEN}✓${NC} $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo -e "${RED}✗ $1${NC}"; FAIL=$((FAIL + 1)); }
|
||||
warn() { echo -e "${YELLOW}⚠ $1${NC}"; WARN=$((WARN + 1)); }
|
||||
|
||||
PY=python3; command -v python3 >/dev/null 2>&1 || PY=python
|
||||
json_get() { # json_get <file> <expr>, e.g. '.get("authorization_servers",[None])[0]'
|
||||
"$PY" -c "import json,sys;d=json.load(open(sys.argv[1]));v=eval('d'+sys.argv[2]);print(v if v is not None else '')" "$1" "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
ORIGIN=$(echo "$MCP_URL" | sed -E 's#^(https?://[^/]+).*#\1#')
|
||||
RESOURCE_PATH=$(echo "$MCP_URL" | sed -E 's#^https?://[^/]+##')
|
||||
|
||||
echo -e "${BLUE}MCP discovery walk for:${NC} $MCP_URL"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}[1] Unauthenticated challenge${NC}"
|
||||
HDRS=$(curl -s -o /dev/null -D - --max-time 15 -X POST "$MCP_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}')
|
||||
CODE=$(echo "$HDRS" | head -1 | grep -o '[0-9][0-9][0-9]' | head -1)
|
||||
[ "$CODE" = "401" ] && pass "POST $MCP_URL -> 401" || fail "POST $MCP_URL -> $CODE (expected 401)"
|
||||
ADVERTISED=$(echo "$HDRS" | tr -d '\r' | grep -i '^WWW-Authenticate:' | sed -n 's/.*resource_metadata="\([^"]*\)".*/\1/p')
|
||||
if [ -n "$ADVERTISED" ]; then
|
||||
pass "WWW-Authenticate advertises resource_metadata: $ADVERTISED"
|
||||
else
|
||||
fail "no resource_metadata in WWW-Authenticate header"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}[2] Protected resource metadata (advertised + path-inserted)${NC}"
|
||||
DERIVED="$ORIGIN/.well-known/oauth-protected-resource$RESOURCE_PATH"
|
||||
AS_URL=""
|
||||
for URL in "$ADVERTISED" "$DERIVED"; do
|
||||
[ -z "$URL" ] && continue
|
||||
BODY_FILE=$(mktemp)
|
||||
HTTP=$(curl -s -o "$BODY_FILE" -w "%{http_code}" --max-time 15 "$URL")
|
||||
if [ "$HTTP" != "200" ]; then
|
||||
fail "GET $URL -> $HTTP (expected 200)"
|
||||
continue
|
||||
fi
|
||||
AS=$(json_get "$BODY_FILE" '.get("authorization_servers",[None])[0]')
|
||||
RES=$(json_get "$BODY_FILE" '.get("resource")')
|
||||
if [ -n "$AS" ]; then
|
||||
pass "GET $URL -> resource=$RES, authorization_server=$AS"
|
||||
AS_URL="$AS"
|
||||
else
|
||||
fail "GET $URL has NO authorization_servers - a client using this URL falls back to $ORIGIN as its OAuth server"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}[3] Authorization server metadata (RFC 8414 / OIDC)${NC}"
|
||||
if [ -z "$AS_URL" ]; then
|
||||
fail "no authorization server discovered; cannot continue"
|
||||
else
|
||||
AS_ORIGIN=$(echo "$AS_URL" | sed -E 's#^(https?://[^/]+).*#\1#')
|
||||
AS_PATH=$(echo "$AS_URL" | sed -E 's#^https?://[^/]+##')
|
||||
ASM_FILE=$(mktemp)
|
||||
FOUND=""
|
||||
for CAND in \
|
||||
"$AS_ORIGIN/.well-known/oauth-authorization-server$AS_PATH" \
|
||||
"$AS_ORIGIN/.well-known/openid-configuration$AS_PATH" \
|
||||
"$AS_URL/.well-known/openid-configuration"; do
|
||||
HTTP=$(curl -s -o "$ASM_FILE" -w "%{http_code}" --max-time 15 "$CAND")
|
||||
ISS=$(json_get "$ASM_FILE" '.get("issuer")')
|
||||
if [ "$HTTP" = "200" ] && [ -n "$ISS" ]; then
|
||||
pass "AS metadata found at $CAND"
|
||||
FOUND=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$FOUND" ]; then
|
||||
fail "no AS metadata at any well-known location for $AS_URL"
|
||||
else
|
||||
AUTHZ=$(json_get "$ASM_FILE" '.get("authorization_endpoint")')
|
||||
TOKEN=$(json_get "$ASM_FILE" '.get("token_endpoint")')
|
||||
REG=$(json_get "$ASM_FILE" '.get("registration_endpoint")')
|
||||
SCOPES=$(json_get "$ASM_FILE" '.get("scopes_supported")')
|
||||
[ -n "$AUTHZ" ] && pass "authorization_endpoint: $AUTHZ" || fail "no authorization_endpoint"
|
||||
[ -n "$TOKEN" ] && pass "token_endpoint: $TOKEN" || fail "no token_endpoint"
|
||||
if [ -n "$REG" ]; then
|
||||
pass "registration_endpoint: $REG (dynamic client registration available)"
|
||||
else
|
||||
warn "no registration_endpoint - clients needing DCR (Claude etc.) cannot self-register; pre-register a client or enable DCR on the IdP"
|
||||
fi
|
||||
echo -e " ${BLUE}scopes_supported:${NC} $SCOPES"
|
||||
case "$SCOPES" in
|
||||
*mcp.tools*) pass "IdP can issue mcp.tools.* scopes" ;;
|
||||
*) warn "IdP does not advertise mcp.tools.* scopes - run Stirling with MCP_SCOPESENABLED=false or add the scopes to the IdP" ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE}Results: ${GREEN}$PASS passed${NC}, ${YELLOW}$WARN warnings${NC}, ${RED}$FAIL failed${NC}"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
Reference in New Issue
Block a user