Skip to content

Commit 122d3e3

Browse files
committed
feat(config): per-upstream auth_broker block + teams credential keys (spec 074)
Add server-edition configuration surface for per-user upstream token brokering (spec 074, T2 / MCP-1035): - ServerConfig.AuthBroker *AuthBrokerConfig with mode (token_exchange|entra_obo|oauth_connect), token_endpoint, resource (RFC 8707 audience), scopes, client_id/secret, and configurable header + header_format (defaults "Authorization" / "Bearer {token}", FR-016). - TeamsConfig.CredentialEncryptionKey (env fallback MCPPROXY_CRED_KEY, explicit config wins) and StoreIDPTokens bool, default false (FR-006). - Validation rejects auth_broker on stdio/non-HTTP-family upstreams with an "unsupported in this phase" message (FR-002); HTTP-family upstreams pass and have header defaults applied. Opt-in per server; upstreams without a broker behave exactly as today (FR-003). The AuthBrokerConfig type and validation are behind //go:build server with a personal-edition stub (empty struct + no-op validator), so the personal edition is unaffected. AuthBroker carries swaggerignore (mirrors Teams) — swagger-verify confirms no OpenAPI drift. Related #587
1 parent 206bee3 commit 122d3e3

6 files changed

Lines changed: 408 additions & 0 deletions

File tree

internal/config/auth_broker.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
//go:build server
2+
3+
package config
4+
5+
import "fmt"
6+
7+
// Auth-broker modes (spec 074, FR-001/FR-003). Each names the upstream
8+
// credential-acquisition strategy the gateway uses on behalf of the caller.
9+
const (
10+
// AuthBrokerModeTokenExchange uses RFC 8693 OAuth 2.0 Token Exchange.
11+
AuthBrokerModeTokenExchange = "token_exchange"
12+
// AuthBrokerModeEntraOBO uses Microsoft Entra On-Behalf-Of flow.
13+
AuthBrokerModeEntraOBO = "entra_obo"
14+
// AuthBrokerModeOAuthConnect uses a per-user OAuth connect/authorize flow.
15+
AuthBrokerModeOAuthConnect = "oauth_connect"
16+
)
17+
18+
// Default header injection settings (FR-016).
19+
const (
20+
defaultAuthBrokerHeader = "Authorization"
21+
defaultAuthBrokerHeaderFormat = "Bearer {token}"
22+
)
23+
24+
// AuthBrokerConfig is the per-upstream token-brokering block (server edition).
25+
// It is opt-in per server (FR-003); upstreams without it behave exactly as
26+
// today. Brokering applies only to HTTP-family upstreams in this phase
27+
// (FR-002).
28+
type AuthBrokerConfig struct {
29+
// Mode selects the credential-acquisition strategy: token_exchange,
30+
// entra_obo, or oauth_connect.
31+
Mode string `json:"mode" mapstructure:"mode"`
32+
// TokenEndpoint is the IdP token endpoint used to mint the upstream credential.
33+
TokenEndpoint string `json:"token_endpoint" mapstructure:"token_endpoint"`
34+
// Resource is the RFC 8707 audience the resulting token is scoped to.
35+
Resource string `json:"resource,omitempty" mapstructure:"resource"`
36+
// Scopes requested for the upstream credential.
37+
Scopes []string `json:"scopes,omitempty" mapstructure:"scopes"`
38+
// ClientID / ClientSecret authenticate the gateway to the token endpoint.
39+
ClientID string `json:"client_id,omitempty" mapstructure:"client_id"`
40+
ClientSecret string `json:"client_secret,omitempty" mapstructure:"client_secret"`
41+
// Header is the outbound header name the resolved credential is injected
42+
// into (FR-016, default "Authorization").
43+
Header string `json:"header,omitempty" mapstructure:"header"`
44+
// HeaderFormat is the value template; "{token}" is replaced with the
45+
// resolved credential (default "Bearer {token}").
46+
HeaderFormat string `json:"header_format,omitempty" mapstructure:"header_format"`
47+
}
48+
49+
// ApplyDefaults fills the optional header-injection fields when unset (FR-016).
50+
func (a *AuthBrokerConfig) ApplyDefaults() {
51+
if a == nil {
52+
return
53+
}
54+
if a.Header == "" {
55+
a.Header = defaultAuthBrokerHeader
56+
}
57+
if a.HeaderFormat == "" {
58+
a.HeaderFormat = defaultAuthBrokerHeaderFormat
59+
}
60+
}
61+
62+
// Validate checks the broker block's own fields (mode + required endpoint).
63+
// Protocol-family enforcement is handled by validateServerAuthBroker, which has
64+
// the surrounding ServerConfig context.
65+
func (a *AuthBrokerConfig) Validate() error {
66+
if a == nil {
67+
return nil
68+
}
69+
switch a.Mode {
70+
case AuthBrokerModeTokenExchange, AuthBrokerModeEntraOBO, AuthBrokerModeOAuthConnect:
71+
// ok
72+
case "":
73+
return fmt.Errorf("auth_broker.mode is required (one of token_exchange, entra_obo, oauth_connect)")
74+
default:
75+
return fmt.Errorf("invalid auth_broker.mode: %q (must be token_exchange, entra_obo, or oauth_connect)", a.Mode)
76+
}
77+
if a.TokenEndpoint == "" {
78+
return fmt.Errorf("auth_broker.token_endpoint is required")
79+
}
80+
return nil
81+
}
82+
83+
// serverIsHTTPFamily reports whether the server is an HTTP/SSE/streamable-HTTP
84+
// upstream, the only kinds that support brokering in this phase (FR-002). A
85+
// server with an explicit stdio protocol, or a bare Command with no URL, is not
86+
// HTTP-family.
87+
func serverIsHTTPFamily(server *ServerConfig) bool {
88+
switch server.Protocol {
89+
case "http", "sse", "streamable-http":
90+
return true
91+
case "stdio":
92+
return false
93+
case "", "auto":
94+
// Inferred: an HTTP-family upstream has a URL and no launch command.
95+
return server.URL != "" && server.Command == ""
96+
default:
97+
return false
98+
}
99+
}
100+
101+
// validateServerAuthBroker applies broker defaults and validates the block in
102+
// the context of its server. It rejects brokering on non-HTTP-family upstreams
103+
// (FR-002) with a clear "unsupported in this phase" message.
104+
func validateServerAuthBroker(server *ServerConfig, fieldPrefix string) []ValidationError {
105+
if server == nil || server.AuthBroker == nil {
106+
return nil
107+
}
108+
109+
var errs []ValidationError
110+
if !serverIsHTTPFamily(server) {
111+
errs = append(errs, ValidationError{
112+
Field: fieldPrefix + ".auth_broker",
113+
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",
114+
})
115+
// Still apply defaults so a later edition flip surfaces a complete block,
116+
// but skip field validation — the protocol error is the actionable one.
117+
server.AuthBroker.ApplyDefaults()
118+
return errs
119+
}
120+
121+
server.AuthBroker.ApplyDefaults()
122+
if err := server.AuthBroker.Validate(); err != nil {
123+
errs = append(errs, ValidationError{
124+
Field: fieldPrefix + ".auth_broker",
125+
Message: err.Error(),
126+
})
127+
}
128+
return errs
129+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//go:build !server
2+
3+
package config
4+
5+
// AuthBrokerConfig is a stub for the personal edition. Per-upstream token
6+
// brokering is a server-edition feature (spec 074); the personal edition keeps
7+
// the field on ServerConfig so configs round-trip, but carries no behavior and
8+
// performs no validation — personal-edition behavior is unaffected.
9+
type AuthBrokerConfig struct{}
10+
11+
// validateServerAuthBroker is a no-op in the personal edition.
12+
func validateServerAuthBroker(_ *ServerConfig, _ string) []ValidationError {
13+
return nil
14+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
//go:build server
2+
3+
package config
4+
5+
import (
6+
"encoding/json"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// baseValidConfig returns a minimal Config that passes Validate() so individual
14+
// tests only need to mutate the single server under test.
15+
func baseValidConfig(server *ServerConfig) *Config {
16+
return &Config{
17+
Listen: "127.0.0.1:8080",
18+
ToolsLimit: 15,
19+
ToolResponseLimit: 1000,
20+
CallToolTimeout: Duration(60000000000),
21+
Servers: []*ServerConfig{server},
22+
}
23+
}
24+
25+
func TestAuthBrokerConfig_ApplyDefaults(t *testing.T) {
26+
t.Run("fills header and header_format when empty", func(t *testing.T) {
27+
b := &AuthBrokerConfig{Mode: AuthBrokerModeTokenExchange, TokenEndpoint: "https://idp/token"}
28+
b.ApplyDefaults()
29+
assert.Equal(t, "Authorization", b.Header)
30+
assert.Equal(t, "Bearer {token}", b.HeaderFormat)
31+
})
32+
33+
t.Run("preserves custom header and header_format", func(t *testing.T) {
34+
b := &AuthBrokerConfig{
35+
Mode: AuthBrokerModeTokenExchange,
36+
TokenEndpoint: "https://idp/token",
37+
Header: "X-Upstream-Auth",
38+
HeaderFormat: "token {token}",
39+
}
40+
b.ApplyDefaults()
41+
assert.Equal(t, "X-Upstream-Auth", b.Header)
42+
assert.Equal(t, "token {token}", b.HeaderFormat)
43+
})
44+
}
45+
46+
func TestAuthBroker_ValidHTTPBroker(t *testing.T) {
47+
server := &ServerConfig{
48+
Name: "github",
49+
Protocol: "http",
50+
URL: "https://api.github.com/mcp",
51+
AuthBroker: &AuthBrokerConfig{
52+
Mode: AuthBrokerModeTokenExchange,
53+
TokenEndpoint: "https://idp.example.com/token",
54+
Resource: "https://api.github.com",
55+
Scopes: []string{"repo"},
56+
ClientID: "client-123",
57+
ClientSecret: "secret-xyz",
58+
},
59+
}
60+
cfg := baseValidConfig(server)
61+
require.NoError(t, cfg.Validate())
62+
63+
// Defaults applied to the in-place broker after Validate().
64+
assert.Equal(t, "Authorization", server.AuthBroker.Header)
65+
assert.Equal(t, "Bearer {token}", server.AuthBroker.HeaderFormat)
66+
}
67+
68+
func TestAuthBroker_RejectedOnStdio(t *testing.T) {
69+
server := &ServerConfig{
70+
Name: "local",
71+
Protocol: "stdio",
72+
Command: "npx",
73+
Args: []string{"some-mcp"},
74+
AuthBroker: &AuthBrokerConfig{
75+
Mode: AuthBrokerModeTokenExchange,
76+
TokenEndpoint: "https://idp.example.com/token",
77+
},
78+
}
79+
cfg := baseValidConfig(server)
80+
err := cfg.Validate()
81+
require.Error(t, err)
82+
assert.Contains(t, err.Error(), "unsupported in this phase")
83+
}
84+
85+
func TestAuthBroker_RejectedOnImpliedStdio(t *testing.T) {
86+
// No protocol + Command set => stdio by inference; broker must be rejected.
87+
server := &ServerConfig{
88+
Name: "local-implied",
89+
Command: "npx",
90+
AuthBroker: &AuthBrokerConfig{
91+
Mode: AuthBrokerModeTokenExchange,
92+
TokenEndpoint: "https://idp.example.com/token",
93+
},
94+
}
95+
cfg := baseValidConfig(server)
96+
err := cfg.Validate()
97+
require.Error(t, err)
98+
assert.Contains(t, err.Error(), "unsupported in this phase")
99+
}
100+
101+
func TestAuthBroker_InvalidMode(t *testing.T) {
102+
server := &ServerConfig{
103+
Name: "github",
104+
Protocol: "http",
105+
URL: "https://api.github.com/mcp",
106+
AuthBroker: &AuthBrokerConfig{
107+
Mode: "magic",
108+
TokenEndpoint: "https://idp.example.com/token",
109+
},
110+
}
111+
cfg := baseValidConfig(server)
112+
err := cfg.Validate()
113+
require.Error(t, err)
114+
assert.Contains(t, err.Error(), "mode")
115+
}
116+
117+
func TestAuthBroker_MissingRequiredFields(t *testing.T) {
118+
t.Run("missing mode", func(t *testing.T) {
119+
cfg := baseValidConfig(&ServerConfig{
120+
Name: "github", Protocol: "http", URL: "https://api.github.com/mcp",
121+
AuthBroker: &AuthBrokerConfig{TokenEndpoint: "https://idp/token"},
122+
})
123+
require.Error(t, cfg.Validate())
124+
})
125+
t.Run("missing token_endpoint", func(t *testing.T) {
126+
cfg := baseValidConfig(&ServerConfig{
127+
Name: "github", Protocol: "http", URL: "https://api.github.com/mcp",
128+
AuthBroker: &AuthBrokerConfig{Mode: AuthBrokerModeEntraOBO},
129+
})
130+
require.Error(t, cfg.Validate())
131+
})
132+
}
133+
134+
func TestAuthBroker_AllValidModes(t *testing.T) {
135+
for _, mode := range []string{AuthBrokerModeTokenExchange, AuthBrokerModeEntraOBO, AuthBrokerModeOAuthConnect} {
136+
t.Run(mode, func(t *testing.T) {
137+
cfg := baseValidConfig(&ServerConfig{
138+
Name: "s", Protocol: "streamable-http", URL: "https://x/mcp",
139+
AuthBroker: &AuthBrokerConfig{Mode: mode, TokenEndpoint: "https://idp/token"},
140+
})
141+
require.NoError(t, cfg.Validate())
142+
})
143+
}
144+
}
145+
146+
func TestAuthBroker_NoBrokerUnaffected(t *testing.T) {
147+
// Servers without a broker block validate exactly as before (FR-003).
148+
cfg := baseValidConfig(&ServerConfig{Name: "plain", Protocol: "stdio", Command: "echo"})
149+
require.NoError(t, cfg.Validate())
150+
}
151+
152+
func TestAuthBroker_JSONRoundTrip(t *testing.T) {
153+
raw := `{
154+
"name": "github",
155+
"protocol": "http",
156+
"url": "https://api.github.com/mcp",
157+
"auth_broker": {
158+
"mode": "entra_obo",
159+
"token_endpoint": "https://login.microsoftonline.com/tenant/oauth2/v2.0/token",
160+
"resource": "api://upstream",
161+
"scopes": ["user.read"],
162+
"client_id": "abc",
163+
"client_secret": "def",
164+
"header": "X-Auth",
165+
"header_format": "Bearer {token}"
166+
}
167+
}`
168+
var sc ServerConfig
169+
require.NoError(t, json.Unmarshal([]byte(raw), &sc))
170+
require.NotNil(t, sc.AuthBroker)
171+
assert.Equal(t, AuthBrokerModeEntraOBO, sc.AuthBroker.Mode)
172+
assert.Equal(t, "api://upstream", sc.AuthBroker.Resource)
173+
assert.Equal(t, []string{"user.read"}, sc.AuthBroker.Scopes)
174+
assert.Equal(t, "X-Auth", sc.AuthBroker.Header)
175+
}

internal/config/config.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,14 @@ type ServerConfig struct {
269269
// RegistryProvenanceCustom, skip_quarantine is forbidden — a custom,
270270
// unverified registry can never opt its servers out of quarantine.
271271
SourceRegistryProvenance string `json:"source_registry_provenance,omitempty" mapstructure:"source_registry_provenance"`
272+
273+
// AuthBroker holds per-upstream token-brokering configuration (spec 074,
274+
// server edition only). When set, the gateway exchanges the caller's IdP
275+
// subject token for an upstream-scoped credential and injects it into the
276+
// outbound request. The concrete type is build-tagged: a full struct in the
277+
// server edition, an empty stub in the personal edition (which ignores it),
278+
// so personal-edition behavior is unaffected. swaggerignore mirrors Teams.
279+
AuthBroker *AuthBrokerConfig `json:"auth_broker,omitempty" mapstructure:"auth_broker" swaggerignore:"true"`
272280
}
273281

274282
// OAuthConfig represents OAuth configuration for a server
@@ -1306,6 +1314,10 @@ func (c *Config) ValidateDetailed() []ValidationError {
13061314
Message: "skip_quarantine is not allowed for a server added from a custom/unverified registry",
13071315
})
13081316
}
1317+
1318+
// Spec 074: per-upstream auth_broker validation + default application.
1319+
// No-op in the personal edition (stub); enforced in the server edition.
1320+
errors = append(errors, validateServerAuthBroker(server, fieldPrefix)...)
13091321
}
13101322

13111323
// Validate DataDir exists (if specified and not empty).

internal/config/teams_config.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package config
44

55
import (
66
"fmt"
7+
"os"
78
"strings"
89
"time"
910
)
@@ -17,6 +18,13 @@ type TeamsConfig struct {
1718
BearerTokenTTL Duration `json:"bearer_token_ttl,omitempty" mapstructure:"bearer-token-ttl"`
1819
WorkspaceIdleTimeout Duration `json:"workspace_idle_timeout,omitempty" mapstructure:"workspace-idle-timeout"`
1920
MaxUserServers int `json:"max_user_servers,omitempty" mapstructure:"max-user-servers"`
21+
22+
// CredentialEncryptionKey encrypts per-user upstream credentials at rest
23+
// (spec 074). When empty, it falls back to the MCPPROXY_CRED_KEY env var.
24+
CredentialEncryptionKey string `json:"credential_encryption_key,omitempty" mapstructure:"credential-encryption-key"`
25+
// StoreIDPTokens controls whether caller IdP subject tokens are persisted.
26+
// Privacy-preserving default: false (FR-006).
27+
StoreIDPTokens bool `json:"store_idp_tokens" mapstructure:"store-idp-tokens"`
2028
}
2129

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

0 commit comments

Comments
 (0)