mirror of
https://github.com/wso2/open-mcp-auth-proxy.git
synced 2025-06-28 09:24:19 +00:00
Merge branch 'main' of https://github.com/wso2/open-mcp-auth-proxy into main
This commit is contained in:
commit
72c44afc14
10 changed files with 784 additions and 128 deletions
146
README.md
146
README.md
|
@ -1,81 +1,115 @@
|
|||
# open-mcp-auth-proxy
|
||||
# Open MCP Auth Proxy
|
||||
|
||||
## Overview
|
||||
The Open MCP Auth Proxy is a lightweight proxy designed to sit in front of MCP servers and enforce authorization in compliance with the [Model Context Protocol authorization](https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/authorization/) requirements. It intercepts incoming requests, validates tokens, and offloads authentication and authorization to an OAuth-compliant Identity Provider.
|
||||
|
||||
OpenMCPAuthProxy is a security middleware that implements the Model Context Protocol (MCP) Authorization Specification (2025-03-26). It functions as a proxy between clients and MCP servers, providing robust authentication and authorization capabilities. The proxy intercepts incoming requests, validates authentication tokens, and forwards only authorized requests to the underlying MCP server, enhancing the security posture of your MCP deployment.
|
||||

|
||||
|
||||
## Setup and Installation
|
||||
## **Setup and Installation**
|
||||
|
||||
### Prerequisites
|
||||
- Go 1.20 or higher
|
||||
- A running MCP server (SSE transport supported)
|
||||
### **Prerequisites**
|
||||
|
||||
* Go 1.20 or higher
|
||||
* A running MCP server (SSE transport supported)
|
||||
* An MCP client that supports MCP authorization
|
||||
|
||||
### **Installation**
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
git clone https://github.com/wso2/open-mcp-auth-proxy
|
||||
cd open-mcp-auth-proxy
|
||||
git clone https://github.com/wso2/open-mcp-auth-proxy
|
||||
cd open-mcp-auth-proxy
|
||||
|
||||
go get github.com/golang-jwt/jwt/v4
|
||||
go get gopkg.in/yaml.v2
|
||||
|
||||
go build -o openmcpauthproxy ./cmd/proxy
|
||||
```
|
||||
|
||||
## Configuration
|
||||
## Using Open MCP Auth Proxy
|
||||
|
||||
Create a configuration file `config.yaml` with the following parameters:
|
||||
### Quick Start
|
||||
|
||||
```yaml
|
||||
mcp_server_base_url: "http://localhost:8000" # URL of your MCP server
|
||||
listen_address: ":8080" # Address where the proxy will listen
|
||||
```
|
||||
Allows you to just enable authentication and authorization for your MCP server with the preconfigured auth provider powered by Asgardeo.
|
||||
|
||||
## Usage Example
|
||||
|
||||
### 1. Start the MCP Server
|
||||
|
||||
Create a file named `echo_server.py`:
|
||||
|
||||
```python
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("Echo")
|
||||
|
||||
|
||||
@mcp.resource("echo://{message}")
|
||||
def echo_resource(message: str) -> str:
|
||||
"""Echo a message as a resource"""
|
||||
return f"Resource echo: {message}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def echo_tool(message: str) -> str:
|
||||
"""Echo a message as a tool"""
|
||||
return f"Tool echo: {message}"
|
||||
|
||||
|
||||
@mcp.prompt()
|
||||
def echo_prompt(message: str) -> str:
|
||||
"""Create an echo prompt"""
|
||||
return f"Please process this message: {message}"
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="sse")
|
||||
```
|
||||
|
||||
Run the server:
|
||||
If you don’t have an MCP server, follow the instructions given here to start your own MCP server for testing purposes.
|
||||
1. Download [sample MCP server](resources/echo_server.py)
|
||||
2. Run the server with
|
||||
```bash
|
||||
python3 echo_server.py
|
||||
```
|
||||
|
||||
### 2. Start the Auth Proxy
|
||||
#### Configure the Auth Proxy
|
||||
|
||||
Update the following parameters in `config.yaml`.
|
||||
|
||||
### demo mode configuration:
|
||||
|
||||
```yaml
|
||||
mcp_server_base_url: "http://localhost:8000" # URL of your MCP server
|
||||
listen_port: 8080 # Address where the proxy will listen
|
||||
```
|
||||
|
||||
#### Start the Auth Proxy
|
||||
|
||||
```bash
|
||||
./openmcpauthproxy --demo
|
||||
```
|
||||
|
||||
The `--demo` flag enables a demonstration mode with pre-configured authentication with [Asgardeo](https://asgardeo.io/).
|
||||
The `--demo` flag enables a demonstration mode with pre-configured authentication and authorization with a sandbox powered by [Asgardeo](https://asgardeo.io/).
|
||||
|
||||
### 3. Connect Using an MCP Client
|
||||
#### Connect Using an MCP Client
|
||||
|
||||
You can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) to test the connection:
|
||||
You can use this fork of the [MCP Inspector](https://github.com/shashimalcse/inspector) to test the connection and try out the complete authorization flow. (this is a temporary fork with fixes for authentication issues in the original implementation)
|
||||
|
||||
## Contributing
|
||||
### Use with Asgardeo
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
Enable authorization for the MCP server through your own Asgardeo organization
|
||||
|
||||
1. [Register]([url](https://asgardeo.io/signup)) and create an organization in Asgardeo
|
||||
2. Now, you need to authorize the OpenMCPAuthProxy to allow dynamically registering MCP Clients as applications in your organization. To do that,
|
||||
1. Create an [M2M application](https://wso2.com/asgardeo/docs/guides/applications/register-machine-to-machine-app/)
|
||||
1. [Authorize this application](https://wso2.com/asgardeo/docs/guides/applications/register-machine-to-machine-app/#authorize-the-api-resources-for-the-app) to invoke “Application Management API” with the `internal_application_mgt_create` scope.
|
||||

|
||||
2. Note the **Client ID** and **Client secret** of this application. This is required by the auth proxy
|
||||
|
||||
#### Configure the Auth Proxy
|
||||
|
||||
Create a configuration file config.yaml with the following parameters:
|
||||
|
||||
```yaml
|
||||
mcp_server_base_url: "http://localhost:8000" # URL of your MCP server
|
||||
listen_port: 8080 # Address where the proxy will listen
|
||||
|
||||
asgardeo:
|
||||
org_name: "<org_name>" # Your Asgardeo org name
|
||||
client_id: "<client_id>" # Client ID of the M2M app
|
||||
client_secret: "<client_secret>" # Client secret of the M2M app
|
||||
```
|
||||
|
||||
#### Start the Auth Proxy
|
||||
|
||||
```bash
|
||||
./openmcpauthproxy --asgardeo
|
||||
```
|
||||
|
||||
### Use with any standard OAuth Server
|
||||
|
||||
Enable authorization for the MCP server with a compliant OAuth server
|
||||
|
||||
#### Configuration
|
||||
|
||||
Create a configuration file config.yaml with the following parameters:
|
||||
|
||||
```yaml
|
||||
mcp_server_base_url: "http://localhost:8000" # URL of your MCP server
|
||||
listen_port: 8080 # Address where the proxy will listen
|
||||
```
|
||||
**TODO**: Update the configs for a standard OAuth Server.
|
||||
|
||||
#### Start the Auth Proxy
|
||||
|
||||
```bash
|
||||
./openmcpauthproxy
|
||||
```
|
||||
#### Integrating with existing OAuth Providers
|
||||
|
||||
- [Auth0](docs/Auth0.md) - Enable authorization for the MCP server through your Auth0 organization.
|
||||
|
|
|
@ -11,12 +11,14 @@ import (
|
|||
|
||||
"github.com/wso2/open-mcp-auth-proxy/internal/authz"
|
||||
"github.com/wso2/open-mcp-auth-proxy/internal/config"
|
||||
"github.com/wso2/open-mcp-auth-proxy/internal/constants"
|
||||
"github.com/wso2/open-mcp-auth-proxy/internal/proxy"
|
||||
"github.com/wso2/open-mcp-auth-proxy/internal/util"
|
||||
)
|
||||
|
||||
func main() {
|
||||
demoMode := flag.Bool("demo", false, "Use Asgardeo-based provider (demo).")
|
||||
asgardeoMode := flag.Bool("asgardeo", false, "Use Asgardeo-based provider (asgardeo).")
|
||||
flag.Parse()
|
||||
|
||||
// 1. Load config
|
||||
|
@ -28,12 +30,20 @@ func main() {
|
|||
// 2. Create the chosen provider
|
||||
var provider authz.Provider
|
||||
if *demoMode {
|
||||
cfg.AuthServerBaseURL = "https://api.asgardeo.io/t/" + cfg.Demo.OrgName + "/oauth2"
|
||||
cfg.JWKSURL = "https://api.asgardeo.io/t/" + cfg.Demo.OrgName + "/oauth2/jwks"
|
||||
cfg.Mode = "demo"
|
||||
cfg.AuthServerBaseURL = constants.ASGARDEO_BASE_URL + cfg.Demo.OrgName + "/oauth2"
|
||||
cfg.JWKSURL = constants.ASGARDEO_BASE_URL + cfg.Demo.OrgName + "/oauth2/jwks"
|
||||
provider = authz.NewAsgardeoProvider(cfg)
|
||||
} else if *asgardeoMode {
|
||||
cfg.Mode = "asgardeo"
|
||||
cfg.AuthServerBaseURL = constants.ASGARDEO_BASE_URL + cfg.Asgardeo.OrgName + "/oauth2"
|
||||
cfg.JWKSURL = constants.ASGARDEO_BASE_URL + cfg.Asgardeo.OrgName + "/oauth2/jwks"
|
||||
provider = authz.NewAsgardeoProvider(cfg)
|
||||
fmt.Println("Using Asgardeo provider (demo).")
|
||||
} else {
|
||||
log.Fatalf("Not supported yet.")
|
||||
cfg.Mode = "default"
|
||||
cfg.JWKSURL = cfg.Default.JWKSURL
|
||||
cfg.AuthServerBaseURL = cfg.Default.BaseURL
|
||||
provider = authz.NewDefaultProvider(cfg)
|
||||
}
|
||||
|
||||
// 3. (Optional) Fetch JWKS if you want local JWT validation
|
||||
|
@ -44,14 +54,17 @@ func main() {
|
|||
// 4. Build the main router
|
||||
mux := proxy.NewRouter(cfg, provider)
|
||||
|
||||
listen_address := fmt.Sprintf(":%d", cfg.ListenPort)
|
||||
|
||||
// 5. Start the server
|
||||
srv := &http.Server{
|
||||
Addr: cfg.ListenAddress,
|
||||
|
||||
Addr: listen_address,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("Server listening on %s", cfg.ListenAddress)
|
||||
log.Printf("Server listening on %s", listen_address)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
}
|
||||
|
|
61
config.yaml
61
config.yaml
|
@ -1,9 +1,7 @@
|
|||
# config.yaml
|
||||
|
||||
auth_server_base_url: ""
|
||||
mcp_server_base_url: "http://localhost:8000"
|
||||
listen_address: ":8080"
|
||||
jwks_url: ""
|
||||
mcp_server_base_url: ""
|
||||
listen_port: 8080
|
||||
timeout_seconds: 10
|
||||
|
||||
mcp_paths:
|
||||
|
@ -11,8 +9,63 @@ mcp_paths:
|
|||
- /sse
|
||||
|
||||
path_mapping:
|
||||
/token: /token
|
||||
/register: /register
|
||||
/authorize: /authorize
|
||||
/.well-known/oauth-authorization-server: /.well-known/oauth-authorization-server
|
||||
|
||||
cors:
|
||||
allowed_origins:
|
||||
- ""
|
||||
allowed_methods:
|
||||
- "GET"
|
||||
- "POST"
|
||||
- "PUT"
|
||||
- "DELETE"
|
||||
allowed_headers:
|
||||
- "Authorization"
|
||||
- "Content-Type"
|
||||
allow_credentials: true
|
||||
|
||||
demo:
|
||||
org_name: "openmcpauthdemo"
|
||||
client_id: "N0U9e_NNGr9mP_0fPnPfPI0a6twa"
|
||||
client_secret: "qFHfiBp5gNGAO9zV4YPnDofBzzfInatfUbHyPZvM0jka"
|
||||
|
||||
asgardeo:
|
||||
org_name: "<org_name>"
|
||||
client_id: "<client_id>"
|
||||
client_secret: "<client_secret>"
|
||||
|
||||
default:
|
||||
base_url: "<base_url>"
|
||||
jwks_url: "<jwks_url>"
|
||||
path:
|
||||
/.well-known/oauth-authorization-server:
|
||||
response:
|
||||
issuer: "<issuer>"
|
||||
jwks_uri: "<jwks_uri>"
|
||||
authorization_endpoint: "<authorization_endpoint>" # Optional
|
||||
token_endpoint: "<token_endpoint>" # Optional
|
||||
registration_endpoint: "<registration_endpoint>" # Optional
|
||||
response_types_supported:
|
||||
- "code"
|
||||
grant_types_supported:
|
||||
- "authorization_code"
|
||||
- "refresh_token"
|
||||
code_challenge_methods_supported:
|
||||
- "S256"
|
||||
- "plain"
|
||||
/authroize:
|
||||
addQueryParams:
|
||||
- name: "<name>"
|
||||
value: "<value>"
|
||||
/token:
|
||||
addBodyParams:
|
||||
- name: "<name>"
|
||||
value: "<value>"
|
||||
/register:
|
||||
addBodyParams:
|
||||
- name: "<name>"
|
||||
value: "<value>"
|
||||
|
||||
|
|
85
docs/Auth0.md
Normal file
85
docs/Auth0.md
Normal file
|
@ -0,0 +1,85 @@
|
|||
## Integrating with Auth0
|
||||
|
||||
This guide will help you configure Open MCP Auth Proxy to use Auth0 as your identity provider.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- An Auth0 organization (sign up here if you don't have one)
|
||||
- Open MCP Auth Proxy installed
|
||||
|
||||
### Setting Up Auth0
|
||||
1. [Enable Dynamic Client Registration](https://auth0.com/docs/get-started/applications/dynamic-client-registration)
|
||||
- Go to your Auth0 dashboard
|
||||
- Navigate to Settings > Advanced
|
||||
- Enable "OIDC Dynamic Application Registration"
|
||||
2. In order to setup connections in dynamically created clients [promote Connections to Domain Level](https://auth0.com/docs/authenticate/identity-providers/promote-connections-to-domain-level)
|
||||
3. Create an API in Auth0:
|
||||
- Go to your Auth0 dashboard
|
||||
- Navigate to Applications > APIs
|
||||
- Click on "Create API"
|
||||
- Set a Name (e.g., "MCP API")
|
||||
- Set an Identifier (e.g., "mcp_proxy")
|
||||
- Keep the default signing algorithm (RS256)
|
||||
- Click "Create"
|
||||
|
||||
### Configuring the Open MCP Auth Proxy
|
||||
|
||||
Update your `config.yaml` with Auth0 settings:
|
||||
|
||||
```yaml
|
||||
# Basic proxy configuration
|
||||
mcp_server_base_url: "http://localhost:8000"
|
||||
listen_port: 8080
|
||||
timeout_seconds: 10
|
||||
|
||||
# CORS configuration
|
||||
cors:
|
||||
allowed_origins:
|
||||
- "http://localhost:5173" # Your client application origin
|
||||
allowed_methods:
|
||||
- "GET"
|
||||
- "POST"
|
||||
- "PUT"
|
||||
- "DELETE"
|
||||
allowed_headers:
|
||||
- "Authorization"
|
||||
- "Content-Type"
|
||||
allow_credentials: true
|
||||
|
||||
# Path mappings for Auth0 endpoints
|
||||
path_mapping:
|
||||
/token: /oauth/token
|
||||
/register: /oidc/register
|
||||
|
||||
# Auth0 configuration
|
||||
default:
|
||||
base_url: "https://YOUR_AUTH0_DOMAIN" # e.g., https://dev-123456.us.auth0.com
|
||||
jwks_url: "https://YOUR_AUTH0_DOMAIN/.well-known/jwks.json"
|
||||
path:
|
||||
/.well-known/oauth-authorization-server:
|
||||
response:
|
||||
issuer: "https://YOUR_AUTH0_DOMAIN/"
|
||||
jwks_uri: "https://YOUR_AUTH0_DOMAIN/.well-known/jwks.json"
|
||||
authorization_endpoint: "https://YOUR_AUTH0_DOMAIN/authorize?audience=mcp_proxy" # Only if you created an API with this identifier
|
||||
response_types_supported:
|
||||
- "code"
|
||||
grant_types_supported:
|
||||
- "authorization_code"
|
||||
- "refresh_token"
|
||||
code_challenge_methods_supported:
|
||||
- "S256"
|
||||
- "plain"
|
||||
/token:
|
||||
addBodyParams:
|
||||
- name: "audience"
|
||||
value: "mcp_proxy" # Only if you created an API with this identifier
|
||||
```
|
||||
|
||||
Replace YOUR_AUTH0_DOMAIN with your Auth0 domain (e.g., dev-abc123.us.auth0.com).
|
||||
|
||||
## Starting the Proxy with Auth0 Integration
|
||||
Start the proxy in default mode (which will use Auth0 based on your configuration):
|
||||
|
||||
```bash
|
||||
./openmcpauthproxy
|
||||
```
|
94
internal/authz/default.go
Normal file
94
internal/authz/default.go
Normal file
|
@ -0,0 +1,94 @@
|
|||
package authz
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/wso2/open-mcp-auth-proxy/internal/config"
|
||||
)
|
||||
|
||||
type defaultProvider struct {
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewDefaultProvider initializes a Provider for Asgardeo (demo mode).
|
||||
func NewDefaultProvider(cfg *config.Config) Provider {
|
||||
return &defaultProvider{cfg: cfg}
|
||||
}
|
||||
|
||||
func (p *defaultProvider) WellKnownHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we have a custom response configuration
|
||||
if p.cfg.Default.Path != nil {
|
||||
pathConfig, exists := p.cfg.Default.Path["/.well-known/oauth-authorization-server"]
|
||||
if exists && pathConfig.Response != nil {
|
||||
// Use configured response values
|
||||
responseConfig := pathConfig.Response
|
||||
|
||||
// Get current host for proxy endpoints
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" {
|
||||
scheme = forwardedProto
|
||||
}
|
||||
host := r.Host
|
||||
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
|
||||
host = forwardedHost
|
||||
}
|
||||
baseURL := scheme + "://" + host
|
||||
|
||||
authorizationEndpoint := responseConfig.AuthorizationEndpoint
|
||||
if authorizationEndpoint == "" {
|
||||
authorizationEndpoint = baseURL + "/authorize"
|
||||
}
|
||||
tokenEndpoint := responseConfig.TokenEndpoint
|
||||
if tokenEndpoint == "" {
|
||||
tokenEndpoint = baseURL + "/token"
|
||||
}
|
||||
registraionEndpoint := responseConfig.RegistrationEndpoint
|
||||
if registraionEndpoint == "" {
|
||||
registraionEndpoint = baseURL + "/register"
|
||||
}
|
||||
|
||||
// Build response from config
|
||||
response := map[string]interface{}{
|
||||
"issuer": responseConfig.Issuer,
|
||||
"authorization_endpoint": authorizationEndpoint,
|
||||
"token_endpoint": tokenEndpoint,
|
||||
"jwks_uri": responseConfig.JwksURI,
|
||||
"response_types_supported": responseConfig.ResponseTypesSupported,
|
||||
"grant_types_supported": responseConfig.GrantTypesSupported,
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic"},
|
||||
"registration_endpoint": registraionEndpoint,
|
||||
"code_challenge_methods_supported": responseConfig.CodeChallengeMethodsSupported,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *defaultProvider) RegisterHandler() http.HandlerFunc {
|
||||
return nil
|
||||
}
|
|
@ -13,17 +13,67 @@ type DemoConfig struct {
|
|||
OrgName string `yaml:"org_name"`
|
||||
}
|
||||
|
||||
type AsgardeoConfig struct {
|
||||
ClientID string `yaml:"client_id"`
|
||||
ClientSecret string `yaml:"client_secret"`
|
||||
OrgName string `yaml:"org_name"`
|
||||
}
|
||||
|
||||
type CORSConfig struct {
|
||||
AllowedOrigins []string `yaml:"allowed_origins"`
|
||||
AllowedMethods []string `yaml:"allowed_methods"`
|
||||
AllowedHeaders []string `yaml:"allowed_headers"`
|
||||
AllowCredentials bool `yaml:"allow_credentials"`
|
||||
}
|
||||
|
||||
type ParamConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
|
||||
type ResponseConfig struct {
|
||||
Issuer string `yaml:"issuer,omitempty"`
|
||||
JwksURI string `yaml:"jwks_uri,omitempty"`
|
||||
AuthorizationEndpoint string `yaml:"authorization_endpoint,omitempty"`
|
||||
TokenEndpoint string `yaml:"token_endpoint,omitempty"`
|
||||
RegistrationEndpoint string `yaml:"registration_endpoint,omitempty"`
|
||||
ResponseTypesSupported []string `yaml:"response_types_supported,omitempty"`
|
||||
GrantTypesSupported []string `yaml:"grant_types_supported,omitempty"`
|
||||
CodeChallengeMethodsSupported []string `yaml:"code_challenge_methods_supported,omitempty"`
|
||||
}
|
||||
|
||||
type PathConfig struct {
|
||||
// For well-known endpoint
|
||||
Response *ResponseConfig `yaml:"response,omitempty"`
|
||||
|
||||
// For authorization endpoint
|
||||
AddQueryParams []ParamConfig `yaml:"addQueryParams,omitempty"`
|
||||
|
||||
// For token and register endpoints
|
||||
AddBodyParams []ParamConfig `yaml:"addBodyParams,omitempty"`
|
||||
}
|
||||
|
||||
type DefaultConfig struct {
|
||||
BaseURL string `yaml:"base_url,omitempty"`
|
||||
Path map[string]PathConfig `yaml:"path,omitempty"`
|
||||
JWKSURL string `yaml:"jwks_url,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
AuthServerBaseURL string `yaml:"auth_server_base_url"`
|
||||
MCPServerBaseURL string `yaml:"mcp_server_base_url"`
|
||||
ListenAddress string `yaml:"listen_address"`
|
||||
JWKSURL string `yaml:"jwks_url"`
|
||||
AuthServerBaseURL string
|
||||
MCPServerBaseURL string `yaml:"mcp_server_base_url"`
|
||||
ListenPort int `yaml:"listen_port"`
|
||||
JWKSURL string
|
||||
TimeoutSeconds int `yaml:"timeout_seconds"`
|
||||
MCPPaths []string `yaml:"mcp_paths"`
|
||||
PathMapping map[string]string `yaml:"path_mapping"`
|
||||
Mode string `yaml:"mode"`
|
||||
CORSConfig CORSConfig `yaml:"cors"`
|
||||
|
||||
// Nested config for Asgardeo
|
||||
Demo DemoConfig `yaml:"demo"`
|
||||
Demo DemoConfig `yaml:"demo"`
|
||||
Asgardeo AsgardeoConfig `yaml:"asgardeo"`
|
||||
Default DefaultConfig `yaml:"default"`
|
||||
}
|
||||
|
||||
// LoadConfig reads a YAML config file into Config struct.
|
||||
|
|
7
internal/constants/constants.go
Normal file
7
internal/constants/constants.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package constants
|
||||
|
||||
// Package constant provides constants for the MCP Auth Proxy
|
||||
|
||||
const (
|
||||
ASGARDEO_BASE_URL = "https://api.asgardeo.io/t/"
|
||||
)
|
199
internal/proxy/modifier.go
Normal file
199
internal/proxy/modifier.go
Normal file
|
@ -0,0 +1,199 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/wso2/open-mcp-auth-proxy/internal/config"
|
||||
)
|
||||
|
||||
// RequestModifier modifies requests before they are proxied
|
||||
type RequestModifier interface {
|
||||
ModifyRequest(req *http.Request) (*http.Request, error)
|
||||
}
|
||||
|
||||
// AuthorizationModifier adds parameters to authorization requests
|
||||
type AuthorizationModifier struct {
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// TokenModifier adds parameters to token requests
|
||||
type TokenModifier struct {
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
type RegisterModifier struct {
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// ModifyRequest adds configured parameters to authorization requests
|
||||
func (m *AuthorizationModifier) ModifyRequest(req *http.Request) (*http.Request, error) {
|
||||
// Check if we have parameters to add
|
||||
if m.Config.Default.Path == nil {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
pathConfig, exists := m.Config.Default.Path["/authorize"]
|
||||
if !exists || len(pathConfig.AddQueryParams) == 0 {
|
||||
return req, nil
|
||||
}
|
||||
// Get current query parameters
|
||||
query := req.URL.Query()
|
||||
|
||||
// Add parameters from config
|
||||
for _, param := range pathConfig.AddQueryParams {
|
||||
query.Set(param.Name, param.Value)
|
||||
}
|
||||
|
||||
// Update the request URL
|
||||
req.URL.RawQuery = query.Encode()
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// ModifyRequest adds configured parameters to token requests
|
||||
func (m *TokenModifier) ModifyRequest(req *http.Request) (*http.Request, error) {
|
||||
// Only modify POST requests
|
||||
if req.Method != http.MethodPost {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// Check if we have parameters to add
|
||||
if m.Config.Default.Path == nil {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
pathConfig, exists := m.Config.Default.Path["/token"]
|
||||
if !exists || len(pathConfig.AddBodyParams) == 0 {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
contentType := req.Header.Get("Content-Type")
|
||||
|
||||
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
|
||||
// Parse form data
|
||||
if err := req.ParseForm(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Clone form data
|
||||
formData := req.PostForm
|
||||
|
||||
// Add configured parameters
|
||||
for _, param := range pathConfig.AddBodyParams {
|
||||
formData.Set(param.Name, param.Value)
|
||||
}
|
||||
|
||||
// Create new request body with modified form
|
||||
formEncoded := formData.Encode()
|
||||
req.Body = io.NopCloser(strings.NewReader(formEncoded))
|
||||
req.ContentLength = int64(len(formEncoded))
|
||||
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(formEncoded)))
|
||||
|
||||
} else if strings.Contains(contentType, "application/json") {
|
||||
// Read body
|
||||
bodyBytes, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
var jsonData map[string]interface{}
|
||||
if err := json.Unmarshal(bodyBytes, &jsonData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add parameters
|
||||
for _, param := range pathConfig.AddBodyParams {
|
||||
jsonData[param.Name] = param.Value
|
||||
}
|
||||
|
||||
// Marshal back to JSON
|
||||
modifiedBody, err := json.Marshal(jsonData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update request
|
||||
req.Body = io.NopCloser(bytes.NewReader(modifiedBody))
|
||||
req.ContentLength = int64(len(modifiedBody))
|
||||
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody)))
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (m *RegisterModifier) ModifyRequest(req *http.Request) (*http.Request, error) {
|
||||
// Only modify POST requests
|
||||
if req.Method != http.MethodPost {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// Check if we have parameters to add
|
||||
if m.Config.Default.Path == nil {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
pathConfig, exists := m.Config.Default.Path["/register"]
|
||||
if !exists || len(pathConfig.AddBodyParams) == 0 {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
contentType := req.Header.Get("Content-Type")
|
||||
|
||||
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
|
||||
// Parse form data
|
||||
if err := req.ParseForm(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Clone form data
|
||||
formData := req.PostForm
|
||||
|
||||
// Add configured parameters
|
||||
for _, param := range pathConfig.AddBodyParams {
|
||||
formData.Set(param.Name, param.Value)
|
||||
}
|
||||
|
||||
// Create new request body with modified form
|
||||
formEncoded := formData.Encode()
|
||||
req.Body = io.NopCloser(strings.NewReader(formEncoded))
|
||||
req.ContentLength = int64(len(formEncoded))
|
||||
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(formEncoded)))
|
||||
|
||||
} else if strings.Contains(contentType, "application/json") {
|
||||
// Read body
|
||||
bodyBytes, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
var jsonData map[string]interface{}
|
||||
if err := json.Unmarshal(bodyBytes, &jsonData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add parameters
|
||||
for _, param := range pathConfig.AddBodyParams {
|
||||
jsonData[param.Name] = param.Value
|
||||
}
|
||||
|
||||
// Marshal back to JSON
|
||||
modifiedBody, err := json.Marshal(jsonData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update request
|
||||
req.Body = io.NopCloser(bytes.NewReader(modifiedBody))
|
||||
req.ContentLength = int64(len(modifiedBody))
|
||||
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody)))
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
|
@ -20,34 +20,87 @@ import (
|
|||
func NewRouter(cfg *config.Config, provider authz.Provider) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// 1. Custom well-known
|
||||
mux.HandleFunc("/.well-known/oauth-authorization-server", provider.WellKnownHandler())
|
||||
modifiers := map[string]RequestModifier{
|
||||
"/authorize": &AuthorizationModifier{Config: cfg},
|
||||
"/token": &TokenModifier{Config: cfg},
|
||||
"/register": &RegisterModifier{Config: cfg},
|
||||
}
|
||||
|
||||
// 2. Registration
|
||||
mux.HandleFunc("/register", provider.RegisterHandler())
|
||||
registeredPaths := make(map[string]bool)
|
||||
|
||||
// 3. Default "auth" paths, proxied
|
||||
defaultPaths := []string{"/authorize", "/token"}
|
||||
var defaultPaths []string
|
||||
|
||||
// Handle based on mode configuration
|
||||
if cfg.Mode == "demo" || cfg.Mode == "asgardeo" {
|
||||
// Demo/Asgardeo mode: Custom handlers for well-known and register
|
||||
mux.HandleFunc("/.well-known/oauth-authorization-server", provider.WellKnownHandler())
|
||||
registeredPaths["/.well-known/oauth-authorization-server"] = true
|
||||
|
||||
mux.HandleFunc("/register", provider.RegisterHandler())
|
||||
registeredPaths["/register"] = true
|
||||
|
||||
// Authorize and token will be proxied with parameter modification
|
||||
defaultPaths = []string{"/authorize", "/token"}
|
||||
} else {
|
||||
// Default provider mode
|
||||
if cfg.Default.Path != nil {
|
||||
// Check if we have custom response for well-known
|
||||
wellKnownConfig, exists := cfg.Default.Path["/.well-known/oauth-authorization-server"]
|
||||
if exists && wellKnownConfig.Response != nil {
|
||||
// If there's a custom response defined, use our handler
|
||||
mux.HandleFunc("/.well-known/oauth-authorization-server", provider.WellKnownHandler())
|
||||
registeredPaths["/.well-known/oauth-authorization-server"] = true
|
||||
} else {
|
||||
// No custom response, add well-known to proxy paths
|
||||
defaultPaths = append(defaultPaths, "/.well-known/oauth-authorization-server")
|
||||
}
|
||||
|
||||
defaultPaths = append(defaultPaths, "/authorize")
|
||||
defaultPaths = append(defaultPaths, "/token")
|
||||
defaultPaths = append(defaultPaths, "/register")
|
||||
} else {
|
||||
defaultPaths = []string{"/authorize", "/token", "/register", "/.well-known/oauth-authorization-server"}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates from defaultPaths
|
||||
uniquePaths := make(map[string]bool)
|
||||
cleanPaths := []string{}
|
||||
for _, path := range defaultPaths {
|
||||
mux.HandleFunc(path, buildProxyHandler(cfg))
|
||||
if !uniquePaths[path] {
|
||||
uniquePaths[path] = true
|
||||
cleanPaths = append(cleanPaths, path)
|
||||
}
|
||||
}
|
||||
defaultPaths = cleanPaths
|
||||
|
||||
for _, path := range defaultPaths {
|
||||
if !registeredPaths[path] {
|
||||
mux.HandleFunc(path, buildProxyHandler(cfg, modifiers))
|
||||
registeredPaths[path] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 4. MCP paths
|
||||
// MCP paths
|
||||
for _, path := range cfg.MCPPaths {
|
||||
mux.HandleFunc(path, buildProxyHandler(cfg))
|
||||
mux.HandleFunc(path, buildProxyHandler(cfg, modifiers))
|
||||
registeredPaths[path] = true
|
||||
}
|
||||
|
||||
// 5. If you want to map additional paths from config.PathMapping
|
||||
// to the same proxy logic:
|
||||
// Register paths from PathMapping that haven't been registered yet
|
||||
for path := range cfg.PathMapping {
|
||||
mux.HandleFunc(path, buildProxyHandler(cfg))
|
||||
if !registeredPaths[path] {
|
||||
mux.HandleFunc(path, buildProxyHandler(cfg, modifiers))
|
||||
registeredPaths[path] = true
|
||||
}
|
||||
}
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func buildProxyHandler(cfg *config.Config) http.HandlerFunc {
|
||||
func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier) http.HandlerFunc {
|
||||
// Parse the base URLs up front
|
||||
|
||||
authBase, err := url.Parse(cfg.AuthServerBaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid auth server URL: %v", err)
|
||||
|
@ -57,13 +110,6 @@ func buildProxyHandler(cfg *config.Config) http.HandlerFunc {
|
|||
log.Fatalf("Invalid MCP server URL: %v", err)
|
||||
}
|
||||
|
||||
// We'll define sets for known auth paths, SSE paths, etc.
|
||||
authPaths := map[string]bool{
|
||||
"/authorize": true,
|
||||
"/token": true,
|
||||
"/.well-known/oauth-authorization-server": true,
|
||||
}
|
||||
|
||||
// Detect SSE paths from config
|
||||
ssePaths := make(map[string]bool)
|
||||
for _, p := range cfg.MCPPaths {
|
||||
|
@ -73,23 +119,38 @@ func buildProxyHandler(cfg *config.Config) http.HandlerFunc {
|
|||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
allowedOrigin := getAllowedOrigin(origin, cfg)
|
||||
// Handle OPTIONS
|
||||
if r.Method == http.MethodOptions {
|
||||
addCORSHeaders(w)
|
||||
if allowedOrigin == "" {
|
||||
log.Printf("[proxy] Preflight request from disallowed origin: %s", origin)
|
||||
http.Error(w, "CORS origin not allowed", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
addCORSHeaders(w, cfg, allowedOrigin, r.Header.Get("Access-Control-Request-Headers"))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
addCORSHeaders(w)
|
||||
if allowedOrigin == "" {
|
||||
log.Printf("[proxy] Request from disallowed origin: %s for %s", origin, r.URL.Path)
|
||||
http.Error(w, "CORS origin not allowed", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Add CORS headers to all responses
|
||||
addCORSHeaders(w, cfg, allowedOrigin, "")
|
||||
|
||||
// Decide whether the request should go to the auth server or MCP
|
||||
var targetURL *url.URL
|
||||
isSSE := false
|
||||
|
||||
if authPaths[r.URL.Path] {
|
||||
if isAuthPath(r.URL.Path) {
|
||||
targetURL = authBase
|
||||
} else if isMCPPath(r.URL.Path, cfg) {
|
||||
// Validate JWT if you want
|
||||
// Validate JWT for MCP paths if required
|
||||
// Placeholder for JWT validation logic
|
||||
if err := util.ValidateJWT(r.Header.Get("Authorization")); err != nil {
|
||||
log.Printf("[proxy] Unauthorized request to %s: %v", r.URL.Path, err)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
|
@ -100,11 +161,21 @@ func buildProxyHandler(cfg *config.Config) http.HandlerFunc {
|
|||
isSSE = true
|
||||
}
|
||||
} else {
|
||||
// If it's not recognized as an auth path or an MCP path
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Apply request modifiers to add parameters
|
||||
if modifier, exists := modifiers[r.URL.Path]; exists {
|
||||
var err error
|
||||
r, err = modifier.ModifyRequest(r)
|
||||
if err != nil {
|
||||
log.Printf("[proxy] Error modifying request: %v", err)
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Build the reverse proxy
|
||||
rp := &httputil.ReverseProxy{
|
||||
Director: func(req *http.Request) {
|
||||
|
@ -120,23 +191,27 @@ func buildProxyHandler(cfg *config.Config) http.HandlerFunc {
|
|||
req.URL.RawQuery = r.URL.RawQuery
|
||||
req.Host = targetURL.Host
|
||||
|
||||
for header, values := range r.Header {
|
||||
cleanHeaders := http.Header{}
|
||||
|
||||
for k, v := range r.Header {
|
||||
// Skip hop-by-hop headers
|
||||
if strings.EqualFold(header, "Connection") ||
|
||||
strings.EqualFold(header, "Keep-Alive") ||
|
||||
strings.EqualFold(header, "Transfer-Encoding") ||
|
||||
strings.EqualFold(header, "Upgrade") ||
|
||||
strings.EqualFold(header, "Proxy-Authorization") ||
|
||||
strings.EqualFold(header, "Proxy-Connection") {
|
||||
if skipHeader(k) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
req.Header.Set(header, value)
|
||||
}
|
||||
// Set only the first value to avoid duplicates
|
||||
cleanHeaders.Set(k, v[0])
|
||||
}
|
||||
|
||||
req.Header = cleanHeaders
|
||||
|
||||
log.Printf("[proxy] %s -> %s%s", r.URL.Path, req.URL.Host, req.URL.Path)
|
||||
},
|
||||
ModifyResponse: func(resp *http.Response) error {
|
||||
log.Printf("[proxy] Response from %s%s: %d", resp.Request.URL.Host, resp.Request.URL.Path, resp.StatusCode)
|
||||
resp.Header.Del("Access-Control-Allow-Origin") // Avoid upstream conflicts
|
||||
return nil
|
||||
},
|
||||
ErrorHandler: func(rw http.ResponseWriter, req *http.Request, err error) {
|
||||
log.Printf("[proxy] Error proxying: %v", err)
|
||||
http.Error(rw, "Bad Gateway", http.StatusBadGateway)
|
||||
|
@ -156,13 +231,47 @@ func buildProxyHandler(cfg *config.Config) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func addCORSHeaders(w http.ResponseWriter) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
func getAllowedOrigin(origin string, cfg *config.Config) string {
|
||||
if origin == "" {
|
||||
return cfg.CORSConfig.AllowedOrigins[0] // Default to first allowed origin
|
||||
}
|
||||
for _, allowed := range cfg.CORSConfig.AllowedOrigins {
|
||||
if allowed == origin {
|
||||
return allowed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// addCORSHeaders adds configurable CORS headers
|
||||
func addCORSHeaders(w http.ResponseWriter, cfg *config.Config, allowedOrigin, requestHeaders string) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", strings.Join(cfg.CORSConfig.AllowedMethods, ", "))
|
||||
if requestHeaders != "" {
|
||||
w.Header().Set("Access-Control-Allow-Headers", requestHeaders)
|
||||
} else {
|
||||
w.Header().Set("Access-Control-Allow-Headers", strings.Join(cfg.CORSConfig.AllowedHeaders, ", "))
|
||||
}
|
||||
if cfg.CORSConfig.AllowCredentials {
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
w.Header().Set("Vary", "Origin")
|
||||
}
|
||||
|
||||
func isAuthPath(path string) bool {
|
||||
authPaths := map[string]bool{
|
||||
"/authorize": true,
|
||||
"/token": true,
|
||||
"/register": true,
|
||||
"/.well-known/oauth-authorization-server": true,
|
||||
}
|
||||
if strings.HasPrefix(path, "/u/") {
|
||||
return true
|
||||
}
|
||||
return authPaths[path]
|
||||
}
|
||||
|
||||
// isMCPPath checks if the path is an MCP path
|
||||
func isMCPPath(path string, cfg *config.Config) bool {
|
||||
for _, p := range cfg.MCPPaths {
|
||||
if strings.HasPrefix(path, p) {
|
||||
|
@ -172,22 +281,10 @@ func isMCPPath(path string, cfg *config.Config) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func copyHeaders(src http.Header, dst http.Header) {
|
||||
// Exclude hop-by-hop
|
||||
hopByHop := map[string]bool{
|
||||
"Connection": true,
|
||||
"Keep-Alive": true,
|
||||
"Transfer-Encoding": true,
|
||||
"Upgrade": true,
|
||||
"Proxy-Authorization": true,
|
||||
"Proxy-Connection": true,
|
||||
}
|
||||
for k, vv := range src {
|
||||
if hopByHop[strings.ToLower(k)] {
|
||||
continue
|
||||
}
|
||||
for _, v := range vv {
|
||||
dst.Add(k, v)
|
||||
}
|
||||
func skipHeader(h string) bool {
|
||||
switch strings.ToLower(h) {
|
||||
case "connection", "keep-alive", "transfer-encoding", "upgrade", "proxy-authorization", "proxy-connection", "te", "trailer":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
24
resources/echo_server.py
Normal file
24
resources/echo_server.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("Echo")
|
||||
|
||||
|
||||
@mcp.resource("echo://{message}")
|
||||
def echo_resource(message: str) -> str:
|
||||
"""Echo a message as a resource"""
|
||||
return f"Resource echo: {message}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def echo_tool(message: str) -> str:
|
||||
"""Echo a message as a tool"""
|
||||
return f"Tool echo: {message}"
|
||||
|
||||
|
||||
@mcp.prompt()
|
||||
def echo_prompt(message: str) -> str:
|
||||
"""Create an echo prompt"""
|
||||
return f"Please process this message: {message}"
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="sse")
|
Loading…
Add table
Add a link
Reference in a new issue