diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..4ec2c7f --- /dev/null +++ b/.github/workflows/check.yaml @@ -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 diff --git a/README.md b/README.md index f712f25..e217d2b 100644 --- a/README.md +++ b/README.md @@ -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 | - | @@ -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 @@ -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 ``` @@ -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 diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..bfdc987 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,8 @@ +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true diff --git a/go.mod b/go.mod index ed7bfdf..ebc09d1 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index ad1104f..55c4b2d 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index da974c5..41557bb 100644 --- a/main.go +++ b/main.go @@ -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 @@ -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 != "" { @@ -88,6 +113,13 @@ func main() { githubClientID, githubClientSecret, githubAllowedUsersList, + oidcConfigurationURL, + oidcClientID, + oidcClientSecret, + oidcScopesList, + oidcUserIDField, + oidcProviderName, + oidcAllowedUsersList, password, passwordHash, proxyHeadersList, @@ -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") diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 9e4b65c..3acb5b6 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -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" diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go new file mode 100644 index 0000000..f9341f5 --- /dev/null +++ b/pkg/auth/oidc.go @@ -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 +} diff --git a/pkg/mcp-proxy/main.go b/pkg/mcp-proxy/main.go index 4381f8c..efe732a 100644 --- a/pkg/mcp-proxy/main.go +++ b/pkg/mcp-proxy/main.go @@ -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, @@ -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