-
Notifications
You must be signed in to change notification settings - Fork 191
Description
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.goexists and contains aUpstreamInjectStrategystruct (zero-field, stateless) that satisfies theauth.Strategyinterface -
UpstreamInjectStrategy.Name()returnsauthtypes.StrategyTypeUpstreamInject(the constant"upstream_inject") -
UpstreamInjectStrategy.Authenticate()injectsAuthorization: Bearer <token>whenidentity.UpstreamTokens[providerName]is present and non-empty -
UpstreamInjectStrategy.Authenticate()returnsfmt.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 returntrue -
UpstreamInjectStrategy.Authenticate()returnsnilwith no header set whenhealth.IsHealthCheck(ctx)is true (health check bypass) -
UpstreamInjectStrategy.Authenticate()returns an error (notErrUpstreamTokenNotFound) whenstrategy == nilorstrategy.UpstreamInject == nil -
UpstreamInjectStrategy.Validate()returnsnilfor a valid config with non-emptyProviderName -
UpstreamInjectStrategy.Validate()returns an error whenstrategy == nil,strategy.UpstreamInject == nil, orstrategy.UpstreamInject.ProviderName == "" -
TokenExchangeStrategyinternaltokenExchangeConfigstruct has aSubjectProviderName stringfield -
parseTokenExchangeConfig()populatesSubjectProviderNamefromstrategy.TokenExchange.SubjectProviderName -
TokenExchangeStrategy.Authenticate()usesidentity.UpstreamTokens[config.SubjectProviderName]as the subject token whenconfig.SubjectProviderName != ""; falls back toidentity.TokenwhenSubjectProviderName == ""(no regression for existing behavior) -
TokenExchangeStrategy.Authenticate()returns a wrappedErrUpstreamTokenNotFoundwhenSubjectProviderNameis set but the named provider's token is absent or empty -
buildCacheKeydoes NOT includeSubjectProviderName(the server-levelExchangeConfigcache is subject-agnostic) -
pkg/vmcp/auth/factory/outgoing.goregistersupstream_injectunconditionally viaregistry.RegisterStrategy(authtypes.StrategyTypeUpstreamInject, strategies.NewUpstreamInjectStrategy()) -
pkg/vmcp/auth/strategies/upstream_inject_test.goexists with 9 table-driven test cases covering all specified scenarios -
pkg/vmcp/auth/strategies/tokenexchange_test.gohas 3 new table entries forSubjectProviderNamebehavior - No token value appears in any error message or log line
- All new Go files have SPDX headers (
task license-checkpasses) - 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:
- Health check guard first:
if health.IsHealthCheck(ctx) { return nil } - Nil guard: check
strategy == nil || strategy.UpstreamInject == nil - Identity retrieval:
auth.IdentityFromContext(ctx)— return non-sentinel error on missing identity - Token lookup:
identity.UpstreamTokens[providerName]— both absent key and empty value → wrappedErrUpstreamTokenNotFound - 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.SubjectProviderNameIn 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
UnauthenticatedStrategyinpkg/vmcp/auth/strategies/unauthenticated.go - Context identity access: follow
TokenExchangeStrategy.Authenticate()inpkg/vmcp/auth/strategies/tokenexchange.go - Health check bypass:
health.IsHealthCheck(ctx)frompkg/vmcp/health/monitor.go— must be the first check inAuthenticate() - Sentinel error wrapping:
fmt.Errorf("...%q: %w", providerName, authtypes.ErrUpstreamTokenNotFound)— always wrap with%wforerrors.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_Authenticatepattern intokenexchange_test.go - SPDX headers:
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc./// SPDX-License-Identifier: Apache-2.0on 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 andValidate()patternpkg/vmcp/auth/strategies/tokenexchange.go— reference forauth.IdentityFromContext(ctx)usage,parseTokenExchangeConfigpattern, and thetokenExchangeConfigstruct to extend; note currentAuthenticate()order before health check handlingpkg/vmcp/auth/strategies/tokenexchange_test.go— reference for test helpers (createContextWithIdentity,createMockEnvReader, table structure) and thecreateTokenExchangeStrategybuilder function to follow for new test casespkg/vmcp/auth/factory/outgoing.go— file to extend with the newRegisterStrategycallpkg/vmcp/auth/types/types.go— containsStrategyTypeUpstreamInject,UpstreamInjectConfig,ErrUpstreamTokenNotFound(added by Phase 1)pkg/auth/context.go— containsauth.IdentityFromContext(ctx)andauth.WithIdentity(ctx, identity)used in testspkg/vmcp/health/monitor.go— containshealth.IsHealthCheck(ctx)andhealth.WithHealthCheckMarker(ctx)used in testspkg/vmcp/auth/auth.go— contains theauth.Strategyinterface thatUpstreamInjectStrategymust 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) errorInternal 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-abcset on request - Token not found:
identity.UpstreamTokensempty map,providerName: "github"→errors.Is(err, authtypes.ErrUpstreamTokenNotFound)istrue - Empty token value:
identity.UpstreamTokens["github"] = ""→ same as token not found (ErrUpstreamTokenNotFoundreturned) - Health check bypass:
health.WithHealthCheckMarker(ctx)→nilreturned, noAuthorizationheader set - Nil strategy:
strategy == nil→ non-nil error that is NOTErrUpstreamTokenNotFound - Nil UpstreamInject:
strategy.UpstreamInject == nil→ non-nil error that is NOTErrUpstreamTokenNotFound -
Validatevalid:strategywithProviderName: "github"→nil -
Validateempty provider:strategywithProviderName: ""→ non-nil error -
Validatenil config:strategy == nil→ non-nil error
Unit Tests — pkg/vmcp/auth/strategies/tokenexchange_test.go (3 new table entries)
Add to TestTokenExchangeStrategy_Authenticate:
-
SubjectProviderNameset and provider present:SubjectProviderName: "github",UpstreamTokens["github"] = "upstream-tok"→ verify token server receivessubject_token=upstream-tok(notidentity.Token) -
SubjectProviderNameset but provider absent:SubjectProviderName: "github",UpstreamTokensempty →errors.Is(err, authtypes.ErrUpstreamTokenNotFound)istrue -
SubjectProviderNameabsent (regression): noSubjectProviderNameset,identity.Token = "id-tok"→ verify token server receivessubject_token=id-tok(existing behavior unchanged)
Edge Cases
- Multiple providers in
UpstreamTokens— verify correct provider is selected by name - Verify
buildCacheKeyoutput is identical for same config with and withoutSubjectProviderName(cache is subject-agnostic)
Out of Scope
- Step-up auth signaling mechanics for UC-06 —
ErrUpstreamTokenNotFoundis 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.UpstreamTokensdirectly - 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_injectis Bearer-only by design; non-Bearer formats useheader_injection - Actor token (
ActorProviderName) for delegation in token exchange — deferred per RFC-0054 non-goals
References
- RFC-0054 (primary):
docs/proposals/THV-0054-vmcp-upstream-inject-strategy.md - Parent epic: vMCP: implement upstream_inject outgoing auth strategy #3925
- Phase 1 (upstream dependency): Phase 1: Add core types and sentinel for upstream_inject strategy (RFC-0054) #4144
- RFC-0052 (multi-upstream IDP support, defines
identity.UpstreamTokens): Auth Server: multi-upstream provider support #3924 - RFC-0053 (embedded AS in vMCP, prerequisite for full e2e testing): vMCP: add embedded authorization server #4120
pkg/vmcp/auth/strategies/unauthenticated.go— structural model for stateless strategypkg/vmcp/auth/strategies/tokenexchange.go— reference for identity context access and internal config struct patternpkg/vmcp/auth/factory/outgoing.go— factory registration target