Skip to content

Commit e5faa2f

Browse files
jhrozekclaude
andauthored
Wire identityFromToken through authserver config and runtime (#5285)
* Translate identityFromToken from CRD to runtime config Add the operator-side and runtime-side translation that carries IdentityFromToken from MCPExternalAuthConfig (v1beta1 CRD) through the authserver run-config and into upstream.OAuth2Config so the provider added in the previous commit actually receives the field. Two translation points mirror the existing TokenResponseMapping plumbing: * cmd/thv-operator/pkg/controllerutil/authserver.go CRD v1beta1.IdentityFromTokenConfig -> authserver.IdentityFromTokenRunConfig (operator path) * pkg/authserver/runner/embeddedauthserver.go authserver.IdentityFromTokenRunConfig -> upstream.IdentityFromTokenConfig (runtime path) A new IdentityFromTokenRunConfig type lives next to TokenResponseMappingRunConfig in pkg/authserver/config.go. Tests cover the round-trip on both sides and the nil-config case (no spurious allocation). * Register gjson modifiers in embedded auth-server bootstrap IdentityFromToken paths that use the `@upstreamjwt` modifier (e.g. `access_token|@upstreamjwt|sub` for upstreams that embed identity inside a JWS access token) require RegisterModifiers() to run before any extraction. Until now the call lived only in test TestMain functions, so production paths silently no-op'd and operators saw a generic "path not found" error. Add the call near the top of NewEmbeddedAuthServer. Protect the gjson.AddModifier write with a sync.Once so concurrent constructors (parallel test cases, thundering-herd restarts) do not race on the gjson global modifier map. * Regenerate swagger docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 836c570 commit e5faa2f

10 files changed

Lines changed: 334 additions & 4 deletions

File tree

cmd/thv-operator/pkg/controllerutil/authserver.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -834,6 +834,14 @@ func buildOAuth2UpstreamRunConfig(
834834
ExpiresInPath: m.ExpiresInPath,
835835
}
836836
}
837+
if cfg.IdentityFromToken != nil {
838+
ift := cfg.IdentityFromToken
839+
runConfig.IdentityFromToken = &authserver.IdentityFromTokenRunConfig{
840+
SubjectPath: ift.SubjectPath,
841+
NamePath: ift.NamePath,
842+
EmailPath: ift.EmailPath,
843+
}
844+
}
837845
if cfg.DCRConfig != nil {
838846
runConfig.DCRConfig = buildDCRUpstreamRunConfig(cfg.DCRConfig, initialAccessTokenEnvVar)
839847
}

