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
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 @@ -834,6 +834,14 @@ func buildOAuth2UpstreamRunConfig(
ExpiresInPath: m.ExpiresInPath,
}
}
if cfg.IdentityFromToken != nil {
ift := cfg.IdentityFromToken
runConfig.IdentityFromToken = &authserver.IdentityFromTokenRunConfig{
SubjectPath: ift.SubjectPath,
NamePath: ift.NamePath,
EmailPath: ift.EmailPath,
}
}
if cfg.DCRConfig != nil {
runConfig.DCRConfig = buildDCRUpstreamRunConfig(cfg.DCRConfig, initialAccessTokenEnvVar)
}
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
21 changes: 21 additions & 0 deletions docs/server/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions docs/server/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions docs/server/swagger.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 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,22 @@ 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.
// Required when IdentityFromToken is set.
SubjectPath string `json:"subject_path" yaml:"subject_path"`

// 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 Expand Up @@ -618,6 +642,10 @@ func (c *OAuth2UpstreamRunConfig) Validate() error {
}
}

if c.IdentityFromToken != nil && c.IdentityFromToken.SubjectPath == "" {
return fmt.Errorf("oauth2 upstream: identity_from_token.subject_path must not be empty when identity_from_token is configured")
}

return nil
}

Expand Down
26 changes: 26 additions & 0 deletions pkg/authserver/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,32 @@ func TestOAuth2UpstreamRunConfigValidate(t *testing.T) {
},
},
},

// IdentityFromToken subject_path requirement.
{
name: "IdentityFromToken with empty SubjectPath rejects",
config: OAuth2UpstreamRunConfig{
ClientID: "c",
IdentityFromToken: &IdentityFromTokenRunConfig{},
},
wantErr: true,
errMsg: "identity_from_token.subject_path must not be empty",
},
{
name: "IdentityFromToken with non-empty SubjectPath is valid",
config: OAuth2UpstreamRunConfig{
ClientID: "c",
IdentityFromToken: &IdentityFromTokenRunConfig{
SubjectPath: "username",
},
},
},
{
name: "nil IdentityFromToken is valid",
config: OAuth2UpstreamRunConfig{
ClientID: "c",
},
},
}

for _, tt := range tests {
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
Loading
Loading