diff --git a/AGENTS.md b/AGENTS.md index dc020a38..78570d56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ Quick reference for AI agents working with MCP Gateway (Go-based MCP proxy serve - `internal/launcher/` - Backend process management - `internal/difc/` - Security labels (not enabled) - `internal/guard/` - Security guards (NoopGuard active) +- `internal/auth/` - Authentication header parsing and middleware - `internal/logger/` - Debug logging framework (micro logger) - `internal/timeutil/` - Time formatting utilities @@ -121,6 +122,7 @@ golangci-lint run --enable=gosec,testifylint --timeout=5m **Add MCP Server**: Update config.toml with new server entry **Add Route**: Edit `internal/server/routed.go` or `unified.go` **Add Guard**: Implement in `internal/guard/` and register +**Add Auth Logic**: Implement in `internal/auth/` package **Add Unit Test**: Create `*_test.go` in the appropriate `internal/` package **Add Integration Test**: Create test in `test/integration/` that uses the binary diff --git a/README.md b/README.md index c48104f0..c1495036 100644 --- a/README.md +++ b/README.md @@ -391,6 +391,34 @@ Supported JSON-RPC 2.0 methods: ## Security Features +### Authentication + +MCPG implements MCP specification 7.1 for API key authentication. + +**Authentication Header Format:** +- Per MCP spec 7.1: Authorization header MUST contain the API key directly +- Format: `Authorization: ` (plain API key, NOT Bearer scheme) +- Example: `Authorization: my-secret-api-key-123` + +**Configuration:** +- Set via `MCP_GATEWAY_API_KEY` environment variable +- When configured, all endpoints except `/health` require authentication +- When not configured, authentication is disabled + +**Implementation:** +- The `internal/auth` package provides centralized authentication logic +- `auth.ParseAuthHeader()` - Parses Authorization headers per MCP spec 7.1 +- `auth.ValidateAPIKey()` - Validates provided API keys +- Backward compatibility for Bearer tokens is maintained + +**Example Request:** +```bash +curl -X POST http://localhost:8000/mcp/github \ + -H "Authorization: my-api-key" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}' +``` + ### Enhanced Error Debugging Command failures now include extensive debugging information: diff --git a/internal/auth/header.go b/internal/auth/header.go new file mode 100644 index 00000000..9b732f09 --- /dev/null +++ b/internal/auth/header.go @@ -0,0 +1,70 @@ +// Package auth provides authentication header parsing and middleware +// for the MCP Gateway server. +// +// This package implements MCP specification 7.1 for authentication, +// which requires Authorization headers to contain the API key directly +// without any scheme prefix (e.g., NOT "Bearer "). +// +// Example usage: +// +// apiKey, agentID, err := auth.ParseAuthHeader(r.Header.Get("Authorization")) +// if err != nil { +// // Handle error +// } +package auth + +import ( + "errors" + "strings" +) + +var ( + // ErrMissingAuthHeader is returned when the Authorization header is missing + ErrMissingAuthHeader = errors.New("missing Authorization header") + // ErrInvalidAuthHeader is returned when the Authorization header format is invalid + ErrInvalidAuthHeader = errors.New("invalid Authorization header format") +) + +// ParseAuthHeader parses the Authorization header and extracts the API key and agent ID. +// Per MCP spec 7.1, the Authorization header should contain the API key directly +// without any Bearer prefix or other scheme. +// +// For backward compatibility, this function also supports: +// - "Bearer " format (uses token as both API key and agent ID) +// - "Agent " format (extracts agent ID) +// +// Returns: +// - apiKey: The extracted API key +// - agentID: The extracted agent/session identifier +// - error: ErrMissingAuthHeader if header is empty, nil otherwise +func ParseAuthHeader(authHeader string) (apiKey string, agentID string, error error) { + if authHeader == "" { + return "", "", ErrMissingAuthHeader + } + + // Handle "Bearer " format (backward compatibility) + if strings.HasPrefix(authHeader, "Bearer ") { + token := strings.TrimPrefix(authHeader, "Bearer ") + return token, token, nil + } + + // Handle "Agent " format + if strings.HasPrefix(authHeader, "Agent ") { + agentIDValue := strings.TrimPrefix(authHeader, "Agent ") + return agentIDValue, agentIDValue, nil + } + + // Per MCP spec 7.1: Authorization header contains API key directly + // Use the entire header value as both API key and agent/session ID + return authHeader, authHeader, nil +} + +// ValidateAPIKey checks if the provided API key matches the expected key. +// Returns true if they match, false otherwise. +func ValidateAPIKey(provided, expected string) bool { + if expected == "" { + // No API key configured, authentication is disabled + return true + } + return provided == expected +} diff --git a/internal/auth/header_test.go b/internal/auth/header_test.go new file mode 100644 index 00000000..c95c8cbd --- /dev/null +++ b/internal/auth/header_test.go @@ -0,0 +1,105 @@ +package auth + +import ( + "testing" +) + +func TestParseAuthHeader(t *testing.T) { + tests := []struct { + name string + authHeader string + wantAPIKey string + wantAgentID string + wantErr error + }{ + { + name: "Empty header", + authHeader: "", + wantAPIKey: "", + wantAgentID: "", + wantErr: ErrMissingAuthHeader, + }, + { + name: "Plain API key (MCP spec 7.1)", + authHeader: "my-secret-api-key", + wantAPIKey: "my-secret-api-key", + wantAgentID: "my-secret-api-key", + wantErr: nil, + }, + { + name: "Bearer token (backward compatibility)", + authHeader: "Bearer my-token-123", + wantAPIKey: "my-token-123", + wantAgentID: "my-token-123", + wantErr: nil, + }, + { + name: "Agent format", + authHeader: "Agent agent-123", + wantAPIKey: "agent-123", + wantAgentID: "agent-123", + wantErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotAPIKey, gotAgentID, gotErr := ParseAuthHeader(tt.authHeader) + + if gotErr != tt.wantErr { + t.Errorf("ParseAuthHeader() error = %v, wantErr %v", gotErr, tt.wantErr) + return + } + + if gotAPIKey != tt.wantAPIKey { + t.Errorf("ParseAuthHeader() gotAPIKey = %v, want %v", gotAPIKey, tt.wantAPIKey) + } + + if gotAgentID != tt.wantAgentID { + t.Errorf("ParseAuthHeader() gotAgentID = %v, want %v", gotAgentID, tt.wantAgentID) + } + }) + } +} + +func TestValidateAPIKey(t *testing.T) { + tests := []struct { + name string + provided string + expected string + want bool + }{ + { + name: "Matching keys", + provided: "my-secret-key", + expected: "my-secret-key", + want: true, + }, + { + name: "Non-matching keys", + provided: "wrong-key", + expected: "correct-key", + want: false, + }, + { + name: "Empty expected (auth disabled)", + provided: "any-key", + expected: "", + want: true, + }, + { + name: "Empty provided with expected", + provided: "", + expected: "required-key", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ValidateAPIKey(tt.provided, tt.expected); got != tt.want { + t.Errorf("ValidateAPIKey() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/guard/context.go b/internal/guard/context.go index 5be35abf..497daada 100644 --- a/internal/guard/context.go +++ b/internal/guard/context.go @@ -31,7 +31,11 @@ func SetAgentIDInContext(ctx context.Context, agentID string) context.Context { } // ExtractAgentIDFromAuthHeader extracts agent ID from Authorization header -// Supports formats: +// +// Note: For MCP spec 7.1 compliant parsing, see internal/auth.ParseAuthHeader() +// which provides centralized authentication header parsing. +// +// This function supports formats: // - "Bearer " - uses token as agent ID // - "Agent " - uses agent-id directly // - Any other format - uses the entire value as agent ID diff --git a/internal/server/auth.go b/internal/server/auth.go index 300a1a1f..d7d450f3 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -10,6 +10,10 @@ import ( // authMiddleware implements API key authentication per spec section 7.1 // Per spec: Authorization header MUST contain the API key directly (NOT Bearer scheme) +// +// For header parsing logic, see internal/auth package which provides: +// - ParseAuthHeader() for extracting API keys and agent IDs +// - ValidateAPIKey() for key validation func authMiddleware(apiKey string, next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Extract Authorization header