-
Notifications
You must be signed in to change notification settings - Fork 191
Description
Description
Wire the embedded authorization server into the Kubernetes operator and add comprehensive test coverage — unit tests for the vMCP HTTP handler, CLI E2E tests for Mode A/Mode B routing and negative validation, and operator E2E tests verifying the AuthServerConfigValid status condition lifecycle. This is the final phase of RFC-0053 that closes the loop between the CRD API, the operator reconciler, the config converter, and observable runtime behavior.
Context
RFC-0053 adds an optional embedded OAuth/OIDC authorization server to vMCP. Phase 1 (#4140) established structural types and CRD fields. Phase 2 (#4141) mounted the AS HTTP routes on the vMCP mux. Phase 3 (#4142) added startup validation rules V-01 through V-07. Phase 4 (this task) completes the Kubernetes operator path: the reconciler resolves spec.authServerConfigRef, performs cross-resource validation, surfaces the AuthServerConfigValid status condition, and the config converter converts the referenced MCPExternalAuthConfig into the AuthServerConfig that vMCP reads at startup. This phase also delivers all deferred test coverage and documentation updates.
Parent epic: #4120 — vMCP: add embedded authorization server
RFC document: docs/proposals/THV-0053-vmcp-embedded-authserver.md
Dependencies: #4141 (Phase 2: Server wiring), #4142 (Phase 3: Startup validation)
Blocks: nothing — this is the final phase. Full end-to-end token flow testing depends on RFC-0052 (#3924) for identity.UpstreamTokens population.
Acceptance Criteria
Operator Reconciler
-
cmd/thv-operator/controllers/virtualmcpserver_controller.gorunValidations()includes a newvalidateAuthServerConfigRefstep that, whenspec.authServerConfigRefis non-nil: fetches the referencedMCPExternalAuthConfigfrom the same namespace; verifies itsspec.typeis"embeddedAuthServer"(surfacing aFailedphase with descriptive message if not); verifiesspec.embeddedAuthServer.issuermatchesspec.incomingAuth.oidcConfig.inline.issuer(V-04 check, surfacingFailedphase with message containing"issuer"and"mismatch"); and verifies that the audience derived fromspec.incomingAuth.oidcConfig.inline.audienceis non-empty (defense-in-depth empty-audience guard) - A new
ConditionTypeAuthServerConfigValid = "AuthServerConfigValid"constant is defined incmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go - On successful
authServerConfigRefvalidation,statusManager.SetCondition(ConditionTypeAuthServerConfigValid, "AuthServerConfigValid", "Auth server config is valid", metav1.ConditionTrue)is called - On validation failure,
statusManager.SetCondition(ConditionTypeAuthServerConfigValid, "AuthServerConfigInvalid", <descriptive message>, metav1.ConditionFalse)is called and the phase is set toFailed; reconciliation does not proceed toensureAllResources - When
spec.authServerConfigRefis nil (Mode A),validateAuthServerConfigRefis skipped entirely and noAuthServerConfigValidcondition is set
Config Converter
-
cmd/thv-operator/pkg/vmcpconfig/converter.goConvert()includes a newconvertAuthServerConfigstep: whenvmcp.Spec.AuthServerConfigRef != nil, fetches theMCPExternalAuthConfig, verifies type is"embeddedAuthServer", converts theEmbeddedAuthServerConfigtoauthserver.RunConfig, derivesallowedAudiencesfromvmcp.Spec.IncomingAuth.OIDCConfig.Inline.Audience, setsconfig.AuthServer = &vmcpconfig.AuthServerConfig{RunConfig: runConfig}, and returns an error on any failure - When
spec.authServerConfigRefis nil (Mode A),config.AuthServerremains nil (unchanged from the deep-copied spec config) - The converter derives
allowedAudiencesfromspec.incomingAuth.oidcConfig.inline.audienceand does NOT read anyallowedAudiencesfield fromMCPExternalAuthConfig(the CRD intentionally omits that field)
HTTP Handler Unit Tests
-
pkg/vmcp/server/server_test.go(orpkg/vmcp/server/handler_test.go) contains unit tests for the serverHandler()method covering Mode A routing:GET /.well-known/oauth-protected-resourcereturns HTTP 200;GET /.well-known/openid-configurationreturns HTTP 404;GET /.well-known/oauth-authorization-serverreturns HTTP 404;GET /.well-known/jwks.jsonreturns HTTP 404;GET /oauth/tokenreturns HTTP 404 - Mode B routing tests:
GET /.well-known/openid-configurationis served by the AS handler (HTTP 200 with JSON body);GET /.well-known/oauth-authorization-serveris served by the AS handler (HTTP 200);GET /.well-known/jwks.jsonis served by the AS handler (HTTP 200);GET /oauth/authorizeis served by the AS handler (not 404) - Both Mode A and Mode B:
GET /.well-known/oauth-protected-resourcereturns HTTP 200 (explicit registration, not catch-all) - Both Mode A and Mode B:
GET /mcpwithout a bearer token returns HTTP 401 whenAuthMiddlewareis configured
vMCP CLI E2E Tests
-
test/e2e/vmcp_authserver_test.goexists and is in packagee2e_test; it joins the existing Ginkgo suite intest/e2e/e2e_suite_test.goautomatically - Positive — Mode B startup: start vMCP with a Mode B YAML config (using
OIDCMockServeras the upstream IDP);GET /.well-known/openid-configurationreturns HTTP 200 with a JSON body containing a non-emptyissuerfield matching the configured AS issuer - Positive — Mode B 401:
GET /mcp(or configured endpoint path) without a bearer token returns HTTP 401 (auth middleware active) - Positive — Mode A: start vMCP with no
authServerblock;GET /.well-known/openid-configurationreturns HTTP 404;GET /.well-known/oauth-protected-resourcereturns HTTP 200 - Negative — V-04 issuer mismatch: start vMCP with
authServer.runConfig.issuerdifferent fromincomingAuth.oidc.issuer; process exits non-zero; stderr contains a string matching"issuer"and"mismatch"(or the V-04 message text) - Negative — V-01
upstream_injectwithout AS: start vMCP with anupstream_injectbackend auth strategy but noauthServerblock; process exits non-zero; stderr contains a string referencing V-01 or"upstream_inject"and"authServer" - Negative — V-02 unknown provider: start vMCP with an
upstream_injectstrategy whoseproviderNamedoes not match any upstream inauthServer.runConfig.upstreams; process exits non-zero; stderr contains a string referencing the unknown provider name
Operator E2E Tests
-
test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_test.goexists in packagevirtualmcp; it joins the existing Ginkgo suite intest/e2e/thv-operator/virtualmcp/suite_test.go - Test creates an
MCPExternalAuthConfigwithspec.type: embeddedAuthServerand a validspec.embeddedAuthServerconfiguration (with an upstream provider pointing to an in-cluster mock OIDC server or a test fixture) - Test creates a
VirtualMCPServerwithspec.authServerConfigRef.namepointing to the aboveMCPExternalAuthConfig;spec.incomingAuth.oidcConfig.inline.issuermatches theembeddedAuthServer.issuer -
Eventuallyassertion verifies theAuthServerConfigValidcondition transitions toTrue(statusConditionTrue) within the test timeout - Deployment is created and becomes available (following the pattern in
virtualmcp_external_auth_test.go) - Negative test: create a
VirtualMCPServerwith an issuer mismatch;Eventuallyassertion verifies theAuthServerConfigValidcondition transitions toFalsewith a message containing"issuer"; phase isFailed; no Deployment is created for this VirtualMCPServer
Documentation
-
docs/arch/09-operator-architecture.mdis updated to document the newAuthServerConfigReffield onVirtualMCPServerSpec, thevalidateAuthServerConfigRefreconciliation step, and theAuthServerConfigValidstatus condition -
docs/arch/02-core-concepts.mdis updated to describe the Mode A / Mode B distinction for vMCP auth (embedded AS vs. external IDP) - A new vMCP auth server guide is added under
docs/(e.g.,docs/vmcp-auth-server.md) covering: config walkthrough for both CLI YAML and Kubernetes CRD paths; example YAML snippets; explanation of thejwksAllowPrivateIP: truerequirement for in-cluster loopback OIDC discovery; troubleshooting the self-referencing OIDC discovery setup -
task docsis run to regenerate CRD reference documentation after any CRD changes
General
- All new Go files include
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.and// SPDX-License-Identifier: Apache-2.0headers -
task lintpasses (ortask lint-fixresolves all linting issues) -
task testpasses (unit tests) - All existing tests continue to pass (Mode A configs with nil
AuthServerand nilAuthServerConfigRefmust not be affected)
Technical Approach
Recommended Implementation
Work in four coordinated steps:
Step 1 — Reconciler validation (cmd/thv-operator/controllers/virtualmcpserver_controller.go): Add a validateAuthServerConfigRef method following the same structure as validateCompositeToolRefs and validateGroupRef. It is called inside runValidations() only when vmcp.Spec.AuthServerConfigRef != nil. The method uses r.Get to fetch the MCPExternalAuthConfig by name and namespace. After verifying spec.type == "embeddedAuthServer", perform the V-04 issuer consistency check (exact string comparison — no URL normalization). Use statusManager.SetCondition(ConditionTypeAuthServerConfigValid, ...) to surface results; on failure also call statusManager.SetPhase(VirtualMCPServerPhaseFailed) and statusManager.SetMessage(...). On validation failure, return (false, nil) from runValidations to stop reconciliation without requeueing (user must fix spec).
Step 2 — Converter step (cmd/thv-operator/pkg/vmcpconfig/converter.go): Add a convertAuthServerConfig private method that accepts ctx, vmcp, and the resolved MCPExternalAuthConfig. It converts EmbeddedAuthServerConfig fields to an authserver.RunConfig, derives allowedAudiences from vmcp.Spec.IncomingAuth.OIDCConfig.Inline.Audience, and returns *vmcpconfig.AuthServerConfig. Call this method from Convert() after the existing convertIncomingAuth block, gated on vmcp.Spec.AuthServerConfigRef != nil. The converter must also resolve the MCPExternalAuthConfig by calling c.k8sClient.Get (see existing convertBackendAuthConfig pattern for the Get call structure).
Step 3 — Unit tests: Add pkg/vmcp/server/server_test.go (or extend the existing file) with table-driven tests for Handler(). Construct a minimal Config with a mock AuthMiddleware (using httptest.NewRecorder and httptest.NewRequest). For Mode B tests, create an EmbeddedAuthServer via runner.NewEmbeddedAuthServer(ctx, runConfig) with a minimal dev-mode config (ephemeral keys, in-memory storage). After each test, defer as.Close(). Use httptest.NewServer(handler) to get a testable HTTP server. Assert HTTP status codes for each path.
Step 4 — E2E tests: For CLI E2E tests in test/e2e/vmcp_authserver_test.go, follow test/e2e/proxy_oauth_test.go exactly — Describe + BeforeEach/AfterEach, By() annotations, Eventually for async assertions. Start a OIDCMockServer as the upstream IDP and write YAML config files to t.TempDir(). For negative tests, start the vMCP binary and assert non-zero exit code and stderr content using exec.Command. For operator E2E tests in test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_test.go, follow virtualmcp_external_auth_test.go — use k8sClient.Create, Eventually with k8sClient.Get to assert condition status.
Patterns & Frameworks
runValidationspattern: Follow the exact structure ofvalidateCompositeToolRefsinvirtualmcpserver_controller.go— return(false, nil)on user-fixable spec errors (to stop reconciliation without requeueing), return(false, err)for transient errors that should trigger requeue (e.g., API server unavailable)- Status condition surfacing: Use
statusManager.SetCondition(conditionType, reason, message, status)directly (as invalidateAndUpdatePodTemplateStatus) rather than a specialized helper, sinceAuthServerConfigValiddoes not warrant a dedicated method onStatusManager - Converter pattern: Follow
convertBackendAuthConfiginconverter.go— usec.k8sClient.Getwithtypes.NamespacedName, checkerrors.IsNotFoundseparately from other errors, returnfmt.Errorfwith%w omitemptyand nil safety: All code paths gated onAuthServerConfigRef != nil; Mode A must pass zero new lines when the field is absent- Exact string match for V-04: Use
==for issuer comparison; no URL normalization applied. Operators must use identical strings in both configs - SPDX headers: Every new Go file must start with
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.and// SPDX-License-Identifier: Apache-2.0. Usetask license-fixto add missing headers automatically require.NoError(t, err)for test assertions: Userequirefromgithub.com/stretchr/testify/requirerather thant.Fatalin unit tests; useExpect(...).To(Succeed())in operator E2E tests (Gomega)- Ginkgo for E2E tests: Use
Describe,BeforeAll/AfterAll(for Ordered suites),By()annotations, andEventually(...).Within(...).ProbeEvery(...)for async assertions. Follow the existing operator E2E test structure intest/e2e/thv-operator/virtualmcp/
Code Pointers
cmd/thv-operator/controllers/virtualmcpserver_controller.go—runValidations()(line 276): add the newvalidateAuthServerConfigRefcall here, aftervalidateEmbeddingServerRef. Pattern for the new method: followvalidateCompositeToolRefs(line 385) for structure; followvalidateGroupRef(line 327) for theSetPhase/SetMessage/SetConditioncall patterncmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go— condition type constants block (line 209): addConditionTypeAuthServerConfigValid = "AuthServerConfigValid"alongside existing constantscmd/thv-operator/pkg/vmcpconfig/converter.go—Convert()(line 68): add theconvertAuthServerConfigstep after theconvertIncomingAuthblock (line 81). Pattern: followconvertBackendAuthConfig(line 283) for thek8sClient.Getcall structurecmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go—EmbeddedAuthServerConfigstruct (line 152): source of fields to map toauthserver.RunConfig; noteAllowedAudiencesis absent by design (derived from the referencing VirtualMCPServer)pkg/authserver/config.go—authserver.RunConfig: target type for the converter; key fields:Issuer string,AllowedAudiences []string,Upstreams []UpstreamRunConfigpkg/vmcp/server/server.go—Configstruct (line 83),Handler()method (line ~444): target for unit tests; theAuthServer *runner.EmbeddedAuthServerfield added in Phase 2 (Phase 2: Server wiring — mount embedded auth server routes on vMCP mux #4141) is what Mode B tests populatepkg/authserver/runner/embeddedauthserver.go—NewEmbeddedAuthServer(ctx, *authserver.RunConfig)andRegisterHandlers(mux)(added in Phase 2: Server wiring — mount embedded auth server routes on vMCP mux #4141): used to construct Mode B test fixturestest/e2e/proxy_oauth_test.go— reference pattern for CLI E2E tests:OIDCMockServer, process start/stop,Eventually,By()annotationstest/e2e/oidc_mock.go—OIDCMockServer: reusable mock OIDC server backed by Ory Fosite; use as the upstream IDP in Mode B E2E teststest/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go— reference pattern for operator E2E tests:k8sClient.Create,Eventuallywith condition assertions,BeforeAll/AfterAllcleanuptest/e2e/thv-operator/virtualmcp/helpers.go— shared helpers for operator E2E tests:CreateMCPGroupAndWait,CreateMockHTTPServer; check what is available before writing new helpers
Component Interfaces
New constant in cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go:
// ConditionTypeAuthServerConfigValid indicates whether the authServerConfigRef
// references a valid, compatible MCPExternalAuthConfig.
const ConditionTypeAuthServerConfigValid = "AuthServerConfigValid"New method on VirtualMCPServerReconciler in cmd/thv-operator/controllers/virtualmcpserver_controller.go:
// validateAuthServerConfigRef validates the MCPExternalAuthConfig referenced by
// spec.authServerConfigRef. It verifies the type is "embeddedAuthServer", checks
// issuer consistency with IncomingAuth.OIDC.Issuer (V-04), and verifies the
// audience is non-empty (defense-in-depth guard).
// Returns (true, nil) to continue, (false, nil) for spec errors (no requeue),
// (false, err) for transient errors (triggers requeue).
func (r *VirtualMCPServerReconciler) validateAuthServerConfigRef(
ctx context.Context,
vmcp *mcpv1alpha1.VirtualMCPServer,
statusManager virtualmcpserverstatus.StatusManager,
) (bool, error)New private method on Converter in cmd/thv-operator/pkg/vmcpconfig/converter.go:
// convertAuthServerConfig resolves spec.authServerConfigRef to an AuthServerConfig.
// It fetches the MCPExternalAuthConfig, verifies its type, converts EmbeddedAuthServerConfig
// to authserver.RunConfig, and derives allowedAudiences from the VirtualMCPServer's
// incoming auth audience.
func (c *Converter) convertAuthServerConfig(
ctx context.Context,
vmcp *mcpv1alpha1.VirtualMCPServer,
) (*vmcpconfig.AuthServerConfig, error)Key mapping from EmbeddedAuthServerConfig to authserver.RunConfig (in the converter):
// Derive allowedAudiences from the VirtualMCPServer incoming auth config.
// Note: AllowedAudiences is NOT a field on EmbeddedAuthServerConfig in the CRD.
// It is always derived here so the CRD does not need to duplicate the audience value.
var allowedAudiences []string
if vmcp.Spec.IncomingAuth != nil &&
vmcp.Spec.IncomingAuth.OIDCConfig != nil &&
vmcp.Spec.IncomingAuth.OIDCConfig.Inline != nil &&
vmcp.Spec.IncomingAuth.OIDCConfig.Inline.Audience != "" {
allowedAudiences = []string{vmcp.Spec.IncomingAuth.OIDCConfig.Inline.Audience}
}
runConfig := &authserver.RunConfig{
Issuer: embeddedCfg.Issuer,
AllowedAudiences: allowedAudiences,
// ... map SigningKeySecretRefs, HMACSecretRefs, TokenLifespans, Storage, Upstreams
}Testing Strategy
Unit Tests — HTTP Handler (pkg/vmcp/server/server_test.go or pkg/vmcp/server/handler_test.go)
Use httptest.NewRecorder() and httptest.NewRequest() to test Handler() without starting a real server. Use httptest.NewServer for integration-style route tests.
- Mode A (nil
AuthServer):GET /.well-known/oauth-protected-resource→ 200 - Mode A:
GET /.well-known/openid-configuration→ 404 - Mode A:
GET /.well-known/oauth-authorization-server→ 404 - Mode A:
GET /.well-known/jwks.json→ 404 - Mode A:
GET /oauth/token→ 404 - Mode B (non-nil
AuthServerfromNewEmbeddedAuthServerwith ephemeral dev config):GET /.well-known/openid-configuration→ 200 withContent-Type: application/json - Mode B:
GET /.well-known/jwks.json→ 200 - Mode B:
GET /.well-known/oauth-protected-resource→ 200 (still served in Mode B) - Both modes:
GET /mcpwithout bearer token → 401 whenAuthMiddlewareis set to a simple "reject all" middleware
CLI E2E Tests (test/e2e/vmcp_authserver_test.go)
Follow test/e2e/proxy_oauth_test.go exactly. Use OIDCMockServer as the upstream IDP for Mode B. Write YAML config files to a temp dir. Start the vMCP binary with exec.Command.
- Mode B positive: OIDC discovery returns 200 with valid JSON (non-empty
issuer, non-emptyjwks_uri) - Mode B positive: unauthenticated MCP request returns 401
- Mode A positive: OIDC discovery path returns 404;
oauth-protected-resourcereturns 200 - Negative V-04: process exits non-zero; stderr contains issuer mismatch message
- Negative V-01: process exits non-zero; stderr contains
upstream_inject/ AS required message - Negative V-02: process exits non-zero; stderr contains unknown provider name message
Operator E2E Tests (test/e2e/thv-operator/virtualmcp/virtualmcp_authserver_test.go)
Follow virtualmcp_external_auth_test.go. Use Ordered suite with BeforeAll/AfterAll for resource cleanup.
- Positive:
MCPExternalAuthConfig+VirtualMCPServercreated;EventuallyassertsAuthServerConfigValidcondition isTrue;Eventuallyasserts Deployment exists and is available - Negative:
VirtualMCPServerwith issuer mismatch;EventuallyassertsAuthServerConfigValidcondition isFalsewith message containing"issuer"; phase isFailed; no Deployment exists for this VirtualMCPServer
Edge Cases
-
spec.authServerConfigRefnil (Mode A):validateAuthServerConfigRefis skipped;Convert()does not setconfig.AuthServer; noAuthServerConfigValidcondition appears - Referenced
MCPExternalAuthConfignot found: reconciler returns a transient error (triggers requeue), phase is set toFailed - Referenced
MCPExternalAuthConfighas wrong type (e.g.,"tokenExchange"): reconciler surfacesAuthServerConfigValid=Falsewith a descriptive type mismatch message; phase isFailed; no requeue (spec error) -
spec.incomingAuth.oidcConfig.inline.audienceis empty: converter setsallowedAudiences = nil(not an empty slice); reconciler audience guard logs a warning but does not fail (defense-in-depth, not blocking) - Issuer mismatch (V-04): exact string comparison; trailing slash difference (
"https://as.example.com"vs"https://as.example.com/") must trigger the mismatch error
Out of Scope
upstream_injectoutgoing auth strategy implementation (deferred to a follow-up RFC — the constant, config struct, and validation were added in Phase 3 but the actual token injection middleware is not implemented in RFC-0053)- Full end-to-end token flow via
identity.UpstreamTokens— this requires RFC-0052 (Auth Server: multi-upstream provider support #3924) to be merged; the E2E tests here cover Mode A/Mode B routing and theAuthServerConfigValidcondition, not the full token issuance and upstream token population chain - Hot-reload of AS configuration (requires pod restart; out of scope for this RFC)
- Multi-upstream IDP support beyond what
UpstreamRunConfig.Namealready provides (defined by RFC-0052) - CLI-path upstream token swap (
pkg/auth/upstreamswapmiddleware) - Step-up auth signaling
References
- RFC-0053 design document:
docs/proposals/THV-0053-vmcp-embedded-authserver.md - Parent epic: vMCP: add embedded authorization server #4120
- Phase 2 (server wiring, upstream dependency): Phase 2: Server wiring — mount embedded auth server routes on vMCP mux #4141
- Phase 3 (startup validation, upstream dependency): Phase 3: Startup validation (V-01..V-07) — upstream_inject types and validateAuthServerIntegration #4142
- RFC-0052 (multi-upstream IDP, required for full E2E token flow): Auth Server: multi-upstream provider support #3924
- Operator reconciler (primary edit target):
cmd/thv-operator/controllers/virtualmcpserver_controller.go - CRD types (condition constant):
cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go - Config converter (primary edit target):
cmd/thv-operator/pkg/vmcpconfig/converter.go EmbeddedAuthServerConfig(source type for converter):cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.goauthserver.RunConfig(target type for converter):pkg/authserver/config.go- vMCP server HTTP handler (unit test target):
pkg/vmcp/server/server.go EmbeddedAuthServer(for unit test fixtures):pkg/authserver/runner/embeddedauthserver.go- Operator E2E test pattern reference:
test/e2e/thv-operator/virtualmcp/virtualmcp_external_auth_test.go - CLI E2E test pattern reference:
test/e2e/proxy_oauth_test.go - Mock OIDC server helper:
test/e2e/oidc_mock.go - Architecture docs to update:
docs/arch/09-operator-architecture.md,docs/arch/02-core-concepts.md - RFC 9728 (OAuth 2.0 Protected Resource Metadata): https://datatracker.ietf.org/doc/html/rfc9728
- RFC 8414 (OAuth 2.0 Authorization Server Metadata): https://datatracker.ietf.org/doc/html/rfc8414