cmd/thv-operator/pkg/controllerutil/authserver_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1423,6 +1423,121 @@ func TestBuildAuthServerRunConfig(t *testing.T) {
14231423
"DCRConfig should remain nil when only ClientID is set")
14241424
},
14251425
},
1426+
{
1427+
name: "OAuth2 upstream with identityFromToken all fields set",
1428+
resourceURL: defaultResourceURL,
1429+
authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
1430+
Issuer: "https://auth.example.com",
1431+
SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{
1432+
{Name: "signing-key", Key: "private.pem"},
1433+
},
1434+
HMACSecretRefs: []mcpv1beta1.SecretKeyRef{
1435+
{Name: "hmac-secret", Key: "hmac"},
1436+
},
1437+
UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
1438+
{
1439+
Name: "snowflake",
1440+
Type: mcpv1beta1.UpstreamProviderTypeOAuth2,
1441+
OAuth2Config: &mcpv1beta1.OAuth2UpstreamConfig{
1442+
AuthorizationEndpoint: "https://account.snowflakecomputing.com/oauth/authorize",
1443+
TokenEndpoint: "https://account.snowflakecomputing.com/oauth/token-request",
1444+
ClientID: "sf-client-id",
1445+
IdentityFromToken: &mcpv1beta1.IdentityFromTokenConfig{
1446+
SubjectPath: "username",
1447+
NamePath: "display_name",
1448+
EmailPath: "email",
1449+
},
1450+
},
1451+
},
1452+
},
1453+
},
1454+
allowedAudiences: defaultAudiences,
1455+
scopesSupported: defaultScopes,
1456+
checkFunc: func(t *testing.T, config *authserver.RunConfig) {
1457+
t.Helper()
1458+
require.Len(t, config.Upstreams, 1)
1459+
upstream := config.Upstreams[0]
1460+
require.NotNil(t, upstream.OAuth2Config)
1461+
require.NotNil(t, upstream.OAuth2Config.IdentityFromToken)
1462+
assert.Equal(t, "username", upstream.OAuth2Config.IdentityFromToken.SubjectPath)
1463+
assert.Equal(t, "display_name", upstream.OAuth2Config.IdentityFromToken.NamePath)
1464+
assert.Equal(t, "email", upstream.OAuth2Config.IdentityFromToken.EmailPath)
1465+
},
1466+
},
1467+
{
1468+
name: "OAuth2 upstream with identityFromToken only subjectPath set",
1469+
resourceURL: defaultResourceURL,
1470+
authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
1471+
Issuer: "https://auth.example.com",
1472+
SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{
1473+
{Name: "signing-key", Key: "private.pem"},
1474+
},
1475+
HMACSecretRefs: []mcpv1beta1.SecretKeyRef{
1476+
{Name: "hmac-secret", Key: "hmac"},
1477+
},
1478+
UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
1479+
{
1480+
Name: "slack",
1481+
Type: mcpv1beta1.UpstreamProviderTypeOAuth2,
1482+
OAuth2Config: &mcpv1beta1.OAuth2UpstreamConfig{
1483+
AuthorizationEndpoint: "https://slack.com/oauth/v2/authorize",
1484+
TokenEndpoint: "https://slack.com/api/oauth.v2.access",
1485+
ClientID: "slack-client-id",
1486+
IdentityFromToken: &mcpv1beta1.IdentityFromTokenConfig{
1487+
SubjectPath: "authed_user.id",
1488+
},
1489+
},
1490+
},
1491+
},
1492+
},
1493+
allowedAudiences: defaultAudiences,
1494+
scopesSupported: defaultScopes,
1495+
checkFunc: func(t *testing.T, config *authserver.RunConfig) {
1496+
t.Helper()
1497+
require.Len(t, config.Upstreams, 1)
1498+
upstream := config.Upstreams[0]
1499+
require.NotNil(t, upstream.OAuth2Config)
1500+
require.NotNil(t, upstream.OAuth2Config.IdentityFromToken)
1501+
assert.Equal(t, "authed_user.id", upstream.OAuth2Config.IdentityFromToken.SubjectPath)
1502+
assert.Empty(t, upstream.OAuth2Config.IdentityFromToken.NamePath)
1503+
assert.Empty(t, upstream.OAuth2Config.IdentityFromToken.EmailPath)
1504+
},
1505+
},
1506+
{
1507+
name: "OAuth2 upstream with no identityFromToken produces nil",
1508+
resourceURL: defaultResourceURL,
1509+
authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
1510+
Issuer: "https://auth.example.com",
1511+
SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{
1512+
{Name: "signing-key", Key: "private.pem"},
1513+
},
1514+
HMACSecretRefs: []mcpv1beta1.SecretKeyRef{
1515+
{Name: "hmac-secret", Key: "hmac"},
1516+
},
1517+
UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
1518+
{
1519+
Name: "github-no-ift",
1520+
Type: mcpv1beta1.UpstreamProviderTypeOAuth2,
1521+
OAuth2Config: &mcpv1beta1.OAuth2UpstreamConfig{
1522+
AuthorizationEndpoint: "https://github.com/login/oauth/authorize",
1523+
TokenEndpoint: "https://github.com/login/oauth/access_token",
1524+
UserInfo: &mcpv1beta1.UserInfoConfig{EndpointURL: "https://api.github.com/user"},
1525+
ClientID: "client-id",
1526+
},
1527+
},
1528+
},
1529+
},
1530+
allowedAudiences: defaultAudiences,
1531+
scopesSupported: defaultScopes,
1532+
checkFunc: func(t *testing.T, config *authserver.RunConfig) {
1533+
t.Helper()
1534+
require.Len(t, config.Upstreams, 1)
1535+
upstream := config.Upstreams[0]
1536+
require.NotNil(t, upstream.OAuth2Config)
1537+
assert.Nil(t, upstream.OAuth2Config.IdentityFromToken,
1538+
"IdentityFromToken must be nil when not configured")
1539+
},
1540+
},
14261541
}
14271542

