From 4bddd767c5ad9f37672b640d7c95f2592af7acd7 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 15:15:38 +0200 Subject: [PATCH] fix(githubactions): fetch a fresh ID token on every call The provider cached tokens until shortly before expiry, so an early rfc7523 refresh could re-send a token whose jti was already exchanged. Anthropic WIF rejects that as a replay. --- pkg/token/githubactions/githubactions.go | 39 +-------- pkg/token/githubactions/githubactions_test.go | 80 ++++--------------- 2 files changed, 18 insertions(+), 101 deletions(-) diff --git a/pkg/token/githubactions/githubactions.go b/pkg/token/githubactions/githubactions.go index 91ce3d0..355a413 100644 --- a/pkg/token/githubactions/githubactions.go +++ b/pkg/token/githubactions/githubactions.go @@ -13,7 +13,6 @@ import ( "net/http" "net/url" "os" - "time" "emperror.dev/errors" "github.com/golang-jwt/jwt/v5" @@ -26,9 +25,6 @@ import ( 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 { @@ -38,15 +34,11 @@ type providerConfig struct { httpClient *http.Client } -// IdentityTokenProvider fetches GitHub Actions OIDC ID tokens and caches them until shortly before they expire. +// IdentityTokenProvider fetches GitHub Actions OIDC ID tokens. 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) @@ -95,39 +87,16 @@ 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) { - 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) { +// GetToken fetches a new ID token on every call. Tokens are not cached, since relying parties +// such as Anthropic WIF reject a token whose jti was already exchanged. +func (p *IdentityTokenProvider) GetToken(ctx context.Context, _ ...option.Option) (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") diff --git a/pkg/token/githubactions/githubactions_test.go b/pkg/token/githubactions/githubactions_test.go index 92cf94c..248efc1 100644 --- a/pkg/token/githubactions/githubactions_test.go +++ b/pkg/token/githubactions/githubactions_test.go @@ -8,7 +8,6 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "sync" "sync/atomic" "testing" "time" @@ -115,83 +114,32 @@ func TestGetToken_NoAudience(t *testing.T) { require.NoError(t, err) } -func TestGetToken_Caching(t *testing.T) { +func TestGetToken_FetchesEveryCall(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}, + exp := time.Now().Add(10 * time.Minute).Unix() + tokens := []string{ + newJWT(t, jwt.MapClaims{"jti": "1", "exp": exp}), + newJWT(t, jwt.MapClaims{"jti": "2", "exp": exp}), } - 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) }) + var requests atomic.Int32 srv := newServer(t, func(w http.ResponseWriter, r *http.Request) { - select { - case received <- struct{}{}: - default: - } - - <-release - tokenHandler(idToken)(w, r) + n := requests.Add(1) + tokenHandler(tokens[int(n-1)%len(tokens)])(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() + first, err := p.GetToken(t.Context()) + require.NoError(t, err) - _, err := p.GetToken(ctx) - require.ErrorIs(t, err, context.DeadlineExceeded) + second, err := p.GetToken(t.Context()) + require.NoError(t, err) - releaseOnce() - require.NoError(t, <-done) + assert.Equal(t, int32(2), requests.Load()) + assert.NotEqual(t, first.Token, second.Token) } func TestGetToken_Errors(t *testing.T) {