Skip to content

Phase 2: Server wiring — mount embedded auth server routes on vMCP mux #4141

@tgrunnagle

Description

@tgrunnagle

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

  • 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.go runServe() 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.
  • omitempty and nil safety: Config.AuthServer is pointer-typed; all callers in Handler() must nil-check before use. The Phase 1 AuthServerConfig struct wrapping authserver.RunConfig is already in place after Phase 1: Foundation — add AuthServerConfig model, CRD field, and structural validation #4140 merges.

Code Pointers

  • 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.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-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)
  • Operator E2E tests (Phase 4)
  • Full end-to-end token flow requiring identity.UpstreamTokens population — this depends on RFC-0052 (Auth Server: multi-upstream provider support #3924); the token flow is a Phase 4 concern
  • upstream_inject outgoing 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    authenticationauthorizationenhancementNew feature or requestgoPull requests that update go codevmcpVirtual MCP Server related issues

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions