Skip to content
Closed
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
221 changes: 221 additions & 0 deletions docs/AUTHORIZATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
# Authorization Architecture

This document describes how MCP Gateway handles authorization per the MCP specification (2025-03-26).

## MCP Specification Compliance

The gateway implements OAuth 2.1-compliant authorization for HTTP-based transports:

### HTTP Status Codes

Per MCP spec, the following HTTP status codes are used:

| Status Code | Description | Usage |
|-------------|-------------|-------|
| 400 Bad Request | Malformed authorization request | - Missing "Bearer " prefix<br>- Empty token<br>- Token in query string |
| 401 Unauthorized | Authorization required or token invalid | - Missing Authorization header<br>- Invalid/expired token |
| 403 Forbidden | Valid token but insufficient permissions | Not yet implemented (requires OAuth scopes) |

### Authorization Header Format

**Required Format:** `Authorization: Bearer <token>`

- **MUST** use "Bearer " prefix (case-sensitive)
- **MUST NOT** include tokens in URI query strings
- Plain API keys without "Bearer " prefix are **rejected** (returns 400)

## Two-Layer Authorization Architecture

The gateway uses a two-layer authorization approach:

### Layer 1: HTTP Authentication (authMiddleware)

**Purpose:** Validate that the request includes a valid API key

**Location:** `internal/server/auth.go` - `authMiddleware()`

**Behavior:**
- Applied to MCP endpoints when `--api-key` flag is set
- Validates `Authorization: Bearer <token>` header
- Compares token against configured API key
- Returns 401 if token is missing or invalid
- Returns 400 if header is malformed

**When Applied:**
- `/mcp` endpoint (unified mode)
- `/mcp/{serverID}` endpoints (routed mode)
- `/close` endpoint

**Not Applied:**
- `/health` endpoint (always public)
- `/.well-known/oauth-authorization-server` endpoint

### Layer 2: Session Identification (Transport Layer)

**Purpose:** Extract Bearer token to use as session ID for request routing

**Location:**
- `internal/server/transport.go` - `CreateHTTPServerForMCP()`
- `internal/server/routed.go` - `CreateHTTPServerForRoutedMode()`

**Behavior:**
- Extracts Bearer token from `Authorization` header
- Uses token as session ID to group requests from same client
- Rejects connections without Bearer token (returns nil server)
- **Does NOT validate token value** (that's Layer 1's job)

**Session Management:**
- Same Bearer token = same session
- Session persists across multiple MCP requests
- DIFC labels accumulate within a session

## Authorization Flow

```
┌─────────────────────────────────────────────────────────────┐
│ 1. Client Request │
│ POST /mcp/github │
│ Authorization: Bearer secret123 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 2. HTTP Auth Layer (if API key configured) │
│ - Check Bearer format (400 if wrong) │
│ - Validate token == apiKey (401 if wrong) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 3. Transport Layer │
│ - Extract Bearer token → session ID │
│ - Reject if no token (return nil) │
│ - Store session ID in context │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 4. MCP Protocol Handling │
│ - Route to backend server │
│ - Execute tool calls │
│ - Apply DIFC policies (if enabled) │
└─────────────────────────────────────────────────────────────┘
```

## Configuration

### Without API Key (Development Mode)

```bash
./awmg --config config.toml
```

- **HTTP Auth Layer:** Disabled (all requests accepted)
- **Transport Layer:** Still requires Bearer token for session ID
- **Behavior:** Clients must send `Authorization: Bearer <any-value>` for session management

### With API Key (Production Mode)

```bash
./awmg --config config.toml --api-key secret123
```

Or via environment:
```bash
MCP_GATEWAY_API_KEY=secret123 ./awmg --config config.toml
```

- **HTTP Auth Layer:** Enabled (validates token == apiKey)
- **Transport Layer:** Uses validated Bearer token as session ID
- **Behavior:** Clients must send `Authorization: Bearer secret123` (exact match required)

## Bearer Token Usage Patterns

### Pattern 1: Development (No API Key)

```
Client A: Authorization: Bearer dev-session-1
Client B: Authorization: Bearer dev-session-2
```

- Both accepted (no validation)
- Separate sessions maintained

### Pattern 2: Production (With API Key)

```
Client A: Authorization: Bearer secret123 ✅ Accepted
Client B: Authorization: Bearer wrong-key ❌ 401 Unauthorized
```

- Only matching API key accepted
- All clients share same session (same token)

### Pattern 3: Multi-Agent (Future)

When OAuth scopes are added:

```
Agent A: Authorization: Bearer token-with-read-scope ✅ Read operations only
Agent B: Authorization: Bearer token-with-write-scope ✅ Read + Write operations
Agent C: Authorization: Bearer token-no-scopes ❌ 403 Forbidden
```

## Why Two Layers?

The separation provides flexibility:

1. **Development**: No API key needed, but still requires Bearer token for session management
2. **Production**: API key validation ensures only authorized clients can connect
3. **Future**: OAuth scopes can be added to auth layer without changing transport layer

## Testing

See `internal/server/auth_test.go` for comprehensive test coverage:

- ✅ Bearer token format validation
- ✅ Query string rejection
- ✅ Empty token rejection
- ✅ Invalid token rejection
- ✅ Case sensitivity
- ✅ Whitespace handling

## Limitations

### Current Limitations