14281543
for _, tt := range tests {

docs/server/docs.go

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.json

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.yaml

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/authserver/config.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,14 @@ type OAuth2UpstreamRunConfig struct {
280280
//nolint:lll // field tags require full JSON+YAML names
281281
TokenResponseMapping *TokenResponseMappingRunConfig `json:"token_response_mapping,omitempty" yaml:"token_response_mapping,omitempty"`
282282

283+
// IdentityFromToken extracts user identity (subject, name, email) directly from the
284+
// OAuth2 token-endpoint response body using gjson dot-notation paths. When set, the
285+
// embedded auth server skips the userinfo HTTP call entirely. Mirrors the CRD type
286+
// (cmd/thv-operator/api/v1beta1.IdentityFromTokenConfig) — the authoritative
287+
// trust-model and uniqueness documentation lives there.
288+
//nolint:lll // field tags require full JSON+YAML names
289+
IdentityFromToken *IdentityFromTokenRunConfig `json:"identity_from_token,omitempty" yaml:"identity_from_token,omitempty"`
290+
283291
// AdditionalAuthorizationParams are extra query parameters to include in
284292
// authorization requests. Useful for provider-specific parameters like
285293
// Google's access_type=offline.
@@ -383,6 +391,22 @@ type TokenResponseMappingRunConfig struct {
383391
ExpiresInPath string `json:"expires_in_path,omitempty" yaml:"expires_in_path,omitempty"`
384392
}
385393

394+
// IdentityFromTokenRunConfig configures extracting user identity claims directly from
395+
// the token-endpoint response body. Mirrors the CRD type
396+
// (cmd/thv-operator/api/v1beta1.IdentityFromTokenConfig) — the authoritative
397+
// trust-model and uniqueness documentation lives there.
398+
type IdentityFromTokenRunConfig struct {
399+
// SubjectPath is the dot-notation path to the subject (user ID) field.
400+
// Required when IdentityFromToken is set.
401+
SubjectPath string `json:"subject_path" yaml:"subject_path"`
402+
403+
// NamePath is the dot-notation path to the display name field.
404+
NamePath string `json:"name_path,omitempty" yaml:"name_path,omitempty"`
405+
406+
// EmailPath is the dot-notation path to the email address field.
407+
EmailPath string `json:"email_path,omitempty" yaml:"email_path,omitempty"`
408+
}
409+
386410
// UserInfoRunConfig contains UserInfo endpoint configuration.
387411
// This supports both standard OIDC UserInfo endpoints and custom provider-specific endpoints.
388412
type UserInfoRunConfig struct {
@@ -618,6 +642,10 @@ func (c *OAuth2UpstreamRunConfig) Validate() error {
618642
}
619643
}
620644

645+
if c.IdentityFromToken != nil && c.IdentityFromToken.SubjectPath == "" {
646+
return fmt.Errorf("oauth2 upstream: identity_from_token.subject_path must not be empty when identity_from_token is configured")
647+
}
648+
621649
return nil
622650
}
623651

pkg/authserver/config_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,32 @@ func TestOAuth2UpstreamRunConfigValidate(t *testing.T) {
347347
},
348348
},
349349
},
350+
351+
// IdentityFromToken subject_path requirement.
352+
{
353+
name: "IdentityFromToken with empty SubjectPath rejects",
354+
config: OAuth2UpstreamRunConfig{
355+
ClientID: "c",
356+
IdentityFromToken: &IdentityFromTokenRunConfig{},
357+
},
358+
wantErr: true,
359+
errMsg: "identity_from_token.subject_path must not be empty",
360+
},
361+
{
362+
name: "IdentityFromToken with non-empty SubjectPath is valid",
363+
config: OAuth2UpstreamRunConfig{
364+
ClientID: "c",
365+
IdentityFromToken: &IdentityFromTokenRunConfig{
366+
SubjectPath: "username",
367+
},
368+
},
369+
},
370+
{
371+
name: "nil IdentityFromToken is valid",
372+
config: OAuth2UpstreamRunConfig{
373+
ClientID: "c",
374+
},
375+
},
350376
}
351377

352378
for _, tt := range tests {

pkg/authserver/runner/embeddedauthserver.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ func NewEmbeddedAuthServer(ctx context.Context, cfg *authserver.RunConfig) (*Emb
6262
return nil, fmt.Errorf("config is required")
6363
}
6464

65+
// Register gjson modifiers used by IdentityFromToken configs (e.g. @upstreamjwt).
66+
// Without this, modifier-bearing paths silently fail to resolve.
67+
upstream.RegisterModifiers()
68+
6569
// Fail loudly on operator-supplied misconfiguration (e.g. a baseline
6670
// scope absent from scopes_supported) BEFORE touching storage or any
6771
// other side-effecting work, so a bad config never reaches the network
@@ -569,6 +573,14 @@ func buildPureOAuth2Config(rc *authserver.UpstreamRunConfig) (*upstream.OAuth2Co
569573
}
570574
}
571575

576+
if oauth2.IdentityFromToken != nil {
577+
cfg.IdentityFromToken = &upstream.IdentityFromTokenConfig{
578+
SubjectPath: oauth2.IdentityFromToken.SubjectPath,
579+
NamePath: oauth2.IdentityFromToken.NamePath,
580+
EmailPath: oauth2.IdentityFromToken.EmailPath,
581+
}
582+
}
583+
572584
return cfg, nil
573585
}
574586

0 commit comments

Comments
 (0)