-
Notifications
You must be signed in to change notification settings - Fork 19
Description
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 typesauth.audience— optional, defaults to the server URL; allows the upstream to reject tokens not intended for itauthandheaderscoexist — OIDC setsAuthorization, 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.typeis set, it must be"github-oidc"(fail-fast on unknown types) authis only valid ontype: "http"servers (error if used withtype: "stdio")- If
auth.audienceis empty, default to the serverurl
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:
- Check cache — return if token has >60s remaining
- Call
ACTIONS_ID_TOKEN_REQUEST_URL?audience=<aud>withAuthorization: Bearer <ACTIONS_ID_TOKEN_REQUEST_TOKEN> - Parse response:
{"value": "<jwt>"} - Parse JWT
expclaim (without verification — the upstream server validates) - Cache with expiry
- Return token
Graceful degradation:
- If
ACTIONS_ID_TOKEN_REQUEST_URLis 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/)
oidcRoundTrippersets Authorization header- Static headers coexist with OIDC (OIDC overwrites Authorization, other headers preserved)
- Error propagation when OIDC provider fails
Unit tests (internal/config/)
- Parse
authfield from JSON stdin config - Parse
authfield from TOML config - Validation:
authon stdio server → error - Validation: unknown
auth.type→ error - Validation:
auth.audiencedefaults 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
authcontinue to use staticheaders:as today authandheaderscoexist (OIDC handlesAuthorization, 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: writepermission, 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
- Upstream request: Feature Request: GitHub OIDC token support for custom HTTP MCP server authentication gh-aw#23566
- GitHub Docs: OIDC for Actions
- Actions OIDC token endpoint
- Current HTTP proxy code:
internal/mcp/http_transport.go(headerInjectingRoundTripper) - Current HTTP config:
internal/config/config_core.go(ServerConfig.Headers)