Skip to content

Commit 6578f21

Browse files
yroblataskbot
andauthored
fix(vmcp): apply outgoing auth credentials during health checks (#4101) (#4118)
Health check probes were sent unauthenticated to backend MCPServers because both auth strategies short-circuited on the health-check context marker before injecting any credentials. HeaderInjectionStrategy: remove the early return — static headers have no dependency on user identity and must be injected for all requests, including probes. TokenExchangeStrategy: instead of a blanket skip, perform an OAuth2 client_credentials grant when client_id + client_secret are configured, so the health probe carries a valid Bearer token. When credentials are not configured the probe is still sent unauthenticated (unchanged behaviour for that case). Unit tests are updated / added to cover both the fixed header-injection path and the two token-exchange health-check branches (with and without client credentials). E2E tests are added in test/e2e/thv-operator/virtualmcp for both auth strategies, including a new DeployMockOAuth2ServerWithNodePort helper that lets the test assert client_credentials grants were actually made to the token endpoint. Closes: #4101 Co-authored-by: taskbot <taskbot@users.noreply.github.com>
1 parent 74fcfe6 commit 6578f21

7 files changed

Lines changed: 808 additions & 32 deletions

File tree

pkg/vmcp/auth/strategies/header_injection.go

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111

1212
httpval "github.com/stacklok/toolhive-core/validation/http"
1313
authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types"
14-
"github.com/stacklok/toolhive/pkg/vmcp/health"
1514
)
1615

1716
// HeaderInjectionStrategy injects a static header value into request headers.
@@ -50,26 +49,23 @@ func (*HeaderInjectionStrategy) Name() string {
5049
// Authenticate injects the header value from the strategy config into the request header.
5150
//
5251
// This method:
53-
// 1. Skips authentication if this is a health check request
54-
// 2. Validates that HeaderName and HeaderValue are present in the strategy config
55-
// 3. Sets the specified header with the provided value
52+
// 1. Validates that HeaderName and HeaderValue are present in the strategy config
53+
// 2. Sets the specified header with the provided value
54+
//
55+
// This strategy applies to all requests including health checks, since the header
56+
// value is static and does not depend on user identity.
5657
//
5758
// Parameters:
58-
// - ctx: Request context (used to check for health check marker)
59+
// - ctx: Request context
5960
// - req: The HTTP request to authenticate
6061
// - strategy: The backend auth strategy configuration containing HeaderInjection
6162
//
6263
// Returns an error if:
6364
// - HeaderName is missing or empty
6465
// - HeaderValue is missing or empty
6566
func (*HeaderInjectionStrategy) Authenticate(
66-
ctx context.Context, req *http.Request, strategy *authtypes.BackendAuthStrategy,
67+
_ context.Context, req *http.Request, strategy *authtypes.BackendAuthStrategy,
6768
) error {
68-
// Skip authentication for health checks
69-
if health.IsHealthCheck(ctx) {
70-
return nil
71-
}
72-
7369
if strategy == nil || strategy.HeaderInjection == nil {
7470
return fmt.Errorf("header_injection configuration required")
7571
}

pkg/vmcp/auth/strategies/header_injection_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func TestHeaderInjectionStrategy_Authenticate(t *testing.T) {
3535
checkHeader func(t *testing.T, req *http.Request)
3636
}{
3737
{
38-
name: "skips authentication for health checks",
38+
name: "injects header for health checks",
3939
strategy: &authtypes.BackendAuthStrategy{
4040
Type: authtypes.StrategyTypeHeaderInjection,
4141
HeaderInjection: &authtypes.HeaderInjectionConfig{
@@ -47,7 +47,7 @@ func TestHeaderInjectionStrategy_Authenticate(t *testing.T) {
4747
expectError: false,
4848
checkHeader: func(t *testing.T, req *http.Request) {
4949
t.Helper()
50-
assert.Empty(t, req.Header.Get("X-API-Key"), "X-API-Key header should not be set for health checks")
50+
assert.Equal(t, "secret-key-123", req.Header.Get("X-API-Key"), "static header must be injected for health checks")
5151
},
5252
},
5353
{

pkg/vmcp/auth/strategies/tokenexchange.go

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ import (
77
"context"
88
"fmt"
99
"net/http"
10+
"net/url"
1011
"sort"
1112
"strings"
1213
"sync"
1314

15+
"golang.org/x/oauth2/clientcredentials"
16+
1417
"github.com/stacklok/toolhive-core/env"
1518
"github.com/stacklok/toolhive/pkg/auth"
1619
"github.com/stacklok/toolhive/pkg/auth/tokenexchange"
@@ -73,13 +76,12 @@ func (*TokenExchangeStrategy) Name() string {
7376
// Authenticate exchanges the client's token for a backend token and injects it.
7477
//
7578
// This method:
76-
// 1. Skips authentication if this is a health check request
77-
// 2. Retrieves the client's identity and token from the context
78-
// 3. Parses and validates the token exchange configuration from strategy
79-
// 4. Gets or creates a cached ExchangeConfig for this backend configuration
80-
// 5. Creates a TokenSource with the current identity token
81-
// 6. Obtains an access token by performing the exchange
82-
// 7. Injects the token into the backend request's Authorization header
79+
// 1. Parses and validates the token exchange configuration from strategy
80+
// 2. For health check requests: uses a client credentials grant if client_id and
81+
// client_secret are configured; otherwise skips authentication
82+
// 3. For regular requests: retrieves the client's identity and token from the context,
83+
// gets or creates a cached ExchangeConfig, performs the token exchange, and injects
84+
// the token into the backend request's Authorization header
8385
//
8486
// Token caching per user is handled by the upper vMCP TokenCache layer.
8587
// This strategy only caches the ExchangeConfig template per backend.
@@ -90,15 +92,25 @@ func (*TokenExchangeStrategy) Name() string {
9092
// - strategy: Backend auth strategy containing token exchange configuration
9193
//
9294
// Returns an error if:
93-
// - No identity is found in the context
94-
// - The identity has no token
9595
// - Strategy configuration is invalid or incomplete
96-
// - Token exchange fails
96+
// - No identity is found in the context (regular requests only)
97+
// - The identity has no token (regular requests only)
98+
// - Token exchange or client credentials grant fails
9799
func (s *TokenExchangeStrategy) Authenticate(
98100
ctx context.Context, req *http.Request, strategy *authtypes.BackendAuthStrategy,
99101
) error {
100-
// Skip authentication for health checks
102+
config, err := s.parseTokenExchangeConfig(strategy)
103+
if err != nil {
104+
return fmt.Errorf("invalid strategy configuration: %w", err)
105+
}
106+
107+
// For health checks there is no user identity to exchange. If client credentials
108+
// are configured, use a client credentials grant to authenticate the probe request.
109+
// Otherwise skip authentication — the backend will be probed unauthenticated.
101110
if health.IsHealthCheck(ctx) {
111+
if config.ClientID != "" && config.ClientSecret != "" {
112+
return s.authenticateWithClientCredentials(ctx, req, config)
113+
}
102114
return nil
103115
}
104116

@@ -111,11 +123,6 @@ func (s *TokenExchangeStrategy) Authenticate(
111123
return fmt.Errorf("identity has no token")
112124
}
113125

114-
config, err := s.parseTokenExchangeConfig(strategy)
115-
if err != nil {
116-
return fmt.Errorf("invalid strategy configuration: %w", err)
117-
}
118-
119126
// Get user-specific exchange config. This creates a fresh config instance
120127
// with the current user's token. The underlying server config is cached.
121128
exchangeConfig := s.createUserConfig(config, identity.Token)
@@ -131,6 +138,31 @@ func (s *TokenExchangeStrategy) Authenticate(
131138
return nil
132139
}
133140

141+
// authenticateWithClientCredentials performs an OAuth2 client credentials grant and
142+
// injects the resulting token into the request. Used for health check probes when
143+
// client_id and client_secret are configured.
144+
func (*TokenExchangeStrategy) authenticateWithClientCredentials(
145+
ctx context.Context, req *http.Request, config *tokenExchangeConfig,
146+
) error {
147+
ccConfig := clientcredentials.Config{
148+
ClientID: config.ClientID,
149+
ClientSecret: config.ClientSecret,
150+
TokenURL: config.TokenURL,
151+
Scopes: config.Scopes,
152+
}
153+
if config.Audience != "" {
154+
ccConfig.EndpointParams = url.Values{"audience": {config.Audience}}
155+
}
156+
157+
token, err := ccConfig.Token(ctx)
158+
if err != nil {
159+
return fmt.Errorf("client credentials grant failed: %w", err)
160+
}
161+
162+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
163+
return nil
164+
}
165+
134166
// Validate checks if the required configuration fields are present and valid.
135167
//
136168
// This method verifies that:

pkg/vmcp/auth/strategies/tokenexchange_test.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,11 @@ func TestTokenExchangeStrategy_Authenticate(t *testing.T) {
9696
checkAuthHeader func(t *testing.T, req *http.Request)
9797
}{
9898
{
99-
name: "skips authentication for health checks",
99+
name: "health check without client credentials skips authentication",
100100
setupCtx: func() context.Context { return health.WithHealthCheckMarker(context.Background()) },
101101
setupServer: func() *httptest.Server {
102102
return httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
103-
t.Error("Server should not be called for health checks")
103+
t.Error("token endpoint should not be called when no client credentials are configured")
104104
}))
105105
},
106106
strategy: func(server *httptest.Server) *authtypes.BackendAuthStrategy {
@@ -109,7 +109,39 @@ func TestTokenExchangeStrategy_Authenticate(t *testing.T) {
109109
expectError: false,
110110
checkAuthHeader: func(t *testing.T, req *http.Request) {
111111
t.Helper()
112-
assert.Empty(t, req.Header.Get("Authorization"), "Authorization header should not be set for health checks")
112+
assert.Empty(t, req.Header.Get("Authorization"), "Authorization header should not be set when no client credentials are available")
113+
},
114+
},
115+
{
116+
name: "health check with client credentials uses client credentials grant",
117+
setupCtx: func() context.Context { return health.WithHealthCheckMarker(context.Background()) },
118+
setupServer: func() *httptest.Server {
119+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
120+
t.Helper()
121+
require.NoError(t, r.ParseForm())
122+
assert.Equal(t, "client_credentials", r.Form.Get("grant_type"))
123+
clientID, clientSecret, ok := r.BasicAuth()
124+
assert.True(t, ok, "expected Basic Auth credentials")
125+
assert.Equal(t, "health-client-id", clientID)
126+
assert.Equal(t, "health-client-secret", clientSecret)
127+
128+
w.Header().Set("Content-Type", "application/json")
129+
json.NewEncoder(w).Encode(map[string]any{
130+
"access_token": "health-check-token",
131+
"token_type": "Bearer",
132+
})
133+
}))
134+
},
135+
strategy: func(server *httptest.Server) *authtypes.BackendAuthStrategy {
136+
return createTokenExchangeStrategy(server.URL, func(cfg *authtypes.TokenExchangeConfig) {
137+
cfg.ClientID = "health-client-id"
138+
cfg.ClientSecret = "health-client-secret"
139+
})
140+
},
141+
expectError: false,
142+
checkAuthHeader: func(t *testing.T, req *http.Request) {
143+
t.Helper()
144+
assert.Equal(t, "Bearer health-check-token", req.Header.Get("Authorization"))
113145
},
114146
},
115147
{

pkg/vmcp/health/checker_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,109 @@ func TestHealthChecker_CheckHealth_Timeout(t *testing.T) {
442442
assert.Equal(t, vmcp.BackendUnhealthy, status)
443443
}
444444

445+
// TestHealthChecker_CheckHealth_ContextCarriesHealthCheckMarker verifies that CheckHealth
446+
// passes a context with the health check marker to ListCapabilities.
447+
// This is critical because the auth strategies (header_injection, token_exchange) read
448+
// this marker to decide how to authenticate probe requests.
449+
func TestHealthChecker_CheckHealth_ContextCarriesHealthCheckMarker(t *testing.T) {
450+
t.Parallel()
451+
452+
ctrl := gomock.NewController(t)
453+
defer ctrl.Finish()
454+
455+
var capturedCtx context.Context
456+
mockClient := mocks.NewMockBackendClient(ctrl)
457+
mockClient.EXPECT().
458+
ListCapabilities(gomock.Any(), gomock.Any()).
459+
DoAndReturn(func(ctx context.Context, _ *vmcp.BackendTarget) (*vmcp.CapabilityList, error) {
460+
capturedCtx = ctx
461+
return &vmcp.CapabilityList{}, nil
462+
}).
463+
Times(1)
464+
465+
checker := NewHealthChecker(mockClient, 5*time.Second, 0)
466+
target := &vmcp.BackendTarget{
467+
WorkloadID: "backend-1",
468+
WorkloadName: "test-backend",
469+
BaseURL: "http://localhost:8080",
470+
}
471+
472+
status, err := checker.CheckHealth(context.Background(), target)
473+
require.NoError(t, err)
474+
assert.Equal(t, vmcp.BackendHealthy, status)
475+
476+
// The context passed to ListCapabilities must carry the health check marker so
477+
// that auth strategies (header_injection, token_exchange) apply the correct
478+
// authentication path for probe requests.
479+
require.NotNil(t, capturedCtx, "context must have been captured")
480+
assert.True(t, IsHealthCheck(capturedCtx),
481+
"ListCapabilities must receive a context with the health check marker; "+
482+
"without it, header_injection and token_exchange strategies cannot "+
483+
"apply outgoing auth to health check probes")
484+
}
485+
486+
// TestHealthChecker_CheckHealth_AuthErrorsCategorizesAsUnauthenticated verifies that auth
487+
// errors from health checks are correctly categorised as BackendUnauthenticated, not
488+
// BackendUnhealthy. This distinguishes a backend that is reachable-but-needs-auth
489+
// from one that is down, which is important for health check probes that apply
490+
// outgoing auth credentials (header_injection or token_exchange).
491+
func TestHealthChecker_CheckHealth_AuthErrorsCategorizesAsUnauthenticated(t *testing.T) {
492+
t.Parallel()
493+
494+
tests := []struct {
495+
name string
496+
err error
497+
}{
498+
{
499+
name: "header injection auth failure - http 401",
500+
err: fmt.Errorf("transport error: http 401"),
501+
},
502+
{
503+
name: "token exchange auth failure - status code 401",
504+
err: fmt.Errorf("backend unavailable: failed to initialize client for backend my-backend: status code 401"),
505+
},
506+
{
507+
name: "sentinel auth error",
508+
err: vmcp.ErrAuthenticationFailed,
509+
},
510+
{
511+
name: "sentinel authz error",
512+
err: vmcp.ErrAuthorizationFailed,
513+
},
514+
{
515+
name: "wrapped sentinel auth error",
516+
err: fmt.Errorf("client credentials grant failed: %w", vmcp.ErrAuthenticationFailed),
517+
},
518+
}
519+
520+
for _, tt := range tests {
521+
t.Run(tt.name, func(t *testing.T) {
522+
t.Parallel()
523+
524+
ctrl := gomock.NewController(t)
525+
defer ctrl.Finish()
526+
527+
mockClient := mocks.NewMockBackendClient(ctrl)
528+
mockClient.EXPECT().
529+
ListCapabilities(gomock.Any(), gomock.Any()).
530+
Return(nil, tt.err).
531+
Times(1)
532+
533+
checker := NewHealthChecker(mockClient, 5*time.Second, 0)
534+
target := &vmcp.BackendTarget{
535+
WorkloadID: "backend-1",
536+
WorkloadName: "test-backend",
537+
BaseURL: "http://localhost:8080",
538+
}
539+
540+
status, err := checker.CheckHealth(context.Background(), target)
541+
assert.Error(t, err)
542+
assert.Equal(t, vmcp.BackendUnauthenticated, status,
543+
"auth failure from a health probe should be BackendUnauthenticated, not BackendUnhealthy")
544+
})
545+
}
546+
}
547+
445548
func TestHealthChecker_CheckHealth_MultipleBackends(t *testing.T) {
446549
t.Parallel()
447550

0 commit comments

Comments
 (0)