Skip to content

Phase 2: Implement upstream_inject strategy and SubjectProviderName for token_exchange (RFC-0054) #4145

@tgrunnagle

Description

@tgrunnagle

Description

Implement the upstream_inject outgoing auth strategy and update the token_exchange strategy to support SubjectProviderName. This phase introduces the runtime behavior that reads identity.UpstreamTokens[providerName] from the request context and injects it as an Authorization: Bearer header on outgoing backend requests. It also extends TokenExchangeStrategy so that when subjectProviderName is set, the RFC 8693 subject token comes from an upstream IDP rather than the default identity.Token. The strategy and factory registration are the load-bearing implementation that Phase 3 (startup validation) and downstream consumers (UC-06 step-up auth) build on.

Context

This is Phase 2 of the RFC-0054 epic (#3925), which implements the upstream_inject outgoing auth strategy for vMCP. Phase 1 (#4144) landed the type definitions (StrategyTypeUpstreamInject, UpstreamInjectConfig, ErrUpstreamTokenNotFound, the UpstreamInject field on BackendAuthStrategy, and SubjectProviderName on TokenExchangeConfig). This phase uses those types to implement the actual runtime logic.

The strategy is intentionally stateless — UpstreamInjectStrategy is a zero-field struct. All state comes from identity.UpstreamTokens in the request context (populated by RFC-0052's auth middleware). This matches the structural pattern of UnauthenticatedStrategy while borrowing the context identity access pattern from TokenExchangeStrategy.

Phase 3 (startup validation) and Phase 4 (CRD/converter) can proceed once this phase is merged. Full end-to-end testing requires RFC-0052 (which adds identity.UpstreamTokens to pkg/auth/identity.go) and RFC-0053 (which wires the embedded AS into the vMCP flow so tokens are populated). Unit tests in this phase populate identity.UpstreamTokens directly and do not require those RFCs.

Dependencies: #4144 (Phase 1: Core types and sentinel)
Blocks: TASK-003 (Phase 3: Startup validation)

Acceptance Criteria

  • pkg/vmcp/auth/strategies/upstream_inject.go exists and contains a UpstreamInjectStrategy struct (zero-field, stateless) that satisfies the auth.Strategy interface
  • UpstreamInjectStrategy.Name() returns authtypes.StrategyTypeUpstreamInject (the constant "upstream_inject")
  • UpstreamInjectStrategy.Authenticate() injects Authorization: Bearer <token> when identity.UpstreamTokens[providerName] is present and non-empty
  • UpstreamInjectStrategy.Authenticate() returns fmt.Errorf("upstream_inject provider %q: %w", providerName, authtypes.ErrUpstreamTokenNotFound) when the provider key is absent or the value is empty; errors.Is(err, authtypes.ErrUpstreamTokenNotFound) must return true
  • UpstreamInjectStrategy.Authenticate() returns nil with no header set when health.IsHealthCheck(ctx) is true (health check bypass)
  • UpstreamInjectStrategy.Authenticate() returns an error (not ErrUpstreamTokenNotFound) when strategy == nil or strategy.UpstreamInject == nil
  • UpstreamInjectStrategy.Validate() returns nil for a valid config with non-empty ProviderName
  • UpstreamInjectStrategy.Validate() returns an error when strategy == nil, strategy.UpstreamInject == nil, or strategy.UpstreamInject.ProviderName == ""
  • TokenExchangeStrategy internal tokenExchangeConfig struct has a SubjectProviderName string field
  • parseTokenExchangeConfig() populates SubjectProviderName from strategy.TokenExchange.SubjectProviderName
  • TokenExchangeStrategy.Authenticate() uses identity.UpstreamTokens[config.SubjectProviderName] as the subject token when config.SubjectProviderName != ""; falls back to identity.Token when SubjectProviderName == "" (no regression for existing behavior)
  • TokenExchangeStrategy.Authenticate() returns a wrapped ErrUpstreamTokenNotFound when SubjectProviderName is set but the named provider's token is absent or empty
  • buildCacheKey does NOT include SubjectProviderName (the server-level ExchangeConfig cache is subject-agnostic)
  • pkg/vmcp/auth/factory/outgoing.go registers upstream_inject unconditionally via registry.RegisterStrategy(authtypes.StrategyTypeUpstreamInject, strategies.NewUpstreamInjectStrategy())
  • pkg/vmcp/auth/strategies/upstream_inject_test.go exists with 9 table-driven test cases covering all specified scenarios
  • pkg/vmcp/auth/strategies/tokenexchange_test.go has 3 new table entries for SubjectProviderName behavior
  • No token value appears in any error message or log line
  • All new Go files have SPDX headers (task license-check passes)
  • All unit tests pass (task test)

Technical Approach

Recommended Implementation

1. pkg/vmcp/auth/strategies/upstream_inject.go (new file)

Model the file structure on unauthenticated.go (zero-field struct, simple Name()/Validate()) combined with the context identity access pattern from tokenexchange.go.

The Authenticate() method must follow this exact ordering:

  1. Health check guard first: if health.IsHealthCheck(ctx) { return nil }
  2. Nil guard: check strategy == nil || strategy.UpstreamInject == nil
  3. Identity retrieval: auth.IdentityFromContext(ctx) — return non-sentinel error on missing identity
  4. Token lookup: identity.UpstreamTokens[providerName] — both absent key and empty value → wrapped ErrUpstreamTokenNotFound
  5. Header injection: req.Header.Set("Authorization", "Bearer "+token)

The token value must never appear in error messages. Error messages include only the provider name.

2. pkg/vmcp/auth/strategies/tokenexchange.go (modified)

Two targeted changes:

In tokenExchangeConfig struct: add SubjectProviderName string as a new field (after SubjectTokenType, consistent with field ordering in TokenExchangeConfig).

In parseTokenExchangeConfig(): after the existing SubjectTokenType block, add:

config.SubjectProviderName = tokenExchangeCfg.SubjectProviderName

In Authenticate(): After the identity check and identity.Token empty check, and after parseTokenExchangeConfig, resolve the subject token:

subjectToken := identity.Token
if config.SubjectProviderName != "" {
    upstream, ok := identity.UpstreamTokens[config.SubjectProviderName]
    if !ok || upstream == "" {
        return fmt.Errorf("token_exchange subjectProvider %q: %w",
            config.SubjectProviderName, authtypes.ErrUpstreamTokenNotFound)
    }
    subjectToken = upstream
}

Then pass subjectToken to createUserConfig instead of identity.Token.

Note: The health check path in Authenticate() uses client credentials grant (not user token), so SubjectProviderName is irrelevant for health check requests.

3. pkg/vmcp/auth/factory/outgoing.go (modified)

Add one RegisterStrategy call after the existing three:

if err := registry.RegisterStrategy(
    authtypes.StrategyTypeUpstreamInject,
    strategies.NewUpstreamInjectStrategy(),
); err != nil {
    return nil, err
}

No signature changes to NewOutgoingAuthRegistry. Update the doc comment to include "upstream_inject" in the registered strategies list.

Patterns & Frameworks

  • Zero-field stateless strategy struct: follow UnauthenticatedStrategy in pkg/vmcp/auth/strategies/unauthenticated.go
  • Context identity access: follow TokenExchangeStrategy.Authenticate() in pkg/vmcp/auth/strategies/tokenexchange.go
  • Health check bypass: health.IsHealthCheck(ctx) from pkg/vmcp/health/monitor.go — must be the first check in Authenticate()
  • Sentinel error wrapping: fmt.Errorf("...%q: %w", providerName, authtypes.ErrUpstreamTokenNotFound) — always wrap with %w for errors.Is() to work at any depth
  • Factory registration: follow the existing pattern in pkg/vmcp/auth/factory/outgoing.go
  • Table-driven tests: follow the TestTokenExchangeStrategy_Authenticate pattern in tokenexchange_test.go
  • SPDX headers: // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. / // SPDX-License-Identifier: Apache-2.0 on all new Go files
  • Public methods in the top half of the file; private methods in the bottom half

Code Pointers

  • pkg/vmcp/auth/strategies/unauthenticated.go — structural model for the stateless zero-field strategy; copy the doc comment style and Validate() pattern
  • pkg/vmcp/auth/strategies/tokenexchange.go — reference for auth.IdentityFromContext(ctx) usage, parseTokenExchangeConfig pattern, and the tokenExchangeConfig struct to extend; note current Authenticate() order before health check handling
  • pkg/vmcp/auth/strategies/tokenexchange_test.go — reference for test helpers (createContextWithIdentity, createMockEnvReader, table structure) and the createTokenExchangeStrategy builder function to follow for new test cases
  • pkg/vmcp/auth/factory/outgoing.go — file to extend with the new RegisterStrategy call
  • pkg/vmcp/auth/types/types.go — contains StrategyTypeUpstreamInject, UpstreamInjectConfig, ErrUpstreamTokenNotFound (added by Phase 1)
  • pkg/auth/context.go — contains auth.IdentityFromContext(ctx) and auth.WithIdentity(ctx, identity) used in tests
  • pkg/vmcp/health/monitor.go — contains health.IsHealthCheck(ctx) and health.WithHealthCheckMarker(ctx) used in tests
  • pkg/vmcp/auth/auth.go — contains the auth.Strategy interface that UpstreamInjectStrategy must satisfy

Component Interfaces

The UpstreamInjectStrategy must satisfy the auth.Strategy interface from pkg/vmcp/auth/auth.go:

// auth.Strategy interface (no changes — defined in pkg/vmcp/auth/auth.go)
type Strategy interface {
    Name() string
    Authenticate(ctx context.Context, req *http.Request, strategy *authtypes.BackendAuthStrategy) error
    Validate(strategy *authtypes.BackendAuthStrategy) error
}

Constructor and primary methods:

// pkg/vmcp/auth/strategies/upstream_inject.go

// UpstreamInjectStrategy injects an upstream IDP access token as Authorization: Bearer.
// Stateless: all state comes from identity.UpstreamTokens in the request context.
type UpstreamInjectStrategy struct{}

func NewUpstreamInjectStrategy() *UpstreamInjectStrategy

// Name returns "upstream_inject" (authtypes.StrategyTypeUpstreamInject)
func (*UpstreamInjectStrategy) Name() string

// Authenticate injects Authorization: Bearer from identity.UpstreamTokens[providerName].
// Health check contexts return nil with no header set.
// Returns ErrUpstreamTokenNotFound (wrapped) when provider absent or token empty.
// Never logs or formats the token value.
func (*UpstreamInjectStrategy) Authenticate(
    ctx context.Context, req *http.Request, strategy *authtypes.BackendAuthStrategy,
) error

// Validate checks that strategy.UpstreamInject is non-nil and ProviderName is non-empty.
func (*UpstreamInjectStrategy) Validate(strategy *authtypes.BackendAuthStrategy) error

Internal tokenExchangeConfig struct extension (in pkg/vmcp/auth/strategies/tokenexchange.go):

type tokenExchangeConfig struct {
    TokenURL         string
    ClientID         string
    ClientSecret     string //nolint:gosec
    Audience         string
    Scopes           []string
    SubjectTokenType string
    SubjectProviderName string // NEW: when set, use upstream token as RFC 8693 subject
}

The buildCacheKey function must NOT include SubjectProviderName — the cached ExchangeConfig template is server-level and subject-agnostic. The subject token is resolved per-user in Authenticate() and passed to createUserConfig.

Testing Strategy

Unit Tests — pkg/vmcp/auth/strategies/upstream_inject_test.go (new file)

9 table-driven test cases. Use auth.WithIdentity and a crafted *auth.Identity with UpstreamTokens populated directly (no mock required). Use health.WithHealthCheckMarker for the health check case.

  • Happy path: identity.UpstreamTokens["github"] = "tok-abc", providerName: "github"Authorization: Bearer tok-abc set on request
  • Token not found: identity.UpstreamTokens empty map, providerName: "github"errors.Is(err, authtypes.ErrUpstreamTokenNotFound) is true
  • Empty token value: identity.UpstreamTokens["github"] = "" → same as token not found (ErrUpstreamTokenNotFound returned)
  • Health check bypass: health.WithHealthCheckMarker(ctx)nil returned, no Authorization header set
  • Nil strategy: strategy == nil → non-nil error that is NOT ErrUpstreamTokenNotFound
  • Nil UpstreamInject: strategy.UpstreamInject == nil → non-nil error that is NOT ErrUpstreamTokenNotFound
  • Validate valid: strategy with ProviderName: "github"nil
  • Validate empty provider: strategy with ProviderName: "" → non-nil error
  • Validate nil config: strategy == nil → non-nil error

Unit Tests — pkg/vmcp/auth/strategies/tokenexchange_test.go (3 new table entries)

Add to TestTokenExchangeStrategy_Authenticate:

  • SubjectProviderName set and provider present: SubjectProviderName: "github", UpstreamTokens["github"] = "upstream-tok" → verify token server receives subject_token=upstream-tok (not identity.Token)
  • SubjectProviderName set but provider absent: SubjectProviderName: "github", UpstreamTokens empty → errors.Is(err, authtypes.ErrUpstreamTokenNotFound) is true
  • SubjectProviderName absent (regression): no SubjectProviderName set, identity.Token = "id-tok" → verify token server receives subject_token=id-tok (existing behavior unchanged)

Edge Cases

  • Multiple providers in UpstreamTokens — verify correct provider is selected by name
  • Verify buildCacheKey output is identical for same config with and without SubjectProviderName (cache is subject-agnostic)

Out of Scope

  • Step-up auth signaling mechanics for UC-06 — ErrUpstreamTokenNotFound is defined and returned by this phase, but the intercept/redirect flow is a separate RFC
  • Startup validation rules V-01, V-02, V-06 — these are Phase 3 (TASK-003)
  • CRD type additions (ExternalAuthTypeUpstreamInject, UpstreamInjectSpec) and converter (UpstreamInjectConverter) — these are Phase 4 (TASK-004)
  • End-to-end or integration tests — full flow E2E is covered by RFC-0053's test specification; unit tests here populate identity.UpstreamTokens directly
  • Architecture documentation updates (docs/arch/02-core-concepts.md, docs/vmcp-auth.md) — explicitly out of scope per intake
  • Token refresh or expiry checking — expired tokens are injected as-is per RFC-0052's design
  • Non-Bearer upstream token injection — upstream_inject is Bearer-only by design; non-Bearer formats use header_injection
  • Actor token (ActorProviderName) for delegation in token exchange — deferred per RFC-0054 non-goals

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