-
Notifications
You must be signed in to change notification settings - Fork 192
Description
Description
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
-
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 routes -
pkg/vmcp/server.Confighas a new fieldAuthServer *runner.EmbeddedAuthServer; when non-nil,Handler()callss.config.AuthServer.RegisterHandlers(mux)before mounting the authenticated catch-all -
cmd/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) - The
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) - Mode A (no
authServerin YAML): all existing tests pass without any code-path changes; zero new lines execute - Mode B smoke test: start vMCP with a minimal
authServerconfig block,GET /.well-known/openid-configurationreturns HTTP 200 with a valid JSON OIDC discovery document containing a non-emptyissuerandjwks_uri - Mode B:
GET /mcpwithout a valid bearer token returns HTTP 401 (auth middleware remains active for the MCP catch-all) - Mode B:
GET /.well-known/oauth-protected-resourcereturns HTTP 200 (unauthenticated, explicit registration) - The
EmbeddedAuthServeris closed during server shutdown (deferredClose()call after creation) - All new Go files include the SPDX license header;
task lintpasses
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 != 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. - 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:
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 headers: Every 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. - Defer
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 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.EmbeddedAuthServerHandler replacement in pkg/vmcp/server/server.go Handler():
// Replace the catch-all:
// mux.Handle("/.well-known/", wellKnownHandler)
// With an explicit path:
if wellKnownHandler := 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)
if s.config.AuthServer != nil {
s.config.AuthServer.RegisterHandlers(mux)
slog.Info("embedded auth server endpoints mounted")
}Conditional AS creation in cmd/vmcp/app/commands.go runServe():
// Create embedded auth server if configured (Mode B).
// Hard fail on error — never silently fall back to Mode A.
var authServer *runner.EmbeddedAuthServer
if cfg.AuthServer != nil {
authServer, err = runner.NewEmbeddedAuthServer(ctx, cfg.AuthServer.RunConfig)
if err != nil {
return fmt.Errorf("failed to create embedded auth server: %w", err)
}
defer func() {
if closeErr := 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-resourcereturns HTTP 200;GET /.well-known/openid-configurationreturns HTTP 404;GET /oauth/tokenreturns HTTP 404 - Mode B (non-nil
AuthServer):GET /.well-known/openid-configurationis served by the AS handler (not 404);GET /oauth/authorizeis served by the AS handler - Mode A and Mode B both:
GET /.well-known/oauth-protected-resourcereturns HTTP 200 - Mode A and Mode B both:
GET /mcpwithout auth token returns HTTP 401 whenAuthMiddlewareis 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
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-emptyissuerfield -
curl -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) - Start vMCP with Mode A config (no
authServerblock): same endpoints as above return 404/401 as before
Edge Cases
-
NewEmbeddedAuthServerreturns an error (e.g., invalidRunConfig):runServe()must return that error immediately and log it; the process must exit non-zero -
authServer.Close()failure during shutdown is logged asslog.Warn, not surfaced as a startup error -
AuthServerfield 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)- Operator reconciler cross-resource validation and
AuthServerConfigValidstatus condition (Phase 4) - CRD-to-config converter changes in
cmd/thv-operator/pkg/vmcpconfig/converter.go(Phase 4) - Operator E2E tests (Phase 4)
- Full end-to-end token flow requiring
identity.UpstreamTokenspopulation — this depends on RFC-0052 (Auth Server: multi-upstream provider support #3924); the token flow is a Phase 4 concern upstream_injectoutgoing auth strategy implementation (deferred to a follow-up RFC)- Documentation updates to
docs/arch/(Phase 4) - Hot-reload of AS configuration (requires pod restart; out of scope for this RFC)
References
- RFC-0053 design document:
docs/proposals/THV-0053-vmcp-embedded-authserver.md - Parent epic: vMCP: add embedded authorization server #4120
- Phase 1 (upstream dependency): Phase 1: Foundation — add AuthServerConfig model, CRD field, and structural validation #4140
- RFC-0052 (multi-upstream IDP, required for full E2E token flow): Auth Server: multi-upstream provider support #3924
EmbeddedAuthServerimplementation:pkg/authserver/runner/embeddedauthserver.goauthserver.RunConfig(wrapped byAuthServerConfig):pkg/authserver/config.go- vMCP server HTTP handler:
pkg/vmcp/server/server.go - vMCP serve command:
cmd/vmcp/app/commands.go - Incoming auth factory (pattern reference):
pkg/vmcp/auth/factory/incoming.go - E2E test pattern reference:
test/e2e/proxy_oauth_test.go - RFC 9728 (OAuth 2.0 Protected Resource Metadata): https://datatracker.ietf.org/doc/html/rfc9728