TASK-10: Outbound Authentication — Bearer, API Key, OAuth2 Client Credentials
Context
Implement outbound authentication for upstream API calls. Each upstream has an independent outbound_auth configuration. The proxy never forwards inbound MCP client tokens to upstream APIs — it always uses its own independently configured credentials. This task implements Bearer static token, API key header injection, and OAuth2 client credentials with automatic token caching and refresh.
No stubs.
Active development: No backward compatibility.
Prerequisites
- TASK-09 (inbound auth, registry with
AuthRequired per tool)
Spec References
SPEC.md §15 Authentication Layer (outbound section)
SPEC.md §9 Multi-Upstream Routing (outbound auth in OutboundTokenProvider)
SPEC.md AC-18 (outbound auth)
Config Changes
Add to UpstreamConfig:
type OutboundAuthConfig struct {
Strategy string `koanf:"strategy"` // bearer|api_key|oauth2_client_credentials|lua|none
Bearer BearerOutboundConfig `koanf:"bearer"`
APIKey APIKeyOutboundConfig `koanf:"api_key"`
OAuth2ClientCredentials OAuth2CCConfig `koanf:"oauth2_client_credentials"`
// Lua deferred to TASK-16
}
type BearerOutboundConfig struct {
TokenEnv string `koanf:"token_env"` // env var name containing the token
}
type APIKeyOutboundConfig struct {
Header string `koanf:"header"` // header name to inject
ValueEnv string `koanf:"value_env"` // env var name containing the value
Prefix string `koanf:"prefix"` // prepended to value, e.g. "ApiKey "
}
type OAuth2CCConfig struct {
TokenURL string `koanf:"token_url"`
ClientID string `koanf:"client_id"`
ClientSecret string `koanf:"client_secret"`
Scopes []string `koanf:"scopes"`
}
All *Env fields are environment variable names resolved at runtime via os.Getenv. Log an error (not a panic) if an env var is missing at startup.
ClientSecret expanded via os.ExpandEnv to support ${VAR} syntax.
OutboundTokenProvider Interface
Implement internal/auth/outbound/provider.go:
// OutboundTokenProvider supplies credentials for upstream API calls.
// Implementations must be safe for concurrent use.
type OutboundTokenProvider interface {
// Token returns the current Bearer token, refreshing if necessary.
// Returns empty string if the strategy does not use a Bearer token.
Token(ctx context.Context) (string, error)
// RawHeaders returns headers to inject in addition to (or instead of) Token.
// If non-empty, these headers are injected verbatim.
// If both Token() and RawHeaders() return values, RawHeaders take precedence for Authorization.
RawHeaders(ctx context.Context) (map[string]string, error)
}
Implement a RoundTripper wrapper in internal/auth/outbound/transport.go:
// AuthTransport is an http.RoundTripper that injects outbound auth on every request.
type AuthTransport struct {
Base http.RoundTripper
Provider OutboundTokenProvider
}
func (t *AuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
headers, err := t.Provider.RawHeaders(req.Context())
if err != nil { return nil, fmt.Errorf("get raw headers: %w", err) }
if len(headers) > 0 {
req = req.Clone(req.Context())
for k, v := range headers { req.Header.Set(k, v) }
} else {
token, err := t.Provider.Token(req.Context())
if err != nil { return nil, fmt.Errorf("get outbound token: %w", err) }
if token != "" {
req = req.Clone(req.Context())
req.Header.Set("Authorization", "Bearer "+token)
}
}
return t.Base.RoundTrip(req)
}
Update upstream.NewHTTPClient to wrap the base transport with AuthTransport.
Bearer Provider
Implement internal/auth/outbound/bearer.go:
type BearerProvider struct {
tokenEnv string
}
func NewBearerProvider(cfg BearerOutboundConfig) *BearerProvider
func (p *BearerProvider) Token(ctx context.Context) (string, error)
func (p *BearerProvider) RawHeaders(ctx context.Context) (map[string]string, error)
Token() calls os.Getenv(p.tokenEnv). If empty, return an error. RawHeaders() returns nil, nil.
API Key Provider
Implement internal/auth/outbound/apikey.go:
type APIKeyProvider struct {
header string
valueEnv string
prefix string
}
func NewAPIKeyProvider(cfg APIKeyOutboundConfig) *APIKeyProvider
func (p *APIKeyProvider) Token(ctx context.Context) (string, error)
func (p *APIKeyProvider) RawHeaders(ctx context.Context) (map[string]string, error)
Token() returns "", nil. RawHeaders() returns map[string]string{p.header: p.prefix + os.Getenv(p.valueEnv)}. If the env var is empty, return an error.
OAuth2 Client Credentials Provider
Implement internal/auth/outbound/oauth2.go:
type OAuth2CCProvider struct {
src oauth2.TokenSource // from golang.org/x/oauth2/clientcredentials
mu sync.Mutex
}
func NewOAuth2CCProvider(ctx context.Context, cfg OAuth2CCConfig) (*OAuth2CCProvider, error)
func (p *OAuth2CCProvider) Token(ctx context.Context) (string, error)
func (p *OAuth2CCProvider) RawHeaders(ctx context.Context) (map[string]string, error)
NewOAuth2CCProvider:
ccCfg := &clientcredentials.Config{
ClientID: cfg.ClientID,
ClientSecret: os.ExpandEnv(cfg.ClientSecret),
TokenURL: cfg.TokenURL,
Scopes: cfg.Scopes,
}
src := ccCfg.TokenSource(ctx) // golang.org/x/oauth2 handles caching + refresh
return &OAuth2CCProvider{src: oauth2.ReuseTokenSource(nil, src)}, nil
Token() calls p.src.Token() which handles caching and refresh transparently. RawHeaders() returns nil, nil.
None Provider
Implement internal/auth/outbound/none.go:
type NoneProvider struct{}
func (p *NoneProvider) Token(ctx context.Context) (string, error) { return "", nil }
func (p *NoneProvider) RawHeaders(ctx context.Context) (map[string]string, error) { return nil, nil }
Factory
Implement internal/auth/outbound/factory.go:
// New builds the appropriate OutboundTokenProvider from config.
func New(ctx context.Context, cfg *config.OutboundAuthConfig) (OutboundTokenProvider, error)
Switch on cfg.Strategy:
"bearer" → NewBearerProvider
"api_key" → NewAPIKeyProvider
"oauth2_client_credentials" → NewOAuth2CCProvider
"none" or "" → NoneProvider{}
- unknown → return error
Integration Test
Create tests/integration/outbound_auth_test.go with build tag //go:build integration and package integration_test.
Tests do NOT import any internal/ packages. The proxy runs as a Docker container built via proxyContainerRequest(). All assertions go through MCP protocol calls and HTTP endpoints. See tests/integration/mvp_test.go for the full setup pattern.
Setup (shared across tests in this file):
- Create a shared Docker network via
network.New(ctx, network.WithDriver("bridge"))
- Start a WireMock container on the shared network with alias
wiremock (serves as the protected upstream API)
- Start a Keycloak container (
quay.io/keycloak/keycloak:25.0) on the shared network with alias keycloak for OAuth2 tests
CMD: ["start-dev"], env KEYCLOAK_ADMIN=admin, KEYCLOAK_ADMIN_PASSWORD=admin
- Wait for
http://localhost:8080/health/ready
- Configure via Keycloak REST API: create realm, client
mcp-upstream with client credentials enabled
- Write OpenAPI spec and config files to a temp dir
- Mount files into the proxy container via
testcontainers.ContainerFile
- Env vars for secrets (e.g.
UPSTREAM_TOKEN, UPSTREAM_KEY) set on the proxy container via Env field in ContainerRequest
Test: TestBearerTokenInjected
- WireMock stub:
GET /data with header match Authorization: Bearer static-test-token → 200; without → 401
- Set
Env: map[string]string{"UPSTREAM_TOKEN": "static-test-token"} on the proxy container
- Mount config with
outbound_auth.strategy: bearer, bearer.token_env: UPSTREAM_TOKEN
- Start proxy container on the shared network, wait for
/healthz
- Connect MCP client and call the tool that maps to
GET /data
- Assert tool result is not
IsError
- Verify via WireMock request journal (
GET /__admin/requests) that the request had Authorization: Bearer static-test-token
Test: TestAPIKeyHeaderInjected
- WireMock stub:
GET /data with header match X-API-Key: mysecret → 200; without → 403
- Set
Env: map[string]string{"UPSTREAM_KEY": "mysecret"} on the proxy container
- Mount config with
outbound_auth.strategy: api_key, api_key.header: X-API-Key, api_key.value_env: UPSTREAM_KEY
- Start proxy container, connect MCP client, call the tool
- Assert success and verify WireMock received the correct
X-API-Key header
Test: TestOAuth2ClientCredentials
- Start Keycloak container (configured with client
mcp-upstream, client credentials flow)
- WireMock stub:
GET /data expects Authorization: Bearer {any-non-empty-token} → 200
- Mount config with
outbound_auth.strategy: oauth2_client_credentials, Keycloak token URL (http://keycloak:8080/realms/test-realm/protocol/openid-connect/token), client ID + secret
- Start proxy container with Keycloak client secret in
Env
- Connect MCP client, call the tool
- Assert tool result is not
IsError
- Verify via WireMock request journal that the request had a non-empty
Authorization: Bearer ... header
Test: TestInboundTokenNotForwardedToUpstream
- Start Keycloak container
- Configure proxy with inbound JWT auth (Keycloak issuer) AND outbound Bearer auth (static token via env var)
- Set
Env: map[string]string{"UPSTREAM_TOKEN": "outbound-static-token"} on the proxy container
- Obtain a real inbound JWT from Keycloak (for the MCP client identity, via the external mapped port)
- Call MCP tool with inbound JWT in
Authorization header
- Verify via WireMock request journal that the upstream request had
Authorization: Bearer outbound-static-token (NOT the inbound JWT)
This test explicitly verifies SPEC.md AC-18.2.
Checks Before Committing
make check
make integration
Acceptance Criteria
Definition of Done
make check exits 0. Integration tests pass. Upstream APIs requiring authentication receive the correct credentials, and the inbound-to-outbound token isolation is verified by a dedicated test.
TASK-10: Outbound Authentication — Bearer, API Key, OAuth2 Client Credentials
Context
Implement outbound authentication for upstream API calls. Each upstream has an independent
outbound_authconfiguration. The proxy never forwards inbound MCP client tokens to upstream APIs — it always uses its own independently configured credentials. This task implements Bearer static token, API key header injection, and OAuth2 client credentials with automatic token caching and refresh.No stubs.
Active development: No backward compatibility.
Prerequisites
AuthRequiredper tool)Spec References
SPEC.md§15 Authentication Layer (outbound section)SPEC.md§9 Multi-Upstream Routing (outbound auth inOutboundTokenProvider)SPEC.mdAC-18 (outbound auth)Config Changes
Add to
UpstreamConfig:All
*Envfields are environment variable names resolved at runtime viaos.Getenv. Log an error (not a panic) if an env var is missing at startup.ClientSecretexpanded viaos.ExpandEnvto support${VAR}syntax.OutboundTokenProvider Interface
Implement
internal/auth/outbound/provider.go:Implement a
RoundTripperwrapper ininternal/auth/outbound/transport.go:Update
upstream.NewHTTPClientto wrap the base transport withAuthTransport.Bearer Provider
Implement
internal/auth/outbound/bearer.go:Token()callsos.Getenv(p.tokenEnv). If empty, return an error.RawHeaders()returns nil, nil.API Key Provider
Implement
internal/auth/outbound/apikey.go:Token()returns"", nil.RawHeaders()returnsmap[string]string{p.header: p.prefix + os.Getenv(p.valueEnv)}. If the env var is empty, return an error.OAuth2 Client Credentials Provider
Implement
internal/auth/outbound/oauth2.go:NewOAuth2CCProvider:Token()callsp.src.Token()which handles caching and refresh transparently.RawHeaders()returns nil, nil.None Provider
Implement
internal/auth/outbound/none.go:Factory
Implement
internal/auth/outbound/factory.go:Switch on
cfg.Strategy:"bearer"→NewBearerProvider"api_key"→NewAPIKeyProvider"oauth2_client_credentials"→NewOAuth2CCProvider"none"or""→NoneProvider{}Integration Test
Create
tests/integration/outbound_auth_test.gowith build tag//go:build integrationand packageintegration_test.Tests do NOT import any
internal/packages. The proxy runs as a Docker container built viaproxyContainerRequest(). All assertions go through MCP protocol calls and HTTP endpoints. Seetests/integration/mvp_test.gofor the full setup pattern.Setup (shared across tests in this file):
network.New(ctx, network.WithDriver("bridge"))wiremock(serves as the protected upstream API)quay.io/keycloak/keycloak:25.0) on the shared network with aliaskeycloakfor OAuth2 testsCMD: ["start-dev"], envKEYCLOAK_ADMIN=admin,KEYCLOAK_ADMIN_PASSWORD=adminhttp://localhost:8080/health/readymcp-upstreamwith client credentials enabledtestcontainers.ContainerFileUPSTREAM_TOKEN,UPSTREAM_KEY) set on the proxy container viaEnvfield inContainerRequestTest:
TestBearerTokenInjectedGET /datawith header matchAuthorization: Bearer static-test-token→ 200; without → 401Env: map[string]string{"UPSTREAM_TOKEN": "static-test-token"}on the proxy containeroutbound_auth.strategy: bearer,bearer.token_env: UPSTREAM_TOKEN/healthzGET /dataIsErrorGET /__admin/requests) that the request hadAuthorization: Bearer static-test-tokenTest:
TestAPIKeyHeaderInjectedGET /datawith header matchX-API-Key: mysecret→ 200; without → 403Env: map[string]string{"UPSTREAM_KEY": "mysecret"}on the proxy containeroutbound_auth.strategy: api_key,api_key.header: X-API-Key,api_key.value_env: UPSTREAM_KEYX-API-KeyheaderTest:
TestOAuth2ClientCredentialsmcp-upstream, client credentials flow)GET /dataexpectsAuthorization: Bearer {any-non-empty-token}→ 200outbound_auth.strategy: oauth2_client_credentials, Keycloak token URL (http://keycloak:8080/realms/test-realm/protocol/openid-connect/token), client ID + secretEnvIsErrorAuthorization: Bearer ...headerTest:
TestInboundTokenNotForwardedToUpstreamEnv: map[string]string{"UPSTREAM_TOKEN": "outbound-static-token"}on the proxy containerAuthorizationheaderAuthorization: Bearer outbound-static-token(NOT the inbound JWT)This test explicitly verifies SPEC.md AC-18.2.
Checks Before Committing
Acceptance Criteria
outbound_authconfigAuthorization: Bearer {token}from env varstrategy: nonemakes upstream requests with no auth headers addedDefinition of Done
make checkexits 0. Integration tests pass. Upstream APIs requiring authentication receive the correct credentials, and the inbound-to-outbound token isolation is verified by a dedicated test.