From ad298737cfb4ee3967eb88e539d2e3540d3cf4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=A1ndor=20Istv=C3=A1n=20Kr=C3=A1cser?= Date: Wed, 30 Sep 2026 13:50:31 +0200 Subject: [PATCH 1/2] feat(token): add GitHub Actions OIDC identity token provider Fetches ID tokens from the Actions runtime so workflows can use them with any exchange, e.g. Anthropic WIF via rfc7523. Tokens are cached until shortly before they expire. --- README.md | 50 ++++ pkg/token/githubactions/githubactions.go | 172 ++++++++++++ pkg/token/githubactions/githubactions_test.go | 259 ++++++++++++++++++ pkg/token/githubactions/option.go | 67 +++++ 4 files changed, 548 insertions(+) create mode 100644 pkg/token/githubactions/githubactions.go create mode 100644 pkg/token/githubactions/githubactions_test.go create mode 100644 pkg/token/githubactions/option.go diff --git a/README.md b/README.md index f3ae63f..2ffc1ea 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 to use as the identity token for any of the exchanges above. ## 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 use them as the identity token for any provider above. 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..2a43cc0 --- /dev/null +++ b/pkg/token/githubactions/githubactions.go @@ -0,0 +1,172 @@ +// 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" + "sync" + "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 + + mu sync.Mutex + 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 == "" || cfg.requestToken == "" { + return nil, errors.Errorf("%s and %s are not set (the job needs the id-token: write permission)", requestURLEnvVar, 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, + }, 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) { + p.mu.Lock() + defer p.mu.Unlock() + + 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..8a00ab4 --- /dev/null +++ b/pkg/token/githubactions/githubactions_test.go @@ -0,0 +1,259 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package githubactions_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "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_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) { + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + + _, err := githubactions.NewIdentityTokenProvider() + require.Error(t, err) +} + +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 + }) +} From 250aacf2b2eea7602bf4c6bae1db9bd46542a3c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=A1ndor=20Istv=C3=A1n=20Kr=C3=A1cser?= Date: Wed, 30 Sep 2026 14:04:29 +0200 Subject: [PATCH 2/2] fix(githubactions): address review comments Let callers waiting on an in-flight fetch return when their context is done, name the specific missing setting in the config error, and scope the README claim to providers that take an identity token. --- README.md | 4 +- pkg/token/githubactions/githubactions.go | 22 +++++-- pkg/token/githubactions/githubactions_test.go | 65 +++++++++++++++++-- 3 files changed, 79 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2ffc1ea..81633c5 100644 --- a/README.md +++ b/README.md @@ -47,7 +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 to use as the identity token for any of the exchanges above. +- **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 @@ -845,7 +845,7 @@ for cred := range credCh { ### GitHub Actions Identity Token Provider -Fetches OIDC ID tokens from the GitHub Actions runtime, so a workflow can use them as the identity token for any provider above. The job needs the `id-token: write` permission: +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: diff --git a/pkg/token/githubactions/githubactions.go b/pkg/token/githubactions/githubactions.go index 2a43cc0..91ce3d0 100644 --- a/pkg/token/githubactions/githubactions.go +++ b/pkg/token/githubactions/githubactions.go @@ -13,7 +13,6 @@ import ( "net/http" "net/url" "os" - "sync" "time" "emperror.dev/errors" @@ -45,7 +44,8 @@ type IdentityTokenProvider struct { requestToken string httpClient *http.Client - mu sync.Mutex + // lock serializes fetches. It is a channel rather than a mutex so waiting callers can honor ctx cancellation. + lock chan struct{} token credential.Token } @@ -67,8 +67,12 @@ func NewIdentityTokenProvider(opts ...option.Option) (*IdentityTokenProvider, er } } - if cfg.requestURL == "" || cfg.requestToken == "" { - return nil, errors.Errorf("%s and %s are not set (the job needs the id-token: write permission)", requestURLEnvVar, requestTokenEnvVar) + 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) @@ -91,13 +95,19 @@ func NewIdentityTokenProvider(opts ...option.Option) (*IdentityTokenProvider, er 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) { - p.mu.Lock() - defer p.mu.Unlock() + 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 diff --git a/pkg/token/githubactions/githubactions_test.go b/pkg/token/githubactions/githubactions_test.go index 8a00ab4..92cf94c 100644 --- a/pkg/token/githubactions/githubactions_test.go +++ b/pkg/token/githubactions/githubactions_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "sync" "sync/atomic" "testing" "time" @@ -151,6 +152,48 @@ func TestGetToken_Caching(t *testing.T) { } } +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() @@ -208,11 +251,25 @@ func TestNewIdentityTokenProvider_FromEnv(t *testing.T) { } func TestNewIdentityTokenProvider_MissingEnv(t *testing.T) { - t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "") - t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + 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.Error(t, err) + _, err := githubactions.NewIdentityTokenProvider() + require.ErrorContains(t, err, tt.wantMissing) + }) + } } func TestWithRFC7523Exchange(t *testing.T) {