Skip to content
Closed
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
8 changes: 8 additions & 0 deletions cmd/thv-operator/pkg/controllerutil/authserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
}
}

Expand Down
115 changes: 115 additions & 0 deletions cmd/thv-operator/pkg/controllerutil/authserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions pkg/authserver/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions pkg/authserver/runner/embeddedauthserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
67 changes: 67 additions & 0 deletions pkg/authserver/runner/embeddedauthserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
16 changes: 13 additions & 3 deletions pkg/authserver/upstream/identity_from_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"log/slog"
"strings"
"sync"

"github.com/tidwall/gjson"
)
Expand Down Expand Up @@ -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
Expand Down
Loading