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
68 changes: 68 additions & 0 deletions docs/oauthserver_testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
16 changes: 15 additions & 1 deletion scripts/run-oauth-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -42,6 +44,10 @@ for arg in "$@"; do
ERROR_INJECTION=true
shift
;;
--runlayer-mode)
RUNLAYER_MODE=true
shift
;;
--verbose)
VERBOSE=true
shift
Expand Down Expand Up @@ -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

Expand Down
127 changes: 127 additions & 0 deletions test/integration/test_runlayer_mode.sh
Original file line number Diff line number Diff line change
@@ -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}"
30 changes: 30 additions & 0 deletions tests/oauthserver/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"crypto/sha256"
"embed"
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"net/http"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
12 changes: 11 additions & 1 deletion tests/oauthserver/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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,
Expand All @@ -102,7 +108,10 @@ func main() {

// Security
RequirePKCE: *requirePKCE,
RequireResourceIndicator: *requireResource,
RequireResourceIndicator: effectiveRequireResource,

// Compatibility modes
RunlayerMode: *runlayerMode,

// Detection
DetectionMode: dm,
Expand Down Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions tests/oauthserver/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions tests/oauthserver/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down