Skip to content

feat: OIDC token authentication for custom HTTP MCP servers #2877

@lpcox

Description

@lpcox

Summary

Add GitHub Actions OIDC token support for authenticating to custom HTTP MCP servers, eliminating long-lived static API keys. The gateway acquires short-lived JWTs from the Actions OIDC endpoint and injects them as Authorization: Bearer <jwt> headers, refreshing automatically before expiry.

Upstream feature request: github/gh-aw#23566

Problem

Custom HTTP MCP servers requiring authentication currently rely on static, long-lived tokens stored in GitHub Secrets:

mcp-servers:
  my-server:
    url: "https://my-server.example.com/mcp"
    headers:
      Authorization: "Bearer ${{ secrets.MY_MCP_TOKEN }}"

This has security and operational drawbacks:

  • Long-lived secrets with no automatic rotation
  • No identity context — the MCP server receives an opaque token with no repo/workflow/actor information
  • Manual rotation burden requiring coordinated updates
  • Secret sprawl across orgs and repos

GitHub Actions OIDC already solves this for cloud providers (AWS, Azure, GCP). The same pattern should work for custom HTTP MCP servers.

Proposed Configuration

JSON stdin format (workflow frontmatter)

{
  "mcpServers": {
    "my-server": {
      "type": "http",
      "url": "https://my-server.example.com/mcp",
      "auth": {
        "type": "github-oidc",
        "audience": "https://my-server.example.com"
      }
    }
  }
}

TOML format

[servers.my-server]
type = "http"
url = "https://my-server.example.com/mcp"

[servers.my-server.auth]
type = "github-oidc"
audience = "https://my-server.example.com"

Key design decisions

  • auth.type: github-oidc — extensible for future auth types
  • auth.audience — optional, defaults to the server URL; allows the upstream to reject tokens not intended for it
  • auth and headers coexist — OIDC sets Authorization, static headers provide additional custom headers (e.g., X-Custom-Header)

Architecture

┌─────────────┐
│   Agent     │
└──────┬──────┘
       │ tool call
       ▼
┌──────────────┐     ┌─────────────────────────┐
│  MCP Gateway │────>│ GitHub OIDC Endpoint     │
│              │<────│ (ACTIONS_ID_TOKEN_       │
│  1. tool call│     │  REQUEST_URL)            │
│  2. get JWT  │     └─────────────────────────┘
│  3. forward  │
└──────┬───────┘
       │ Authorization: Bearer <jwt>
       ▼
┌──────────────┐     ┌─────────────────────────┐
│  Custom HTTP │────>│ GitHub JWKS             │
│  MCP Server  │     │ (token.actions.         │
│              │     │  githubusercontent.com)  │
│  Validates:  │     └─────────────────────────┘
│  - signature │
│  - issuer    │
│  - audience  │
│  - claims    │
└──────────────┘

Implementation Plan

1. Config changes (internal/config/)

Add Auth struct to ServerConfig:

// AuthConfig configures upstream authentication for HTTP MCP servers.
type AuthConfig struct {
    Type     string `toml:"type" json:"type"`           // "github-oidc"
    Audience string `toml:"audience" json:"audience,omitempty"`
}

Add field to ServerConfig (config_core.go):

Auth *AuthConfig `toml:"auth" json:"auth,omitempty"`

Add field to StdinServerConfig (config_stdin.go):

Auth *AuthConfig `json:"auth,omitempty"`

Validation (validation.go):

  • If auth.type is set, it must be "github-oidc" (fail-fast on unknown types)
  • auth is only valid on type: "http" servers (error if used with type: "stdio")
  • If auth.audience is empty, default to the server url

2. OIDC token provider (internal/oidc/)

Create a new package internal/oidc/ with a token provider that handles acquisition and caching:

// Provider acquires and caches GitHub Actions OIDC tokens.
type Provider struct {
    requestURL   string // ACTIONS_ID_TOKEN_REQUEST_URL
    requestToken string // ACTIONS_ID_TOKEN_REQUEST_TOKEN
    mu           sync.Mutex
    cache        map[string]*cachedToken // keyed by audience
}

type cachedToken struct {
    token     string
    expiresAt time.Time
}

// Token returns a valid OIDC JWT for the given audience, refreshing if needed.
func (p *Provider) Token(ctx context.Context, audience string) (string, error)

Token acquisition flow:

  1. Check cache — return if token has >60s remaining
  2. Call ACTIONS_ID_TOKEN_REQUEST_URL?audience=<aud> with Authorization: Bearer <ACTIONS_ID_TOKEN_REQUEST_TOKEN>
  3. Parse response: {"value": "<jwt>"}
  4. Parse JWT exp claim (without verification — the upstream server validates)
  5. Cache with expiry
  6. Return token

