You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Wire the embedded authorization server into the vMCP HTTP layer, making Mode B operationally active for the first time. This task adds RegisterHandlers to EmbeddedAuthServer, conditionally creates the AS in the vMCP startup path, and replaces the /.well-known/ catch-all handler with explicit path registrations — the first phase in the RFC-0053 implementation that changes observable server behavior when an authServer block is present in the vMCP YAML config.
Context
RFC-0053 adds an optional embedded OAuth/OIDC authorization server to vMCP. Phase 1 (#4140) established all structural types and config fields without touching runtime behavior. Phase 2 (this task) flips the first runtime switch: when cfg.AuthServer != nil (Mode B), vMCP now creates an EmbeddedAuthServer at startup and mounts its OAuth/OIDC routes on the same mux. When cfg.AuthServer == nil (Mode A), the server is byte-for-byte identical to today. The hard-fail requirement (no silent fallback on AS creation error) is central to this phase.
Parent epic: #4120 — vMCP: add embedded authorization server RFC document: docs/proposals/THV-0053-vmcp-embedded-authserver.md Dependencies: #4140 (Phase 1: Foundation) Blocks: Phase 4 (operator reconciler) — Phase 3 (startup validation) can proceed in parallel with this task
Acceptance Criteria
EmbeddedAuthServer has a new RegisterHandlers(mux *http.ServeMux) method in pkg/authserver/runner/embeddedauthserver.go that mounts /oauth/, /.well-known/openid-configuration, /.well-known/oauth-authorization-server, and /.well-known/jwks.json as unauthenticated routes
pkg/vmcp/server.Config has a new field AuthServer *runner.EmbeddedAuthServer; when non-nil, Handler() calls s.config.AuthServer.RegisterHandlers(mux) before mounting the authenticated catch-all
cmd/vmcp/app/commands.gorunServe() conditionally creates the AS with runner.NewEmbeddedAuthServer(ctx, cfg.AuthServer.RunConfig) immediately after loadAndValidateConfig(); any error from NewEmbeddedAuthServer causes runServe to return that error immediately (hard fail — no silent fallback to Mode A)
The mux.Handle("/.well-known/", wellKnownHandler) catch-all in pkg/vmcp/server/server.go is replaced with an explicit mux.Handle("/.well-known/oauth-protected-resource", wellKnownHandler) registration; behavior is unchanged (Mode A and Mode B both serve oauth-protected-resource at this exact path, all other /.well-known/ paths that were formerly 404 remain 404 except for the Mode B AS routes)
Mode A (no authServer in YAML): all existing tests pass without any code-path changes; zero new lines execute
Mode B smoke test: start vMCP with a minimal authServer config block, GET /.well-known/openid-configuration returns HTTP 200 with a valid JSON OIDC discovery document containing a non-empty issuer and jwks_uri
Mode B: GET /mcp without a valid bearer token returns HTTP 401 (auth middleware remains active for the MCP catch-all)
Mode B: GET /.well-known/oauth-protected-resource returns HTTP 200 (unauthenticated, explicit registration)
The EmbeddedAuthServer is closed during server shutdown (deferred Close() call after creation)
All new Go files include the SPDX license header; task lint passes
Technical Approach
Recommended Implementation
Work in three coordinated steps, each targeting a single file:
Step 1 — pkg/authserver/runner/embeddedauthserver.go: Add RegisterHandlers as a public method on *EmbeddedAuthServer. It calls e.Handler() once to get the AS HTTP handler and registers it at four paths on the provided mux. This method is purely additive and has no callers until Step 3.
Step 2 — pkg/vmcp/server/server.go: Add AuthServer *runner.EmbeddedAuthServer to the Config struct (after AuthInfoHandler, before TelemetryProvider). In Handler(), replace the mux.Handle("/.well-known/", wellKnownHandler) catch-all (around line 485) with an explicit mux.Handle("/.well-known/oauth-protected-resource", wellKnownHandler) registration, and then conditionally call s.config.AuthServer.RegisterHandlers(mux) if non-nil. Place the AS route registration in the unauthenticated block (before the MCP catch-all with auth middleware) so AS routes bypass auth.
Step 3 — cmd/vmcp/app/commands.go: In runServe(), after loadAndValidateConfig succeeds and before building serverCfg, insert the conditional AS creation block. If cfg.AuthServer != nil, call runner.NewEmbeddedAuthServer(ctx, cfg.AuthServer.RunConfig). On error, return immediately (hard fail). On success, assign serverCfg.AuthServer = authServer and defer authServer.Close(). No else branch — the nil case flows through unchanged.
Patterns and Frameworks
Mode A nil-gate: Every Mode B code path starts with a nil check (cfg.AuthServer != nil in commands.go, s.config.AuthServer != nil in server.go). Mode A must execute zero new lines — verified by reading the if conditions, not by behavioral tests alone.
Hard fail, no silent fallback: Follow the pattern in pkg/vmcp/auth/factory/incoming.go — check config, construct component, return error immediately if construction fails. Never swallow an AS creation error to fall back to Mode A; that would mask a misconfiguration.
Additive-only changes: RegisterHandlers is a new method; AuthServer is a new field; the /.well-known/ replacement is a behavioral no-op (old catch-all only served one path). No existing struct fields are removed or renamed, no existing method signatures change.
SPDX headers: Every Go file must start with // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. and // SPDX-License-Identifier: Apache-2.0. Use task license-fix to add missing headers automatically.
Defer Close() for resource cleanup: After a successful NewEmbeddedAuthServer, immediately add defer authServer.Close() in runServe() so cleanup is guaranteed on any return path.
pkg/authserver/runner/embeddedauthserver.go — Add RegisterHandlers(mux *http.ServeMux) after the existing UpstreamTokenRefresher() method (line 141). Existing Handler() method (lines 115–117) returns the AS HTTP handler; RegisterHandlers calls it once and registers it at four paths.
pkg/vmcp/server/server.go (Config struct, lines 84–169) — Add AuthServer *runner.EmbeddedAuthServer after AuthInfoHandler http.Handler (line 121). Import runner "github.com/stacklok/toolhive/pkg/authserver/runner" in the import block.
pkg/vmcp/server/server.go (Handler() method, around lines 482–487) — Replace mux.Handle("/.well-known/", wellKnownHandler) catch-all with mux.Handle("/.well-known/oauth-protected-resource", wellKnownHandler). Then add the conditional AS route mounting block immediately after.
cmd/vmcp/app/commands.go (runServe(), line 454–461) — After the authMiddleware, authzMiddleware, authInfoHandler, err := factory.NewIncomingAuthMiddleware(...) block and before building serverCfg, insert the conditional AS creation block.
cmd/vmcp/app/commands.go (serverCfg construction, lines 538–556) — Add AuthServer: authServer to the vmcpserver.Config literal. (The authServer variable will be nil in Mode A, satisfying the nil-gate.)
pkg/vmcp/auth/factory/incoming.go — Reference pattern: conditional creation that hard-fails on error. The newOIDCAuthMiddleware function (lines 131–166) demonstrates "check config, construct, hard fail on error, return component" — follow this exact pattern for AS creation.
test/e2e/proxy_oauth_test.go — Reference pattern for vMCP auth E2E tests: starts a mock OIDC server, runs a process, makes HTTP requests with Eventually. Follow this pattern for the smoke test.
Component Interfaces
New method on EmbeddedAuthServer in pkg/authserver/runner/embeddedauthserver.go:
// RegisterHandlers mounts OAuth/OIDC endpoints on mux as unauthenticated routes.// The AS handler is obtained once from e.Handler() and registered at each path.// Call this before registering authenticated routes so AS paths bypass auth middleware.func (e*EmbeddedAuthServer) RegisterHandlers(mux*http.ServeMux) {
h:=e.Handler()
mux.Handle("/oauth/", h)
mux.Handle("/.well-known/openid-configuration", h)
mux.Handle("/.well-known/oauth-authorization-server", h)
mux.Handle("/.well-known/jwks.json", h)
}
New field on Config in pkg/vmcp/server/server.go:
// AuthServer is the embedded OAuth authorization server. nil in Mode A (no AS).// When non-nil (Mode B), RegisterHandlers is called during Handler() to mount// /oauth/ and /.well-known/ AS endpoints as unauthenticated routes.AuthServer*runner.EmbeddedAuthServer
Handler replacement in pkg/vmcp/server/server.goHandler():
// Replace the catch-all:// mux.Handle("/.well-known/", wellKnownHandler)// With an explicit path:ifwellKnownHandler:=auth.NewWellKnownHandler(s.config.AuthInfoHandler); wellKnownHandler!=nil {
mux.Handle("/.well-known/oauth-protected-resource", wellKnownHandler)
slog.Info("rFC 9728 OAuth discovery endpoint enabled at /.well-known/oauth-protected-resource")
}
// Mode B: mount AS OAuth/OIDC endpoints (unauthenticated, no auth middleware applied)ifs.config.AuthServer!=nil {
s.config.AuthServer.RegisterHandlers(mux)
slog.Info("embedded auth server endpoints mounted")
}
Conditional AS creation in cmd/vmcp/app/commands.gorunServe():
// Create embedded auth server if configured (Mode B).// Hard fail on error — never silently fall back to Mode A.varauthServer*runner.EmbeddedAuthServerifcfg.AuthServer!=nil {
authServer, err=runner.NewEmbeddedAuthServer(ctx, cfg.AuthServer.RunConfig)
iferr!=nil {
returnfmt.Errorf("failed to create embedded auth server: %w", err)
}
deferfunc() {
ifcloseErr:=authServer.Close(); closeErr!=nil {
slog.Warn("failed to close embedded auth server", "error", closeErr)
}
}()
slog.Info("embedded auth server created (Mode B)")
}
Updated serverCfg construction in cmd/vmcp/app/commands.go:
serverCfg:=&vmcpserver.Config{
// ... existing fields unchanged ...AuthServer: authServer, // nil in Mode A, non-nil in Mode B
}
Testing Strategy
Unit Tests
Unit tests for the HTTP handler behavior go in pkg/vmcp/server/server_test.go (or a new pkg/vmcp/server/handler_test.go). These are Phase 4 deliverables per the DAG; however, adding them here during Phase 2 is encouraged to lock in the expected route behavior immediately.
Mode A (nil AuthServer): GET /.well-known/oauth-protected-resource returns HTTP 200; GET /.well-known/openid-configuration returns HTTP 404; GET /oauth/token returns HTTP 404
Mode B (non-nil AuthServer): GET /.well-known/openid-configuration is served by the AS handler (not 404); GET /oauth/authorize is served by the AS handler
Mode A and Mode B both: GET /.well-known/oauth-protected-resource returns HTTP 200
Mode A and Mode B both: GET /mcp without auth token returns HTTP 401 when AuthMiddleware is set
Test construction: use httptest.NewRecorder() and httptest.NewRequest(). For Mode B tests, construct an EmbeddedAuthServer using NewEmbeddedAuthServer with a minimal dev-mode authserver.RunConfig (nil SigningKeyConfig triggers ephemeral key generation). Pass it as serverCfg.AuthServer in the test.
Smoke Test (manual / CI)
Start vMCP with a minimal Mode B YAML config containing a valid authServer block (e.g., in-memory storage, ephemeral signing key)
curl -s http://localhost:4483/.well-known/openid-configuration returns HTTP 200 with Content-Type: application/json and a non-empty issuer field
curl -s http://localhost:4483/.well-known/oauth-protected-resource returns HTTP 200 (RFC 9728 response, still served in Mode B)
curl -s http://localhost:4483/mcp returns HTTP 401 (auth middleware still active for MCP endpoint)
Start vMCP with Mode A config (no authServer block): same endpoints as above return 404/401 as before
Edge Cases
NewEmbeddedAuthServer returns an error (e.g., invalid RunConfig): runServe() must return that error immediately and log it; the process must exit non-zero
authServer.Close() failure during shutdown is logged as slog.Warn, not surfaced as a startup error
AuthServer field is nil in all Mode A config round-trips (existing YAML files that omit authServer must not break after adding the new field to serverCfg)
RegisterHandlers is called with a mux that already has overlapping registrations: Go's http.ServeMux panics on duplicate exact-path registrations. The four AS paths (/oauth/, /.well-known/openid-configuration, /.well-known/oauth-authorization-server, /.well-known/jwks.json) must not overlap with existing registrations in Handler(). Verify by inspection — the existing mux in Handler() only registers /health, /ping, /readyz, /status, /api/backends/health, /metrics, /.well-known/oauth-protected-resource, and /.
Out of Scope
validateAuthServerIntegration function and validation rules V-01 through V-07 (Phase 3, independent parallel track)
StrategyTypeUpstreamInject constant and UpstreamInjectConfig struct (Phase 3)
Operator reconciler cross-resource validation and AuthServerConfigValid status condition (Phase 4)
CRD-to-config converter changes in cmd/thv-operator/pkg/vmcpconfig/converter.go (Phase 4)
Description
Wire the embedded authorization server into the vMCP HTTP layer, making Mode B operationally active for the first time. This task adds
RegisterHandlerstoEmbeddedAuthServer, conditionally creates the AS in the vMCP startup path, and replaces the/.well-known/catch-all handler with explicit path registrations — the first phase in the RFC-0053 implementation that changes observable server behavior when anauthServerblock is present in the vMCP YAML config.Context
RFC-0053 adds an optional embedded OAuth/OIDC authorization server to vMCP. Phase 1 (#4140) established all structural types and config fields without touching runtime behavior. Phase 2 (this task) flips the first runtime switch: when
cfg.AuthServer != nil(Mode B), vMCP now creates anEmbeddedAuthServerat startup and mounts its OAuth/OIDC routes on the same mux. Whencfg.AuthServer == nil(Mode A), the server is byte-for-byte identical to today. The hard-fail requirement (no silent fallback on AS creation error) is central to this phase.Parent epic: #4120 — vMCP: add embedded authorization server
RFC document:
docs/proposals/THV-0053-vmcp-embedded-authserver.mdDependencies: #4140 (Phase 1: Foundation)
Blocks: Phase 4 (operator reconciler) — Phase 3 (startup validation) can proceed in parallel with this task
Acceptance Criteria
EmbeddedAuthServerhas a newRegisterHandlers(mux *http.ServeMux)method inpkg/authserver/runner/embeddedauthserver.gothat mounts/oauth/,/.well-known/openid-configuration,/.well-known/oauth-authorization-server, and/.well-known/jwks.jsonas unauthenticated routespkg/vmcp/server.Confighas a new fieldAuthServer *runner.EmbeddedAuthServer; when non-nil,Handler()callss.config.AuthServer.RegisterHandlers(mux)before mounting the authenticated catch-allcmd/vmcp/app/commands.gorunServe()conditionally creates the AS withrunner.NewEmbeddedAuthServer(ctx, cfg.AuthServer.RunConfig)immediately afterloadAndValidateConfig(); any error fromNewEmbeddedAuthServercausesrunServeto return that error immediately (hard fail — no silent fallback to Mode A)mux.Handle("/.well-known/", wellKnownHandler)catch-all inpkg/vmcp/server/server.gois replaced with an explicitmux.Handle("/.well-known/oauth-protected-resource", wellKnownHandler)registration; behavior is unchanged (Mode A and Mode B both serveoauth-protected-resourceat this exact path, all other/.well-known/paths that were formerly 404 remain 404 except for the Mode B AS routes)authServerin YAML): all existing tests pass without any code-path changes; zero new lines executeauthServerconfig block,GET /.well-known/openid-configurationreturns HTTP 200 with a valid JSON OIDC discovery document containing a non-emptyissuerandjwks_uriGET /mcpwithout a valid bearer token returns HTTP 401 (auth middleware remains active for the MCP catch-all)GET /.well-known/oauth-protected-resourcereturns HTTP 200 (unauthenticated, explicit registration)EmbeddedAuthServeris closed during server shutdown (deferredClose()call after creation)task lintpassesTechnical Approach
Recommended Implementation
Work in three coordinated steps, each targeting a single file:
Step 1 —
pkg/authserver/runner/embeddedauthserver.go: AddRegisterHandlersas a public method on*EmbeddedAuthServer. It callse.Handler()once to get the AS HTTP handler and registers it at four paths on the provided mux. This method is purely additive and has no callers until Step 3.Step 2 —
pkg/vmcp/server/server.go: AddAuthServer *runner.EmbeddedAuthServerto theConfigstruct (afterAuthInfoHandler, beforeTelemetryProvider). InHandler(), replace themux.Handle("/.well-known/", wellKnownHandler)catch-all (around line 485) with an explicitmux.Handle("/.well-known/oauth-protected-resource", wellKnownHandler)registration, and then conditionally calls.config.AuthServer.RegisterHandlers(mux)if non-nil. Place the AS route registration in the unauthenticated block (before the MCP catch-all with auth middleware) so AS routes bypass auth.Step 3 —
cmd/vmcp/app/commands.go: InrunServe(), afterloadAndValidateConfigsucceeds and before buildingserverCfg, insert the conditional AS creation block. Ifcfg.AuthServer != nil, callrunner.NewEmbeddedAuthServer(ctx, cfg.AuthServer.RunConfig). On error, return immediately (hard fail). On success, assignserverCfg.AuthServer = authServerand deferauthServer.Close(). No else branch — the nil case flows through unchanged.Patterns and Frameworks
cfg.AuthServer != nilin commands.go,s.config.AuthServer != nilin server.go). Mode A must execute zero new lines — verified by reading theifconditions, not by behavioral tests alone.pkg/vmcp/auth/factory/incoming.go— check config, construct component, return error immediately if construction fails. Never swallow an AS creation error to fall back to Mode A; that would mask a misconfiguration.RegisterHandlersis a new method;AuthServeris a new field; the/.well-known/replacement is a behavioral no-op (old catch-all only served one path). No existing struct fields are removed or renamed, no existing method signatures change.// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.and// SPDX-License-Identifier: Apache-2.0. Usetask license-fixto add missing headers automatically.Close()for resource cleanup: After a successfulNewEmbeddedAuthServer, immediately adddefer authServer.Close()inrunServe()so cleanup is guaranteed on any return path.omitemptyand nil safety:Config.AuthServeris pointer-typed; all callers inHandler()must nil-check before use. The Phase 1AuthServerConfigstruct wrappingauthserver.RunConfigis already in place after Phase 1: Foundation — add AuthServerConfig model, CRD field, and structural validation #4140 merges.Code Pointers
pkg/authserver/runner/embeddedauthserver.go— AddRegisterHandlers(mux *http.ServeMux)after the existingUpstreamTokenRefresher()method (line 141). ExistingHandler()method (lines 115–117) returns the AS HTTP handler;RegisterHandlerscalls it once and registers it at four paths.pkg/vmcp/server/server.go(Config struct, lines 84–169) — AddAuthServer *runner.EmbeddedAuthServerafterAuthInfoHandler http.Handler(line 121). Importrunner "github.com/stacklok/toolhive/pkg/authserver/runner"in the import block.pkg/vmcp/server/server.go(Handler() method, around lines 482–487) — Replacemux.Handle("/.well-known/", wellKnownHandler)catch-all withmux.Handle("/.well-known/oauth-protected-resource", wellKnownHandler). Then add the conditional AS route mounting block immediately after.cmd/vmcp/app/commands.go(runServe(), line 454–461) — After theauthMiddleware, authzMiddleware, authInfoHandler, err := factory.NewIncomingAuthMiddleware(...)block and before buildingserverCfg, insert the conditional AS creation block.cmd/vmcp/app/commands.go(serverCfg construction, lines 538–556) — AddAuthServer: authServerto thevmcpserver.Configliteral. (TheauthServervariable will benilin Mode A, satisfying the nil-gate.)pkg/vmcp/auth/factory/incoming.go— Reference pattern: conditional creation that hard-fails on error. ThenewOIDCAuthMiddlewarefunction (lines 131–166) demonstrates "check config, construct, hard fail on error, return component" — follow this exact pattern for AS creation.test/e2e/proxy_oauth_test.go— Reference pattern for vMCP auth E2E tests: starts a mock OIDC server, runs a process, makes HTTP requests withEventually. Follow this pattern for the smoke test.Component Interfaces
New method on
EmbeddedAuthServerinpkg/authserver/runner/embeddedauthserver.go:New field on
Configinpkg/vmcp/server/server.go:Handler replacement in
pkg/vmcp/server/server.goHandler():Conditional AS creation in
cmd/vmcp/app/commands.gorunServe():Updated
serverCfgconstruction incmd/vmcp/app/commands.go:Testing Strategy
Unit Tests
Unit tests for the HTTP handler behavior go in
pkg/vmcp/server/server_test.go(or a newpkg/vmcp/server/handler_test.go). These are Phase 4 deliverables per the DAG; however, adding them here during Phase 2 is encouraged to lock in the expected route behavior immediately.AuthServer):GET /.well-known/oauth-protected-resourcereturns HTTP 200;GET /.well-known/openid-configurationreturns HTTP 404;GET /oauth/tokenreturns HTTP 404AuthServer):GET /.well-known/openid-configurationis served by the AS handler (not 404);GET /oauth/authorizeis served by the AS handlerGET /.well-known/oauth-protected-resourcereturns HTTP 200GET /mcpwithout auth token returns HTTP 401 whenAuthMiddlewareis setTest construction: use
httptest.NewRecorder()andhttptest.NewRequest(). For Mode B tests, construct anEmbeddedAuthServerusingNewEmbeddedAuthServerwith a minimal dev-modeauthserver.RunConfig(nilSigningKeyConfigtriggers ephemeral key generation). Pass it asserverCfg.AuthServerin the test.Smoke Test (manual / CI)
authServerblock (e.g., in-memory storage, ephemeral signing key)curl -s http://localhost:4483/.well-known/openid-configurationreturns HTTP 200 withContent-Type: application/jsonand a non-emptyissuerfieldcurl -s http://localhost:4483/.well-known/oauth-protected-resourcereturns HTTP 200 (RFC 9728 response, still served in Mode B)curl -s http://localhost:4483/mcpreturns HTTP 401 (auth middleware still active for MCP endpoint)authServerblock): same endpoints as above return 404/401 as beforeEdge Cases
NewEmbeddedAuthServerreturns an error (e.g., invalidRunConfig):runServe()must return that error immediately and log it; the process must exit non-zeroauthServer.Close()failure during shutdown is logged asslog.Warn, not surfaced as a startup errorAuthServerfield is nil in all Mode A config round-trips (existing YAML files that omitauthServermust not break after adding the new field toserverCfg)RegisterHandlersis called with a mux that already has overlapping registrations: Go'shttp.ServeMuxpanics on duplicate exact-path registrations. The four AS paths (/oauth/,/.well-known/openid-configuration,/.well-known/oauth-authorization-server,/.well-known/jwks.json) must not overlap with existing registrations inHandler(). Verify by inspection — the existing mux inHandler()only registers/health,/ping,/readyz,/status,/api/backends/health,/metrics,/.well-known/oauth-protected-resource, and/.Out of Scope
validateAuthServerIntegrationfunction and validation rules V-01 through V-07 (Phase 3, independent parallel track)StrategyTypeUpstreamInjectconstant andUpstreamInjectConfigstruct (Phase 3)AuthServerConfigValidstatus condition (Phase 4)cmd/thv-operator/pkg/vmcpconfig/converter.go(Phase 4)identity.UpstreamTokenspopulation — this depends on RFC-0052 (Auth Server: multi-upstream provider support #3924); the token flow is a Phase 4 concernupstream_injectoutgoing auth strategy implementation (deferred to a follow-up RFC)docs/arch/(Phase 4)References
docs/proposals/THV-0053-vmcp-embedded-authserver.mdEmbeddedAuthServerimplementation:pkg/authserver/runner/embeddedauthserver.goauthserver.RunConfig(wrapped byAuthServerConfig):pkg/authserver/config.gopkg/vmcp/server/server.gocmd/vmcp/app/commands.gopkg/vmcp/auth/factory/incoming.gotest/e2e/proxy_oauth_test.go