Skip to content

TASK-10: Outbound Authentication — Bearer, API Key, OAuth2 Client Credentials #13

Description

@gaarutyunov

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

  • AC-18.1: Each upstream uses its own outbound_auth config
  • AC-18.2: Inbound MCP client Bearer token is NEVER forwarded to any upstream (verified by integration test)
  • AC-18.3: OAuth2 CC provider obtains token via client credentials flow and caches/refreshes automatically
  • AC-18.4: Bearer provider injects Authorization: Bearer {token} from env var
  • AC-18.5: API key provider injects key into configured header with prefix
  • AC-18.7: strategy: none makes upstream requests with no auth headers added
  • All integration tests pass with real Keycloak + WireMock containers

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions