diff --git a/README.md b/README.md index f3ae63f..81633c5 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ This library provides a unified interface for obtaining and refreshing credentia * [Vault Provider](#vault-credentials-provider) * [Conjur Provider](#conjur-credentials-provider) * [GitHub App Provider](#github-app-credentials-provider) + * [GitHub Actions Identity Token Provider](#github-actions-identity-token-provider) * [Channel Behavior](#channel-behavior) * [License](#license) * [Contributing](#contributing) @@ -46,6 +47,7 @@ This library provides a unified interface for obtaining and refreshing credentia - **Vault** Exchanges ID tokens for secrets from Vault using Vault's JWT authentication. - **Conjur:** Exchanges ID tokens for secrets from CyberArk Conjur (Secrets Manager Self-Hosted and SaaS) using Conjur's JWT authenticator, and polls for rotated values. - **GitHub App:** Mints GitHub App installation access tokens by signing a JWT with the App's private key and refreshes them before expiration. +- **GitHub Actions:** Fetches OIDC ID tokens from the GitHub Actions runtime for the providers that exchange an identity token (AWS, GCP, Azure, OCI, Vault, Conjur, Generic, RFC 7523 and RFC 8693). ## Installation @@ -841,6 +843,54 @@ for cred := range credCh { } ``` +### GitHub Actions Identity Token Provider + +Fetches OIDC ID tokens from the GitHub Actions runtime, so a workflow can pass them to any provider that takes an `IdentityTokenProvider` (AWS, GCP, Azure, OCI, Vault, Conjur, Generic, RFC 7523 and RFC 8693). The job needs the `id-token: write` permission: + +```yaml +permissions: + id-token: write +``` + +The example exchanges the token for an Anthropic access token via Workload Identity Federation: + +```go +import ( + "go.riptides.io/tokenex/pkg/credential" + "go.riptides.io/tokenex/pkg/rfc7523" + "go.riptides.io/tokenex/pkg/token/githubactions" +) + +// Reads ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN from the environment. +// Use WithRequestURL / WithRequestToken where the runner's environment isn't inherited, e.g. in a container. +idTokenProvider, err := githubactions.NewIdentityTokenProvider( + // Must match the audience the federation rule expects. + githubactions.WithAudience("api.anthropic.com"), +) +if err != nil { + log.Fatalf("new provider: %v", err) +} + +credCh, err := rfc7523.GetAnthropicCredentials(ctx, logr.Discard(), idTokenProvider, rfc7523.AnthropicWIFConfig{ + FederationRuleID: "fdrl_...", + OrganizationID: "...", + ServiceAccountID: "...", + WorkspaceID: "...", +}) +if err != nil { + log.Fatalf("get credentials: %v", err) +} + +for cred := range credCh { + if cred.Err != nil { + log.Printf("anthropic token error: %v", cred.Err) + break + } + tok := cred.Credential.(*credential.Oauth2Creds) + log.Printf("got anthropic access token, expires %s", tok.Expiry) +} +``` + ## Channel Behavior All credential providers in this library follow a consistent pattern for credential delivery: diff --git a/pkg/token/githubactions/githubactions.go b/pkg/token/githubactions/githubactions.go new file mode 100644 index 0000000..91ce3d0 --- /dev/null +++ b/pkg/token/githubactions/githubactions.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +// Package githubactions provides an IdentityTokenProvider that fetches OIDC ID tokens from the +// GitHub Actions runtime, so a workflow can exchange them with any tokenex credentials provider. +// The job needs the `id-token: write` permission. +package githubactions + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "os" + "time" + + "emperror.dev/errors" + "github.com/golang-jwt/jwt/v5" + + "go.riptides.io/tokenex/pkg/credential" + "go.riptides.io/tokenex/pkg/option" + "go.riptides.io/tokenex/pkg/token" +) + +const ( + requestURLEnvVar = "ACTIONS_ID_TOKEN_REQUEST_URL" + requestTokenEnvVar = "ACTIONS_ID_TOKEN_REQUEST_TOKEN" + + // minValidity is how long a cached token must remain valid to be returned instead of fetching a new one. + minValidity = time.Minute +) + +type providerConfig struct { + audience string + requestURL string + requestToken string + httpClient *http.Client +} + +// IdentityTokenProvider fetches GitHub Actions OIDC ID tokens and caches them until shortly before they expire. +type IdentityTokenProvider struct { + requestURL string + requestToken string + httpClient *http.Client + + // lock serializes fetches. It is a channel rather than a mutex so waiting callers can honor ctx cancellation. + lock chan struct{} + token credential.Token +} + +var _ token.IdentityTokenProvider = (*IdentityTokenProvider)(nil) + +// NewIdentityTokenProvider creates a provider for GitHub Actions OIDC ID tokens. +// The request URL and bearer token default to the ACTIONS_ID_TOKEN_REQUEST_URL and +// ACTIONS_ID_TOKEN_REQUEST_TOKEN environment variables, which the runner sets for jobs +// with the `id-token: write` permission. +func NewIdentityTokenProvider(opts ...option.Option) (*IdentityTokenProvider, error) { + cfg := &providerConfig{ + requestURL: os.Getenv(requestURLEnvVar), + requestToken: os.Getenv(requestTokenEnvVar), + } + + for _, opt := range opts { + if o, ok := isProviderOption(opt); ok { + o.Apply(cfg) + } + } + + if cfg.requestURL == "" { + return nil, errors.Errorf("%s is not set (the job needs the id-token: write permission)", requestURLEnvVar) + } + + if cfg.requestToken == "" { + return nil, errors.Errorf("%s is not set (the job needs the id-token: write permission)", requestTokenEnvVar) + } + + u, err := url.Parse(cfg.requestURL) + if err != nil { + return nil, errors.WrapIf(err, "could not parse token request URL") + } + + if cfg.audience != "" { + q := u.Query() + q.Set("audience", cfg.audience) + u.RawQuery = q.Encode() + } + + httpClient := cfg.httpClient + if httpClient == nil { + httpClient = http.DefaultClient + } + + return &IdentityTokenProvider{ + requestURL: u.String(), + requestToken: cfg.requestToken, + httpClient: httpClient, + lock: make(chan struct{}, 1), + }, nil +} + +// GetToken returns the cached ID token, fetching a new one if it is missing or about to expire. +func (p *IdentityTokenProvider) GetToken(ctx context.Context, _ ...option.Option) (credential.Token, error) { + select { + case p.lock <- struct{}{}: + case <-ctx.Done(): + return credential.Token{}, errors.WithStackIf(ctx.Err()) + } + + defer func() { <-p.lock }() + + if p.token.Token != "" && time.Until(p.token.ExpiresAt) > minValidity { + return p.token, nil + } + + tok, err := p.fetchToken(ctx) + if err != nil { + return credential.Token{}, err + } + + p.token = tok + + return tok, nil +} + +type tokenResponse struct { + Value string `json:"value"` +} + +func (p *IdentityTokenProvider) fetchToken(ctx context.Context) (credential.Token, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.requestURL, nil) + if err != nil { + return credential.Token{}, errors.WrapIf(err, "could not build token request") + } + + req.Header.Set("Authorization", "Bearer "+p.requestToken) + req.Header.Set("Accept", "application/json; api-version=2.0") + + resp, err := p.httpClient.Do(req) + if err != nil { + return credential.Token{}, errors.WrapIf(err, "token request failed") + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return credential.Token{}, errors.WrapIf(err, "could not read token response body") + } + + if resp.StatusCode != http.StatusOK { + return credential.Token{}, errors.Errorf("token request returned status %d: %s", resp.StatusCode, string(body)) + } + + var tokenResp tokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return credential.Token{}, errors.WrapIf(err, "could not parse token response") + } + + if tokenResp.Value == "" { + return credential.Token{}, errors.New("token response has no value") + } + + t, _, err := jwt.NewParser().ParseUnverified(tokenResp.Value, jwt.MapClaims{}) + if err != nil { + return credential.Token{}, errors.WrapIf(err, "could not parse ID token") + } + + exp, err := t.Claims.GetExpirationTime() + if err != nil { + return credential.Token{}, errors.WrapIf(err, "could not get ID token expiration time") + } + + if exp == nil { + return credential.Token{}, errors.New("ID token has no exp claim") + } + + return credential.Token{ + Token: tokenResp.Value, + ExpiresAt: exp.Time, + }, nil +} diff --git a/pkg/token/githubactions/githubactions_test.go b/pkg/token/githubactions/githubactions_test.go new file mode 100644 index 0000000..92cf94c --- /dev/null +++ b/pkg/token/githubactions/githubactions_test.go @@ -0,0 +1,316 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package githubactions_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.riptides.io/tokenex/pkg/credential" + "go.riptides.io/tokenex/pkg/option" + "go.riptides.io/tokenex/pkg/rfc7523" + "go.riptides.io/tokenex/pkg/token/githubactions" +) + +const requestToken = "request-token" + +func newJWT(t *testing.T, claims jwt.MapClaims) string { + t.Helper() + + s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("test-key")) + require.NoError(t, err) + + return s +} + +func newIDToken(t *testing.T, exp time.Time) string { + t.Helper() + + return newJWT(t, jwt.MapClaims{ + "iss": "https://token.actions.githubusercontent.com", + "sub": "repo:riptideslabs/tokenex:ref:refs/heads/main", + "exp": exp.Unix(), + }) +} + +func tokenHandler(value string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"count": len(value), "value": value}) //nolint:errcheck + } +} + +func newServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + return srv +} + +func newProvider(t *testing.T, srv *httptest.Server, opts ...option.Option) *githubactions.IdentityTokenProvider { + t.Helper() + + all := append([]option.Option{ + githubactions.WithRequestURL(srv.URL + "/token?api-version=2.0"), + githubactions.WithRequestToken(requestToken), + githubactions.WithHTTPClient(srv.Client()), + }, opts...) + + p, err := githubactions.NewIdentityTokenProvider(all...) + require.NoError(t, err) + + return p +} + +func TestGetToken_RequestShape(t *testing.T) { + t.Parallel() + + exp := time.Now().Add(10 * time.Minute) + idToken := newIDToken(t, exp) + + srv := newServer(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/token", r.URL.Path) + assert.Equal(t, "Bearer "+requestToken, r.Header.Get("Authorization")) + assert.Equal(t, "2.0", r.URL.Query().Get("api-version")) + assert.Equal(t, "api.anthropic.com", r.URL.Query().Get("audience")) + + tokenHandler(idToken)(w, r) + }) + + p := newProvider(t, srv, githubactions.WithAudience("api.anthropic.com")) + + tok, err := p.GetToken(t.Context()) + require.NoError(t, err) + assert.Equal(t, idToken, tok.Token) + assert.Equal(t, exp.Unix(), tok.ExpiresAt.Unix()) +} + +func TestGetToken_NoAudience(t *testing.T) { + t.Parallel() + + idToken := newIDToken(t, time.Now().Add(10*time.Minute)) + + srv := newServer(t, func(w http.ResponseWriter, r *http.Request) { + assert.False(t, r.URL.Query().Has("audience")) + + tokenHandler(idToken)(w, r) + }) + + _, err := newProvider(t, srv).GetToken(t.Context()) + require.NoError(t, err) +} + +func TestGetToken_Caching(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ttl time.Duration + wantRequests int32 + }{ + {name: "reuses valid token", ttl: 10 * time.Minute, wantRequests: 1}, + {name: "refetches token about to expire", ttl: 30 * time.Second, wantRequests: 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + idToken := newIDToken(t, time.Now().Add(tt.ttl)) + + var requests atomic.Int32 + + srv := newServer(t, func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + tokenHandler(idToken)(w, r) + }) + + p := newProvider(t, srv) + + for range 2 { + _, err := p.GetToken(t.Context()) + require.NoError(t, err) + } + + assert.Equal(t, tt.wantRequests, requests.Load()) + }) + } +} + +func TestGetToken_WaitingCallerHonorsContext(t *testing.T) { + t.Parallel() + + idToken := newIDToken(t, time.Now().Add(10*time.Minute)) + + received := make(chan struct{}, 1) + release := make(chan struct{}) + releaseOnce := sync.OnceFunc(func() { close(release) }) + + srv := newServer(t, func(w http.ResponseWriter, r *http.Request) { + select { + case received <- struct{}{}: + default: + } + + <-release + tokenHandler(idToken)(w, r) + }) + // Registered after newServer, so it runs before srv.Close and unblocks the handler if the test fails early. + t.Cleanup(releaseOnce) + + p := newProvider(t, srv) + + done := make(chan error, 1) + + go func() { + _, err := p.GetToken(t.Context()) + done <- err + }() + + <-received + + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + + _, err := p.GetToken(ctx) + require.ErrorIs(t, err, context.DeadlineExceeded) + + releaseOnce() + require.NoError(t, <-done) +} + +func TestGetToken_Errors(t *testing.T) { + t.Parallel() + + noExp := newJWT(t, jwt.MapClaims{"sub": "repo:riptideslabs/tokenex:ref:refs/heads/main"}) + + tests := []struct { + name string + handler http.HandlerFunc + }{ + { + name: "non-200 status", + handler: func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + }, + }, + { + name: "invalid JSON", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("not json")) //nolint:errcheck + }, + }, + {name: "empty value", handler: tokenHandler("")}, + {name: "value is not a JWT", handler: tokenHandler("not-a-jwt")}, + {name: "JWT without exp", handler: tokenHandler(noExp)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := newProvider(t, newServer(t, tt.handler)).GetToken(t.Context()) + require.Error(t, err) + }) + } +} + +func TestNewIdentityTokenProvider_FromEnv(t *testing.T) { + idToken := newIDToken(t, time.Now().Add(10*time.Minute)) + + srv := newServer(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer "+requestToken, r.Header.Get("Authorization")) + + tokenHandler(idToken)(w, r) + }) + + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", srv.URL+"/token?api-version=2.0") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", requestToken) + + p, err := githubactions.NewIdentityTokenProvider(githubactions.WithHTTPClient(srv.Client())) + require.NoError(t, err) + + tok, err := p.GetToken(t.Context()) + require.NoError(t, err) + assert.Equal(t, idToken, tok.Token) +} + +func TestNewIdentityTokenProvider_MissingEnv(t *testing.T) { + tests := []struct { + name string + requestURL string + requestToken string + wantMissing string + }{ + {name: "request URL", requestToken: requestToken, wantMissing: "ACTIONS_ID_TOKEN_REQUEST_URL"}, + {name: "request token", requestURL: "https://example.com/token", wantMissing: "ACTIONS_ID_TOKEN_REQUEST_TOKEN"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", tt.requestURL) + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", tt.requestToken) + + _, err := githubactions.NewIdentityTokenProvider() + require.ErrorContains(t, err, tt.wantMissing) + }) + } +} + +func TestWithRFC7523Exchange(t *testing.T) { + t.Parallel() + + idToken := newIDToken(t, time.Now().Add(10*time.Minute)) + + actions := newServer(t, tokenHandler(idToken)) + + exchange := newServer(t, func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, rfc7523.GrantType, body["grant_type"]) + assert.Equal(t, idToken, body["assertion"]) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck + "access_token": "access-token", + "token_type": "Bearer", + "expires_in": 3600, + }) + }) + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + t.Cleanup(cancel) + + cp, err := rfc7523.NewCredentialsProvider(ctx, logr.Discard()) + require.NoError(t, err) + + ch, err := cp.GetCredentials(ctx, exchange.URL, newProvider(t, actions), + rfc7523.WithBodyFormat(rfc7523.BodyFormatJSON), + rfc7523.WithHTTPClient(exchange.Client()), + ) + require.NoError(t, err) + + cred, ok := <-ch + require.True(t, ok, "channel closed without sending a credential") + require.NoError(t, cred.Err) + + creds, ok := cred.Credential.(*credential.Oauth2Creds) + require.True(t, ok) + assert.Equal(t, "access-token", creds.AccessToken) +} diff --git a/pkg/token/githubactions/option.go b/pkg/token/githubactions/option.go new file mode 100644 index 0000000..cc6ea75 --- /dev/null +++ b/pkg/token/githubactions/option.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package githubactions + +import ( + "net/http" + + "go.riptides.io/tokenex/pkg/option" +) + +type ( + ProviderOption interface { + Apply(*providerConfig) + } + providerOption struct { + option.Option + + f func(*providerConfig) + } +) + +func (o *providerOption) Apply(c *providerConfig) { + o.f(c) +} + +func withProviderOption(f func(*providerConfig)) option.Option { + return &providerOption{option.OptionImpl{}, f} +} + +func isProviderOption(opt any) (ProviderOption, bool) { + if o, ok := opt.(*providerOption); ok { + return o, ok + } + + return nil, false +} + +// WithAudience sets the aud claim of the issued ID tokens. It must match what the relying party expects. +// If not set, GitHub uses its default audience (the URL of the repository owner). +func WithAudience(audience string) option.Option { + return withProviderOption(func(c *providerConfig) { + c.audience = audience + }) +} + +// WithRequestURL overrides the token request URL read from ACTIONS_ID_TOKEN_REQUEST_URL. +// Useful when the provider runs somewhere the runner's environment is not inherited, e.g. a container. +func WithRequestURL(requestURL string) option.Option { + return withProviderOption(func(c *providerConfig) { + c.requestURL = requestURL + }) +} + +// WithRequestToken overrides the bearer token read from ACTIONS_ID_TOKEN_REQUEST_TOKEN. +func WithRequestToken(requestToken string) option.Option { + return withProviderOption(func(c *providerConfig) { + c.requestToken = requestToken + }) +} + +// WithHTTPClient sets the HTTP client used for token requests. Defaults to http.DefaultClient. +func WithHTTPClient(client *http.Client) option.Option { + return withProviderOption(func(c *providerConfig) { + c.httpClient = client + }) +}