diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh
new file mode 100644
index 0000000..2a1f6a9
--- /dev/null
+++ b/.github/scripts/release.sh
@@ -0,0 +1,124 @@
+#!/bin/bash
+
+# Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com).
+#
+# This software is the property of WSO2 LLC. and its suppliers, if any.
+# Dissemination of any information or reproduction of any material contained
+# herein in any form is strictly forbidden, unless permitted by WSO2 expressly.
+# You may not alter or remove any copyright or other notice from copies of this content.
+#
+
+# Exit the script on any command with non-zero exit status.
+set -e
+set -o pipefail
+
+UPSTREAM_BRANCH="main"
+
+# Assign command line arguments to variables.
+GIT_TOKEN=$1
+WORK_DIR=$2
+VERSION_TYPE=$3 # possible values: major, minor, patch
+
+# Check if GIT_TOKEN is empty
+if [ -z "$GIT_TOKEN" ]; then
+ echo "β Error: GIT_TOKEN is not set."
+ exit 1
+fi
+
+# Check if WORK_DIR is empty
+if [ -z "$WORK_DIR" ]; then
+ echo "β Error: WORK_DIR is not set."
+ exit 1
+fi
+
+# Validate VERSION_TYPE
+if [[ "$VERSION_TYPE" != "major" && "$VERSION_TYPE" != "minor" && "$VERSION_TYPE" != "patch" ]]; then
+ echo "β Error: VERSION_TYPE must be one of: major, minor, or patch."
+ exit 1
+fi
+
+BUILD_DIRECTORY="$WORK_DIR/build"
+RELEASE_DIRECTORY="$BUILD_DIRECTORY/releases"
+
+# Navigate to the working directory.
+cd "${WORK_DIR}"
+
+# Create the release directory.
+if [ ! -d "$RELEASE_DIRECTORY" ]; then
+ mkdir -p "$RELEASE_DIRECTORY"
+else
+ rm -rf "$RELEASE_DIRECTORY"/*
+fi
+
+# Extract current version.
+CURRENT_VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0")
+IFS='.' read -r MAJOR MINOR PATCH <<< "${CURRENT_VERSION}"
+
+# Determine which part to increment
+case "$VERSION_TYPE" in
+ major)
+ MAJOR=$((MAJOR + 1))
+ MINOR=0
+ PATCH=0
+ ;;
+ minor)
+ MINOR=$((MINOR + 1))
+ PATCH=0
+ ;;
+ patch|*)
+ PATCH=$((PATCH + 1))
+ ;;
+esac
+
+NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
+
+echo "Creating release packages for version $NEW_VERSION..."
+
+# List of supported OSes.
+oses=("linux" "linux-arm" "darwin")
+
+# Navigate to the release directory.
+cd "${RELEASE_DIRECTORY}"
+
+for os in "${oses[@]}"; do
+ os_dir="../$os"
+
+ if [ -d "$os_dir" ]; then
+ release_artifact_folder="openmcpauthproxy_${os}-v${NEW_VERSION}"
+ mkdir -p "$release_artifact_folder"
+
+ cp -r $os_dir/* "$release_artifact_folder"
+
+ # Zip the release package.
+ zip_file="$release_artifact_folder.zip"
+ echo "Creating $zip_file..."
+ zip -r "$zip_file" "$release_artifact_folder"
+
+ # Delete the folder after zipping.
+ rm -rf "$release_artifact_folder"
+
+ # Generate checksum file.
+ sha256sum "$zip_file" | sed "s|target/releases/||" > "$zip_file.sha256"
+ echo "Checksum generated for the $os package."
+
+ echo "Release packages created successfully for $os."
+ else
+ echo "Skipping $os release package creation as the build artifacts are not available."
+ fi
+done
+
+echo "Release packages created successfully in $RELEASE_DIRECTORY."
+
+# Navigate back to the project root directory.
+cd "${WORK_DIR}"
+
+# Collect all ZIP and .sha256 files in the target/releases directory.
+FILES_TO_UPLOAD=$(find build/releases -type f \( -name "*.zip" -o -name "*.sha256" \))
+
+# Create a release with the current version.
+TAG_NAME="v${NEW_VERSION}"
+export GITHUB_TOKEN="${GIT_TOKEN}"
+gh release create "${TAG_NAME}" ${FILES_TO_UPLOAD} --title "${TAG_NAME}" --notes "OpenMCPAuthProxy - ${TAG_NAME}" --target "${UPSTREAM_BRANCH}" || { echo "Failed to create release"; exit 1; }
+
+
+echo "Release ${TAG_NAME} created successfully."
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..775003e
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,71 @@
+name: Build and Push container
+run-name: Build and Push container
+on:
+ workflow_dispatch:
+ #schedule:
+ # - cron: "0 10 * * *"
+ push:
+ branches:
+ - 'main'
+ - 'master'
+ tags:
+ - 'v*'
+ pull_request:
+ branches:
+ - 'main'
+ - 'master'
+env:
+ IMAGE: git.kvant.cloud/${{github.repository}}
+jobs:
+ build_concierge_backend:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set current time
+ uses: https://github.com/gerred/actions/current-time@master
+ id: current_time
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to git.kvant.cloud registry
+ uses: docker/login-action@v3
+ with:
+ registry: git.kvant.cloud
+ username: ${{ vars.ORG_PACKAGE_WRITER_USERNAME }}
+ password: ${{ secrets.ORG_PACKAGE_WRITER_TOKEN }}
+
+ - name: Docker meta
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ # list of Docker images to use as base name for tags
+ images: |
+ ${{env.IMAGE}}
+ # generate Docker tags based on the following events/attributes
+ tags: |
+ type=schedule
+ type=ref,event=branch
+ type=ref,event=pr
+ type=semver,pattern={{version}}
+
+ - name: Build and push to gitea registry
+ uses: docker/build-push-action@v6
+ with:
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ context: .
+ provenance: mode=max
+ sbom: true
+ build-args: |
+ BUILD_DATE=${{ steps.current_time.outputs.time }}
+ cache-from: |
+ type=registry,ref=${{ env.IMAGE }}:buildcache
+ type=registry,ref=${{ env.IMAGE }}:${{ github.ref_name }}
+ type=registry,ref=${{ env.IMAGE }}:main
+ cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max,image-manifest=true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..0c51bc7
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,64 @@
+#
+# Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com).
+#
+# This software is the property of WSO2 LLC. and its suppliers, if any.
+# Dissemination of any information or reproduction of any material contained
+# herein in any form is strictly forbidden, unless permitted by WSO2 expressly.
+# You may not alter or remove any copyright or other notice from copies of this content.
+#
+
+name: Release
+
+on:
+ workflow_dispatch:
+ inputs:
+ version_type:
+ type: choice
+ description: Choose the type of version update
+ options:
+ - 'major'
+ - 'minor'
+ - 'patch'
+ required: true
+
+jobs:
+ update-and-release:
+ runs-on: ubuntu-latest
+ env:
+ GOPROXY: https://proxy.golang.org
+ if: github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
+ steps:
+ - uses: actions/checkout@v2
+ with:
+ ref: 'main'
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+ - uses: actions/checkout@v2
+
+ - name: Set up Go 1.x
+ uses: actions/setup-go@v3
+ with:
+ go-version: "^1.x"
+
+ - name: Cache Go modules
+ id: cache-go-modules
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.cache/go-build
+ ~/go/pkg/mod
+ key: ${{ runner.os }}-go-modules-${{ hashFiles('**/go.sum') }}
+ restore-keys: |
+ ${{ runner.os }}-go-modules-
+
+ - name: Install dependencies
+ run: go mod download
+
+ - name: Build and test
+ run: make build
+ working-directory: .
+
+ - name: Update artifact version, package, commit, and create release.
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: bash ./.github/scripts/release.sh $GITHUB_TOKEN ${{ github.workspace }} ${{ github.event.inputs.version_type }}
diff --git a/.gitignore b/.gitignore
index 2a2b503..d200b58 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,10 +24,15 @@
hs_err_pid*
replay_pid*
-# Go module cache files
-go.sum
-
# OS generated files
.DS_Store
-openmcpauthproxy
+# builds
+build
+
+# test out files
+coverage.out
+coverage.html
+
+# IDE files
+.vscode
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..dc468b1
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,48 @@
+FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:1.24@sha256:d9db32125db0c3a680cfb7a1afcaefb89c898a075ec148fdc2f0f646cc2ed509 AS build
+
+ARG TARGETPLATFORM
+ARG BUILDPLATFORM
+ARG TARGETOS
+ARG TARGETARCH
+
+WORKDIR /workspace
+
+RUN apt update -qq && apt install -qq -y git bash curl g++
+
+# Download libraries
+ADD go.* .
+RUN go mod download
+
+# Build
+ADD cmd cmd
+ADD internal internal
+RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o webhook -ldflags '-w -extldflags "-static"' -o openmcpauthproxy ./cmd/proxy
+
+#Test
+RUN CGO_ENABLED=1 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go test -v -race ./...
+
+
+# Build production container
+FROM --platform=${BUILDPLATFORM:-linux/amd64} ubuntu:24.04
+
+RUN apt-get update \
+ && apt-get install --no-install-recommends -y \
+ python3-pip \
+ python-is-python3 \
+ npm \
+ && apt-get autoremove \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN pip install uvenv --break-system-packages
+
+WORKDIR /app
+COPY --from=build /workspace/openmcpauthproxy /app/
+
+ADD config.yaml /app
+
+
+ENTRYPOINT ["/app/openmcpauthproxy"]
+
+ARG IMAGE_SOURCE
+LABEL org.opencontainers.image.source=$IMAGE_SOURCE
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b0d0926
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,88 @@
+# Makefile for open-mcp-auth-proxy
+
+# Variables
+PROJECT_ROOT := $(realpath $(dir $(abspath $(lastword $(MAKEFILE_LIST)))))
+BINARY_NAME := openmcpauthproxy
+GO := go
+GOFMT := gofmt
+GOVET := go vet
+GOTEST := go test
+GOLINT := golangci-lint
+GOCOV := go tool cover
+BUILD_DIR := build
+
+# Source files
+SRC := $(shell find . -name "*.go" -not -path "./vendor/*")
+PKGS := $(shell go list ./... | grep -v /vendor/)
+
+# Set build options
+BUILD_OPTS := -v
+
+# Set test options
+TEST_OPTS := -v -race
+
+.PHONY: all clean test fmt lint vet coverage help
+
+# Default target
+all: lint test build-linux build-linux-arm build-darwin
+
+build: clean test build-linux build-linux-arm build-darwin
+
+build-linux:
+ mkdir -p $(BUILD_DIR)/linux
+ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -x -ldflags "-X main.version=$(BUILD_VERSION)" \
+ -o $(BUILD_DIR)/linux/openmcpauthproxy $(PROJECT_ROOT)/cmd/proxy
+ cp config.yaml $(BUILD_DIR)/linux
+
+build-linux-arm:
+ mkdir -p $(BUILD_DIR)/linux-arm
+ GOOS=linux GOARCH=arm CGO_ENABLED=0 go build -x -ldflags "-X main.version=$(BUILD_VERSION)" \
+ -o $(BUILD_DIR)/linux-arm/openmcpauthproxy $(PROJECT_ROOT)/cmd/proxy
+ cp config.yaml $(BUILD_DIR)/linux-arm
+
+build-darwin:
+ mkdir -p $(BUILD_DIR)/darwin
+ GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -x -ldflags "-X main.version=$(BUILD_VERSION)" \
+ -o $(BUILD_DIR)/darwin/openmcpauthproxy $(PROJECT_ROOT)/cmd/proxy
+ cp config.yaml $(BUILD_DIR)/darwin
+
+# Clean build artifacts
+clean:
+ @echo "Cleaning build artifacts..."
+ @rm -rf $(BUILD_DIR)
+ @rm -f coverage.out
+
+# Run tests
+test:
+ @echo "Running tests..."
+ $(GOTEST) $(TEST_OPTS) ./...
+
+# Run tests with coverage report
+coverage:
+ @echo "Running tests with coverage..."
+ @$(GOTEST) -coverprofile=coverage.out ./...
+ @$(GOCOV) -func=coverage.out
+ @$(GOCOV) -html=coverage.out -o coverage.html
+ @echo "Coverage report generated in coverage.html"
+
+# Run gofmt
+fmt:
+ @echo "Running gofmt..."
+ @$(GOFMT) -w -s $(SRC)
+
+# Run go vet
+vet:
+ @echo "Running go vet..."
+ @$(GOVET) ./...
+
+# Show help
+help:
+ @echo "Available targets:"
+ @echo " all : Run lint, test, and build"
+ @echo " build : Build the application"
+ @echo " clean : Clean build artifacts"
+ @echo " test : Run tests"
+ @echo " coverage : Run tests with coverage report"
+ @echo " fmt : Run gofmt"
+ @echo " vet : Run go vet"
+ @echo " help : Show this help message"
diff --git a/README.md b/README.md
index f197891..6be3ece 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,12 @@
# Open MCP Auth Proxy
-A lightweight authorization proxy for Model Context Protocol (MCP) servers that enforces authorization according to the [MCP authorization specification](https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/authorization/).
+A lightweight authorization proxy for Model Context Protocol (MCP) servers that enforces authorization according to the [MCP authorization specification](https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/authorization/)
+
+[](https://github.com/wso2/open-mcp-auth-proxy/actions/workflows/release.yml)
+[](https://stackoverflow.com/questions/tagged/wso2is)
+[](https://discord.gg/wso2)
+[](https://twitter.com/intent/follow?screen_name=wso2)
+[](https://github.com/wso2/product-is/blob/master/LICENSE)

@@ -19,28 +25,29 @@ Open MCP Auth Proxy sits between MCP clients and your MCP server to:
* 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
+> python3 -m venv .venv
+> 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
-### Installation
-
-```bash
-git clone https://github.com/wso2/open-mcp-auth-proxy
-cd open-mcp-auth-proxy
-go get github.com/golang-jwt/jwt/v4 gopkg.in/yaml.v2
-go build -o openmcpauthproxy ./cmd/proxy
-```
-
### Basic Usage
-1. The repository comes with a default `config.yaml` file that contains the basic configuration:
-
-```yaml
-listen_port: 8080
-base_url: "http://localhost:8000" # Your MCP server URL
-paths:
- sse: "/sse"
- messages: "/messages/"
-```
+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):
@@ -48,31 +55,30 @@ paths:
./openmcpauthproxy --demo
```
+> The repository comes with a default `config.yaml` file that contains the basic configuration:
+>
+> ```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)
-## Identity Provider Integration
+## Connect an Identity Provider
-### Demo Mode
+### Asgardeo
-For quick testing, use the `--demo` flag which includes pre-configured authentication and authorization with an Asgardeo sandbox.
-
-```bash
-./openmcpauthproxy --demo
-```
-
-### Asgardeo Integration
-
-To enable authorization through your own Asgardeo organization:
+To enable authorization through your Asgardeo organization:
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/)
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. Update the existing `config.yaml` with your Asgardeo details:
-
-#### Configure the Auth Proxy
-
-Create a configuration file config.yaml with the following parameters:
+
+3. Update `config.yaml` with the following parameters.
```yaml
base_url: "http://localhost:8000" # URL of your MCP server
@@ -84,7 +90,7 @@ asgardeo:
client_secret: "" # Client secret of the M2M app
```
-3. Start the proxy with Asgardeo integration:
+4. Start the proxy with Asgardeo integration:
```bash
./openmcpauthproxy --asgardeo
@@ -92,26 +98,8 @@ asgardeo:
### Other OAuth Providers
-- [Auth0 Integration Guide](docs/Auth0.md)
-
-## Testing with an Example 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
-python3 -m venv .venv
-source .venv/bin/activate
-pip3 install -r requirements.txt
-```
-
-3. Start the example server:
-
-```bash
-python3 echo_server.py
-```
+- [Auth0](docs/integrations/Auth0.md)
+- [Keycloak](docs/integrations/keycloak.md)
# Advanced Configuration
@@ -226,4 +214,13 @@ asgardeo:
org_name: ""
client_id: ""
client_secret: ""
-```
\ No newline at end of file
+```
+
+### Build from source
+
+```bash
+git clone https://github.com/wso2/open-mcp-auth-proxy
+cd open-mcp-auth-proxy
+go get github.com/golang-jwt/jwt/v4 gopkg.in/yaml.v2
+go build -o openmcpauthproxy ./cmd/proxy
+```
diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go
index 6424f18..c43dd7d 100644
--- a/cmd/proxy/main.go
+++ b/cmd/proxy/main.go
@@ -12,7 +12,7 @@ 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/logging"
+ logger "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/subprocess"
"github.com/wso2/open-mcp-auth-proxy/internal/util"
@@ -58,7 +58,7 @@ func main() {
logger.Warn("%v", err)
logger.Warn("Subprocess may fail to start due to missing dependencies")
}
-
+
procManager = subprocess.NewManager()
if err := procManager.Start(cfg); err != nil {
logger.Warn("Failed to start subprocess: %v", err)
@@ -95,7 +95,7 @@ func main() {
// 5. Build the main router
mux := proxy.NewRouter(cfg, provider)
- listen_address := fmt.Sprintf(":%d", cfg.ListenPort)
+ listen_address := fmt.Sprintf("0.0.0.0:%d", cfg.ListenPort)
// 6. Start the server
srv := &http.Server{
diff --git a/config.yaml b/config.yaml
index 5621195..ef70fbb 100644
--- a/config.yaml
+++ b/config.yaml
@@ -6,29 +6,23 @@ base_url: "http://localhost:8000" # Base URL for the MCP server
port: 8000 # Port for the MCP server
timeout_seconds: 10
-# Path configuration
-paths:
- sse: "/sse" # SSE endpoint path
- messages: "/messages/" # Messages endpoint path
# Transport mode configuration
-transport_mode: "sse" # Options: "sse" or "stdio"
+transport_mode: "stdio" # Options: "sse" or "stdio"
# stdio-specific configuration (used only when transport_mode is "stdio")
stdio:
enabled: true
- user_command: "npx -y @modelcontextprotocol/server-github"
+ user_command: uvx mcp-server-time --local-timezone=Europe/Zurich
+ #user_command: "npx -y @modelcontextprotocol/server-github"
work_dir: "" # Working directory (optional)
# env: # Environment variables (optional)
# - "NODE_ENV=development"
-# Path mapping (optional)
-path_mapping:
-
-# CORS configuration
+# CORS settings
cors:
allowed_origins:
- - "http://localhost:5173"
+ - "http://localhost:6274" # Origin of your frontend/client app
allowed_methods:
- "GET"
- "POST"
@@ -40,8 +34,32 @@ cors:
- "mcp-protocol-version"
allow_credentials: true
-# Demo configuration for Asgardeo
-demo:
- org_name: "openmcpauthdemo"
- client_id: "N0U9e_NNGr9mP_0fPnPfPI0a6twa"
- client_secret: "qFHfiBp5gNGAO9zV4YPnDofBzzfInatfUbHyPZvM0jka"
+# Keycloak endpoint path mappings
+path_mapping:
+ sse: "/sse" # SSE endpoint path
+ messages: "/messages/" # Messages endpoint path
+ /token: /realms/master/protocol/openid-connect/token
+ /register: /realms/master/clients-registrations/openid-connect
+
+# Keycloak configuration block
+default:
+ base_url: "https://iam.phoenix-systems.ch"
+ jwks_url: "https://iam.phoenix-systems.ch/realms/kvant/protocol/openid-connect/certs"
+ path:
+ /.well-known/oauth-authorization-server:
+ response:
+ issuer: "https://iam.phoenix-systems.ch/realms/kvant"
+ jwks_uri: "https://iam.phoenix-systems.ch/realms/kvant/protocol/openid-connect/certs"
+ authorization_endpoint: "https://iam.phoenix-systems.ch/realms/kvant/protocol/openid-connect/auth"
+ response_types_supported:
+ - "code"
+ grant_types_supported:
+ - "authorization_code"
+ - "refresh_token"
+ code_challenge_methods_supported:
+ - "S256"
+ - "plain"
+ /token:
+ addBodyParams:
+ - name: "audience"
+ value: "mcp_proxy"
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 2d26216..0bceb4f 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/wso2/open-mcp-auth-proxy
-go 1.22.3
+go 1.21
require (
github.com/golang-jwt/jwt/v4 v4.5.2
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..9d27ad1
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,6 @@
+github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
+github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
diff --git a/internal/authz/default_test.go b/internal/authz/default_test.go
new file mode 100644
index 0000000..f40030f
--- /dev/null
+++ b/internal/authz/default_test.go
@@ -0,0 +1,125 @@
+package authz
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/wso2/open-mcp-auth-proxy/internal/config"
+)
+
+func TestNewDefaultProvider(t *testing.T) {
+ cfg := &config.Config{}
+ provider := NewDefaultProvider(cfg)
+
+ if provider == nil {
+ t.Fatal("Expected non-nil provider")
+ }
+
+ // Ensure it implements the Provider interface
+ var _ Provider = provider
+}
+
+func TestDefaultProviderWellKnownHandler(t *testing.T) {
+ // Create a config with a custom well-known response
+ cfg := &config.Config{
+ Default: config.DefaultConfig{
+ Path: map[string]config.PathConfig{
+ "/.well-known/oauth-authorization-server": {
+ Response: &config.ResponseConfig{
+ Issuer: "https://test-issuer.com",
+ JwksURI: "https://test-issuer.com/jwks",
+ ResponseTypesSupported: []string{"code"},
+ GrantTypesSupported: []string{"authorization_code"},
+ CodeChallengeMethodsSupported: []string{"S256"},
+ },
+ },
+ },
+ },
+ }
+
+ provider := NewDefaultProvider(cfg)
+ handler := provider.WellKnownHandler()
+
+ // Create a test request
+ req := httptest.NewRequest("GET", "/.well-known/oauth-authorization-server", nil)
+ req.Host = "test-host.com"
+ req.Header.Set("X-Forwarded-Proto", "https")
+
+ // Create a response recorder
+ w := httptest.NewRecorder()
+
+ // Call the handler
+ handler(w, req)
+
+ // Check response status
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status OK, got %v", w.Code)
+ }
+
+ // Verify content type
+ contentType := w.Header().Get("Content-Type")
+ if contentType != "application/json" {
+ t.Errorf("Expected Content-Type: application/json, got %s", contentType)
+ }
+
+ // Decode and check the response body
+ var response map[string]interface{}
+ if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
+ t.Fatalf("Failed to decode response JSON: %v", err)
+ }
+
+ // Check expected values
+ if response["issuer"] != "https://test-issuer.com" {
+ t.Errorf("Expected issuer=https://test-issuer.com, got %v", response["issuer"])
+ }
+ if response["jwks_uri"] != "https://test-issuer.com/jwks" {
+ t.Errorf("Expected jwks_uri=https://test-issuer.com/jwks, got %v", response["jwks_uri"])
+ }
+ if response["authorization_endpoint"] != "https://test-host.com/authorize" {
+ t.Errorf("Expected authorization_endpoint=https://test-host.com/authorize, got %v", response["authorization_endpoint"])
+ }
+}
+
+func TestDefaultProviderHandleOPTIONS(t *testing.T) {
+ provider := NewDefaultProvider(&config.Config{})
+ handler := provider.WellKnownHandler()
+
+ // Create OPTIONS request
+ req := httptest.NewRequest("OPTIONS", "/.well-known/oauth-authorization-server", nil)
+ w := httptest.NewRecorder()
+
+ // Call the handler
+ handler(w, req)
+
+ // Check response
+ if w.Code != http.StatusNoContent {
+ t.Errorf("Expected status NoContent for OPTIONS request, got %v", w.Code)
+ }
+
+ // Check CORS headers
+ if w.Header().Get("Access-Control-Allow-Origin") != "*" {
+ t.Errorf("Expected Access-Control-Allow-Origin: *, got %s", w.Header().Get("Access-Control-Allow-Origin"))
+ }
+ if w.Header().Get("Access-Control-Allow-Methods") != "GET, OPTIONS" {
+ t.Errorf("Expected Access-Control-Allow-Methods: GET, OPTIONS, got %s", w.Header().Get("Access-Control-Allow-Methods"))
+ }
+}
+
+func TestDefaultProviderInvalidMethod(t *testing.T) {
+ provider := NewDefaultProvider(&config.Config{})
+ handler := provider.WellKnownHandler()
+
+ // Create POST request (which should be rejected)
+ req := httptest.NewRequest("POST", "/.well-known/oauth-authorization-server", nil)
+ w := httptest.NewRecorder()
+
+ // Call the handler
+ handler(w, req)
+
+ // Check response
+ if w.Code != http.StatusMethodNotAllowed {
+ t.Errorf("Expected status MethodNotAllowed for POST request, got %v", w.Code)
+ }
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..20c0893
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,196 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLoadConfig(t *testing.T) {
+ // Create a temporary config file
+ tempDir := t.TempDir()
+ configPath := filepath.Join(tempDir, "test_config.yaml")
+
+ // Basic valid config
+ validConfig := `
+listen_port: 8080
+base_url: "http://localhost:8000"
+transport_mode: "sse"
+paths:
+ sse: "/sse"
+ messages: "/messages"
+cors:
+ allowed_origins:
+ - "http://localhost:5173"
+ allowed_methods:
+ - "GET"
+ - "POST"
+ allowed_headers:
+ - "Authorization"
+ - "Content-Type"
+ allow_credentials: true
+`
+ err := os.WriteFile(configPath, []byte(validConfig), 0644)
+ if err != nil {
+ t.Fatalf("Failed to create test config file: %v", err)
+ }
+
+ // Test loading the valid config
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("Failed to load valid config: %v", err)
+ }
+
+ // Verify expected values from the config
+ if cfg.ListenPort != 8080 {
+ t.Errorf("Expected ListenPort=8080, got %d", cfg.ListenPort)
+ }
+ if cfg.BaseURL != "http://localhost:8000" {
+ t.Errorf("Expected BaseURL=http://localhost:8000, got %s", cfg.BaseURL)
+ }
+ if cfg.TransportMode != SSETransport {
+ t.Errorf("Expected TransportMode=sse, got %s", cfg.TransportMode)
+ }
+ if cfg.Paths.SSE != "/sse" {
+ t.Errorf("Expected Paths.SSE=/sse, got %s", cfg.Paths.SSE)
+ }
+ if cfg.Paths.Messages != "/messages" {
+ t.Errorf("Expected Paths.Messages=/messages, got %s", cfg.Paths.Messages)
+ }
+
+ // Test default values
+ if cfg.TimeoutSeconds != 15 {
+ t.Errorf("Expected default TimeoutSeconds=15, got %d", cfg.TimeoutSeconds)
+ }
+ if cfg.Port != 8000 {
+ t.Errorf("Expected default Port=8000, got %d", cfg.Port)
+ }
+}
+
+func TestValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ config Config
+ expectError bool
+ }{
+ {
+ name: "Valid SSE config",
+ config: Config{
+ TransportMode: SSETransport,
+ Paths: PathsConfig{
+ SSE: "/sse",
+ Messages: "/messages",
+ },
+ BaseURL: "http://localhost:8000",
+ },
+ expectError: false,
+ },
+ {
+ name: "Valid stdio config",
+ config: Config{
+ TransportMode: StdioTransport,
+ Stdio: StdioConfig{
+ Enabled: true,
+ UserCommand: "some-command",
+ },
+ },
+ expectError: false,
+ },
+ {
+ name: "Invalid stdio config - not enabled",
+ config: Config{
+ TransportMode: StdioTransport,
+ Stdio: StdioConfig{
+ Enabled: false,
+ UserCommand: "some-command",
+ },
+ },
+ expectError: true,
+ },
+ {
+ name: "Invalid stdio config - no command",
+ config: Config{
+ TransportMode: StdioTransport,
+ Stdio: StdioConfig{
+ Enabled: true,
+ UserCommand: "",
+ },
+ },
+ expectError: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := tc.config.Validate()
+ if tc.expectError && err == nil {
+ t.Errorf("Expected validation error but got none")
+ }
+ if !tc.expectError && err != nil {
+ t.Errorf("Expected no validation error but got: %v", err)
+ }
+ })
+ }
+}
+
+func TestGetMCPPaths(t *testing.T) {
+ cfg := Config{
+ Paths: PathsConfig{
+ SSE: "/custom-sse",
+ Messages: "/custom-messages",
+ },
+ }
+
+ paths := cfg.GetMCPPaths()
+ if len(paths) != 2 {
+ t.Errorf("Expected 2 MCP paths, got %d", len(paths))
+ }
+ if paths[0] != "/custom-sse" {
+ t.Errorf("Expected first path=/custom-sse, got %s", paths[0])
+ }
+ if paths[1] != "/custom-messages" {
+ t.Errorf("Expected second path=/custom-messages, got %s", paths[1])
+ }
+}
+
+func TestBuildExecCommand(t *testing.T) {
+ tests := []struct {
+ name string
+ config Config
+ expectedResult string
+ }{
+ {
+ name: "Valid command",
+ config: Config{
+ Stdio: StdioConfig{
+ UserCommand: "test-command",
+ },
+ Port: 8080,
+ BaseURL: "http://example.com",
+ Paths: PathsConfig{
+ SSE: "/sse-path",
+ Messages: "/msgs",
+ },
+ },
+ expectedResult: `npx -y supergateway --stdio "test-command" --port 8080 --baseUrl http://example.com --ssePath /sse-path --messagePath /msgs`,
+ },
+ {
+ name: "Empty command",
+ config: Config{
+ Stdio: StdioConfig{
+ UserCommand: "",
+ },
+ },
+ expectedResult: "",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := tc.config.BuildExecCommand()
+ if result != tc.expectedResult {
+ t.Errorf("Expected command=%s, got %s", tc.expectedResult, result)
+ }
+ })
+ }
+}
diff --git a/internal/proxy/modifier_test.go b/internal/proxy/modifier_test.go
new file mode 100644
index 0000000..3d2fd44
--- /dev/null
+++ b/internal/proxy/modifier_test.go
@@ -0,0 +1,147 @@
+package proxy
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/wso2/open-mcp-auth-proxy/internal/config"
+)
+
+func TestAuthorizationModifier(t *testing.T) {
+ cfg := &config.Config{
+ Default: config.DefaultConfig{
+ Path: map[string]config.PathConfig{
+ "/authorize": {
+ AddQueryParams: []config.ParamConfig{
+ {Name: "client_id", Value: "test-client-id"},
+ {Name: "scope", Value: "openid"},
+ },
+ },
+ },
+ },
+ }
+
+ modifier := &AuthorizationModifier{Config: cfg}
+
+ // Create a test request
+ req, err := http.NewRequest("GET", "/authorize?response_type=code", nil)
+ if err != nil {
+ t.Fatalf("Failed to create request: %v", err)
+ }
+
+ // Modify the request
+ modifiedReq, err := modifier.ModifyRequest(req)
+ if err != nil {
+ t.Fatalf("ModifyRequest failed: %v", err)
+ }
+
+ // Check that the query parameters were added
+ query := modifiedReq.URL.Query()
+ if query.Get("client_id") != "test-client-id" {
+ t.Errorf("Expected client_id=test-client-id, got %s", query.Get("client_id"))
+ }
+ if query.Get("scope") != "openid" {
+ t.Errorf("Expected scope=openid, got %s", query.Get("scope"))
+ }
+ if query.Get("response_type") != "code" {
+ t.Errorf("Expected response_type=code, got %s", query.Get("response_type"))
+ }
+}
+
+func TestTokenModifier(t *testing.T) {
+ cfg := &config.Config{
+ Default: config.DefaultConfig{
+ Path: map[string]config.PathConfig{
+ "/token": {
+ AddBodyParams: []config.ParamConfig{
+ {Name: "audience", Value: "test-audience"},
+ },
+ },
+ },
+ },
+ }
+
+ modifier := &TokenModifier{Config: cfg}
+
+ // Create a test request with form data
+ form := url.Values{}
+
+ req, err := http.NewRequest("POST", "/token", strings.NewReader(form.Encode()))
+ if err != nil {
+ t.Fatalf("Failed to create request: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ // Modify the request
+ modifiedReq, err := modifier.ModifyRequest(req)
+ if err != nil {
+ t.Fatalf("ModifyRequest failed: %v", err)
+ }
+
+ body := make([]byte, 1024)
+ n, err := modifiedReq.Body.Read(body)
+ if err != nil && err.Error() != "EOF" {
+ t.Fatalf("Failed to read body: %v", err)
+ }
+ bodyStr := string(body[:n])
+
+ // Parse the form data from the modified request
+ if err := modifiedReq.ParseForm(); err != nil {
+ t.Fatalf("Failed to parse form data: %v", err)
+ }
+
+ // Check that the body parameters were added
+ if !strings.Contains(bodyStr, "audience") {
+ t.Errorf("Expected body to contain audience, got %s", bodyStr)
+ }
+}
+
+func TestRegisterModifier(t *testing.T) {
+ cfg := &config.Config{
+ Default: config.DefaultConfig{
+ Path: map[string]config.PathConfig{
+ "/register": {
+ AddBodyParams: []config.ParamConfig{
+ {Name: "client_name", Value: "test-client"},
+ },
+ },
+ },
+ },
+ }
+
+ modifier := &RegisterModifier{Config: cfg}
+
+ // Create a test request with JSON data
+ jsonBody := `{"redirect_uris":["https://example.com/callback"]}`
+ req, err := http.NewRequest("POST", "/register", strings.NewReader(jsonBody))
+ if err != nil {
+ t.Fatalf("Failed to create request: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ // Modify the request
+ modifiedReq, err := modifier.ModifyRequest(req)
+ if err != nil {
+ t.Fatalf("ModifyRequest failed: %v", err)
+ }
+
+ // Read the body and check that it still contains the original data
+ // This test would need to be enhanced with a proper JSON parsing to verify
+ // the added parameters
+ body := make([]byte, 1024)
+ n, err := modifiedReq.Body.Read(body)
+ if err != nil && err.Error() != "EOF" {
+ t.Fatalf("Failed to read body: %v", err)
+ }
+ bodyStr := string(body[:n])
+
+ // Simple check to see if the modified body contains the expected fields
+ if !strings.Contains(bodyStr, "client_name") {
+ t.Errorf("Expected body to contain client_name, got %s", bodyStr)
+ }
+ if !strings.Contains(bodyStr, "redirect_uris") {
+ t.Errorf("Expected body to contain redirect_uris, got %s", bodyStr)
+ }
+}
diff --git a/internal/util/jwks_test.go b/internal/util/jwks_test.go
new file mode 100644
index 0000000..3b00c68
--- /dev/null
+++ b/internal/util/jwks_test.go
@@ -0,0 +1,143 @@
+package util
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/golang-jwt/jwt/v4"
+)
+
+func TestValidateJWT(t *testing.T) {
+ // Initialize the test JWKS data
+ initTestJWKS(t)
+
+ // Test cases
+ tests := []struct {
+ name string
+ authHeader string
+ expectError bool
+ }{
+ {
+ name: "Valid JWT token",
+ authHeader: "Bearer " + createValidJWT(t),
+ expectError: false,
+ },
+ {
+ name: "No auth header",
+ authHeader: "",
+ expectError: true,
+ },
+ {
+ name: "Invalid auth header format",
+ authHeader: "InvalidFormat",
+ expectError: true,
+ },
+ {
+ name: "Invalid JWT token",
+ authHeader: "Bearer invalid.jwt.token",
+ expectError: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := ValidateJWT(tc.authHeader)
+ if tc.expectError && err == nil {
+ t.Errorf("Expected error but got none")
+ }
+ if !tc.expectError && err != nil {
+ t.Errorf("Expected no error but got: %v", err)
+ }
+ })
+ }
+}
+
+func TestFetchJWKS(t *testing.T) {
+ // Create a mock JWKS server
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Generate a test RSA key
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatalf("Failed to generate RSA key: %v", err)
+ }
+
+ // Create JWKS response
+ jwks := map[string]interface{}{
+ "keys": []map[string]interface{}{
+ {
+ "kty": "RSA",
+ "kid": "test-key-id",
+ "n": base64.RawURLEncoding.EncodeToString(privateKey.N.Bytes()),
+ "e": base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}), // Default exponent 65537
+ },
+ },
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(jwks)
+ }))
+ defer server.Close()
+
+ // Test fetching JWKS
+ err := FetchJWKS(server.URL)
+ if err != nil {
+ t.Fatalf("FetchJWKS failed: %v", err)
+ }
+
+ // Check that keys were stored
+ if len(publicKeys) == 0 {
+ t.Errorf("Expected publicKeys to be populated")
+ }
+}
+
+// Helper function to initialize test JWKS data
+func initTestJWKS(t *testing.T) {
+ // Create a test RSA key pair
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatalf("Failed to generate RSA key: %v", err)
+ }
+
+ // Initialize the publicKeys map
+ publicKeys = map[string]*rsa.PublicKey{
+ "test-key-id": &privateKey.PublicKey,
+ }
+}
+
+// Helper function to create a valid JWT token for testing
+func createValidJWT(t *testing.T) string {
+ // Create a test RSA key pair
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatalf("Failed to generate RSA key: %v", err)
+ }
+
+ // Ensure the test key is in the publicKeys map
+ if publicKeys == nil {
+ publicKeys = map[string]*rsa.PublicKey{}
+ }
+ publicKeys["test-key-id"] = &privateKey.PublicKey
+
+ // Create token
+ token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
+ "sub": "1234567890",
+ "name": "Test User",
+ "iat": time.Now().Unix(),
+ "exp": time.Now().Add(time.Hour).Unix(),
+ })
+ token.Header["kid"] = "test-key-id"
+
+ // Sign the token
+ tokenString, err := token.SignedString(privateKey)
+ if err != nil {
+ t.Fatalf("Failed to sign token: %v", err)
+ }
+
+ return tokenString
+}
diff --git a/pull_request_template.md b/pull_request_template.md
index 9b32185..c401a06 100644
--- a/pull_request_template.md
+++ b/pull_request_template.md
@@ -1,52 +1,11 @@
## Purpose
-> Describe the problems, issues, or needs driving this feature/fix and include links to related issues in the following format: Resolves issue1, issue2, etc.
+
-## Goals
-> Describe the solutions that this feature/fix will introduce to resolve the problems described above
-
-## Approach
-> Describe how you are implementing the solutions. Include an animated GIF or screenshot if the change affects the UI (email documentation@wso2.com to review all UI text). Include a link to a Markdown file or Google doc if the feature write-up is too long to paste here.
-
-## User stories
-> Summary of user stories addressed by this change>
-
-## Release note
-> Brief description of the new feature or bug fix as it will appear in the release notes
-
-## Documentation
-> Link(s) to product documentation that addresses the changes of this PR. If no doc impact, enter βN/Aβ plus brief explanation of why thereβs no doc impact
-
-## Training
-> Link to the PR for changes to the training content in https://github.com/wso2/WSO2-Training, if applicable
-
-## Certification
-> Type βSentβ when you have provided new/updated certification questions, plus four answers for each question (correct answer highlighted in bold), based on this change. Certification questions/answers should be sent to certification@wso2.com and NOT pasted in this PR. If there is no impact on certification exams, type βN/Aβ and explain why.
-
-## Marketing
-> Link to drafts of marketing content that will describe and promote this feature, including product page changes, technical articles, blog posts, videos, etc., if applicable
-
-## Automation tests
- - Unit tests
- > Code coverage information
- - Integration tests
- > Details about the test cases and coverage
-
-## Security checks
- - Followed secure coding standards in http://wso2.com/technical-reports/wso2-secure-engineering-guidelines? yes/no
- - Ran FindSecurityBugs plugin and verified report? yes/no
- - Confirmed that this PR doesn't commit any keys, passwords, tokens, usernames, or other secrets? yes/no
-
-## Samples
-> Provide high-level details about the samples related to this feature
+## Related Issues
+
## Related PRs
-> List any other related PRs
+
## Migrations (if applicable)
-> Describe migration steps and platforms on which migration has been tested
-
-## Test environment
-> List all JDK versions, operating systems, databases, and browser/versions on which this feature/fix was tested
-
-## Learning
-> Describe the research phase and any blog posts, patterns, libraries, or add-ons you used to solve the problem.
\ No newline at end of file
+