1. **Simple API Key Only:** No OAuth scopes or fine-grained permissions
2. **Single API Key:** All clients share same token in production
3. **No Token Expiration:** Tokens don't expire (restart required to change)
4. **No HTTP 403:** All auth failures return 401 or 400 (no permission checks)

### Future Enhancements

1. **OAuth Scopes:** Add scope-based permissions (read, write, admin)
2. **Multiple Tokens:** Support different tokens for different clients
3. **Token Expiration:** JWT with expiry claims
4. **HTTP 403 Support:** Return 403 when valid token lacks required scopes
5. **Dynamic Client Registration:** Support RFC7591 for automatic client registration

## Security Considerations

### ✅ Compliant

- Bearer token required for all MCP operations
- Tokens not allowed in query strings (prevents log/history leaks)
- Strict format validation (prevents format confusion attacks)
- Case-sensitive Bearer prefix (prevents case-folding attacks)

### ⚠️ Recommendations

- **Always use HTTPS in production** (Bearer tokens in plaintext)
- **Use strong random tokens** (at least 32 bytes of entropy)
- **Rotate tokens regularly** (restart gateway with new token)
- **Restrict token exposure** (don't log full token values)

## References

- [MCP Specification 2025-03-26](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/authorization.mdx)
- [OAuth 2.1 IETF Draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12)
- [RFC8414 - OAuth 2.0 Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414)
- [RFC7591 - OAuth 2.0 Dynamic Client Registration](https://datatracker.ietf.org/doc/html/rfc7591)
53 changes: 38 additions & 15 deletions internal/server/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,36 +9,59 @@ import (
"github.com/githubnext/gh-aw-mcpg/internal/logger"
)

// authMiddleware implements API key authentication per spec section 7.1
// authMiddleware implements API key authentication per MCP spec 2025-03-26 Authorization section
// Spec requirement: Access tokens MUST use the Authorization header field in format "Bearer <token>"
// For HTTP-based transports, implementations SHOULD conform to OAuth 2.1
//
// HTTP Status Codes per MCP Spec:
// - 400 Bad Request: Malformed authorization request (wrong format, token in query string)
// - 401 Unauthorized: Authorization required or token invalid/expired
// - 403 Forbidden: Valid token but insufficient permissions/scopes (not yet implemented)
//
// Note: HTTP 403 is currently not used because this gateway uses simple API key validation
// without scope-based permissions. When OAuth scopes are added in the future, 403 should be
// returned when a valid token lacks the required scopes for the requested operation.
func authMiddleware(apiKey string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// MCP Spec: Access tokens MUST NOT be included in URI query strings
if r.URL.Query().Get("token") != "" || r.URL.Query().Get("access_token") != "" || r.URL.Query().Get("apiKey") != "" {
logger.LogError("auth", "Authentication failed: token in query string, remote=%s, path=%s", r.RemoteAddr, r.URL.Path)
logRuntimeError("authentication_failed", "token_in_query_string", r, nil)
http.Error(w, "Bad Request: tokens must not be included in query string", http.StatusBadRequest)
return
}

// Extract Authorization header
authHeader := r.Header.Get("Authorization")

if authHeader == "" {
// Spec 7.1: Missing token returns 401
// MCP Spec: Missing token returns 401
logger.LogError("auth", "Authentication failed: missing Authorization header, remote=%s, path=%s", r.RemoteAddr, r.URL.Path)
logRuntimeError("authentication_failed", "missing_auth_header", r, nil)
http.Error(w, "Unauthorized: missing Authorization header", http.StatusUnauthorized)
return
}

// Spec 7.1: Malformed header returns 400
var token string
if strings.HasPrefix(authHeader, "Bearer ") {
// Bearer token: extract the token after the prefix
token = strings.TrimPrefix(authHeader, "Bearer ")
} else if authHeader == apiKey {
// Plain API key: use the header value directly
token = authHeader
} else {
// Header is neither a Bearer token nor a valid plain API key
logger.LogError("auth", "Authentication failed: malformed Authorization header, remote=%s, path=%s", r.RemoteAddr, r.URL.Path)
// MCP Spec: Authorization header MUST be in format "Bearer <token>"
if !strings.HasPrefix(authHeader, "Bearer ") {
logger.LogError("auth", "Authentication failed: malformed Authorization header (missing 'Bearer ' prefix), remote=%s, path=%s", r.RemoteAddr, r.URL.Path)
logRuntimeError("authentication_failed", "malformed_auth_header", r, nil)
http.Error(w, "Bad Request: Authorization header must be 'Bearer <token>' or plain API key", http.StatusBadRequest)
http.Error(w, "Bad Request: Authorization header must be 'Bearer <token>'", http.StatusBadRequest)
return
}

// Extract token after "Bearer " prefix
token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))

if token == "" {
// Empty token after Bearer prefix
logger.LogError("auth", "Authentication failed: empty token after Bearer prefix, remote=%s, path=%s", r.RemoteAddr, r.URL.Path)
logRuntimeError("authentication_failed", "empty_token", r, nil)
http.Error(w, "Bad Request: Bearer token cannot be empty", http.StatusBadRequest)
return
}
// Spec 7.1: Invalid token returns 401

// MCP Spec: Invalid or expired tokens MUST receive HTTP 401
if token != apiKey {
logger.LogError("auth", "Authentication failed: invalid API key, remote=%s, path=%s", r.RemoteAddr, r.URL.Path)
logRuntimeError("authentication_failed", "invalid_token", r, nil)
Expand Down
Loading