From 0fe0783820d3bcc38ff8154f31a273db4cc5b4c9 Mon Sep 17 00:00:00 2001 From: John Allers Date: Wed, 27 May 2026 10:32:40 -0400 Subject: [PATCH] Update baton-github dependency to v0.3.4 --- go.mod | 2 +- go.sum | 4 +- .../baton-github/pkg/connector/connector.go | 12 +- .../pkg/connector/token_refresh.go | 173 ++++++++++++++++++ vendor/modules.txt | 2 +- 5 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 vendor/github.com/conductorone/baton-github/pkg/connector/token_refresh.go diff --git a/go.mod b/go.mod index f1aa539..e7bec82 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/conductorone/baton-github-enterprise go 1.25.2 require ( - github.com/conductorone/baton-github v0.3.3 + github.com/conductorone/baton-github v0.3.4 github.com/conductorone/baton-sdk v0.9.20 github.com/ennyjfrick/ruleguard-logfatal v0.0.2 github.com/quasilyte/go-ruleguard/dsl v0.3.23 diff --git a/go.sum b/go.sum index acf5cda..0465863 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/conductorone/baton-github v0.3.3 h1:W5BR64l/aTpyvjUcBHt9+aJK1/JmwaFGpOHtau1aHJU= -github.com/conductorone/baton-github v0.3.3/go.mod h1:50KZVcuXgAF+WlkeKQ4yaWr0N46okhIc7d54sYXU7js= +github.com/conductorone/baton-github v0.3.4 h1:XwN2IjsO0Np8W27/haEwLE6bzv3z4DOuDchap7OXNMA= +github.com/conductorone/baton-github v0.3.4/go.mod h1:FNqeFdfuFiweOvw3xaz0wiZzb7FtqaaHAZQu4BXohhs= github.com/conductorone/baton-sdk v0.9.20 h1:yPQ4v/6YRj7Yk2Kl2w18JeGezyfz64MAfL/v5wl3f6w= github.com/conductorone/baton-sdk v0.9.20/go.mod h1:treFEoFwbzu9hgPqpCRD+Sr+p71xkeH98Y4ofDGrjSg= github.com/conductorone/dpop v0.2.6 h1:fakwai/Xm2b/fcDUwJN41WtcSI/2UhQOyRIVvnnrrNA= diff --git a/vendor/github.com/conductorone/baton-github/pkg/connector/connector.go b/vendor/github.com/conductorone/baton-github/pkg/connector/connector.go index ccc1903..06f9758 100644 --- a/vendor/github.com/conductorone/baton-github/pkg/connector/connector.go +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/connector.go @@ -361,7 +361,10 @@ func newWithGithubApp(ctx context.Context, ghc *cfg.Github) (*GitHub, error) { privateKey: string(ghc.AppPrivatekeyPath), }, ) - ts := oauth2.ReuseTokenSource( + // Wrap the installation-token refresher in a refreshableTokenSource so the + // 401-retry middleware can mark the cached token dead when GitHub rotates + // it server-side before its stated Expiry. + ts := newRefreshableTokenSource( &oauth2.Token{ AccessToken: token.GetToken(), Expiry: token.GetExpiresAt().Time, @@ -382,11 +385,14 @@ func newWithGithubApp(ctx context.Context, ghc *cfg.Github) (*GitHub, error) { return nil, err } - ghClient, err := newGitHubClient(ctx, ghc.InstanceUrl, ts) + // Build the layered HTTP client for the installation-token path. The + // tokenRefreshTransport observes 401s, invalidates ts, and retries once + // with the freshly-issued installation token. + appHTTPClient, err := newGitHubAppHTTPClient(ctx, ts) if err != nil { return nil, err } - graphqlClient, err := newGitHubGraphqlClient(ctx, ghc.InstanceUrl, ts) + ghClient, graphqlClient, err := newGitHubAppClients(ghc.InstanceUrl, appHTTPClient) if err != nil { return nil, err } diff --git a/vendor/github.com/conductorone/baton-github/pkg/connector/token_refresh.go b/vendor/github.com/conductorone/baton-github/pkg/connector/token_refresh.go new file mode 100644 index 0000000..e34db98 --- /dev/null +++ b/vendor/github.com/conductorone/baton-github/pkg/connector/token_refresh.go @@ -0,0 +1,173 @@ +package connector + +import ( + "context" + "io" + "net/http" + "net/url" + "strings" + "sync" + + "github.com/conductorone/baton-sdk/pkg/uhttp" + "github.com/google/go-github/v69/github" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "github.com/shurcooL/githubv4" + "go.uber.org/zap" + "golang.org/x/oauth2" +) + +// refreshableTokenSource caches an oauth2 token and exposes Invalidate() so +// the cached value can be marked dead in response to a server-side signal +// (e.g. a 401 from GitHub). Compared to oauth2.ReuseTokenSource, this allows +// recovery from server-side token rotation that happens before the cached +// token's stated Expiry — see inc-814. +type refreshableTokenSource struct { + mu sync.Mutex + cached *oauth2.Token + source oauth2.TokenSource +} + +func newRefreshableTokenSource(initial *oauth2.Token, source oauth2.TokenSource) *refreshableTokenSource { + return &refreshableTokenSource{cached: initial, source: source} +} + +func (r *refreshableTokenSource) Token() (*oauth2.Token, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.cached.Valid() { + return r.cached, nil + } + tok, err := r.source.Token() + if err != nil { + return nil, err + } + r.cached = tok + return tok, nil +} + +// Invalidate clears the cached token. The next Token() call forces a fresh +// fetch from the underlying source. +func (r *refreshableTokenSource) Invalidate() { + r.mu.Lock() + defer r.mu.Unlock() + r.cached = nil +} + +// tokenRefreshTransport observes 401 responses, invalidates the wrapped token +// source, and retries the request exactly once with the freshly-issued token. +// Requests with a non-rewindable body (non-nil Body, nil GetBody) are returned +// with their original 401 unchanged — the only safe move is to surface it. +type tokenRefreshTransport struct { + next http.RoundTripper + rts *refreshableTokenSource +} + +func (t *tokenRefreshTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.next.RoundTrip(req) + if err != nil || resp == nil || resp.StatusCode != http.StatusUnauthorized { + return resp, err + } + + l := ctxzap.Extract(req.Context()) + + if req.Body != nil && req.GetBody == nil { + l.Debug("skipping 401 retry: request body not rewindable (no GetBody)", + zap.String("method", req.Method), + zap.String("url", req.URL.String()), + ) + return resp, nil + } + + var newBody io.ReadCloser + if req.GetBody != nil { + body, gbErr := req.GetBody() + if gbErr != nil { + l.Warn("skipping 401 retry: GetBody failed", + zap.Error(gbErr), + zap.String("method", req.Method), + ) + return resp, nil + } + newBody = body + } + + // Drain and close the 401 body so the underlying connection can be reused. + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + + t.rts.Invalidate() + if newBody != nil { + req.Body = newBody + } + + l.Debug("retrying request after 401 with refreshed token", + zap.String("method", req.Method), + zap.String("url", req.URL.String()), + ) + return t.next.RoundTrip(req) +} + +// newGitHubAppHTTPClient builds the layered transport chain for GitHub App +// auth: uhttp logging at the bottom, oauth2 auth-header injection in the +// middle, 401-retry middleware on top. It bypasses oauth2.NewClient (which +// would re-wrap rts in its own ReuseTokenSource cache that doesn't observe +// our Invalidate calls). +func newGitHubAppHTTPClient(ctx context.Context, rts *refreshableTokenSource) (*http.Client, error) { + base, err := uhttp.NewClient(ctx, uhttp.WithLogger(true, ctxzap.Extract(ctx))) + if err != nil { + return nil, err + } + return &http.Client{ + Timeout: base.Timeout, + Transport: &tokenRefreshTransport{ + next: &oauth2.Transport{ + Base: base.Transport, + Source: rts, + }, + rts: rts, + }, + }, nil +} + +// newGitHubAppClients constructs the REST and GraphQL clients used by the +// GitHub App install-token path, sharing the layered httpClient so the +// 401-refresh middleware applies to both surfaces. +// +// The GraphQL client gets an extra statusClassifyingTransport layered on top of +// the shared transport: shurcooL/githubv4 surfaces non-2xx responses as opaque +// strings, so without it transient errors (429, 5xx) would reach the SDK as +// codes.Unknown and abort the sync instead of being retried. It must sit above +// the 401-retry layer so tokenRefreshTransport still observes a raw 401 and can +// retry it; only a non-2xx that survives the retry is converted to a classified +// error. The REST client doesn't need this — go-github exposes structured +// errors that wrapGitHubError classifies at the call site. +func newGitHubAppClients(instanceURL string, httpClient *http.Client) (*github.Client, *githubv4.Client, error) { + instanceURL = strings.TrimSuffix(instanceURL, "/") + + gc := github.NewClient(httpClient) + if instanceURL != "" && instanceURL != githubDotCom { + var err error + gc, err = gc.WithEnterpriseURLs(instanceURL, instanceURL) + if err != nil { + return nil, nil, err + } + } + + gqlHTTPClient := &http.Client{ + Timeout: httpClient.Timeout, + Transport: &statusClassifyingTransport{base: httpClient.Transport}, + } + + var gqlClient *githubv4.Client + if instanceURL != "" && instanceURL != githubDotCom { + gqlURL, err := url.Parse(instanceURL) + if err != nil { + return nil, nil, err + } + gqlURL.Path = "/api/graphql" + gqlClient = githubv4.NewEnterpriseClient(gqlURL.String(), gqlHTTPClient) + } else { + gqlClient = githubv4.NewClient(gqlHTTPClient) + } + return gc, gqlClient, nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 7eaf58d..339dd12 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -165,7 +165,7 @@ github.com/cenkalti/backoff/v5 # github.com/cespare/xxhash/v2 v2.3.0 ## explicit; go 1.11 github.com/cespare/xxhash/v2 -# github.com/conductorone/baton-github v0.3.3 +# github.com/conductorone/baton-github v0.3.4 ## explicit; go 1.25.2 github.com/conductorone/baton-github/pkg/config github.com/conductorone/baton-github/pkg/connector