diff --git a/cmd/thv-operator/pkg/controllerutil/authserver.go b/cmd/thv-operator/pkg/controllerutil/authserver.go index e735e691e3..10b967dfef 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver.go @@ -750,6 +750,14 @@ func buildUpstreamRunConfig( return nil, err } config.OAuth2Config = oauth2 + if provider.OAuth2Config.IdentityFromToken != nil { + ift := provider.OAuth2Config.IdentityFromToken + config.OAuth2Config.IdentityFromToken = &authserver.IdentityFromTokenRunConfig{ + SubjectPath: ift.SubjectPath, + NamePath: ift.NamePath, + EmailPath: ift.EmailPath, + } + } } } diff --git a/cmd/thv-operator/pkg/controllerutil/authserver_test.go b/cmd/thv-operator/pkg/controllerutil/authserver_test.go index c8cda64f38..fcb0bd6379 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver_test.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver_test.go @@ -1423,6 +1423,121 @@ func TestBuildAuthServerRunConfig(t *testing.T) { "DCRConfig should remain nil when only ClientID is set") }, }, + { + name: "OAuth2 upstream with identityFromToken all fields set", + resourceURL: defaultResourceURL, + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "signing-key", Key: "private.pem"}, + }, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "hmac-secret", Key: "hmac"}, + }, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + { + Name: "snowflake", + Type: mcpv1beta1.UpstreamProviderTypeOAuth2, + OAuth2Config: &mcpv1beta1.OAuth2UpstreamConfig{ + AuthorizationEndpoint: "https://account.snowflakecomputing.com/oauth/authorize", + TokenEndpoint: "https://account.snowflakecomputing.com/oauth/token-request", + ClientID: "sf-client-id", + IdentityFromToken: &mcpv1beta1.IdentityFromTokenConfig{ + SubjectPath: "username", + NamePath: "display_name", + EmailPath: "email", + }, + }, + }, + }, + }, + allowedAudiences: defaultAudiences, + scopesSupported: defaultScopes, + checkFunc: func(t *testing.T, config *authserver.RunConfig) { + t.Helper() + require.Len(t, config.Upstreams, 1) + upstream := config.Upstreams[0] + require.NotNil(t, upstream.OAuth2Config) + require.NotNil(t, upstream.OAuth2Config.IdentityFromToken) + assert.Equal(t, "username", upstream.OAuth2Config.IdentityFromToken.SubjectPath) + assert.Equal(t, "display_name", upstream.OAuth2Config.IdentityFromToken.NamePath) + assert.Equal(t, "email", upstream.OAuth2Config.IdentityFromToken.EmailPath) + }, + }, + { + name: "OAuth2 upstream with identityFromToken only subjectPath set", + resourceURL: defaultResourceURL, + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "signing-key", Key: "private.pem"}, + }, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "hmac-secret", Key: "hmac"}, + }, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + { + Name: "slack", + Type: mcpv1beta1.UpstreamProviderTypeOAuth2, + OAuth2Config: &mcpv1beta1.OAuth2UpstreamConfig{ + AuthorizationEndpoint: "https://slack.com/oauth/v2/authorize", + TokenEndpoint: "https://slack.com/api/oauth.v2.access", + ClientID: "slack-client-id", + IdentityFromToken: &mcpv1beta1.IdentityFromTokenConfig{ + SubjectPath: "authed_user.id", + }, + }, + }, + }, + }, + allowedAudiences: defaultAudiences, + scopesSupported: defaultScopes, + checkFunc: func(t *testing.T, config *authserver.RunConfig) { + t.Helper() + require.Len(t, config.Upstreams, 1) + upstream := config.Upstreams[0] + require.NotNil(t, upstream.OAuth2Config) + require.NotNil(t, upstream.OAuth2Config.IdentityFromToken) + assert.Equal(t, "authed_user.id", upstream.OAuth2Config.IdentityFromToken.SubjectPath) + assert.Empty(t, upstream.OAuth2Config.IdentityFromToken.NamePath) + assert.Empty(t, upstream.OAuth2Config.IdentityFromToken.EmailPath) + }, + }, + { + name: "OAuth2 upstream with no identityFromToken produces nil", + resourceURL: defaultResourceURL, + authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "signing-key", Key: "private.pem"}, + }, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "hmac-secret", Key: "hmac"}, + }, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + { + Name: "github-no-ift", + Type: mcpv1beta1.UpstreamProviderTypeOAuth2, + OAuth2Config: &mcpv1beta1.OAuth2UpstreamConfig{ + AuthorizationEndpoint: "https://github.com/login/oauth/authorize", + TokenEndpoint: "https://github.com/login/oauth/access_token", + UserInfo: &mcpv1beta1.UserInfoConfig{EndpointURL: "https://api.github.com/user"}, + ClientID: "client-id", + }, + }, + }, + }, + allowedAudiences: defaultAudiences, + scopesSupported: defaultScopes, + checkFunc: func(t *testing.T, config *authserver.RunConfig) { + t.Helper() + require.Len(t, config.Upstreams, 1) + upstream := config.Upstreams[0] + require.NotNil(t, upstream.OAuth2Config) + assert.Nil(t, upstream.OAuth2Config.IdentityFromToken, + "IdentityFromToken must be nil when not configured") + }, + }, } for _, tt := range tests { diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go index 5a57047b7e..40cb89c67b 100644 --- a/pkg/authserver/config.go +++ b/pkg/authserver/config.go @@ -280,6 +280,14 @@ type OAuth2UpstreamRunConfig struct { //nolint:lll // field tags require full JSON+YAML names TokenResponseMapping *TokenResponseMappingRunConfig `json:"token_response_mapping,omitempty" yaml:"token_response_mapping,omitempty"` + // IdentityFromToken extracts user identity (subject, name, email) directly from the + // OAuth2 token-endpoint response body using gjson dot-notation paths. When set, the + // embedded auth server skips the userinfo HTTP call entirely. Mirrors the CRD type + // (cmd/thv-operator/api/v1beta1.IdentityFromTokenConfig) — the authoritative + // trust-model and uniqueness documentation lives there. + //nolint:lll // field tags require full JSON+YAML names + IdentityFromToken *IdentityFromTokenRunConfig `json:"identity_from_token,omitempty" yaml:"identity_from_token,omitempty"` + // AdditionalAuthorizationParams are extra query parameters to include in // authorization requests. Useful for provider-specific parameters like // Google's access_type=offline. @@ -383,6 +391,21 @@ type TokenResponseMappingRunConfig struct { ExpiresInPath string `json:"expires_in_path,omitempty" yaml:"expires_in_path,omitempty"` } +// IdentityFromTokenRunConfig configures extracting user identity claims directly from +// the token-endpoint response body. Mirrors the CRD type +// (cmd/thv-operator/api/v1beta1.IdentityFromTokenConfig) — the authoritative +// trust-model and uniqueness documentation lives there. +type IdentityFromTokenRunConfig struct { + // SubjectPath is the dot-notation path to the subject (user ID) field. + SubjectPath string `json:"subject_path,omitempty" yaml:"subject_path,omitempty"` + + // NamePath is the dot-notation path to the display name field. + NamePath string `json:"name_path,omitempty" yaml:"name_path,omitempty"` + + // EmailPath is the dot-notation path to the email address field. + EmailPath string `json:"email_path,omitempty" yaml:"email_path,omitempty"` +} + // UserInfoRunConfig contains UserInfo endpoint configuration. // This supports both standard OIDC UserInfo endpoints and custom provider-specific endpoints. type UserInfoRunConfig struct { diff --git a/pkg/authserver/runner/embeddedauthserver.go b/pkg/authserver/runner/embeddedauthserver.go index b8eb04f99d..19dedb1b74 100644 --- a/pkg/authserver/runner/embeddedauthserver.go +++ b/pkg/authserver/runner/embeddedauthserver.go @@ -62,6 +62,10 @@ func NewEmbeddedAuthServer(ctx context.Context, cfg *authserver.RunConfig) (*Emb return nil, fmt.Errorf("config is required") } + // Register gjson modifiers used by IdentityFromToken configs (e.g. @upstreamjwt). + // Without this, modifier-bearing paths silently fail to resolve. + upstream.RegisterModifiers() + // Fail loudly on operator-supplied misconfiguration (e.g. a baseline // scope absent from scopes_supported) BEFORE touching storage or any // other side-effecting work, so a bad config never reaches the network @@ -569,6 +573,14 @@ func buildPureOAuth2Config(rc *authserver.UpstreamRunConfig) (*upstream.OAuth2Co } } + if oauth2.IdentityFromToken != nil { + cfg.IdentityFromToken = &upstream.IdentityFromTokenConfig{ + SubjectPath: oauth2.IdentityFromToken.SubjectPath, + NamePath: oauth2.IdentityFromToken.NamePath, + EmailPath: oauth2.IdentityFromToken.EmailPath, + } + } + return cfg, nil } diff --git a/pkg/authserver/runner/embeddedauthserver_test.go b/pkg/authserver/runner/embeddedauthserver_test.go index 14259b955e..f9d9030d43 100644 --- a/pkg/authserver/runner/embeddedauthserver_test.go +++ b/pkg/authserver/runner/embeddedauthserver_test.go @@ -623,6 +623,73 @@ func TestBuildPureOAuth2ConfigWithEnvVar(t *testing.T) { }) } +func TestBuildPureOAuth2ConfigIdentityFromToken(t *testing.T) { + t.Parallel() + + baseRC := func() *authserver.UpstreamRunConfig { + return &authserver.UpstreamRunConfig{ + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: "https://example.com/authorize", + TokenEndpoint: "https://example.com/token", + ClientID: "my-client-id", + RedirectURI: "https://my-app.com/callback", + }, + } + } + + t.Run("nil IdentityFromToken produces nil in runtime config", func(t *testing.T) { + t.Parallel() + + rc := baseRC() + // IdentityFromToken is not set + + cfg, err := buildPureOAuth2Config(rc) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.Nil(t, cfg.IdentityFromToken, "IdentityFromToken must be nil when not configured") + }) + + t.Run("all three paths round-trip correctly", func(t *testing.T) { + t.Parallel() + + rc := baseRC() + rc.OAuth2Config.IdentityFromToken = &authserver.IdentityFromTokenRunConfig{ + SubjectPath: "username", + NamePath: "display_name", + EmailPath: "email", + } + + cfg, err := buildPureOAuth2Config(rc) + require.NoError(t, err) + require.NotNil(t, cfg) + + require.NotNil(t, cfg.IdentityFromToken) + assert.Equal(t, "username", cfg.IdentityFromToken.SubjectPath) + assert.Equal(t, "display_name", cfg.IdentityFromToken.NamePath) + assert.Equal(t, "email", cfg.IdentityFromToken.EmailPath) + }) + + t.Run("only SubjectPath set, name and email empty", func(t *testing.T) { + t.Parallel() + + rc := baseRC() + rc.OAuth2Config.IdentityFromToken = &authserver.IdentityFromTokenRunConfig{ + SubjectPath: "authed_user.id", + } + + cfg, err := buildPureOAuth2Config(rc) + require.NoError(t, err) + require.NotNil(t, cfg) + + require.NotNil(t, cfg.IdentityFromToken) + assert.Equal(t, "authed_user.id", cfg.IdentityFromToken.SubjectPath) + assert.Empty(t, cfg.IdentityFromToken.NamePath) + assert.Empty(t, cfg.IdentityFromToken.EmailPath) + }) +} + func TestNewHMACSecrets(t *testing.T) { t.Parallel() diff --git a/pkg/authserver/upstream/identity_from_token.go b/pkg/authserver/upstream/identity_from_token.go index e827d19815..813f5f8df3 100644 --- a/pkg/authserver/upstream/identity_from_token.go +++ b/pkg/authserver/upstream/identity_from_token.go @@ -9,6 +9,7 @@ import ( "fmt" "log/slog" "strings" + "sync" "github.com/tidwall/gjson" ) @@ -43,16 +44,25 @@ type IdentityFromTokenConfig struct { EmailPath string } +// registerOnce ensures gjson modifiers are registered exactly once, even +// when RegisterModifiers is called concurrently from multiple goroutines +// (e.g. parallel auth-server constructors in tests or during thundering-herd +// restarts). gjson.AddModifier writes to a global map without internal +// synchronization, so concurrent calls race without this guard. +var registerOnce sync.Once + // RegisterModifiers registers the gjson custom modifiers used by this // package's path-based identity extractors. Call once during application // or test wire-up before invoking any extractor that consumes a -// modifier-bearing path. Repeated calls are safe — gjson.AddModifier -// overwrites the existing entry. +// modifier-bearing path. Repeated calls are safe — the registration is +// protected by a sync.Once and executes at most once per process. // // Modifiers registered: // - @upstreamjwt: see upstreamJWTModifier. func RegisterModifiers() { - gjson.AddModifier("upstreamjwt", upstreamJWTModifier) + registerOnce.Do(func() { + gjson.AddModifier("upstreamjwt", upstreamJWTModifier) + }) } // extractIdentityFromTokenResponse extracts user identity fields from a raw