mirror of
https://github.com/wso2/open-mcp-auth-proxy.git
synced 2025-08-17 20:03:08 +00:00
Merge pull request #40 from pavinduLakshan/nipuni-new-spec
This commit is contained in:
commit
56d969b785
19 changed files with 750 additions and 297 deletions
62
CONTRIBUTING.md
Normal file
62
CONTRIBUTING.md
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
# Contributing
|
||||||
|
|
||||||
|
## Build from Source
|
||||||
|
|
||||||
|
> Prerequisites
|
||||||
|
>
|
||||||
|
> * Go 1.20 or higher
|
||||||
|
> * Git
|
||||||
|
> * Make (optional, for simplified builds)
|
||||||
|
|
||||||
|
1. **Clone the repository:**
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/wso2/open-mcp-auth-proxy
|
||||||
|
cd open-mcp-auth-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies:**
|
||||||
|
```bash
|
||||||
|
go get -v -t -d ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Build the application:**
|
||||||
|
|
||||||
|
**Option A: Using Make**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build for all platforms
|
||||||
|
make all
|
||||||
|
|
||||||
|
# Or build for specific platforms
|
||||||
|
make build-linux # For Linux (x86_64)
|
||||||
|
make build-linux-arm # For ARM-based Linux
|
||||||
|
make build-darwin # For macOS
|
||||||
|
make build-windows # For Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B: Manual build (works on all platforms)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build for your current platform
|
||||||
|
go build -o openmcpauthproxy ./cmd/proxy
|
||||||
|
|
||||||
|
# Cross-compile for other platforms
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o openmcpauthproxy-linux ./cmd/proxy
|
||||||
|
GOOS=windows GOARCH=amd64 go build -o openmcpauthproxy.exe ./cmd/proxy
|
||||||
|
GOOS=darwin GOARCH=amd64 go build -o openmcpauthproxy-macos ./cmd/proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
After building, you'll find the executables in the `build` directory (when using Make) or in your project root (when building manually).
|
||||||
|
|
||||||
|
### Additional Make Targets
|
||||||
|
|
||||||
|
If you're using Make, these additional targets are available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # Run tests
|
||||||
|
make coverage # Run tests with coverage report
|
||||||
|
make fmt # Format code with gofmt
|
||||||
|
make vet # Run go vet
|
||||||
|
make clean # Clean build artifacts
|
||||||
|
make help # Show all available targets
|
||||||
|
```
|
264
README.md
264
README.md
|
@ -10,70 +10,40 @@ A lightweight authorization proxy for Model Context Protocol (MCP) servers that
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## What it Does
|
## 🚀 Features
|
||||||
|
|
||||||
Open MCP Auth Proxy sits between MCP clients and your MCP server to:
|
- **Dynamic Authorization**: based on MCP Authorization Specification.
|
||||||
|
- **JWT Validation**: Validates the token’s signature, checks the `audience` claim, and enforces scope requirements.
|
||||||
|
- **Identity Provider Integration**: Supports integrating any OAuth/OIDC provider such as Asgardeo, Auth0, Keycloak, etc.
|
||||||
|
- **Protocol Version Negotiation**: via `MCP-Protocol-Version` header.
|
||||||
|
- **Flexible Transport Modes**: Supports STDIO, SSE and streamable HTTP transport options.
|
||||||
|
|
||||||
- Intercept incoming requests
|
## 🛠️ Quick Start
|
||||||
- Validate authorization tokens
|
|
||||||
- Offload authentication and authorization to OAuth-compliant Identity Providers
|
|
||||||
- Support the MCP authorization protocol
|
|
||||||
|
|
||||||
## Quick Start
|
> **Prerequisites**
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
* Go 1.20 or higher
|
|
||||||
* A running MCP server
|
|
||||||
|
|
||||||
> If you don't have an MCP server, you can use the included example:
|
|
||||||
>
|
|
||||||
> 1. Navigate to the `resources` directory
|
|
||||||
> 2. Set up a Python environment:
|
|
||||||
>
|
>
|
||||||
> ```bash
|
> * A running MCP server (Use the [example MCP server](resources/README.md) if you don't have an MCP server already)
|
||||||
> python3 -m venv .venv
|
> * An MCP client that supports MCP authorization specification
|
||||||
> source .venv/bin/activate
|
|
||||||
> pip3 install -r requirements.txt
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> 3. Start the example server:
|
|
||||||
>
|
|
||||||
> ```bash
|
|
||||||
> python3 echo_server.py
|
|
||||||
> ```
|
|
||||||
|
|
||||||
* An MCP client that supports MCP authorization
|
|
||||||
|
|
||||||
### Basic Usage
|
|
||||||
|
|
||||||
1. Download the latest release from [Github releases](https://github.com/wso2/open-mcp-auth-proxy/releases/latest).
|
1. Download the latest release from [Github releases](https://github.com/wso2/open-mcp-auth-proxy/releases/latest).
|
||||||
|
|
||||||
2. Start the proxy in demo mode (uses pre-configured authentication with Asgardeo sandbox):
|
2. Start the proxy in demo mode (uses pre-configured authentication with Asgardeo sandbox):
|
||||||
|
|
||||||
#### Linux/macOS:
|
- Linux/macOS:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./openmcpauthproxy --demo
|
./openmcpauthproxy --demo
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Windows:
|
- Windows:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\openmcpauthproxy.exe --demo
|
.\openmcpauthproxy.exe --demo
|
||||||
```
|
```
|
||||||
|
|
||||||
> The repository comes with a default `config.yaml` file that contains the basic configuration:
|
3. Connect using an MCP client like [MCP Inspector](https://github.com/modelcontextprotocol/inspector).
|
||||||
>
|
|
||||||
> ```yaml
|
|
||||||
> listen_port: 8080
|
|
||||||
> base_url: "http://localhost:8000" # Your MCP server URL
|
|
||||||
> paths:
|
|
||||||
> sse: "/sse"
|
|
||||||
> messages: "/messages/"
|
|
||||||
> ```
|
|
||||||
|
|
||||||
3. Connect using an MCP client like [MCP Inspector](https://github.com/shashimalcse/inspector)(This is a temporary fork with fixes for authentication [issues](https://github.com/modelcontextprotocol/typescript-sdk/issues/257) in the original implementation)
|
## 🔒 Integrate an Identity Provider
|
||||||
|
|
||||||
## Connect an Identity Provider
|
|
||||||
|
|
||||||
### Asgardeo
|
### Asgardeo
|
||||||
|
|
||||||
|
@ -81,22 +51,26 @@ To enable authorization through your Asgardeo organization:
|
||||||
|
|
||||||
1. [Register](https://asgardeo.io/signup) and create an organization in Asgardeo
|
1. [Register](https://asgardeo.io/signup) and create an organization in Asgardeo
|
||||||
2. Create an [M2M application](https://wso2.com/asgardeo/docs/guides/applications/register-machine-to-machine-app/)
|
2. 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
|
3. [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
|
||||||

|

|
||||||
|
|
||||||
3. Update `config.yaml` with the following parameters.
|
4. Update `config.yaml` with the following parameters.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
base_url: "http://localhost:8000" # URL of your MCP server
|
base_url: "http://localhost:8000" # URL of your MCP server
|
||||||
listen_port: 8080 # Address where the proxy will listen
|
listen_port: 8080 # Address where the proxy will listen
|
||||||
|
|
||||||
asgardeo:
|
resource_identifier: "http://localhost:8080" # Proxy server URL
|
||||||
org_name: "<org_name>" # Your Asgardeo org name
|
scopes_supported: # Scopes required to defined for the MCP server
|
||||||
client_id: "<client_id>" # Client ID of the M2M app
|
- "read:tools"
|
||||||
client_secret: "<client_secret>" # Client secret of the M2M app
|
- "read:resources"
|
||||||
|
audience: "<audience_value>" # Access token audience
|
||||||
|
authorization_servers: # Authorization server issuer identifier(s)
|
||||||
|
- "https://api.asgardeo.io/t/acme"
|
||||||
|
jwks_uri: "https://api.asgardeo.io/t/acme/oauth2/jwks" # JWKS URL
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Start the proxy with Asgardeo integration:
|
5. Start the proxy with Asgardeo integration:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./openmcpauthproxy --asgardeo
|
./openmcpauthproxy --asgardeo
|
||||||
|
@ -107,59 +81,24 @@ asgardeo:
|
||||||
- [Auth0](docs/integrations/Auth0.md)
|
- [Auth0](docs/integrations/Auth0.md)
|
||||||
- [Keycloak](docs/integrations/keycloak.md)
|
- [Keycloak](docs/integrations/keycloak.md)
|
||||||
|
|
||||||
# Advanced Configuration
|
## Transport Modes
|
||||||
|
|
||||||
### Transport Modes
|
### **STDIO Mode**
|
||||||
|
|
||||||
The proxy supports two transport modes:
|
|
||||||
|
|
||||||
- **SSE Mode (Default)**: For Server-Sent Events transport
|
|
||||||
- **stdio Mode**: For MCP servers that use stdio transport
|
|
||||||
|
|
||||||
When using stdio mode, the proxy:
|
When using stdio mode, the proxy:
|
||||||
- Starts an MCP server as a subprocess using the command specified in the configuration
|
- Starts an MCP server as a subprocess using the command specified in the configuration
|
||||||
- Communicates with the subprocess through standard input/output (stdio)
|
- Communicates with the subprocess through standard input/output (stdio)
|
||||||
- **Note**: Any commands specified (like `npx` in the example below) must be installed on your system first
|
|
||||||
|
|
||||||
To use stdio mode:
|
> **Note**: Any commands specified (like `npx` in the example below) must be installed on your system first
|
||||||
|
|
||||||
```bash
|
|
||||||
./openmcpauthproxy --demo --stdio
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Example: Running an MCP Server as a Subprocess
|
|
||||||
|
|
||||||
1. Configure stdio mode in your `config.yaml`:
|
1. Configure stdio mode in your `config.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
listen_port: 8080
|
|
||||||
base_url: "http://localhost:8000"
|
|
||||||
|
|
||||||
stdio:
|
stdio:
|
||||||
enabled: true
|
enabled: true
|
||||||
user_command: "npx -y @modelcontextprotocol/server-github" # Example using a GitHub MCP server
|
user_command: "npx -y @modelcontextprotocol/server-github" # Example using a GitHub MCP server
|
||||||
env: # Environment variables (optional)
|
env: # Environment variables (optional)
|
||||||
- "GITHUB_PERSONAL_ACCESS_TOKEN=gitPAT"
|
- "GITHUB_PERSONAL_ACCESS_TOKEN=gitPAT"
|
||||||
|
|
||||||
# CORS configuration
|
|
||||||
cors:
|
|
||||||
allowed_origins:
|
|
||||||
- "http://localhost:5173" # Origin of your client application
|
|
||||||
allowed_methods:
|
|
||||||
- "GET"
|
|
||||||
- "POST"
|
|
||||||
- "PUT"
|
|
||||||
- "DELETE"
|
|
||||||
allowed_headers:
|
|
||||||
- "Authorization"
|
|
||||||
- "Content-Type"
|
|
||||||
allow_credentials: true
|
|
||||||
|
|
||||||
# Demo configuration for Asgardeo
|
|
||||||
demo:
|
|
||||||
org_name: "openmcpauthdemo"
|
|
||||||
client_id: "N0U9e_NNGr9mP_0fPnPfPI0a6twa"
|
|
||||||
client_secret: "qFHfiBp5gNGAO9zV4YPnDofBzzfInatfUbHyPZvM0jka"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Run the proxy with stdio mode:
|
2. Run the proxy with stdio mode:
|
||||||
|
@ -168,128 +107,10 @@ demo:
|
||||||
./openmcpauthproxy --demo
|
./openmcpauthproxy --demo
|
||||||
```
|
```
|
||||||
|
|
||||||
The proxy will:
|
- **SSE Mode (Default)**: For Server-Sent Events transport
|
||||||
- Start the MCP server as a subprocess using the specified command
|
- **Streamable HTTP Mode**: For Streamable HTTP transport
|
||||||
- Handle all authorization requirements
|
|
||||||
- Forward messages between clients and the server
|
|
||||||
|
|
||||||
### Complete Configuration Reference
|
## Available Command Line Options
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Common configuration
|
|
||||||
listen_port: 8080
|
|
||||||
base_url: "http://localhost:8000"
|
|
||||||
port: 8000
|
|
||||||
|
|
||||||
# Path configuration
|
|
||||||
paths:
|
|
||||||
sse: "/sse"
|
|
||||||
messages: "/messages/"
|
|
||||||
|
|
||||||
# Transport mode
|
|
||||||
transport_mode: "sse" # Options: "sse" or "stdio"
|
|
||||||
|
|
||||||
# stdio-specific configuration (used only in stdio mode)
|
|
||||||
stdio:
|
|
||||||
enabled: true
|
|
||||||
user_command: "npx -y @modelcontextprotocol/server-github" # Command to start the MCP server (requires npx to be installed)
|
|
||||||
work_dir: "" # Optional working directory for the subprocess
|
|
||||||
|
|
||||||
# CORS configuration
|
|
||||||
cors:
|
|
||||||
allowed_origins:
|
|
||||||
- "http://localhost:5173"
|
|
||||||
allowed_methods:
|
|
||||||
- "GET"
|
|
||||||
- "POST"
|
|
||||||
- "PUT"
|
|
||||||
- "DELETE"
|
|
||||||
allowed_headers:
|
|
||||||
- "Authorization"
|
|
||||||
- "Content-Type"
|
|
||||||
allow_credentials: true
|
|
||||||
|
|
||||||
# Demo configuration for Asgardeo
|
|
||||||
demo:
|
|
||||||
org_name: "openmcpauthdemo"
|
|
||||||
client_id: "N0U9e_NNGr9mP_0fPnPfPI0a6twa"
|
|
||||||
client_secret: "qFHfiBp5gNGAO9zV4YPnDofBzzfInatfUbHyPZvM0jka"
|
|
||||||
|
|
||||||
# Asgardeo configuration (used with --asgardeo flag)
|
|
||||||
asgardeo:
|
|
||||||
org_name: "<org_name>"
|
|
||||||
client_id: "<client_id>"
|
|
||||||
client_secret: "<client_secret>"
|
|
||||||
```
|
|
||||||
## Build from Source
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
* Go 1.20 or higher
|
|
||||||
* Git
|
|
||||||
* Make (optional, for simplified builds)
|
|
||||||
|
|
||||||
### Clone and Build
|
|
||||||
|
|
||||||
1. **Clone the repository:**
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/wso2/open-mcp-auth-proxy
|
|
||||||
cd open-mcp-auth-proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Install dependencies:**
|
|
||||||
```bash
|
|
||||||
go get -v -t -d ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Build the application:**
|
|
||||||
|
|
||||||
**Option A: Using Make**
|
|
||||||
```bash
|
|
||||||
# Build for all platforms
|
|
||||||
make all
|
|
||||||
|
|
||||||
# Or build for specific platforms
|
|
||||||
make build-linux # For Linux (x86_64)
|
|
||||||
make build-linux-arm # For ARM-based Linux
|
|
||||||
make build-darwin # For macOS
|
|
||||||
make build-windows # For Windows
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option B: Manual build (works on all platforms)**
|
|
||||||
```bash
|
|
||||||
# Build for your current platform
|
|
||||||
go build -o openmcpauthproxy ./cmd/proxy
|
|
||||||
|
|
||||||
# Cross-compile for other platforms
|
|
||||||
GOOS=linux GOARCH=amd64 go build -o openmcpauthproxy-linux ./cmd/proxy
|
|
||||||
GOOS=windows GOARCH=amd64 go build -o openmcpauthproxy.exe ./cmd/proxy
|
|
||||||
GOOS=darwin GOARCH=amd64 go build -o openmcpauthproxy-macos ./cmd/proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run the Built Application
|
|
||||||
|
|
||||||
After building, you'll find the executables in the `build` directory (when using Make) or in your project root (when building manually).
|
|
||||||
|
|
||||||
**Linux/macOS:**
|
|
||||||
```bash
|
|
||||||
# If built with Make
|
|
||||||
./build/linux/openmcpauthproxy --demo
|
|
||||||
|
|
||||||
# If built manually
|
|
||||||
./openmcpauthproxy --demo
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windows:**
|
|
||||||
```powershell
|
|
||||||
# If built with Make
|
|
||||||
.\build\windows\openmcpauthproxy.exe --demo
|
|
||||||
|
|
||||||
# If built manually
|
|
||||||
.\openmcpauthproxy.exe --demo
|
|
||||||
```
|
|
||||||
|
|
||||||
### Available Command Line Options
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start in demo mode (using Asgardeo sandbox)
|
# Start in demo mode (using Asgardeo sandbox)
|
||||||
|
@ -308,15 +129,6 @@ After building, you'll find the executables in the `build` directory (when using
|
||||||
./openmcpauthproxy --help
|
./openmcpauthproxy --help
|
||||||
```
|
```
|
||||||
|
|
||||||
### Additional Make Targets
|
## Contributing
|
||||||
|
|
||||||
If you're using Make, these additional targets are available:
|
We appreciate your contributions, whether it is improving documentation, adding new features, or fixing bugs. To get started, please refer to our [contributing guide](CONTRIBUTING.md).
|
||||||
|
|
||||||
```bash
|
|
||||||
make test # Run tests
|
|
||||||
make coverage # Run tests with coverage report
|
|
||||||
make fmt # Format code with gofmt
|
|
||||||
make vet # Run go vet
|
|
||||||
make clean # Clean build artifacts
|
|
||||||
make help # Show all available targets
|
|
||||||
```
|
|
||||||
|
|
|
@ -11,7 +11,6 @@ import (
|
||||||
|
|
||||||
"github.com/wso2/open-mcp-auth-proxy/internal/authz"
|
"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/config"
|
||||||
"github.com/wso2/open-mcp-auth-proxy/internal/constants"
|
|
||||||
"github.com/wso2/open-mcp-auth-proxy/internal/logging"
|
"github.com/wso2/open-mcp-auth-proxy/internal/logging"
|
||||||
"github.com/wso2/open-mcp-auth-proxy/internal/proxy"
|
"github.com/wso2/open-mcp-auth-proxy/internal/proxy"
|
||||||
"github.com/wso2/open-mcp-auth-proxy/internal/subprocess"
|
"github.com/wso2/open-mcp-auth-proxy/internal/subprocess"
|
||||||
|
@ -68,23 +67,7 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Create the chosen provider
|
// 3. Create the chosen provider
|
||||||
var provider authz.Provider
|
var provider authz.Provider = MakeProvider(cfg, *demoMode, *asgardeoMode)
|
||||||
if *demoMode {
|
|
||||||
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)
|
|
||||||
} else {
|
|
||||||
cfg.Mode = "default"
|
|
||||||
cfg.JWKSURL = cfg.Default.JWKSURL
|
|
||||||
cfg.AuthServerBaseURL = cfg.Default.BaseURL
|
|
||||||
provider = authz.NewDefaultProvider(cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. (Optional) Fetch JWKS if you want local JWT validation
|
// 4. (Optional) Fetch JWKS if you want local JWT validation
|
||||||
if err := util.FetchJWKS(cfg.JWKSURL); err != nil {
|
if err := util.FetchJWKS(cfg.JWKSURL); err != nil {
|
||||||
|
@ -92,12 +75,15 @@ func main() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Build the main router
|
// 5. (Optional) Build the access controler
|
||||||
mux := proxy.NewRouter(cfg, provider)
|
accessController := &authz.ScopeValidator{}
|
||||||
|
|
||||||
|
// 6. Build the main router
|
||||||
|
mux := proxy.NewRouter(cfg, provider, accessController)
|
||||||
|
|
||||||
listen_address := fmt.Sprintf(":%d", cfg.ListenPort)
|
listen_address := fmt.Sprintf(":%d", cfg.ListenPort)
|
||||||
|
|
||||||
// 6. Start the server
|
// 7. Start the server
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: listen_address,
|
Addr: listen_address,
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
|
@ -111,18 +97,18 @@ func main() {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 7. Wait for shutdown signal
|
// 8. Wait for shutdown signal
|
||||||
stop := make(chan os.Signal, 1)
|
stop := make(chan os.Signal, 1)
|
||||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||||
<-stop
|
<-stop
|
||||||
logger.Info("Shutting down...")
|
logger.Info("Shutting down...")
|
||||||
|
|
||||||
// 8. First terminate subprocess if running
|
// 9. First terminate subprocess if running
|
||||||
if procManager != nil && procManager.IsRunning() {
|
if procManager != nil && procManager.IsRunning() {
|
||||||
procManager.Shutdown()
|
procManager.Shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Then shutdown the server
|
// 10. Then shutdown the server
|
||||||
logger.Info("Shutting down HTTP server...")
|
logger.Info("Shutting down HTTP server...")
|
||||||
shutdownCtx, cancel := proxy.NewShutdownContext(5 * time.Second)
|
shutdownCtx, cancel := proxy.NewShutdownContext(5 * time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
45
cmd/proxy/provider.go
Normal file
45
cmd/proxy/provider.go
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MakeProvider(cfg *config.Config, demoMode, asgardeoMode bool) authz.Provider {
|
||||||
|
var mode, orgName string
|
||||||
|
switch {
|
||||||
|
case demoMode:
|
||||||
|
mode = "demo"
|
||||||
|
orgName = cfg.Demo.OrgName
|
||||||
|
case asgardeoMode:
|
||||||
|
mode = "asgardeo"
|
||||||
|
orgName = cfg.Asgardeo.OrgName
|
||||||
|
default:
|
||||||
|
mode = "default"
|
||||||
|
}
|
||||||
|
cfg.Mode = mode
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case "demo", "asgardeo":
|
||||||
|
if len(cfg.ProtectedResourceMetadata.AuthorizationServers) == 0 && cfg.ProtectedResourceMetadata.JwksURI == "" {
|
||||||
|
base := constants.ASGARDEO_BASE_URL + orgName + "/oauth2"
|
||||||
|
cfg.AuthServerBaseURL = base
|
||||||
|
cfg.JWKSURL = base + "/jwks"
|
||||||
|
} else {
|
||||||
|
cfg.AuthServerBaseURL = cfg.ProtectedResourceMetadata.AuthorizationServers[0]
|
||||||
|
cfg.JWKSURL = cfg.ProtectedResourceMetadata.JwksURI
|
||||||
|
}
|
||||||
|
return authz.NewAsgardeoProvider(cfg)
|
||||||
|
|
||||||
|
default:
|
||||||
|
if cfg.Default.BaseURL != "" && cfg.Default.JWKSURL != "" {
|
||||||
|
cfg.AuthServerBaseURL = cfg.Default.BaseURL
|
||||||
|
cfg.JWKSURL = cfg.Default.JWKSURL
|
||||||
|
} else if len(cfg.ProtectedResourceMetadata.AuthorizationServers) > 0 {
|
||||||
|
cfg.AuthServerBaseURL = cfg.ProtectedResourceMetadata.AuthorizationServers[0]
|
||||||
|
cfg.JWKSURL = cfg.ProtectedResourceMetadata.JwksURI
|
||||||
|
}
|
||||||
|
return authz.NewDefaultProvider(cfg)
|
||||||
|
}
|
||||||
|
}
|
23
config.yaml
23
config.yaml
|
@ -1,9 +1,10 @@
|
||||||
# config.yaml
|
# config.yaml
|
||||||
|
|
||||||
# Common configuration for all transport modes
|
# Common configuration for all transport modes
|
||||||
|
proxy_base_url: http://localhost:8080
|
||||||
listen_port: 8080
|
listen_port: 8080
|
||||||
base_url: "http://localhost:3001" # Base URL for the MCP server
|
base_url: "http://localhost:8000" # Base URL for the MCP server
|
||||||
port: 3001 # Port for the MCP server
|
port: 8000 # Port for the MCP server
|
||||||
timeout_seconds: 10
|
timeout_seconds: 10
|
||||||
|
|
||||||
# Path configuration
|
# Path configuration
|
||||||
|
@ -17,7 +18,7 @@ transport_mode: "sse" # Options: "sse" or "stdio"
|
||||||
|
|
||||||
# stdio-specific configuration (used only when transport_mode is "stdio")
|
# stdio-specific configuration (used only when transport_mode is "stdio")
|
||||||
stdio:
|
stdio:
|
||||||
enabled: true
|
enabled: false
|
||||||
user_command: "npx -y @modelcontextprotocol/server-github"
|
user_command: "npx -y @modelcontextprotocol/server-github"
|
||||||
work_dir: "" # Working directory (optional)
|
work_dir: "" # Working directory (optional)
|
||||||
# env: # Environment variables (optional)
|
# env: # Environment variables (optional)
|
||||||
|
@ -30,6 +31,7 @@ path_mapping:
|
||||||
cors:
|
cors:
|
||||||
allowed_origins:
|
allowed_origins:
|
||||||
- "http://127.0.0.1:6274"
|
- "http://127.0.0.1:6274"
|
||||||
|
- "http://localhost:6274"
|
||||||
allowed_methods:
|
allowed_methods:
|
||||||
- "GET"
|
- "GET"
|
||||||
- "POST"
|
- "POST"
|
||||||
|
@ -46,3 +48,18 @@ demo:
|
||||||
org_name: "openmcpauthdemo"
|
org_name: "openmcpauthdemo"
|
||||||
client_id: "N0U9e_NNGr9mP_0fPnPfPI0a6twa"
|
client_id: "N0U9e_NNGr9mP_0fPnPfPI0a6twa"
|
||||||
client_secret: "qFHfiBp5gNGAO9zV4YPnDofBzzfInatfUbHyPZvM0jka"
|
client_secret: "qFHfiBp5gNGAO9zV4YPnDofBzzfInatfUbHyPZvM0jka"
|
||||||
|
|
||||||
|
protected_resource_metadata:
|
||||||
|
resource_identifier: http://localhost:8080/sse
|
||||||
|
audience: 2xGW_poFYoObUE_vUQxvGdPSUPwa
|
||||||
|
scopes_supported:
|
||||||
|
- initialize: "mcp_init"
|
||||||
|
- tools/call:
|
||||||
|
- echo_tool: "mcp_echo_tool"
|
||||||
|
authorization_servers:
|
||||||
|
- https://api.asgardeo.io/t/openmcpauthdemo/oauth2/token
|
||||||
|
jwks_uri: https://api.asgardeo.io/t/openmcpauthdemo/oauth2/jwks
|
||||||
|
bearer_methods_supported:
|
||||||
|
- header
|
||||||
|
- body
|
||||||
|
- query
|
||||||
|
|
24
internal/authz/access_control.go
Normal file
24
internal/authz/access_control.go
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
package authz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
"github.com/wso2/open-mcp-auth-proxy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Decision int
|
||||||
|
|
||||||
|
const (
|
||||||
|
DecisionAllow Decision = iota
|
||||||
|
DecisionDeny
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccessControlResult struct {
|
||||||
|
Decision Decision
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccessControl interface {
|
||||||
|
ValidateAccess(r *http.Request, claims *jwt.MapClaims, config *config.Config) AccessControlResult
|
||||||
|
}
|
|
@ -194,7 +194,7 @@ func (p *asgardeoProvider) createAsgardeoApplication(regReq RegisterRequest) err
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
return fmt.Errorf("Asgardeo creation error (%d): %s", resp.StatusCode, string(respBody))
|
return fmt.Errorf("asgardeo creation error (%d): %s", resp.StatusCode, string(respBody))
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("Created Asgardeo application for clientID=%s", regReq.ClientID)
|
logger.Info("Created Asgardeo application for clientID=%s", regReq.ClientID)
|
||||||
|
@ -363,3 +363,48 @@ func randomString(n int) string {
|
||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *asgardeoProvider) ProtectedResourceMetadataHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Extract only the values into a []string
|
||||||
|
var supportedScopes []string
|
||||||
|
var extractStrings func(interface{})
|
||||||
|
extractStrings = func(val interface{}) {
|
||||||
|
switch v := val.(type) {
|
||||||
|
case string:
|
||||||
|
supportedScopes = append(supportedScopes, v)
|
||||||
|
case []any:
|
||||||
|
for _, item := range v {
|
||||||
|
extractStrings(item)
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
for _, item := range v {
|
||||||
|
extractStrings(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range p.cfg.ProtectedResourceMetadata.ScopesSupported {
|
||||||
|
for _, v := range m {
|
||||||
|
extractStrings(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := map[string]interface{}{
|
||||||
|
"resource": p.cfg.ProtectedResourceMetadata.ResourceIdentifier,
|
||||||
|
"scopes_supported": supportedScopes,
|
||||||
|
"authorization_servers": p.cfg.ProtectedResourceMetadata.AuthorizationServers,
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.cfg.ProtectedResourceMetadata.JwksURI != "" {
|
||||||
|
meta["jwks_uri"] = p.cfg.ProtectedResourceMetadata.JwksURI
|
||||||
|
}
|
||||||
|
if len(p.cfg.ProtectedResourceMetadata.BearerMethodsSupported) > 0 {
|
||||||
|
meta["bearer_methods_supported"] = p.cfg.ProtectedResourceMetadata.BearerMethodsSupported
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(w).Encode(meta); err != nil {
|
||||||
|
http.Error(w, "failed to encode metadata", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/wso2/open-mcp-auth-proxy/internal/config"
|
"github.com/wso2/open-mcp-auth-proxy/internal/config"
|
||||||
"github.com/wso2/open-mcp-auth-proxy/internal/logging"
|
logger "github.com/wso2/open-mcp-auth-proxy/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
type defaultProvider struct {
|
type defaultProvider struct {
|
||||||
|
@ -94,3 +94,26 @@ func (p *defaultProvider) WellKnownHandler() http.HandlerFunc {
|
||||||
func (p *defaultProvider) RegisterHandler() http.HandlerFunc {
|
func (p *defaultProvider) RegisterHandler() http.HandlerFunc {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *defaultProvider) ProtectedResourceMetadataHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
meta := map[string]interface{}{
|
||||||
|
"audience": p.cfg.ProtectedResourceMetadata.Audience,
|
||||||
|
"scopes_supported": p.cfg.ProtectedResourceMetadata.ScopesSupported,
|
||||||
|
"authorization_servers": p.cfg.ProtectedResourceMetadata.AuthorizationServers,
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.cfg.ProtectedResourceMetadata.JwksURI != "" {
|
||||||
|
meta["jwks_uri"] = p.cfg.ProtectedResourceMetadata.JwksURI
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(p.cfg.ProtectedResourceMetadata.BearerMethodsSupported) > 0 {
|
||||||
|
meta["bearer_methods_supported"] = p.cfg.ProtectedResourceMetadata.BearerMethodsSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(meta); err != nil {
|
||||||
|
http.Error(w, "failed to encode metadata", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -7,4 +7,5 @@ import "net/http"
|
||||||
type Provider interface {
|
type Provider interface {
|
||||||
WellKnownHandler() http.HandlerFunc
|
WellKnownHandler() http.HandlerFunc
|
||||||
RegisterHandler() http.HandlerFunc
|
RegisterHandler() http.HandlerFunc
|
||||||
|
ProtectedResourceMetadataHandler() http.HandlerFunc
|
||||||
}
|
}
|
||||||
|
|
72
internal/authz/scope_validator.go
Normal file
72
internal/authz/scope_validator.go
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
package authz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
"github.com/wso2/open-mcp-auth-proxy/internal/config"
|
||||||
|
"github.com/wso2/open-mcp-auth-proxy/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScopeValidator struct{}
|
||||||
|
|
||||||
|
// Evaluate and checks the token claims against one or more required scopes.
|
||||||
|
func (d *ScopeValidator) ValidateAccess(
|
||||||
|
r *http.Request,
|
||||||
|
claims *jwt.MapClaims,
|
||||||
|
config *config.Config,
|
||||||
|
) AccessControlResult {
|
||||||
|
env, err := util.ParseRPCRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
return AccessControlResult{DecisionDeny, "bad JSON-RPC request"}
|
||||||
|
}
|
||||||
|
requiredScopes := util.GetRequiredScopes(config, env)
|
||||||
|
|
||||||
|
if len(requiredScopes) == 0 {
|
||||||
|
return AccessControlResult{DecisionAllow, ""}
|
||||||
|
}
|
||||||
|
|
||||||
|
required := make(map[string]struct{}, len(requiredScopes))
|
||||||
|
for _, s := range requiredScopes {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s != "" {
|
||||||
|
required[s] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenScopes []string
|
||||||
|
if claims, ok := (*claims)["scope"]; ok {
|
||||||
|
switch v := claims.(type) {
|
||||||
|
case string:
|
||||||
|
tokenScopes = strings.Fields(v)
|
||||||
|
case []interface{}:
|
||||||
|
for _, x := range v {
|
||||||
|
if s, ok := x.(string); ok && s != "" {
|
||||||
|
tokenScopes = append(tokenScopes, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenScopeSet := make(map[string]struct{}, len(tokenScopes))
|
||||||
|
for _, s := range tokenScopes {
|
||||||
|
tokenScopeSet[s] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var missing []string
|
||||||
|
for s := range required {
|
||||||
|
if _, ok := tokenScopeSet[s]; !ok {
|
||||||
|
missing = append(missing, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(missing) == 0 {
|
||||||
|
return AccessControlResult{DecisionAllow, ""}
|
||||||
|
}
|
||||||
|
return AccessControlResult{
|
||||||
|
DecisionDeny,
|
||||||
|
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,8 +13,9 @@ import (
|
||||||
type TransportMode string
|
type TransportMode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SSETransport TransportMode = "sse"
|
SSETransport TransportMode = "sse"
|
||||||
StdioTransport TransportMode = "stdio"
|
StdioTransport TransportMode = "stdio"
|
||||||
|
StreamableHTTPTransport TransportMode = "streamable_http"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Common path configuration for all transport modes
|
// Common path configuration for all transport modes
|
||||||
|
@ -68,6 +69,15 @@ type ResponseConfig struct {
|
||||||
CodeChallengeMethodsSupported []string `yaml:"code_challenge_methods_supported,omitempty"`
|
CodeChallengeMethodsSupported []string `yaml:"code_challenge_methods_supported,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProtectedResourceMetadata struct {
|
||||||
|
ResourceIdentifier string `yaml:"resource_identifier"`
|
||||||
|
Audience string `yaml:"audience"`
|
||||||
|
ScopesSupported []map[string]interface{} `yaml:"scopes_supported"`
|
||||||
|
AuthorizationServers []string `yaml:"authorization_servers"`
|
||||||
|
JwksURI string `yaml:"jwks_uri,omitempty"`
|
||||||
|
BearerMethodsSupported []string `yaml:"bearer_methods_supported,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type PathConfig struct {
|
type PathConfig struct {
|
||||||
// For well-known endpoint
|
// For well-known endpoint
|
||||||
Response *ResponseConfig `yaml:"response,omitempty"`
|
Response *ResponseConfig `yaml:"response,omitempty"`
|
||||||
|
@ -86,6 +96,7 @@ type DefaultConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
ProxyBaseURL string `yaml:"proxy_base_url"`
|
||||||
AuthServerBaseURL string
|
AuthServerBaseURL string
|
||||||
ListenPort int `yaml:"listen_port"`
|
ListenPort int `yaml:"listen_port"`
|
||||||
BaseURL string `yaml:"base_url"`
|
BaseURL string `yaml:"base_url"`
|
||||||
|
@ -103,6 +114,9 @@ type Config struct {
|
||||||
Demo DemoConfig `yaml:"demo"`
|
Demo DemoConfig `yaml:"demo"`
|
||||||
Asgardeo AsgardeoConfig `yaml:"asgardeo"`
|
Asgardeo AsgardeoConfig `yaml:"asgardeo"`
|
||||||
Default DefaultConfig `yaml:"default"`
|
Default DefaultConfig `yaml:"default"`
|
||||||
|
|
||||||
|
// Protected resource metadata
|
||||||
|
ProtectedResourceMetadata ProtectedResourceMetadata `yaml:"protected_resource_metadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if the config is valid based on transport mode
|
// Validate checks if the config is valid based on transport mode
|
||||||
|
|
|
@ -1,7 +1,14 @@
|
||||||
package constants
|
package constants
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
// Package constant provides constants for the MCP Auth Proxy
|
// Package constant provides constants for the MCP Auth Proxy
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ASGARDEO_BASE_URL = "https://api.asgardeo.io/t/"
|
ASGARDEO_BASE_URL = "https://api.asgardeo.io/t/"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MCP specification version cutover date
|
||||||
|
var SpecCutoverDate = time.Date(2025, 3, 26, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
const TimeLayout = "2006-01-02"
|
||||||
|
|
|
@ -2,6 +2,7 @@ package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
@ -17,7 +18,7 @@ import (
|
||||||
// NewRouter builds an http.ServeMux that routes
|
// NewRouter builds an http.ServeMux that routes
|
||||||
// * /authorize, /token, /register, /.well-known to the provider or proxy
|
// * /authorize, /token, /register, /.well-known to the provider or proxy
|
||||||
// * MCP paths to the MCP server, etc.
|
// * MCP paths to the MCP server, etc.
|
||||||
func NewRouter(cfg *config.Config, provider authz.Provider) http.Handler {
|
func NewRouter(cfg *config.Config, provider authz.Provider, accessController authz.AccessControl) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
modifiers := map[string]RequestModifier{
|
modifiers := map[string]RequestModifier{
|
||||||
|
@ -63,6 +64,20 @@ func NewRouter(cfg *config.Config, provider authz.Provider) http.Handler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mux.HandleFunc(getProtectedResourceMetadataEndpointPath(cfg), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
allowed := getAllowedOrigin(origin, cfg)
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
addCORSHeaders(w, cfg, allowed, r.Header.Get("Access-Control-Request-Headers"))
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addCORSHeaders(w, cfg, allowed, "")
|
||||||
|
provider.ProtectedResourceMetadataHandler()(w, r)
|
||||||
|
})
|
||||||
|
registeredPaths[getProtectedResourceMetadataEndpointPath(cfg)] = true
|
||||||
|
|
||||||
// Remove duplicates from defaultPaths
|
// Remove duplicates from defaultPaths
|
||||||
uniquePaths := make(map[string]bool)
|
uniquePaths := make(map[string]bool)
|
||||||
cleanPaths := []string{}
|
cleanPaths := []string{}
|
||||||
|
@ -76,7 +91,7 @@ func NewRouter(cfg *config.Config, provider authz.Provider) http.Handler {
|
||||||
|
|
||||||
for _, path := range defaultPaths {
|
for _, path := range defaultPaths {
|
||||||
if !registeredPaths[path] {
|
if !registeredPaths[path] {
|
||||||
mux.HandleFunc(path, buildProxyHandler(cfg, modifiers))
|
mux.HandleFunc(path, buildProxyHandler(cfg, modifiers, accessController))
|
||||||
registeredPaths[path] = true
|
registeredPaths[path] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -84,14 +99,14 @@ func NewRouter(cfg *config.Config, provider authz.Provider) http.Handler {
|
||||||
// MCP paths
|
// MCP paths
|
||||||
mcpPaths := cfg.GetMCPPaths()
|
mcpPaths := cfg.GetMCPPaths()
|
||||||
for _, path := range mcpPaths {
|
for _, path := range mcpPaths {
|
||||||
mux.HandleFunc(path, buildProxyHandler(cfg, modifiers))
|
mux.HandleFunc(path, buildProxyHandler(cfg, modifiers, accessController))
|
||||||
registeredPaths[path] = true
|
registeredPaths[path] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register paths from PathMapping that haven't been registered yet
|
// Register paths from PathMapping that haven't been registered yet
|
||||||
for path := range cfg.PathMapping {
|
for path := range cfg.PathMapping {
|
||||||
if !registeredPaths[path] {
|
if !registeredPaths[path] {
|
||||||
mux.HandleFunc(path, buildProxyHandler(cfg, modifiers))
|
mux.HandleFunc(path, buildProxyHandler(cfg, modifiers, accessController))
|
||||||
registeredPaths[path] = true
|
registeredPaths[path] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -99,7 +114,7 @@ func NewRouter(cfg *config.Config, provider authz.Provider) http.Handler {
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier) http.HandlerFunc {
|
func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier, accessController authz.AccessControl) http.HandlerFunc {
|
||||||
// Parse the base URLs up front
|
// Parse the base URLs up front
|
||||||
authBase, err := url.Parse(cfg.AuthServerBaseURL)
|
authBase, err := url.Parse(cfg.AuthServerBaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -141,20 +156,31 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier)
|
||||||
// Add CORS headers to all responses
|
// Add CORS headers to all responses
|
||||||
addCORSHeaders(w, cfg, allowedOrigin, "")
|
addCORSHeaders(w, cfg, allowedOrigin, "")
|
||||||
|
|
||||||
|
// Check if the request is for the latest spec
|
||||||
|
specVersion := util.GetVersionWithDefault(r.Header.Get("MCP-Protocol-Version"))
|
||||||
|
ver, err := util.ParseVersionDate(specVersion)
|
||||||
|
isLatestSpec := util.IsLatestSpec(ver, err)
|
||||||
|
|
||||||
// Decide whether the request should go to the auth server or MCP
|
// Decide whether the request should go to the auth server or MCP
|
||||||
var targetURL *url.URL
|
var targetURL *url.URL
|
||||||
isSSE := false
|
isSSE := false
|
||||||
|
|
||||||
if isAuthPath(r.URL.Path) {
|
if isAuthPath(r.URL.Path, cfg) {
|
||||||
targetURL = authBase
|
targetURL = authBase
|
||||||
} else if isMCPPath(r.URL.Path, cfg) {
|
} else if isMCPPath(r.URL.Path, cfg) {
|
||||||
// Validate JWT for MCP paths if required
|
if ssePaths[r.URL.Path] {
|
||||||
// Placeholder for JWT validation logic
|
if err := authorizeSSE(w, r, isLatestSpec, cfg); err != nil {
|
||||||
if err := util.ValidateJWT(r.Header.Get("Authorization")); err != nil {
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
logger.Warn("Unauthorized request to %s: %v", r.URL.Path, err)
|
return
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
}
|
||||||
return
|
isSSE = true
|
||||||
|
} else {
|
||||||
|
if err := authorizeMCP(w, r, isLatestSpec, cfg, accessController); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
targetURL = mcpBase
|
targetURL = mcpBase
|
||||||
if ssePaths[r.URL.Path] {
|
if ssePaths[r.URL.Path] {
|
||||||
isSSE = true
|
isSSE = true
|
||||||
|
@ -214,7 +240,17 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier)
|
||||||
},
|
},
|
||||||
ModifyResponse: func(resp *http.Response) error {
|
ModifyResponse: func(resp *http.Response) error {
|
||||||
logger.Debug("Response from %s%s: %d", resp.Request.URL.Host, resp.Request.URL.Path, resp.StatusCode)
|
logger.Debug("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
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
resp.Header.Set(
|
||||||
|
"WWW-Authenticate",
|
||||||
|
fmt.Sprintf(
|
||||||
|
`Bearer resource_metadata="%s"`,
|
||||||
|
cfg.ProxyBaseURL+getProtectedResourceMetadataEndpointPath(cfg),
|
||||||
|
))
|
||||||
|
resp.Header.Set("Access-Control-Expose-Headers", "WWW-Authenticate")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.Header.Del("Access-Control-Allow-Origin")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
ErrorHandler: func(rw http.ResponseWriter, req *http.Request, err error) {
|
ErrorHandler: func(rw http.ResponseWriter, req *http.Request, err error) {
|
||||||
|
@ -236,7 +272,7 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier)
|
||||||
w.Header().Set("X-Accel-Buffering", "no")
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
// Keep SSE connections open
|
// Keep SSE connections open
|
||||||
HandleSSE(w, r, rp)
|
HandleSSE(w, r, rp)
|
||||||
} else {
|
} else {
|
||||||
|
@ -248,6 +284,76 @@ func buildProxyHandler(cfg *config.Config, modifiers map[string]RequestModifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if the request is for SSE handshake and authorize it
|
||||||
|
func authorizeSSE(w http.ResponseWriter, r *http.Request, isLatestSpec bool, cfg *config.Config) error {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
if isLatestSpec {
|
||||||
|
realm := cfg.BaseURL + getProtectedResourceMetadataEndpointPath(cfg)
|
||||||
|
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata="%s"`, realm))
|
||||||
|
w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("missing or invalid Authorization header")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles both v1 (just signature) and v2 (aud + scope) flows
|
||||||
|
func authorizeMCP(w http.ResponseWriter, r *http.Request, isLatestSpec bool, cfg *config.Config, accessController authz.AccessControl) error {
|
||||||
|
authzHeader := r.Header.Get("Authorization")
|
||||||
|
accessToken, _ := util.ExtractAccessToken(authzHeader)
|
||||||
|
if !strings.HasPrefix(authzHeader, "Bearer ") {
|
||||||
|
if isLatestSpec {
|
||||||
|
realm := cfg.ProxyBaseURL + getProtectedResourceMetadataEndpointPath(cfg)
|
||||||
|
w.Header().Set("WWW-Authenticate", fmt.Sprintf(
|
||||||
|
`Bearer resource_metadata=%q`, realm,
|
||||||
|
))
|
||||||
|
w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate")
|
||||||
|
}
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return fmt.Errorf("missing or invalid Authorization header")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := util.ValidateJWT(isLatestSpec, accessToken, cfg.ProtectedResourceMetadata.Audience)
|
||||||
|
if err != nil {
|
||||||
|
if isLatestSpec {
|
||||||
|
realm := cfg.ProxyBaseURL + getProtectedResourceMetadataEndpointPath(cfg)
|
||||||
|
w.Header().Set("WWW-Authenticate", fmt.Sprintf(err.Error(),
|
||||||
|
`Bearer realm=%q`,
|
||||||
|
realm,
|
||||||
|
))
|
||||||
|
w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate")
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
} else {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLatestSpec {
|
||||||
|
_, err := util.ParseRPCRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
claimsMap, err := util.ParseJWT(accessToken)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
|
||||||
|
return fmt.Errorf("invalid token claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
pr := accessController.ValidateAccess(r, &claimsMap, cfg)
|
||||||
|
if pr.Decision == authz.DecisionDeny {
|
||||||
|
http.Error(w, "Forbidden: "+pr.Message, http.StatusForbidden)
|
||||||
|
return fmt.Errorf("forbidden — %s", pr.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func getAllowedOrigin(origin string, cfg *config.Config) string {
|
func getAllowedOrigin(origin string, cfg *config.Config) string {
|
||||||
if origin == "" {
|
if origin == "" {
|
||||||
return cfg.CORSConfig.AllowedOrigins[0] // Default to first allowed origin
|
return cfg.CORSConfig.AllowedOrigins[0] // Default to first allowed origin
|
||||||
|
@ -265,6 +371,7 @@ func getAllowedOrigin(origin string, cfg *config.Config) string {
|
||||||
func addCORSHeaders(w http.ResponseWriter, cfg *config.Config, allowedOrigin, requestHeaders string) {
|
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-Origin", allowedOrigin)
|
||||||
w.Header().Set("Access-Control-Allow-Methods", strings.Join(cfg.CORSConfig.AllowedMethods, ", "))
|
w.Header().Set("Access-Control-Allow-Methods", strings.Join(cfg.CORSConfig.AllowedMethods, ", "))
|
||||||
|
w.Header().Set("Access-Control-Expose-Headers", "WWW-Authenticate, MCP-Protocol-Version")
|
||||||
if requestHeaders != "" {
|
if requestHeaders != "" {
|
||||||
w.Header().Set("Access-Control-Allow-Headers", requestHeaders)
|
w.Header().Set("Access-Control-Allow-Headers", requestHeaders)
|
||||||
} else {
|
} else {
|
||||||
|
@ -272,17 +379,19 @@ func addCORSHeaders(w http.ResponseWriter, cfg *config.Config, allowedOrigin, re
|
||||||
}
|
}
|
||||||
if cfg.CORSConfig.AllowCredentials {
|
if cfg.CORSConfig.AllowCredentials {
|
||||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
w.Header().Set("MCP-Protocol-Version", ", ")
|
||||||
}
|
}
|
||||||
w.Header().Set("Vary", "Origin")
|
w.Header().Set("Vary", "Origin")
|
||||||
w.Header().Set("X-Accel-Buffering", "no")
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
}
|
}
|
||||||
|
|
||||||
func isAuthPath(path string) bool {
|
func isAuthPath(path string, cfg *config.Config) bool {
|
||||||
authPaths := map[string]bool{
|
authPaths := map[string]bool{
|
||||||
"/authorize": true,
|
"/authorize": true,
|
||||||
"/token": true,
|
"/token": true,
|
||||||
"/register": true,
|
"/register": true,
|
||||||
"/.well-known/oauth-authorization-server": true,
|
"/.well-known/oauth-authorization-server": true,
|
||||||
|
getProtectedResourceMetadataEndpointPath(cfg): true,
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(path, "/u/") {
|
if strings.HasPrefix(path, "/u/") {
|
||||||
return true
|
return true
|
||||||
|
@ -308,3 +417,17 @@ func skipHeader(h string) bool {
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getProtectedResourceMetadataEndpointPath(cfg *config.Config) string {
|
||||||
|
|
||||||
|
protectedResourceMetadataPath := "/.well-known/oauth-protected-resource"
|
||||||
|
|
||||||
|
switch cfg.TransportMode {
|
||||||
|
case config.SSETransport:
|
||||||
|
protectedResourceMetadataPath += cfg.Paths.SSE
|
||||||
|
case config.StreamableHTTPTransport:
|
||||||
|
protectedResourceMetadataPath += cfg.Paths.StreamableHTTP
|
||||||
|
}
|
||||||
|
|
||||||
|
return protectedResourceMetadataPath
|
||||||
|
}
|
||||||
|
|
|
@ -4,21 +4,27 @@ import (
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
"github.com/wso2/open-mcp-auth-proxy/internal/logging"
|
"github.com/wso2/open-mcp-auth-proxy/internal/config"
|
||||||
|
logger "github.com/wso2/open-mcp-auth-proxy/internal/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type TokenClaims struct {
|
||||||
|
Scopes []string
|
||||||
|
}
|
||||||
|
|
||||||
type JWKS struct {
|
type JWKS struct {
|
||||||
Keys []json.RawMessage `json:"keys"`
|
Keys []json.RawMessage `json:"keys"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var publicKeys map[string]*rsa.PublicKey
|
var publicKeys map[string]*rsa.PublicKey
|
||||||
|
|
||||||
// FetchJWKS downloads JWKS and stores in a package-level map
|
// FetchJWKS downloads JWKS and stores in a package‐level map
|
||||||
func FetchJWKS(jwksURL string) error {
|
func FetchJWKS(jwksURL string) error {
|
||||||
resp, err := http.Get(jwksURL)
|
resp, err := http.Get(jwksURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -31,23 +37,23 @@ func FetchJWKS(jwksURL string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
publicKeys = make(map[string]*rsa.PublicKey)
|
publicKeys = make(map[string]*rsa.PublicKey, len(jwks.Keys))
|
||||||
for _, keyData := range jwks.Keys {
|
for _, keyData := range jwks.Keys {
|
||||||
var parsedKey struct {
|
var parsed struct {
|
||||||
Kid string `json:"kid"`
|
Kid string `json:"kid"`
|
||||||
N string `json:"n"`
|
N string `json:"n"`
|
||||||
E string `json:"e"`
|
E string `json:"e"`
|
||||||
Kty string `json:"kty"`
|
Kty string `json:"kty"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(keyData, &parsedKey); err != nil {
|
if err := json.Unmarshal(keyData, &parsed); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if parsedKey.Kty != "RSA" {
|
if parsed.Kty != "RSA" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pubKey, err := parseRSAPublicKey(parsedKey.N, parsedKey.E)
|
pubKey, err := parseRSAPublicKey(parsed.N, parsed.E)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
publicKeys[parsedKey.Kid] = pubKey
|
publicKeys[parsed.Kid] = pubKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.Info("Loaded %d public keys.", len(publicKeys))
|
logger.Info("Loaded %d public keys.", len(publicKeys))
|
||||||
|
@ -73,25 +79,150 @@ func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) {
|
||||||
return &rsa.PublicKey{N: n, E: e}, nil
|
return &rsa.PublicKey{N: n, E: e}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateJWT checks the Authorization: Bearer token using stored JWKS
|
// ValidateJWT checks the Bearer token according to the Mcp-Protocol-Version.
|
||||||
func ValidateJWT(authHeader string) error {
|
func ValidateJWT(
|
||||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
isLatestSpec bool,
|
||||||
return errors.New("missing or invalid Authorization header")
|
accessToken string,
|
||||||
}
|
audience string,
|
||||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
) error {
|
||||||
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
|
logger.Warn("isLatestSpec: %s", isLatestSpec)
|
||||||
kid, _ := token.Header["kid"].(string)
|
// Parse & verify the signature
|
||||||
pubKey, ok := publicKeys[kid]
|
token, err := jwt.Parse(accessToken, func(token *jwt.Token) (interface{}, error) {
|
||||||
if !ok {
|
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||||
return nil, errors.New("unknown or missing kid in token header")
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
}
|
}
|
||||||
return pubKey, nil
|
kid, ok := token.Header["kid"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("kid header not found")
|
||||||
|
}
|
||||||
|
key, ok := publicKeys[kid]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("key not found for kid: %s", kid)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("invalid token: " + err.Error())
|
logger.Warn("Error detected, returning early")
|
||||||
|
return fmt.Errorf("invalid token: %w", err)
|
||||||
}
|
}
|
||||||
if !token.Valid {
|
if !token.Valid {
|
||||||
return errors.New("invalid token: token not valid")
|
logger.Warn("Token invalid, returning early")
|
||||||
|
return errors.New("token not valid")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
claimsMap, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("unexpected claim type")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isLatestSpec {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
audRaw, exists := claimsMap["aud"]
|
||||||
|
if !exists {
|
||||||
|
return errors.New("aud claim missing")
|
||||||
|
}
|
||||||
|
switch v := audRaw.(type) {
|
||||||
|
case string:
|
||||||
|
if v != audience {
|
||||||
|
return fmt.Errorf("aud %q does not match %q", v, audience)
|
||||||
|
}
|
||||||
|
case []interface{}:
|
||||||
|
var found bool
|
||||||
|
for _, a := range v {
|
||||||
|
if s, ok := a.(string); ok && s == audience {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("audience %v does not include %q", v, audience)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return errors.New("aud claim has unexpected type")
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parses the JWT token and returns the claims
|
||||||
|
func ParseJWT(tokenStr string) (jwt.MapClaims, error) {
|
||||||
|
if tokenStr == "" {
|
||||||
|
return nil, fmt.Errorf("empty JWT")
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims jwt.MapClaims
|
||||||
|
_, _, err := jwt.NewParser().ParseUnverified(tokenStr, &claims)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse JWT: %w", err)
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the required scopes
|
||||||
|
func GetRequiredScopes(cfg *config.Config, requestBody *RPCEnvelope) []string {
|
||||||
|
|
||||||
|
var scopeObj interface{}
|
||||||
|
found := false
|
||||||
|
for _, m := range cfg.ProtectedResourceMetadata.ScopesSupported {
|
||||||
|
if val, ok := m[requestBody.Method]; ok {
|
||||||
|
scopeObj = val
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := scopeObj.(type) {
|
||||||
|
case string:
|
||||||
|
return []string{v}
|
||||||
|
case []any:
|
||||||
|
if requestBody.Params != nil {
|
||||||
|
if paramsMap, ok := requestBody.Params.(map[string]any); ok {
|
||||||
|
name, ok := paramsMap["name"].(string)
|
||||||
|
if ok {
|
||||||
|
for _, item := range v {
|
||||||
|
if scopeMap, ok := item.(map[interface{}]interface{}); ok {
|
||||||
|
if scopeVal, exists := scopeMap[name]; exists {
|
||||||
|
if scopeStr, ok := scopeVal.(string); ok {
|
||||||
|
return []string{scopeStr}
|
||||||
|
}
|
||||||
|
if scopeArr, ok := scopeVal.([]any); ok {
|
||||||
|
var scopes []string
|
||||||
|
for _, s := range scopeArr {
|
||||||
|
if str, ok := s.(string); ok {
|
||||||
|
scopes = append(scopes, str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scopes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracts the Bearer token from the Authorization header
|
||||||
|
func ExtractAccessToken(authHeader string) (string, error) {
|
||||||
|
if authHeader == "" {
|
||||||
|
return "", errors.New("empty authorization header")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
return "", fmt.Errorf("invalid authorization header format: %s", authHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStr := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
|
||||||
|
if tokenStr == "" {
|
||||||
|
return "", errors.New("empty bearer token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenStr, nil
|
||||||
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -47,7 +48,14 @@ func TestValidateJWT(t *testing.T) {
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
err := ValidateJWT(tc.authHeader)
|
var accessToken string
|
||||||
|
parts := strings.Split(tc.authHeader, "Bearer ")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
accessToken = parts[1]
|
||||||
|
} else {
|
||||||
|
accessToken = ""
|
||||||
|
}
|
||||||
|
err := ValidateJWT(true, accessToken, "test-audience")
|
||||||
if tc.expectError && err == nil {
|
if tc.expectError && err == nil {
|
||||||
t.Errorf("Expected error but got none")
|
t.Errorf("Expected error but got none")
|
||||||
}
|
}
|
||||||
|
@ -128,6 +136,7 @@ func createValidJWT(t *testing.T) string {
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
||||||
"sub": "1234567890",
|
"sub": "1234567890",
|
||||||
"name": "Test User",
|
"name": "Test User",
|
||||||
|
"aud": "test-audience",
|
||||||
"iat": time.Now().Unix(),
|
"iat": time.Now().Unix(),
|
||||||
"exp": time.Now().Add(time.Hour).Unix(),
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
})
|
})
|
||||||
|
|
38
internal/util/rpc.go
Normal file
38
internal/util/rpc.go
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
logger "github.com/wso2/open-mcp-auth-proxy/internal/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RPCEnvelope struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params any `json:"params"`
|
||||||
|
ID any `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// This function parses a JSON-RPC request from an HTTP request body
|
||||||
|
func ParseRPCRequest(r *http.Request) (*RPCEnvelope, error) {
|
||||||
|
bodyBytes, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||||
|
|
||||||
|
if len(bodyBytes) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var env RPCEnvelope
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(bodyBytes))
|
||||||
|
if err := dec.Decode(&env); err != nil && err != io.EOF {
|
||||||
|
logger.Warn("Error parsing JSON-RPC envelope: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &env, nil
|
||||||
|
}
|
26
internal/util/version.go
Normal file
26
internal/util/version.go
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wso2/open-mcp-auth-proxy/internal/constants"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This function checks if the given version date is after the spec cutover date
|
||||||
|
func IsLatestSpec(versionDate time.Time, err error) bool {
|
||||||
|
return err == nil && versionDate.After(constants.SpecCutoverDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This function parses a version string into a time.Time
|
||||||
|
func ParseVersionDate(version string) (time.Time, error) {
|
||||||
|
return time.Parse("2006-01-02", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This function returns the version string, using the cutover date if empty
|
||||||
|
func GetVersionWithDefault(version string) string {
|
||||||
|
if version == "" {
|
||||||
|
defaultTime, _ := time.Parse(constants.TimeLayout, "2025-05-15")
|
||||||
|
return defaultTime.Format(constants.TimeLayout)
|
||||||
|
}
|
||||||
|
return version
|
||||||
|
}
|
19
resources/README.md
Normal file
19
resources/README.md
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# Example MCP server
|
||||||
|
|
||||||
|
Use this example MCP server, if you don't already have an MCP server to test the open-mcp-auth-proxy.
|
||||||
|
|
||||||
|
## Setting Up
|
||||||
|
|
||||||
|
1. Set up a Python virtual environment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the example server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 echo_server.py
|
||||||
|
```
|
|
@ -2,7 +2,6 @@ from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
mcp = FastMCP("Echo")
|
mcp = FastMCP("Echo")
|
||||||
|
|
||||||
|
|
||||||
@mcp.resource("echo://{message}")
|
@mcp.resource("echo://{message}")
|
||||||
def echo_resource(message: str) -> str:
|
def echo_resource(message: str) -> str:
|
||||||
"""Echo a message as a resource"""
|
"""Echo a message as a resource"""
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue