Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 4 additions & 35 deletions pkg/token/githubactions/githubactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
"net/http"
"net/url"
"os"
"time"

"emperror.dev/errors"
"github.com/golang-jwt/jwt/v5"
Expand All @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
80 changes: 14 additions & 66 deletions pkg/token/githubactions/githubactions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
Expand Down Expand Up @@ -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) {
Expand Down
Loading