feat: GitHub Actions OIDC token authentication for custom HTTP MCP servers#2878
feat: GitHub Actions OIDC token authentication for custom HTTP MCP servers#2878
Conversation
- Add AuthConfig struct and Auth field to ServerConfig (TOML and JSON) - Add auth field parsing in StdinServerConfig with JSON unmarshaling - Strip auth extension field before upstream schema validation - Add validation: auth only valid on http servers, type must be github-oidc - Create internal/oidc package with token provider (acquisition + caching) - Add oidcRoundTripper to internal/mcp/http_transport.go - Modify NewHTTPConnection to accept optional OIDC provider + audience - Update Launcher to initialize OIDC provider from env vars at startup - Add unit tests for OIDC provider, config parsing, and round tripper - Update all callers of NewHTTPConnection with new signature Agent-Logs-Url: https://github.com/github/gh-aw-mcpg/sessions/5ee2f664-7a63-4029-a762-b6f6ae243c02 Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw-mcpg/sessions/5ee2f664-7a63-4029-a762-b6f6ae243c02 Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
- Fix ST1023 lint error in connection.go (omit explicit type) - Add 5 launcher OIDC tests: provider init, missing env, missing provider error, audience defaulting, mixed auth types - Add 6 OIDC provider edge case tests: malformed JWT (wrong part count), invalid base64 payload, malformed claims JSON, request token auth header, audience query parameter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
OIDC Validation AssessmentReviewed the OIDC implementation against standard conformance expectations. The current design is architecturally sound — no changes needed. What the implementation does
Validation posture (intentionally minimal — and correct)
Why this is correctThe gateway acts as an OIDC client (token acquirer + forwarder), not a relying party (token verifier). Adding The comment on line 135 of Test coverage added
|
There was a problem hiding this comment.
Pull request overview
Adds first-class GitHub Actions OIDC authentication for custom HTTP MCP servers, replacing the need for static long-lived Authorization secrets by minting short-lived tokens per audience and injecting them into outgoing HTTP requests.
Changes:
- Introduces
auth.type: github-oidc(+ optionalaudience) in config parsing + validation, including strippingauthbefore upstream schema validation. - Adds
internal/oidc.Providerto acquire/cache/refresh Actions OIDC JWTs and wires it into the HTTP transport stack to override staticAuthorization. - Updates launcher + tests to plumb OIDC provider/audience into
mcp.NewHTTPConnection.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
internal/config/config_core.go |
Adds AuthConfig and attaches it to ServerConfig. |
internal/config/config_stdin.go |
Parses/strips auth extension field and converts it into ServerConfig.Auth with audience defaulting. |
internal/config/config_stdin_test.go |
Tests auth conversion + JSON unmarshalling behavior. |
internal/config/validation.go |
Validates auth usage (HTTP-only) and supported auth.type. |
internal/config/validation_test.go |
Adds validation test coverage for auth configurations. |
internal/oidc/provider.go |
Implements Actions OIDC token acquisition, caching, and pre-expiry refresh. |
internal/oidc/provider_test.go |
Unit tests for token fetching, caching, refresh behavior, and error handling. |
internal/mcp/http_transport.go |
Adds OIDC RoundTripper and client builder to inject Authorization: Bearer <jwt>. |
internal/mcp/connection.go |
Extends NewHTTPConnection to accept OIDC provider/audience and composes transport layering. |
internal/launcher/launcher.go |
Initializes OIDC provider from env and passes it to HTTP server connections when configured. |
internal/launcher/launcher_test.go |
Tests provider initialization, missing-env behavior, and audience defaulting. |
internal/mcp/http_transport_test.go |
Updates existing tests for new NewHTTPConnection signature; adds OIDC transport tests. |
internal/mcp/http_connection_test.go |
Updates tests for new NewHTTPConnection signature. |
internal/mcp/http_error_propagation_test.go |
Updates tests for new NewHTTPConnection signature. |
internal/mcp/connection_test.go |
Updates tests for new NewHTTPConnection signature. |
internal/mcp/connection_stderr_test.go |
Updates tests for new NewHTTPConnection signature. |
internal/mcp/connection_arguments_test.go |
Updates tests for new NewHTTPConnection signature. |
test/integration/http_error_test.go |
Updates integration tests for new NewHTTPConnection signature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // It wraps an inner transport (typically a headerInjectingRoundTripper for static headers) | ||
| // and overrides any Authorization header set by that inner layer. |
There was a problem hiding this comment.
oidcRoundTripper doc comment says it wraps an inner headerInjectingRoundTripper, but the actual layering used (and described in NewHTTPConnection) is the opposite: the static header injector wraps the OIDC transport so OIDC runs last. Please update the comment to match the real transport order to avoid future misuse/regressions.
| // It wraps an inner transport (typically a headerInjectingRoundTripper for static headers) | |
| // and overrides any Authorization header set by that inner layer. | |
| // In the typical layering used by NewHTTPConnection, this transport is wrapped by a | |
| // headerInjectingRoundTripper for static headers, so its Authorization header overwrites | |
| // any value set earlier in the chain. |
| func (p *Provider) Token(ctx context.Context, audience string) (string, error) { | ||
| p.mu.Lock() | ||
| defer p.mu.Unlock() | ||
|
|
||
| // Return cached token if still valid | ||
| if cached, ok := p.cache[audience]; ok && cached.isValid() { | ||
| logOIDC.Printf("Returning cached OIDC token: audience=%s", audience) | ||
| return cached.token, nil | ||
| } | ||
|
|
||
| logOIDC.Printf("Fetching new OIDC token: audience=%s", audience) | ||
| token, expiresAt, err := p.fetchToken(ctx, audience) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
|
|
||
| p.cache[audience] = &cachedToken{ | ||
| token: token, | ||
| expiresAt: expiresAt, | ||
| } | ||
| logOIDC.Printf("OIDC token cached: audience=%s, expiresAt=%s", audience, expiresAt.Format(time.RFC3339)) | ||
| return token, nil | ||
| } |
There was a problem hiding this comment.
Provider.Token holds mu across the entire token fetch, including the network call in fetchToken. This serializes all token requests (even for different audiences) and blocks callers while the OIDC endpoint is slow. Consider narrowing the critical section (check cache under lock, fetch without lock, then store under lock with a double-check) and/or using per-audience singleflight/locks to avoid both global contention and duplicate fetches.
| if reqURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL"); reqURL != "" { | ||
| reqToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") | ||
| oidcProvider = oidc.NewProvider(reqURL, reqToken) | ||
| logLauncher.Printf("OIDC provider initialized from ACTIONS_ID_TOKEN_REQUEST_URL") | ||
| logger.LogInfo("startup", "GitHub Actions OIDC provider initialized") | ||
| } | ||
|
|
||
| // Validate that all servers requiring OIDC have the provider available | ||
| for serverID, serverCfg := range cfg.Servers { | ||
| if serverCfg.Auth != nil && serverCfg.Auth.Type == "github-oidc" && oidcProvider == nil { | ||
| log.Printf("[LAUNCHER] ERROR: Server %q requires OIDC authentication but ACTIONS_ID_TOKEN_REQUEST_URL is not set.", serverID) | ||
| log.Printf("[LAUNCHER] OIDC auth is only available when running in GitHub Actions with `permissions: { id-token: write }`.") | ||
| logger.LogError("startup", | ||
| "Server %q requires OIDC authentication but ACTIONS_ID_TOKEN_REQUEST_URL is not set. "+ |
There was a problem hiding this comment.
OIDC provider initialization only checks ACTIONS_ID_TOKEN_REQUEST_URL. If the URL is set but ACTIONS_ID_TOKEN_REQUEST_TOKEN is empty/unset, the provider is still created and will send an empty Authorization: Bearer to the OIDC endpoint, causing confusing 401s later. Please validate that both env vars are present (and non-empty) before creating the provider, and log a clear startup error if only one is set.
| if reqURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL"); reqURL != "" { | |
| reqToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") | |
| oidcProvider = oidc.NewProvider(reqURL, reqToken) | |
| logLauncher.Printf("OIDC provider initialized from ACTIONS_ID_TOKEN_REQUEST_URL") | |
| logger.LogInfo("startup", "GitHub Actions OIDC provider initialized") | |
| } | |
| // Validate that all servers requiring OIDC have the provider available | |
| for serverID, serverCfg := range cfg.Servers { | |
| if serverCfg.Auth != nil && serverCfg.Auth.Type == "github-oidc" && oidcProvider == nil { | |
| log.Printf("[LAUNCHER] ERROR: Server %q requires OIDC authentication but ACTIONS_ID_TOKEN_REQUEST_URL is not set.", serverID) | |
| log.Printf("[LAUNCHER] OIDC auth is only available when running in GitHub Actions with `permissions: { id-token: write }`.") | |
| logger.LogError("startup", | |
| "Server %q requires OIDC authentication but ACTIONS_ID_TOKEN_REQUEST_URL is not set. "+ | |
| reqURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") | |
| reqToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") | |
| if reqURL != "" && reqToken != "" { | |
| oidcProvider = oidc.NewProvider(reqURL, reqToken) | |
| logLauncher.Printf("OIDC provider initialized from ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN") | |
| logger.LogInfo("startup", "GitHub Actions OIDC provider initialized") | |
| } else if (reqURL != "" && reqToken == "") || (reqURL == "" && reqToken != "") { | |
| // Partial GitHub Actions OIDC configuration; do not initialize provider | |
| logLauncher.Printf("ERROR: Partial GitHub Actions OIDC configuration detected: both ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN must be set and non-empty.") | |
| logger.LogError("startup", | |
| "Partial GitHub Actions OIDC configuration detected: both ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN must be set and non-empty.") | |
| } | |
| // Validate that all servers requiring OIDC have the provider available | |
| for serverID, serverCfg := range cfg.Servers { | |
| if serverCfg.Auth != nil && serverCfg.Auth.Type == "github-oidc" && oidcProvider == nil { | |
| log.Printf("[LAUNCHER] ERROR: Server %q requires OIDC authentication but GitHub Actions OIDC environment variables are not fully configured (ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN).", serverID) | |
| log.Printf("[LAUNCHER] OIDC auth is only available when running in GitHub Actions with `permissions: { id-token: write }`.") | |
| logger.LogError("startup", | |
| "Server %q requires OIDC authentication but GitHub Actions OIDC environment variables are not fully configured (ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN). "+ |
Static long-lived secrets in
headers.Authorizationare the only way to authenticate custom HTTP MCP servers today. This addsauth.type: github-oidcas a first-class config option, acquiring short-lived JWTs from the Actions OIDC endpoint and injecting them as `Authorization: ****** with automatic per-audience caching and pre-expiry refresh.Config
JSON stdin:
{ "mcpServers": { "my-server": { "type": "http", "url": "https://my-server.example.com/mcp", "auth": { "type": "github-oidc", "audience": "https://my-server.example.com" } } } }TOML:
authandheaderscoexist — OIDC setsAuthorization, static headers (e.g.X-Custom-Header) pass through. OIDC Authorization always wins over a staticAuthorizationheader.Changes
internal/config/AuthConfig{Type, Audience}struct;Auth *AuthConfigonServerConfigandStdinServerConfigauthstripped from JSON before upstream schema validation (extension field, same treatment asguard)authonly valid onhttpservers;auth.typemust be"github-oidc"; emptyaudiencedefaults to serverurlinternal/oidc/(new)Provideracquires tokens fromACTIONS_ID_TOKEN_REQUEST_URLwithACTIONS_ID_TOKEN_REQUEST_TOKEN; caches per audience; refreshes when <60 s remain; falls back to 5-min TTL if JWTexpis unparseableinternal/mcp/oidcRoundTripperwraps anyhttp.RoundTripper, injecting a fresh OIDC token asAuthorization: Beareron each requestNewHTTPConnection:headerInjectingRoundTripper(outer) →oidcRoundTripper(inner) →DefaultTransport, ensuring OIDC Authorization is the last writer before the wireNewHTTPConnectiongainsoidcProvider *oidc.Provider, oidcAudience stringparams (nil-safe; existing callers passnil, "")internal/launcher/Launchercreates anoidc.Providerat startup ifACTIONS_ID_TOKEN_REQUEST_URLis set; logs a clear error at startup and returns a hard error on connection if a server requires OIDC but the env vars are absentWarning
Firewall rules blocked me from connecting to one or more addresses (expand for details)
I tried to connect to the following addresses, but was blocked by firewall rules:
example.com/tmp/go-build2434691255/b334/launcher.test /tmp/go-build2434691255/b334/launcher.test -test.testlogfile=/tmp/go-build2434691255/b334/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true unset yv9aJYIYS 64/pkg/tool/linu-nolocalimports pull.rebase(dns block)/tmp/go-build1699981511/b334/launcher.test /tmp/go-build1699981511/b334/launcher.test -test.testlogfile=/tmp/go-build1699981511/b334/testlog.txt -test.paniconexit0 -test.timeout=10m0s /tmp/go-build1699981511/b321/vet.cfg 220886/b238/_pkg_.a /tmp/go-build415220886/b151/ ache/go/1.25.8/x64/pkg/tool/linu-nilfunc . telabs/wazero/ininspect --64 ache/go/1.25.8/x{{json .NetworkSettings.Ports}}(dns block)/tmp/go-build1606339925/b335/launcher.test /tmp/go-build1606339925/b335/launcher.test -test.testlogfile=/tmp/go-build1606339925/b335/testlog.txt -test.paniconexit0 -test.timeout=10m0s /tmp/go-build1606339925/b316/vet.cfg _2xT/XdsJuWeeIf7BD0Xr_2xT git c.test 64/src/runtime/cbash GSV43eHHl ache/go/1.25.8/x--noprofile c.test 6999�� k/gh-aw-mcpg/gh-aw-mcpg/internal/testutil/mcptest/example_test.go k/gh-aw-mcpg/gh-aw-mcpg/internal/testutil/mcptest/gateway_integration_test.go /tmp/go-build2434691255/b361/sys.test ternal/engine/wa/opt/hostedtoolcache/go/1.25.8/x64/pkg/tool/linux_amd64/vet ternal/engine/wa-atomic x_amd64/compile /tmp/go-build243-buildtags(dns block)invalid-host-that-does-not-exist-12345.com/tmp/go-build2434691255/b316/config.test /tmp/go-build2434691255/b316/config.test -test.testlogfile=/tmp/go-build2434691255/b316/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true go --global 64/pkg/tool/linu-lang=go1.25 pull.rebase(dns block)/tmp/go-build2932051910/b316/config.test /tmp/go-build2932051910/b316/config.test -test.testlogfile=/tmp/go-build2932051910/b316/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true 64/src/net -trimpath x_amd64/vet -p internal/runtimedocker-cli-plugin-metadata -lang=go1.25 x_amd64/vet -p 64/src/net -trimpath x_amd64/compile -I /tmp/go-build415docker-cli-plugin-metadata -I x_amd64/compile(dns block)/tmp/go-build1699981511/b316/config.test /tmp/go-build1699981511/b316/config.test -test.testlogfile=/tmp/go-build1699981511/b316/testlog.txt -test.paniconexit0 -test.timeout=10m0s rtcf�� ache/go/1.25.8/x64/src/net .cfg 64/pkg/tool/linux_amd64/vet --gdwarf-5 --64 -o 64/pkg/tool/linux_amd64/vet 2208�� 220886/b207/_pkg_.a ache/go/1.25.8/x64/src/compress/-w ache/go/1.25.8/x64/pkg/tool/linu-buildmode=exe . l/httpcommon --64 ache/go/1.25.8/x64/pkg/tool/linu-extld=gcc(dns block)nonexistent.local/tmp/go-build2434691255/b334/launcher.test /tmp/go-build2434691255/b334/launcher.test -test.testlogfile=/tmp/go-build2434691255/b334/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true unset yv9aJYIYS 64/pkg/tool/linu-nolocalimports pull.rebase(dns block)/tmp/go-build1699981511/b334/launcher.test /tmp/go-build1699981511/b334/launcher.test -test.testlogfile=/tmp/go-build1699981511/b334/testlog.txt -test.paniconexit0 -test.timeout=10m0s /tmp/go-build1699981511/b321/vet.cfg 220886/b238/_pkg_.a /tmp/go-build415220886/b151/ ache/go/1.25.8/x64/pkg/tool/linu-nilfunc . telabs/wazero/ininspect --64 ache/go/1.25.8/x{{json .NetworkSettings.Ports}}(dns block)/tmp/go-build1606339925/b335/launcher.test /tmp/go-build1606339925/b335/launcher.test -test.testlogfile=/tmp/go-build1606339925/b335/testlog.txt -test.paniconexit0 -test.timeout=10m0s /tmp/go-build1606339925/b316/vet.cfg _2xT/XdsJuWeeIf7BD0Xr_2xT git c.test 64/src/runtime/cbash GSV43eHHl ache/go/1.25.8/x--noprofile c.test 6999�� k/gh-aw-mcpg/gh-aw-mcpg/internal/testutil/mcptest/example_test.go k/gh-aw-mcpg/gh-aw-mcpg/internal/testutil/mcptest/gateway_integration_test.go /tmp/go-build2434691255/b361/sys.test ternal/engine/wa/opt/hostedtoolcache/go/1.25.8/x64/pkg/tool/linux_amd64/vet ternal/engine/wa-atomic x_amd64/compile /tmp/go-build243-buildtags(dns block)slow.example.com/tmp/go-build2434691255/b334/launcher.test /tmp/go-build2434691255/b334/launcher.test -test.testlogfile=/tmp/go-build2434691255/b334/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true unset yv9aJYIYS 64/pkg/tool/linu-nolocalimports pull.rebase(dns block)/tmp/go-build1699981511/b334/launcher.test /tmp/go-build1699981511/b334/launcher.test -test.testlogfile=/tmp/go-build1699981511/b334/testlog.txt -test.paniconexit0 -test.timeout=10m0s /tmp/go-build1699981511/b321/vet.cfg 220886/b238/_pkg_.a /tmp/go-build415220886/b151/ ache/go/1.25.8/x64/pkg/tool/linu-nilfunc . telabs/wazero/ininspect --64 ache/go/1.25.8/x{{json .NetworkSettings.Ports}}(dns block)/tmp/go-build1606339925/b335/launcher.test /tmp/go-build1606339925/b335/launcher.test -test.testlogfile=/tmp/go-build1606339925/b335/testlog.txt -test.paniconexit0 -test.timeout=10m0s /tmp/go-build1606339925/b316/vet.cfg _2xT/XdsJuWeeIf7BD0Xr_2xT git c.test 64/src/runtime/cbash GSV43eHHl ache/go/1.25.8/x--noprofile c.test 6999�� k/gh-aw-mcpg/gh-aw-mcpg/internal/testutil/mcptest/example_test.go k/gh-aw-mcpg/gh-aw-mcpg/internal/testutil/mcptest/gateway_integration_test.go /tmp/go-build2434691255/b361/sys.test ternal/engine/wa/opt/hostedtoolcache/go/1.25.8/x64/pkg/tool/linux_amd64/vet ternal/engine/wa-atomic x_amd64/compile /tmp/go-build243-buildtags(dns block)this-host-does-not-exist-12345.com/tmp/go-build2434691255/b343/mcp.test /tmp/go-build2434691255/b343/mcp.test -test.testlogfile=/tmp/go-build2434691255/b343/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true _.a --local ache/go/1.25.8/x-importcfg user.name a20(dns block)/tmp/go-build1699981511/b343/mcp.test /tmp/go-build1699981511/b343/mcp.test -test.testlogfile=/tmp/go-build1699981511/b343/testlog.txt -test.paniconexit0 -test.timeout=10m0s(dns block)/tmp/go-build2365210584/b001/mcp.test /tmp/go-build2365210584/b001/mcp.test -test.testlogfile=/tmp/go-build2365210584/b001/testlog.txt -test.paniconexit0 -test.timeout=10m0s --ve�� .13/x64/bin/git x_amd64/vet /usr/libexec/docker/cli-plugins/docker-compose azero@v1.11.0/bu/tmp/go-build1606339925/b351/server.test azero@v1.11.0/ca-test.testlogfile=/tmp/go-build1606339925/b351/testlog.txt x_amd64/compile /usr/libexec/doc-test.timeout=10m0s dock�� -lang=go1.25 x_amd64/compile .cfg _.a 220886/b151/ 64/pkg/tool/linux_amd64/compile runc(dns block)If you need me to access, download, or install something from one of these locations, you can either: