Skip to content
45 changes: 45 additions & 0 deletions cmd/altinity-mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,51 @@ func validateOAuthRuntimeConfig(cfg config.Config) error {
return fmt.Errorf("oauth forward mode requires clickhouse protocol http")
}

// H-1: gating + cluster_secret uses oauthClaims.Email verbatim as the
// ClickHouse `initial_user`; ClickHouse trusts the impersonation because
// the cluster_secret authenticates the peer. Without RequireEmailVerified,
// any IdP-issued token with email_verified=false (e.g. an Auth0 Database
// Connection that forgot to require verification, a self-hosted OIDC, a
// federated partner IdP) lets the bearer impersonate any provisioned CH
// user just by typing their email at registration. Refuse to start unless
// the operator explicitly opts in to the verified-email check.
if cfg.Server.OAuth.IsGatingMode() &&
strings.TrimSpace(cfg.ClickHouse.ClusterSecret) != "" &&
!cfg.Server.OAuth.RequireEmailVerified {
return fmt.Errorf("oauth gating mode + clickhouse cluster_secret requires oauth.require_email_verified=true (set MCP_OAUTH_REQUIRE_EMAIL_VERIFIED=true): " +
"without it, any IdP-issued token with email_verified=false can impersonate the named CH user via initial_user")
}

// #109: gating mode is now a pure OAuth resource server (Auth0-fronted).
// The fields below belong to the gating-AS role that is being removed.
// Refuse at startup so operators notice and clean up helm values.
if cfg.Server.OAuth.IsGatingMode() {
if cfg.Server.OAuth.ClientID != "" {
return fmt.Errorf("oauth: gating mode forbids oauth.client_id — remove from helm values; client_id is now Auth0's responsibility under #109")
}
if cfg.Server.OAuth.ClientSecret != "" {
return fmt.Errorf("oauth: gating mode forbids oauth.client_secret — remove from helm values; client_secret is now Auth0's responsibility under #109")
}
if cfg.Server.OAuth.TokenURL != "" {
return fmt.Errorf("oauth: gating mode forbids oauth.token_url — remove from helm values; token_url is now Auth0's responsibility under #109")
}
if cfg.Server.OAuth.AuthURL != "" {
return fmt.Errorf("oauth: gating mode forbids oauth.auth_url — remove from helm values; auth_url is now Auth0's responsibility under #109")
}
if cfg.Server.OAuth.UserInfoURL != "" {
return fmt.Errorf("oauth: gating mode forbids oauth.userinfo_url — remove from helm values; userinfo_url is now Auth0's responsibility under #109")
}
if cfg.Server.OAuth.PublicAuthServerURL != "" {
return fmt.Errorf("oauth: gating mode forbids oauth.public_auth_server_url — remove from helm values; public_auth_server_url is now Auth0's responsibility under #109")
}
if strings.TrimSpace(cfg.Server.OAuth.Issuer) == "" {
return fmt.Errorf("oauth: gating mode requires oauth.issuer (the upstream AS, e.g. https://altinity.auth0.com/) to be set")
}
if strings.TrimSpace(cfg.Server.OAuth.Audience) == "" {
return fmt.Errorf("oauth: gating mode requires oauth.audience to byte-equal the MCP public URL (RFC 8707)")
}
}

return nil
}

Expand Down
92 changes: 90 additions & 2 deletions cmd/altinity-mcp/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3288,9 +3288,11 @@ func TestValidateOAuthRuntimeConfig(t *testing.T) {
t.Run("valid_gating_config", func(t *testing.T) {
t.Parallel()
cfg := config.Config{Server: config.ServerConfig{OAuth: config.OAuthConfig{
Enabled: true,
Mode: "gating",
Enabled: true,
Mode: "gating",
SigningSecret: "test-signing-secret-32-byte-key!!",
Issuer: "https://example.auth0.com/",
Audience: "https://example-mcp.test/",
}}}
require.NoError(t, validateOAuthRuntimeConfig(cfg))
})
Expand All @@ -3307,6 +3309,92 @@ func TestValidateOAuthRuntimeConfig(t *testing.T) {
}
require.NoError(t, validateOAuthRuntimeConfig(cfg))
})

// H-1: gating + cluster_secret + !RequireEmailVerified = unverified-email
// impersonation risk. Refuse to start.
t.Run("gating_with_cluster_secret_requires_email_verified", func(t *testing.T) {
t.Parallel()
cfg := config.Config{
Server: config.ServerConfig{OAuth: config.OAuthConfig{
Enabled: true,
Mode: "gating",
SigningSecret: "test-signing-secret-32-byte-key!!",
Issuer: "https://example.auth0.com/",
RequireEmailVerified: false,
}},
ClickHouse: config.ClickHouseConfig{
Protocol: config.TCPProtocol,
ClusterName: "demo",
ClusterSecret: "shared-cluster-interserver-secret",
},
}
err := validateOAuthRuntimeConfig(cfg)
require.Error(t, err)
require.Contains(t, err.Error(), "require_email_verified=true")
})

t.Run("gating_with_cluster_secret_and_email_verified_passes", func(t *testing.T) {
t.Parallel()
cfg := config.Config{
Server: config.ServerConfig{OAuth: config.OAuthConfig{
Enabled: true,
Mode: "gating",
SigningSecret: "test-signing-secret-32-byte-key!!",
Issuer: "https://example.auth0.com/",
Audience: "https://example-mcp.test/",
RequireEmailVerified: true,
}},
ClickHouse: config.ClickHouseConfig{
Protocol: config.TCPProtocol,
ClusterName: "demo",
ClusterSecret: "shared-cluster-interserver-secret",
},
}
require.NoError(t, validateOAuthRuntimeConfig(cfg))
})

t.Run("gating_without_cluster_secret_doesnt_require_email_verified", func(t *testing.T) {
t.Parallel()
// Static-creds gating mode: the email claim never reaches CH as
// initial_user, so RequireEmailVerified isn't load-bearing here.
cfg := config.Config{
Server: config.ServerConfig{OAuth: config.OAuthConfig{
Enabled: true,
Mode: "gating",
SigningSecret: "test-signing-secret-32-byte-key!!",
Issuer: "https://example.auth0.com/",
Audience: "https://example-mcp.test/",
RequireEmailVerified: false,
}},
ClickHouse: config.ClickHouseConfig{Protocol: config.TCPProtocol},
}
require.NoError(t, validateOAuthRuntimeConfig(cfg))
})

t.Run("forward_with_cluster_secret_doesnt_trigger_check", func(t *testing.T) {
t.Parallel()
// Forward mode never uses oauthClaims.Email as initial_user — CH
// re-validates the bearer itself. The H-1 check is gating-specific.
// (Note: forward+cluster_secret is also rejected for being
// http-only-vs-tcp incompatible elsewhere; we just want to confirm
// the H-1 check doesn't fire.)
cfg := config.Config{
Server: config.ServerConfig{OAuth: config.OAuthConfig{
Enabled: true,
Mode: "forward",
SigningSecret: "test-signing-secret-32-byte-key!!",
RequireEmailVerified: false,
}},
ClickHouse: config.ClickHouseConfig{
Protocol: config.HTTPProtocol,
ClusterName: "demo",
ClusterSecret: "shared-cluster-interserver-secret",
},
}
// Should pass H-1 (forward mode); other checks may fail elsewhere
// but validateOAuthRuntimeConfig itself should not fail on H-1.
require.NoError(t, validateOAuthRuntimeConfig(cfg))
})
}

func TestValidateClusterSecretConfig(t *testing.T) {
Expand Down
Loading