Graceful degradation:

  • If ACTIONS_ID_TOKEN_REQUEST_URL is not set (running outside Actions), log a clear error at startup and fail the server config validation
  • If token acquisition fails at runtime, return error to the tool call (don't silently drop auth)
  • If the cached token is expired and refresh fails, return the error (don't use stale tokens)

3. HTTP transport integration (internal/mcp/)

Modify NewHTTPConnection (connection.go) to accept an optional OIDC provider:

func NewHTTPConnection(ctx context.Context, serverID, url string, headers map[string]string, oidc *oidc.Provider, audience string) (*Connection, error)

Create oidcRoundTripper (http_transport.go) — wraps the existing headerInjectingRoundTripper:

type oidcRoundTripper struct {
    base     http.RoundTripper
    provider *oidc.Provider
    audience string
}

func (rt *oidcRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
    token, err := rt.provider.Token(req.Context(), rt.audience)
    if err != nil {
        return nil, fmt.Errorf("OIDC token acquisition failed: %w", err)
    }
    reqCopy := req.Clone(req.Context())
    reqCopy.Header.Set("Authorization", "Bearer "+token)
    return rt.base.RoundTrip(reqCopy)
}

Layering order:

oidcRoundTripper (sets Authorization dynamically)
  └─ headerInjectingRoundTripper (sets static custom headers)
       └─ http.DefaultTransport

This means if both auth and headers are set, OIDC takes precedence for Authorization (dynamic token overwrites any static Authorization header). Static headers like X-Custom-Header pass through normally.

4. Launcher integration (internal/launcher/)

Modify GetOrLaunch (launcher.go) to pass the OIDC provider when creating HTTP connections:

if serverCfg.Type == "http" {
    var oidcProvider *oidc.Provider
    var audience string
    if serverCfg.Auth != nil && serverCfg.Auth.Type == "github-oidc" {
        oidcProvider = l.oidcProvider // set during launcher init
        audience = serverCfg.Auth.Audience
        if audience == "" {
            audience = serverCfg.URL
        }
    }
    conn, err := mcp.NewHTTPConnection(ctx, serverID, serverCfg.URL, serverCfg.Headers, oidcProvider, audience)
    // ...
}

5. Startup initialization (internal/cmd/)

In root.go or server startup, create the OIDC provider from environment:

var oidcProvider *oidc.Provider
if reqURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL"); reqURL != "" {
    reqToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
    oidcProvider = oidc.NewProvider(reqURL, reqToken)
}

Pass oidcProvider to the Launcher so it's available when creating HTTP connections.

Validation at startup: If any server has auth.type: github-oidc but oidcProvider is nil (env vars missing), log a clear error:

ERROR: Server "my-server" requires OIDC authentication but ACTIONS_ID_TOKEN_REQUEST_URL is not set.
       OIDC auth is only available when running in GitHub Actions with `permissions: { id-token: write }`.

Testing Strategy

Unit tests (internal/oidc/)

  • Token acquisition with mock HTTP server
  • Token caching and refresh (verify cache hit when token is valid, refresh when <60s remaining)
  • Audience parameter encoding
  • Error handling: network failure, invalid response, missing env vars
  • Concurrent token requests (race condition testing)

Unit tests (internal/mcp/)

  • oidcRoundTripper sets Authorization header
  • Static headers coexist with OIDC (OIDC overwrites Authorization, other headers preserved)
  • Error propagation when OIDC provider fails

Unit tests (internal/config/)

  • Parse auth field from JSON stdin config
  • Parse auth field from TOML config
  • Validation: auth on stdio server → error
  • Validation: unknown auth.type → error
  • Validation: auth.audience defaults to URL when empty

Integration tests (test/integration/)

  • Gateway starts with OIDC-configured HTTP server (mock OIDC endpoint)
  • Tool calls include Authorization header with valid JWT
  • Token refresh works across multiple calls

Backward Compatibility

  • Fully backward compatible — servers without auth continue to use static headers: as today
  • auth and headers coexist (OIDC handles Authorization, static headers handle everything else)
  • No changes to existing stdio server behavior
  • No changes to existing HTTP server behavior without auth

Out of scope (for this PR)

  • Compiler changes in gh-aw (auto-adding id-token: write permission, passing env vars to container) — tracked separately in gh-aw
  • Additional auth types beyond github-oidc
  • OIDC token validation within the gateway (the upstream server validates)
  • Custom OIDC providers (non-GitHub)

References

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions