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
129 changes: 129 additions & 0 deletions internal/config/auth_broker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//go:build server

package config

import "fmt"

// Auth-broker modes (spec 074, FR-001/FR-003). Each names the upstream
// credential-acquisition strategy the gateway uses on behalf of the caller.
const (
// AuthBrokerModeTokenExchange uses RFC 8693 OAuth 2.0 Token Exchange.
AuthBrokerModeTokenExchange = "token_exchange"
// AuthBrokerModeEntraOBO uses Microsoft Entra On-Behalf-Of flow.
AuthBrokerModeEntraOBO = "entra_obo"
// AuthBrokerModeOAuthConnect uses a per-user OAuth connect/authorize flow.
AuthBrokerModeOAuthConnect = "oauth_connect"
)

// Default header injection settings (FR-016).
const (
defaultAuthBrokerHeader = "Authorization"
defaultAuthBrokerHeaderFormat = "Bearer {token}"
)

// AuthBrokerConfig is the per-upstream token-brokering block (server edition).
// It is opt-in per server (FR-003); upstreams without it behave exactly as
// today. Brokering applies only to HTTP-family upstreams in this phase
// (FR-002).
type AuthBrokerConfig struct {
// Mode selects the credential-acquisition strategy: token_exchange,
// entra_obo, or oauth_connect.
Mode string `json:"mode" mapstructure:"mode"`
// TokenEndpoint is the IdP token endpoint used to mint the upstream credential.
TokenEndpoint string `json:"token_endpoint" mapstructure:"token_endpoint"`
// Resource is the RFC 8707 audience the resulting token is scoped to.
Resource string `json:"resource,omitempty" mapstructure:"resource"`
// Scopes requested for the upstream credential.
Scopes []string `json:"scopes,omitempty" mapstructure:"scopes"`
// ClientID / ClientSecret authenticate the gateway to the token endpoint.
ClientID string `json:"client_id,omitempty" mapstructure:"client_id"`
ClientSecret string `json:"client_secret,omitempty" mapstructure:"client_secret"`
// Header is the outbound header name the resolved credential is injected
// into (FR-016, default "Authorization").
Header string `json:"header,omitempty" mapstructure:"header"`
// HeaderFormat is the value template; "{token}" is replaced with the
// resolved credential (default "Bearer {token}").
HeaderFormat string `json:"header_format,omitempty" mapstructure:"header_format"`
}

// ApplyDefaults fills the optional header-injection fields when unset (FR-016).
func (a *AuthBrokerConfig) ApplyDefaults() {
if a == nil {
return
}
if a.Header == "" {
a.Header = defaultAuthBrokerHeader
}
if a.HeaderFormat == "" {
a.HeaderFormat = defaultAuthBrokerHeaderFormat
}
}

// Validate checks the broker block's own fields (mode + required endpoint).
// Protocol-family enforcement is handled by validateServerAuthBroker, which has
// the surrounding ServerConfig context.
func (a *AuthBrokerConfig) Validate() error {
if a == nil {
return nil
}
switch a.Mode {
case AuthBrokerModeTokenExchange, AuthBrokerModeEntraOBO, AuthBrokerModeOAuthConnect:
// ok
case "":
return fmt.Errorf("auth_broker.mode is required (one of token_exchange, entra_obo, oauth_connect)")
default:
return fmt.Errorf("invalid auth_broker.mode: %q (must be token_exchange, entra_obo, or oauth_connect)", a.Mode)
}
if a.TokenEndpoint == "" {
return fmt.Errorf("auth_broker.token_endpoint is required")
}
return nil
}

// serverIsHTTPFamily reports whether the server is an HTTP/SSE/streamable-HTTP
// upstream, the only kinds that support brokering in this phase (FR-002). A
// server with an explicit stdio protocol, or a bare Command with no URL, is not
// HTTP-family.
func serverIsHTTPFamily(server *ServerConfig) bool {
switch server.Protocol {
case "http", "sse", "streamable-http":
return true
case "stdio":
return false
case "", "auto":
// Inferred: an HTTP-family upstream has a URL and no launch command.
return server.URL != "" && server.Command == ""
default:
return false
}
}

// validateServerAuthBroker applies broker defaults and validates the block in
// the context of its server. It rejects brokering on non-HTTP-family upstreams
// (FR-002) with a clear "unsupported in this phase" message.
func validateServerAuthBroker(server *ServerConfig, fieldPrefix string) []ValidationError {
if server == nil || server.AuthBroker == nil {
return nil
}

var errs []ValidationError
if !serverIsHTTPFamily(server) {
errs = append(errs, ValidationError{
Field: fieldPrefix + ".auth_broker",
Message: "auth_broker is only supported on HTTP-family upstreams (http, sse, streamable-http); brokering for stdio/non-HTTP upstreams is unsupported in this phase",
})
// Still apply defaults so a later edition flip surfaces a complete block,
// but skip field validation — the protocol error is the actionable one.
server.AuthBroker.ApplyDefaults()
return errs
}

server.AuthBroker.ApplyDefaults()
if err := server.AuthBroker.Validate(); err != nil {
errs = append(errs, ValidationError{
Field: fieldPrefix + ".auth_broker",
Message: err.Error(),
})
}
return errs
}
14 changes: 14 additions & 0 deletions internal/config/auth_broker_stub.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//go:build !server

package config

// AuthBrokerConfig is a stub for the personal edition. Per-upstream token
// brokering is a server-edition feature (spec 074); the personal edition keeps
// the field on ServerConfig so configs round-trip, but carries no behavior and
// performs no validation — personal-edition behavior is unaffected.
type AuthBrokerConfig struct{}

// validateServerAuthBroker is a no-op in the personal edition.
func validateServerAuthBroker(_ *ServerConfig, _ string) []ValidationError {
return nil
}
175 changes: 175 additions & 0 deletions internal/config/auth_broker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
//go:build server

package config

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// baseValidConfig returns a minimal Config that passes Validate() so individual
// tests only need to mutate the single server under test.
func baseValidConfig(server *ServerConfig) *Config {
return &Config{
Listen: "127.0.0.1:8080",
ToolsLimit: 15,
ToolResponseLimit: 1000,
CallToolTimeout: Duration(60000000000),
Servers: []*ServerConfig{server},
}
}

func TestAuthBrokerConfig_ApplyDefaults(t *testing.T) {
t.Run("fills header and header_format when empty", func(t *testing.T) {
b := &AuthBrokerConfig{Mode: AuthBrokerModeTokenExchange, TokenEndpoint: "https://idp/token"}
b.ApplyDefaults()
assert.Equal(t, "Authorization", b.Header)
assert.Equal(t, "Bearer {token}", b.HeaderFormat)
})

t.Run("preserves custom header and header_format", func(t *testing.T) {
b := &AuthBrokerConfig{
Mode: AuthBrokerModeTokenExchange,
TokenEndpoint: "https://idp/token",
Header: "X-Upstream-Auth",
HeaderFormat: "token {token}",
}
b.ApplyDefaults()
assert.Equal(t, "X-Upstream-Auth", b.Header)
assert.Equal(t, "token {token}", b.HeaderFormat)
})
}

func TestAuthBroker_ValidHTTPBroker(t *testing.T) {
server := &ServerConfig{
Name: "github",
Protocol: "http",
URL: "https://api.github.com/mcp",
AuthBroker: &AuthBrokerConfig{
Mode: AuthBrokerModeTokenExchange,
TokenEndpoint: "https://idp.example.com/token",
Resource: "https://api.github.com",
Scopes: []string{"repo"},
ClientID: "client-123",
ClientSecret: "secret-xyz",
},
}
cfg := baseValidConfig(server)
require.NoError(t, cfg.Validate())

// Defaults applied to the in-place broker after Validate().
assert.Equal(t, "Authorization", server.AuthBroker.Header)
assert.Equal(t, "Bearer {token}", server.AuthBroker.HeaderFormat)
}

func TestAuthBroker_RejectedOnStdio(t *testing.T) {
server := &ServerConfig{
Name: "local",
Protocol: "stdio",
Command: "npx",
Args: []string{"some-mcp"},
AuthBroker: &AuthBrokerConfig{
Mode: AuthBrokerModeTokenExchange,
TokenEndpoint: "https://idp.example.com/token",
},
}
cfg := baseValidConfig(server)
err := cfg.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported in this phase")
}

func TestAuthBroker_RejectedOnImpliedStdio(t *testing.T) {
// No protocol + Command set => stdio by inference; broker must be rejected.
server := &ServerConfig{
Name: "local-implied",
Command: "npx",
AuthBroker: &AuthBrokerConfig{
Mode: AuthBrokerModeTokenExchange,
TokenEndpoint: "https://idp.example.com/token",
},
}
cfg := baseValidConfig(server)
err := cfg.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported in this phase")
}

func TestAuthBroker_InvalidMode(t *testing.T) {
server := &ServerConfig{
Name: "github",
Protocol: "http",
URL: "https://api.github.com/mcp",
AuthBroker: &AuthBrokerConfig{
Mode: "magic",
TokenEndpoint: "https://idp.example.com/token",
},
}
cfg := baseValidConfig(server)
err := cfg.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "mode")
}

func TestAuthBroker_MissingRequiredFields(t *testing.T) {
t.Run("missing mode", func(t *testing.T) {
cfg := baseValidConfig(&ServerConfig{
Name: "github", Protocol: "http", URL: "https://api.github.com/mcp",
AuthBroker: &AuthBrokerConfig{TokenEndpoint: "https://idp/token"},
})
require.Error(t, cfg.Validate())
})
t.Run("missing token_endpoint", func(t *testing.T) {
cfg := baseValidConfig(&ServerConfig{
Name: "github", Protocol: "http", URL: "https://api.github.com/mcp",
AuthBroker: &AuthBrokerConfig{Mode: AuthBrokerModeEntraOBO},
})
require.Error(t, cfg.Validate())
})
}

