From 7d71cb97e50e05dc03a7c9170bb023f2413cfcb9 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 25 Jan 2026 08:11:32 +0200 Subject: [PATCH] feat(oauth): add Runlayer mode to Go OAuth test server Add `-runlayer-mode` flag to the Go OAuth test server that mimics Runlayer's strict OAuth validation behavior with Pydantic-style 422 error responses when the RFC 8707 resource parameter is missing. Changes: - Add PydanticValidationError and PydanticErrorDetail types - Add RunlayerMode option to OAuth server options - Return 422 with Pydantic format when resource missing in Runlayer mode - Add -runlayer-mode CLI flag (implies -require-resource) - Update run-oauth-e2e.sh to support --runlayer-mode flag - Add test/integration/test_runlayer_mode.sh integration test - Update documentation with Runlayer mode usage and comparison This helps test and debug issue #271 compatibility with Runlayer servers that require the resource parameter. Co-Authored-By: Claude Opus 4.5 --- docs/oauthserver_testing.md | 68 +++++++++++++ scripts/run-oauth-e2e.sh | 16 +++- test/integration/test_runlayer_mode.sh | 127 +++++++++++++++++++++++++ tests/oauthserver/authorize.go | 30 ++++++ tests/oauthserver/cmd/server/main.go | 12 ++- tests/oauthserver/options.go | 3 + tests/oauthserver/types.go | 14 +++ 7 files changed, 268 insertions(+), 2 deletions(-) create mode 100755 test/integration/test_runlayer_mode.sh diff --git a/docs/oauthserver_testing.md b/docs/oauthserver_testing.md index 103de572..4652354d 100644 --- a/docs/oauthserver_testing.md +++ b/docs/oauthserver_testing.md @@ -108,6 +108,7 @@ Test Credentials: testuser / testpass ```bash -require-pkce=true # Require PKCE for auth code flow (default: true) -require-resource=false # Require RFC 8707 resource indicator (default: false) +-runlayer-mode=false # Mimic Runlayer strict validation with Pydantic 422 errors ``` ### Token Lifetimes @@ -166,6 +167,29 @@ go run ./tests/oauthserver/cmd/server -port 9000 \ -require-resource=true ``` +### Runlayer Compatibility Mode (Issue #271) + +Mimics Runlayer's strict OAuth validation with Pydantic-style 422 errors: + +```bash +go run ./tests/oauthserver/cmd/server -port 9000 -runlayer-mode +``` + +When the `resource` parameter is missing, returns: +```json +{ + "detail": [ + { + "type": "missing", + "loc": ["query", "resource"], + "msg": "Field required" + } + ] +} +``` + +This mode implies `-require-resource=true`. + ### Test Error Handling ```bash @@ -322,3 +346,47 @@ The server requires PKCE by default. Ensure your client: - Verify JWKS endpoint is accessible: `curl http://127.0.0.1:9000/jwks.json` - Check token expiration (exp claim) - Ensure issuer matches server URL + +## Alternative: Python Mock Server + +For lightweight testing of issue #271 (RFC 8707 resource parameter), there's also a Python/FastAPI mock server: + +### Location + +`test/integration/oauth_runlayer_mock.py` + +### Quick Start + +```bash +# Using uv (recommended) +PORT=9000 uv run --with fastapi --with uvicorn python test/integration/oauth_runlayer_mock.py + +# Using pip +pip install fastapi uvicorn +PORT=9000 python test/integration/oauth_runlayer_mock.py +``` + +### When to Use Each Server + +| Scenario | Recommended | +|----------|-------------| +| Full E2E tests with Playwright | Go server | +| Testing OAuth discovery modes | Go server | +| Token refresh/expiry testing | Go server | +| Error injection scenarios | Go server | +| JWKS/key rotation testing | Go server | +| Quick RFC 8707 resource tests | Python or Go with `-runlayer-mode` | +| Issue #271 regression testing | Python (lighter) or Go | +| CI/CD integration tests | Go (embeddable) | + +### Issue #271 Integration Test + +```bash +./test/integration/test_oauth_resource_injection.sh +``` + +This script: +1. Starts the Python mock server +2. Starts mcpproxy with test configuration +3. Verifies mcpproxy injects the `resource` parameter correctly +4. Confirms the auth URL is accepted by the mock server diff --git a/scripts/run-oauth-e2e.sh b/scripts/run-oauth-e2e.sh index 5e08bb24..07dd1a2b 100755 --- a/scripts/run-oauth-e2e.sh +++ b/scripts/run-oauth-e2e.sh @@ -17,6 +17,7 @@ # ./scripts/run-oauth-e2e.sh # Run all tests # ./scripts/run-oauth-e2e.sh --short-ttl # Run with 30s token TTL # ./scripts/run-oauth-e2e.sh --error-injection # Run with error injection tests +# ./scripts/run-oauth-e2e.sh --runlayer-mode # Run with Runlayer mode (Pydantic 422 errors) # ./scripts/run-oauth-e2e.sh --verbose # Show verbose output set -e @@ -31,6 +32,7 @@ NC='\033[0m' # No Color # Parse arguments SHORT_TTL=false ERROR_INJECTION=false +RUNLAYER_MODE=false VERBOSE=false for arg in "$@"; do case $arg in @@ -42,6 +44,10 @@ for arg in "$@"; do ERROR_INJECTION=true shift ;; + --runlayer-mode) + RUNLAYER_MODE=true + shift + ;; --verbose) VERBOSE=true shift @@ -163,7 +169,15 @@ mkdir -p "$TEST_RESULTS_DIR" # Start OAuth test server with configured TTL echo -e "${YELLOW}Starting OAuth test server on port $OAUTH_SERVER_PORT...${NC}" echo -e "${BLUE} Access Token TTL: $ACCESS_TOKEN_TTL${NC}" -go run ./tests/oauthserver/cmd/server -port $OAUTH_SERVER_PORT -access-token-ttl=$ACCESS_TOKEN_TTL & + +# Build OAuth server command +OAUTH_SERVER_CMD="go run ./tests/oauthserver/cmd/server -port $OAUTH_SERVER_PORT -access-token-ttl=$ACCESS_TOKEN_TTL" +if [ "$RUNLAYER_MODE" = true ]; then + OAUTH_SERVER_CMD="$OAUTH_SERVER_CMD -runlayer-mode" + echo -e "${BLUE} Runlayer Mode: enabled (Pydantic 422 errors)${NC}" +fi + +$OAUTH_SERVER_CMD & OAUTH_SERVER_PID=$! sleep 3 diff --git a/test/integration/test_runlayer_mode.sh b/test/integration/test_runlayer_mode.sh new file mode 100755 index 00000000..f2c34eef --- /dev/null +++ b/test/integration/test_runlayer_mode.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# +# Integration test for Go OAuth server Runlayer mode +# +# This test verifies that the Go OAuth test server correctly implements +# Runlayer-style strict validation with Pydantic 422 error responses. +# +# Usage: +# ./test/integration/test_runlayer_mode.sh +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +OAUTH_SERVER_PORT=18277 +OAUTH_SERVER_PID="" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +cleanup() { + echo -e "\n${YELLOW}Cleaning up...${NC}" + + if [ -n "$OAUTH_SERVER_PID" ] && kill -0 "$OAUTH_SERVER_PID" 2>/dev/null; then + echo "Stopping OAuth server (PID: $OAUTH_SERVER_PID)" + kill "$OAUTH_SERVER_PID" 2>/dev/null || true + wait "$OAUTH_SERVER_PID" 2>/dev/null || true + fi + + # Kill any remaining processes on our port + lsof -ti :$OAUTH_SERVER_PORT 2>/dev/null | xargs kill -9 2>/dev/null || true + + echo -e "${GREEN}Cleanup complete${NC}" +} + +trap cleanup EXIT + +echo -e "${YELLOW}=== Go OAuth Server Runlayer Mode Integration Test ===${NC}\n" + +# Start OAuth test server in Runlayer mode +echo "Starting Go OAuth test server in Runlayer mode on port $OAUTH_SERVER_PORT..." +cd "$ROOT_DIR" +go run ./tests/oauthserver/cmd/server -port $OAUTH_SERVER_PORT -runlayer-mode > /dev/null 2>&1 & +OAUTH_SERVER_PID=$! + +# Wait for server to be ready +echo "Waiting for server to be ready..." +for i in {1..30}; do + if curl -s "http://localhost:$OAUTH_SERVER_PORT/.well-known/oauth-authorization-server" > /dev/null 2>&1; then + echo -e "${GREEN}OAuth server ready${NC}" + break + fi + if [ $i -eq 30 ]; then + echo -e "${RED}ERROR: OAuth server failed to start${NC}" + exit 1 + fi + sleep 0.5 +done + +# Test 1: Verify 422 Pydantic error when resource is missing +echo -e "\n${YELLOW}Test 1: Verify Pydantic 422 error when resource is missing${NC}" +RESPONSE=$(curl -s "http://localhost:$OAUTH_SERVER_PORT/authorize?client_id=test-client&redirect_uri=http://127.0.0.1/callback&response_type=code&state=test123&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256") +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$OAUTH_SERVER_PORT/authorize?client_id=test-client&redirect_uri=http://127.0.0.1/callback&response_type=code&state=test123&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256") + +# Verify HTTP status is 422 +if [ "$HTTP_CODE" != "422" ]; then + echo -e "${RED}FAIL: Expected HTTP 422, got $HTTP_CODE${NC}" + exit 1 +fi +echo "HTTP Status: $HTTP_CODE (expected: 422) ✓" + +# Verify response contains Pydantic error format +if ! echo "$RESPONSE" | grep -q '"detail"'; then + echo -e "${RED}FAIL: Response missing 'detail' field${NC}" + echo "Response: $RESPONSE" + exit 1 +fi +echo "Response has 'detail' field ✓" + +if ! echo "$RESPONSE" | grep -q '"type":"missing"'; then + echo -e "${RED}FAIL: Response missing type=missing${NC}" + echo "Response: $RESPONSE" + exit 1 +fi +echo "Response has type=missing ✓" + +if ! echo "$RESPONSE" | grep -q '"loc":\["query","resource"\]'; then + echo -e "${RED}FAIL: Response missing correct loc field${NC}" + echo "Response: $RESPONSE" + exit 1 +fi +echo "Response has loc=[query,resource] ✓" + +if ! echo "$RESPONSE" | grep -q '"msg":"Field required"'; then + echo -e "${RED}FAIL: Response missing correct msg field${NC}" + echo "Response: $RESPONSE" + exit 1 +fi +echo "Response has msg=Field required ✓" + +echo -e "${GREEN}PASS: Pydantic 422 error format is correct${NC}" + +# Test 2: Verify successful request when resource is provided +echo -e "\n${YELLOW}Test 2: Verify success when resource is provided${NC}" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$OAUTH_SERVER_PORT/authorize?client_id=test-client&redirect_uri=http://127.0.0.1/callback&response_type=code&state=test123&resource=http://example.com/mcp&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256") + +if [ "$HTTP_CODE" != "200" ]; then + echo -e "${RED}FAIL: Expected HTTP 200, got $HTTP_CODE${NC}" + exit 1 +fi +echo -e "${GREEN}PASS: Request with resource parameter returns 200${NC}" + +# Test 3: Verify discovery endpoint works +echo -e "\n${YELLOW}Test 3: Verify OAuth discovery endpoint${NC}" +DISCOVERY=$(curl -s "http://localhost:$OAUTH_SERVER_PORT/.well-known/oauth-authorization-server") + +if ! echo "$DISCOVERY" | grep -q '"authorization_endpoint"'; then + echo -e "${RED}FAIL: Discovery missing authorization_endpoint${NC}" + exit 1 +fi +echo -e "${GREEN}PASS: Discovery endpoint working${NC}" + +echo -e "\n${GREEN}=== All Runlayer mode tests passed! ===${NC}" diff --git a/tests/oauthserver/authorize.go b/tests/oauthserver/authorize.go index 2586da14..4d03b63c 100644 --- a/tests/oauthserver/authorize.go +++ b/tests/oauthserver/authorize.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "embed" "encoding/base64" + "encoding/json" "fmt" "html/template" "net/http" @@ -92,6 +93,11 @@ func (s *OAuthTestServer) handleAuthorizeGET(w http.ResponseWriter, r *http.Requ // RFC 8707: Require resource indicator if configured if s.options.RequireResourceIndicator && resource == "" { + // In Runlayer mode, return Pydantic-style 422 error + if s.options.RunlayerMode { + s.pydanticValidationError(w, "query", "resource", "Field required") + return + } s.authorizeError(w, redirectURI, state, "invalid_request", "RFC 8707: resource parameter required") return } @@ -158,6 +164,11 @@ func (s *OAuthTestServer) handleAuthorizePOST(w http.ResponseWriter, r *http.Req // RFC 8707: Require resource indicator if configured if s.options.RequireResourceIndicator && resource == "" { + // In Runlayer mode, return Pydantic-style 422 error + if s.options.RunlayerMode { + s.pydanticValidationError(w, "body", "resource", "Field required") + return + } s.authorizeError(w, redirectURI, state, "invalid_request", "RFC 8707: resource parameter required") return } @@ -359,3 +370,22 @@ func verifyPKCE(codeVerifier, codeChallenge, method string) bool { return computed == codeChallenge } + +// pydanticValidationError returns a 422 Unprocessable Entity response +// with Pydantic-style validation error format (used in Runlayer mode). +func (s *OAuthTestServer) pydanticValidationError(w http.ResponseWriter, location, field, msg string) { + resp := PydanticValidationError{ + Detail: []PydanticErrorDetail{ + { + Type: "missing", + Loc: []string{location, field}, + Msg: msg, + Input: nil, + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) // 422 + json.NewEncoder(w).Encode(resp) +} diff --git a/tests/oauthserver/cmd/server/main.go b/tests/oauthserver/cmd/server/main.go index 233f4cac..7a9d1feb 100644 --- a/tests/oauthserver/cmd/server/main.go +++ b/tests/oauthserver/cmd/server/main.go @@ -45,6 +45,9 @@ func main() { requirePKCE := flag.Bool("require-pkce", true, "Require PKCE for authorization code flow (RFC 7636)") requireResource := flag.Bool("require-resource", false, "Require RFC 8707 resource indicator") + // Compatibility modes + runlayerMode := flag.Bool("runlayer-mode", false, "Mimic Runlayer's strict validation (implies -require-resource, returns Pydantic-style 422 errors)") + // Detection mode detectionMode := flag.String("detection", "both", "OAuth detection mode: discovery, www-authenticate, explicit, both") @@ -92,6 +95,9 @@ func main() { errMode.AuthInvalidRequest = true } + // Runlayer mode implies require-resource + effectiveRequireResource := *requireResource || *runlayerMode + opts := oauthserver.Options{ // Feature toggles (inverted from no-* flags) EnableAuthCode: !*noAuthCode, @@ -102,7 +108,10 @@ func main() { // Security RequirePKCE: *requirePKCE, - RequireResourceIndicator: *requireResource, + RequireResourceIndicator: effectiveRequireResource, + + // Compatibility modes + RunlayerMode: *runlayerMode, // Detection DetectionMode: dm, @@ -163,6 +172,7 @@ func main() { fmt.Printf(" Refresh Token: %s\n", boolToEnabled(opts.EnableRefreshToken)) fmt.Printf(" PKCE Required: %s (RFC 7636)\n", boolToEnabled(opts.RequirePKCE)) fmt.Printf(" Resource Req: %s (RFC 8707)\n", boolToEnabled(opts.RequireResourceIndicator)) + fmt.Printf(" Runlayer Mode: %s (Pydantic 422 errors)\n", boolToEnabled(opts.RunlayerMode)) fmt.Printf(" Detection Mode: %s\n", *detectionMode) fmt.Println("") fmt.Println("Test Credentials: testuser / testpass") diff --git a/tests/oauthserver/options.go b/tests/oauthserver/options.go index 85b59ac3..30064c7a 100644 --- a/tests/oauthserver/options.go +++ b/tests/oauthserver/options.go @@ -68,6 +68,9 @@ type Options struct { RequirePKCE bool // Default: true RequireResourceIndicator bool // RFC 8707: Require resource parameter (default: false) + // Compatibility modes + RunlayerMode bool // Mimic Runlayer's strict validation with Pydantic-style 422 errors + // Error injection ErrorMode ErrorMode diff --git a/tests/oauthserver/types.go b/tests/oauthserver/types.go index 2f728e36..f63f2bb5 100644 --- a/tests/oauthserver/types.go +++ b/tests/oauthserver/types.go @@ -140,6 +140,20 @@ type OAuthError struct { ErrorDescription string `json:"error_description,omitempty"` } +// PydanticValidationError represents a Pydantic-style validation error response. +// Used in Runlayer mode to mimic FastAPI/Pydantic 422 responses. +type PydanticValidationError struct { + Detail []PydanticErrorDetail `json:"detail"` +} + +// PydanticErrorDetail represents a single validation error detail. +type PydanticErrorDetail struct { + Type string `json:"type"` // Error type, e.g., "missing", "value_error" + Loc []string `json:"loc"` // Location path, e.g., ["query", "resource"] + Msg string `json:"msg"` // Human-readable message + Input any `json:"input,omitempty"` // The input value that caused the error +} + // RefreshTokenData stores refresh token information. type RefreshTokenData struct { Token string