Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/check.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Check

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
check:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.23"

- run: go test ./...
- run: go vet ./...
- uses: dominikh/staticcheck-action@v1.4.0
with:
install-go: false
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ For a simpler approach to publish local MCP servers over OAuth, consider [MCP Wa
| `GITHUB_CLIENT_ID` | No | GitHub OAuth client ID | - |
| `GITHUB_CLIENT_SECRET` | No | GitHub OAuth client secret | - |
| `GITHUB_ALLOWED_USERS` | No | Comma-separated list of allowed GitHub usernames | - |
| `OIDC_CONFIGURATION_URL` | No | OIDC configuration URL | - |
| `OIDC_CLIENT_ID` | No | OIDC client ID | - |
| `OIDC_CLIENT_SECRET` | No | OIDC client secret | - |
| `OIDC_SCOPES` | No | Comma-separated list of OIDC scopes | `openid,profile,email` |
| `OIDC_USER_ID_FIELD` | No | JSON pointer to user ID field in userinfo endpoint response | `/email` |
| `OIDC_PROVIDER_NAME` | No | Display name for OIDC provider | `OIDC` |
| `OIDC_ALLOWED_USERS` | No | Comma-separated list of allowed OIDC users | - |
| `PASSWORD` | No | Plain text password (will be hashed with bcrypt) | - |
| `PASSWORD_HASH` | No | Bcrypt hash of password for authentication | - |
| `PROXY_BEARER_TOKEN` | No | Bearer token to add to Authorization header when proxying requests | - |
Expand All @@ -135,6 +142,13 @@ For a simpler approach to publish local MCP servers over OAuth, consider [MCP Wa
1. Go to the [Register new GitHub App](https://github.com/settings/apps/new)
2. Set Authorization callback URL: `{EXTERNAL_URL}/.auth/github/callback`

#### OIDC Provider Setup
1. Configure your OIDC provider (e.g., Keycloak, Auth0, Azure AD, etc.)
2. Create a new client application
3. Set redirect URI: `{EXTERNAL_URL}/.auth/oidc/callback`
4. Note the configuration URL (usually issuer URL + /.well-known/openid-configuration), client ID, and client secret
5. Configure the userinfo endpoint to return user identification field (default: email)

## 🚀 Usage

### Method 1: Download Binary
Expand All @@ -152,6 +166,10 @@ Download the latest binary from [releases](https://github.com/sigbit/mcp-auth-pr
--github-client-id "your-github-client-id" \
--github-client-secret "your-github-client-secret" \
--github-allowed-users "username1,username2" \
--oidc-configuration-url "https://your-oidc-provider.com/.well-known/openid-configuration" \
--oidc-client-id "your-oidc-client-id" \
--oidc-client-secret "your-oidc-client-secret" \
--oidc-allowed-users "user1@example.com,user2@example.com" \
http://localhost:8080
```

Expand All @@ -168,6 +186,10 @@ docker run --rm --net=host \
-e GITHUB_CLIENT_ID="your-github-client-id" \
-e GITHUB_CLIENT_SECRET="your-github-client-secret" \
-e GITHUB_ALLOWED_USERS="username1,username2" \
-e OIDC_CONFIGURATION_URL="https://your-oidc-provider.com/.well-known/openid-configuration" \
-e OIDC_CLIENT_ID="your-oidc-client-id" \
-e OIDC_CLIENT_SECRET="your-oidc-client-secret" \
-e OIDC_ALLOWED_USERS="user1@example.com,user2@example.com" \
-v ./data:/data \
ghcr.io/sigbit/mcp-auth-proxy:latest \
http://localhost:8080
Expand Down
8 changes: 8 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
coverage:
status:
project:
default:
informational: true
patch:
default:
informational: true
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/gin-gonic/gin v1.10.1
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/mark3labs/mcp-go v0.37.0
github.com/mattn/go-jsonpointer v0.0.1
github.com/ory/fosite v0.49.0
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.10.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-jsonpointer v0.0.1 h1:j5m5P9BdP4B/zn6J7oH3KIQSOa2OHmcNAKEozUW7wuE=
github.com/mattn/go-jsonpointer v0.0.1/go.mod h1:1s8vx7JSjlgVRF+LW16MPpWSRZAxyrc1/FYzOonxeao=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
Expand Down
41 changes: 41 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ func main() {
var githubClientID string
var githubClientSecret string
var githubAllowedUsers string
var oidcConfigurationURL string
var oidcClientID string
var oidcClientSecret string
var oidcScopes string
var oidcUserIDField string
var oidcProviderName string
var oidcAllowedUsers string
var password string
var passwordHash string
var proxyBearerToken string
Expand All @@ -64,6 +71,24 @@ func main() {
}
}

var oidcAllowedUsersList []string
if oidcAllowedUsers != "" {
oidcAllowedUsersList = strings.Split(oidcAllowedUsers, ",")
for i := range oidcAllowedUsersList {
oidcAllowedUsersList[i] = strings.TrimSpace(oidcAllowedUsersList[i])
}
}

var oidcScopesList []string
if oidcScopes != "" {
oidcScopesList = strings.Split(oidcScopes, ",")
for i := range oidcScopesList {
oidcScopesList[i] = strings.TrimSpace(oidcScopesList[i])
}
} else {
oidcScopesList = []string{"openid", "profile", "email"}
}

// Parse proxy headers into slice
var proxyHeadersList []string
if proxyHeaders != "" {
Expand All @@ -88,6 +113,13 @@ func main() {
githubClientID,
githubClientSecret,
githubAllowedUsersList,
oidcConfigurationURL,
oidcClientID,
oidcClientSecret,
oidcScopesList,
oidcUserIDField,
oidcProviderName,
oidcAllowedUsersList,
password,
passwordHash,
proxyHeadersList,
Expand Down Expand Up @@ -118,6 +150,15 @@ func main() {
rootCmd.Flags().StringVar(&githubClientSecret, "github-client-secret", getEnvWithDefault("GITHUB_CLIENT_SECRET", ""), "GitHub OAuth client secret")
rootCmd.Flags().StringVar(&githubAllowedUsers, "github-allowed-users", getEnvWithDefault("GITHUB_ALLOWED_USERS", ""), "Comma-separated list of allowed GitHub users (usernames)")

// OIDC configuration
rootCmd.Flags().StringVar(&oidcConfigurationURL, "oidc-configuration-url", getEnvWithDefault("OIDC_CONFIGURATION_URL", ""), "OIDC configuration URL")
rootCmd.Flags().StringVar(&oidcClientID, "oidc-client-id", getEnvWithDefault("OIDC_CLIENT_ID", ""), "OIDC client ID")
rootCmd.Flags().StringVar(&oidcClientSecret, "oidc-client-secret", getEnvWithDefault("OIDC_CLIENT_SECRET", ""), "OIDC client secret")
rootCmd.Flags().StringVar(&oidcScopes, "oidc-scopes", getEnvWithDefault("OIDC_SCOPES", "openid,profile,email"), "Comma-separated list of OIDC scopes")
rootCmd.Flags().StringVar(&oidcUserIDField, "oidc-user-id-field", getEnvWithDefault("OIDC_USER_ID_FIELD", "/email"), "JSON pointer to user ID field in userinfo endpoint response")
rootCmd.Flags().StringVar(&oidcProviderName, "oidc-provider-name", getEnvWithDefault("OIDC_PROVIDER_NAME", "OIDC"), "Display name for OIDC provider")
rootCmd.Flags().StringVar(&oidcAllowedUsers, "oidc-allowed-users", getEnvWithDefault("OIDC_ALLOWED_USERS", ""), "Comma-separated list of allowed OIDC users")

// Password authentication
rootCmd.Flags().StringVar(&password, "password", getEnvWithDefault("PASSWORD", ""), "Plain text password for authentication (will be hashed with bcrypt)")
rootCmd.Flags().StringVar(&passwordHash, "password-hash", getEnvWithDefault("PASSWORD_HASH", ""), "Bcrypt hash of password for authentication")
Expand Down
2 changes: 2 additions & 0 deletions pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ const (
GoogleCallbackEndpoint = "/.auth/google/callback"
GitHubAuthEndpoint = "/.auth/github"
GitHubCallbackEndpoint = "/.auth/github/callback"
OIDCAuthEndpoint = "/.auth/oidc"
OIDCCallbackEndpoint = "/.auth/oidc/callback"

PasswordProvider = "password"
PasswordUserID = "password_user"
Expand Down
123 changes: 123 additions & 0 deletions pkg/auth/oidc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package auth

import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"

"github.com/gin-gonic/gin"
"github.com/mattn/go-jsonpointer"
"golang.org/x/oauth2"
)

type oidcProvider struct {
oauth2 oauth2.Config
providerName string
userInfoURL string
userIDField string
allowedUsers []string
}

func NewOIDCProvider(
configurationURL string, scopes []string, userIDField string,
providerName, externalURL, clientID, clientSecret string, allowedUsers []string,
) (Provider, error) {
resp, err := http.Get(configurationURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var cfg struct {
AuthEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserInfo string `json:"userinfo_endpoint"`
}
if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
return nil, err
}
r, err := url.JoinPath(externalURL, OIDCCallbackEndpoint)
if err != nil {
return nil, err
}
return &oidcProvider{
oauth2: oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: r,
Scopes: scopes,
Endpoint: oauth2.Endpoint{
AuthURL: cfg.AuthEndpoint,
TokenURL: cfg.TokenEndpoint,
},
},
providerName: providerName,
userInfoURL: cfg.UserInfo,
userIDField: userIDField,
allowedUsers: allowedUsers,
}, nil
}

func (p *oidcProvider) Name() string {
return p.providerName
}

func (p *oidcProvider) RedirectURL() string {
return OIDCCallbackEndpoint
}

func (p *oidcProvider) AuthURL() string {
return OIDCAuthEndpoint
}

func (p *oidcProvider) AuthCodeURL(c *gin.Context, state string) (string, error) {
authURL := p.oauth2.AuthCodeURL(state)
return authURL, nil
}

func (p *oidcProvider) Exchange(c *gin.Context, state string) (*oauth2.Token, error) {
if c.Query("state") != state {
return nil, errors.New("invalid OAuth state")
}
code := c.Query("code")
token, err := p.oauth2.Exchange(c, code)
if err != nil {
return nil, err
}
return token, nil
}

func (p *oidcProvider) GetUserID(ctx context.Context, token *oauth2.Token) (string, error) {
client := p.oauth2.Client(ctx, token)
resp, err := client.Get(p.userInfoURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
var obj any
if err := json.NewDecoder(resp.Body).Decode(&obj); err != nil {
return "", err
}
v, err := jsonpointer.Get(obj, p.userIDField)
if err != nil {
return "", err
}
userID, ok := v.(string)
if !ok {
return "", errors.New("user ID field is not a string")
}
return userID, nil
}

func (p *oidcProvider) Authorization(userid string) (bool, error) {
if len(p.allowedUsers) == 0 {
return true, nil
}
for _, allowedUser := range p.allowedUsers {
if allowedUser == userid {
return true, nil
}
}
return false, nil
}
25 changes: 25 additions & 0 deletions pkg/mcp-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ func Run(
githubClientID string,
githubClientSecret string,
githubAllowedUsers []string,
oidcConfigurationURL string,
oidcClientID string,
oidcClientSecret string,
oidcScopes []string,
oidcUserIDField string,
oidcProviderName string,
oidcAllowedUsers []string,
password string,
passwordHash string,
proxyHeaders []string,
Expand Down Expand Up @@ -148,6 +155,24 @@ func Run(
providers = append(providers, githubProvider)
}

// Add OIDC provider if configured
if oidcConfigurationURL != "" && oidcClientID != "" && oidcClientSecret != "" {
oidcProvider, err := auth.NewOIDCProvider(
oidcConfigurationURL,
oidcScopes,
oidcUserIDField,
oidcProviderName,
externalURL,
oidcClientID,
oidcClientSecret,
oidcAllowedUsers,
)
if err != nil {
return fmt.Errorf("failed to create OIDC provider: %w", err)
}
providers = append(providers, oidcProvider)
}

var passwordHashes []string

// Handle password argument - generate bcrypt hash if provided
Expand Down