func TestAuthBroker_AllValidModes(t *testing.T) {
for _, mode := range []string{AuthBrokerModeTokenExchange, AuthBrokerModeEntraOBO, AuthBrokerModeOAuthConnect} {
t.Run(mode, func(t *testing.T) {
cfg := baseValidConfig(&ServerConfig{
Name: "s", Protocol: "streamable-http", URL: "https://x/mcp",
AuthBroker: &AuthBrokerConfig{Mode: mode, TokenEndpoint: "https://idp/token"},
})
require.NoError(t, cfg.Validate())
})
}
}

func TestAuthBroker_NoBrokerUnaffected(t *testing.T) {
// Servers without a broker block validate exactly as before (FR-003).
cfg := baseValidConfig(&ServerConfig{Name: "plain", Protocol: "stdio", Command: "echo"})
require.NoError(t, cfg.Validate())
}

func TestAuthBroker_JSONRoundTrip(t *testing.T) {
raw := `{
"name": "github",
"protocol": "http",
"url": "https://api.github.com/mcp",
"auth_broker": {
"mode": "entra_obo",
"token_endpoint": "https://login.microsoftonline.com/tenant/oauth2/v2.0/token",
"resource": "api://upstream",
"scopes": ["user.read"],
"client_id": "abc",
"client_secret": "def",
"header": "X-Auth",
"header_format": "Bearer {token}"
}
}`
var sc ServerConfig
require.NoError(t, json.Unmarshal([]byte(raw), &sc))
require.NotNil(t, sc.AuthBroker)
assert.Equal(t, AuthBrokerModeEntraOBO, sc.AuthBroker.Mode)
assert.Equal(t, "api://upstream", sc.AuthBroker.Resource)
assert.Equal(t, []string{"user.read"}, sc.AuthBroker.Scopes)
assert.Equal(t, "X-Auth", sc.AuthBroker.Header)
}
11 changes: 11 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,14 @@ type ServerConfig struct {
// informational (MCP-1072) — surfaced so a reviewer can see a server's origin
// — and no longer gates quarantine or skip_quarantine.
SourceRegistryProvenance string `json:"source_registry_provenance,omitempty" mapstructure:"source_registry_provenance"`

// AuthBroker holds per-upstream token-brokering configuration (spec 074,
// server edition only). When set, the gateway exchanges the caller's IdP
// subject token for an upstream-scoped credential and injects it into the
// outbound request. The concrete type is build-tagged: a full struct in the
// server edition, an empty stub in the personal edition (which ignores it),
// so personal-edition behavior is unaffected. swaggerignore mirrors Teams.
AuthBroker *AuthBrokerConfig `json:"auth_broker,omitempty" mapstructure:"auth_broker" swaggerignore:"true"`
}

// OAuthConfig represents OAuth configuration for a server
Expand Down Expand Up @@ -1354,6 +1362,9 @@ func (c *Config) ValidateDetailed() []ValidationError {
Message: "enabled_tools and disabled_tools are mutually exclusive; use one or the other",
})
}
// Spec 074: per-upstream auth_broker validation + default application.
// No-op in the personal edition (stub); enforced in the server edition.
errors = append(errors, validateServerAuthBroker(server, fieldPrefix)...)
}

// Validate DataDir exists (if specified and not empty).
Expand Down
16 changes: 11 additions & 5 deletions internal/config/teams_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package config

import (
"fmt"
"os"
"strings"
"time"
)
Expand All @@ -18,12 +19,12 @@ type TeamsConfig struct {
WorkspaceIdleTimeout Duration `json:"workspace_idle_timeout,omitempty" mapstructure:"workspace-idle-timeout"`
MaxUserServers int `json:"max_user_servers,omitempty" mapstructure:"max-user-servers"`

// CredentialEncryptionKey is the base64-encoded 32-byte AES-256 master key
// used by the upstream token broker (spec 074) to encrypt stored
// credentials at rest. The MCPPROXY_CRED_KEY environment variable takes
// precedence over this value. When neither is set, the broker is disabled
// gracefully (the rest of the gateway is unaffected).
// CredentialEncryptionKey encrypts per-user upstream credentials at rest
// (spec 074). When empty, it falls back to the MCPPROXY_CRED_KEY env var.
CredentialEncryptionKey string `json:"credential_encryption_key,omitempty" mapstructure:"credential-encryption-key"`
// StoreIDPTokens controls whether caller IdP subject tokens are persisted.
// Privacy-preserving default: false (FR-006).
StoreIDPTokens bool `json:"store_idp_tokens" mapstructure:"store-idp-tokens"`
}

// TeamsOAuthConfig holds OAuth identity provider configuration for the server edition.
Expand Down Expand Up @@ -61,6 +62,11 @@ func (c *TeamsConfig) Validate() error {
if !c.Enabled {
return nil // disabled, no validation needed
}
// Spec 074: fall back to MCPPROXY_CRED_KEY when no explicit key is set.
// An explicit config value always wins over the environment.
if c.CredentialEncryptionKey == "" {
c.CredentialEncryptionKey = os.Getenv("MCPPROXY_CRED_KEY")
}
if len(c.AdminEmails) == 0 {
return fmt.Errorf("teams.admin_emails must contain at least one admin email")
}
Expand Down
Loading
Loading