From cc81d1b09cb2915c81ec831205715644a3ea77c8 Mon Sep 17 00:00:00 2001 From: davidvader Date: Thu, 24 Oct 2024 09:49:04 -0500 Subject: [PATCH] chore: gut required code from ghinstallation --- go.mod | 3 - go.sum | 6 - .../{installation.go => app_install.go} | 9 +- scm/github/app_transport.go | 282 ++++++++++++++++++ scm/github/github.go | 7 +- 5 files changed, 290 insertions(+), 17 deletions(-) rename scm/github/{installation.go => app_install.go} (98%) create mode 100644 scm/github/app_transport.go diff --git a/go.mod b/go.mod index 7da3f69a8..496fb2d59 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,6 @@ require ( github.com/adhocore/gronx v1.19.1 github.com/alicebob/miniredis/v2 v2.33.0 github.com/aws/aws-sdk-go v1.55.5 - github.com/bradleyfalzon/ghinstallation/v2 v2.11.0 github.com/buildkite/yaml v0.0.0-20181016232759-0caa5f0796e3 github.com/distribution/reference v0.6.0 github.com/drone/envsubst v1.0.3 @@ -88,9 +87,7 @@ require ( github.com/go-playground/validator/v10 v10.22.1 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/gomodule/redigo v2.0.0+incompatible // indirect - github.com/google/go-github/v62 v62.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect diff --git a/go.sum b/go.sum index d0b72b0ae..1957fb598 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,6 @@ github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd3 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bradleyfalzon/ghinstallation/v2 v2.11.0 h1:R9d0v+iobRHSaE4wKUnXFiZp53AL4ED5MzgEMwGTZag= -github.com/bradleyfalzon/ghinstallation/v2 v2.11.0/go.mod h1:0LWKQwOHewXO/1acI6TtyE0Xc4ObDb2rFN7eHBAG71M= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -108,8 +106,6 @@ github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= -github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/gomodule/redigo v1.7.1-0.20190322064113-39e2c31b7ca3/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= @@ -119,8 +115,6 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4= -github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= github.com/google/go-github/v65 v65.0.0 h1:pQ7BmO3DZivvFk92geC0jB0q2m3gyn8vnYPgV7GSLhQ= github.com/google/go-github/v65 v65.0.0/go.mod h1:DvrqWo5hvsdhJvHd4WyVF9ttANN3BniqjP8uTFMNb60= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= diff --git a/scm/github/installation.go b/scm/github/app_install.go similarity index 98% rename from scm/github/installation.go rename to scm/github/app_install.go index 4f62dd47e..858f4aa1d 100644 --- a/scm/github/installation.go +++ b/scm/github/app_install.go @@ -4,19 +4,18 @@ package github import ( "context" + "errors" "fmt" "net/http" "strings" "time" - "github.com/pkg/errors" - "github.com/sirupsen/logrus" - "gorm.io/gorm" - "github.com/go-vela/server/api/types" - "github.com/go-vela/server/constants" "github.com/go-vela/server/database" "github.com/go-vela/server/internal" + "github.com/go-vela/types/constants" + "github.com/sirupsen/logrus" + "gorm.io/gorm" ) // ProcessInstallation takes a GitHub installation and processes the changes. diff --git a/scm/github/app_transport.go b/scm/github/app_transport.go new file mode 100644 index 000000000..ee29e4aae --- /dev/null +++ b/scm/github/app_transport.go @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: Apache-2.0 + +package github + +import ( + "bytes" + "context" + "crypto/rsa" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/go-github/v65/github" +) + +const ( + acceptHeader = "application/vnd.github.v3+json" + apiBaseURL = "https://api.github.com" +) + +// AppsTransport provides a http.RoundTripper by wrapping an existing +// http.RoundTripper and provides GitHub Apps authentication as a GitHub App. +// +// Client can also be overwritten, and is useful to change to one which +// provides retry logic if you do experience retryable errors. +// +// See https://developer.github.com/apps/building-integrations/setting-up-and-registering-github-apps/about-authentication-options-for-github-apps/ +type AppsTransport struct { + BaseURL string // BaseURL is the scheme and host for GitHub API, defaults to https://api.github.com + Client Client // Client to use to refresh tokens, defaults to http.Client with provided transport + tr http.RoundTripper // tr is the underlying roundtripper being wrapped + signer Signer // signer signs JWT tokens. + appID int64 // appID is the GitHub App's ID +} + +// NewAppsTransportFromPrivateKey returns an AppsTransport using a crypto/rsa.(*PrivateKey). +func NewAppsTransportFromPrivateKey(tr http.RoundTripper, appID int64, key *rsa.PrivateKey) *AppsTransport { + return &AppsTransport{ + BaseURL: apiBaseURL, + Client: &http.Client{Transport: tr}, + tr: tr, + signer: NewRSASigner(jwt.SigningMethodRS256, key), + appID: appID, + } +} + +// RoundTrip implements http.RoundTripper interface. +func (t *AppsTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // GitHub rejects expiry and issue timestamps that are not an integer, + // while the jwt-go library serializes to fractional timestamps. + // Truncate them before passing to jwt-go. + iss := time.Now().Add(-30 * time.Second).Truncate(time.Second) + exp := iss.Add(2 * time.Minute) + claims := &jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(iss), + ExpiresAt: jwt.NewNumericDate(exp), + Issuer: strconv.FormatInt(t.appID, 10), + } + + ss, err := t.signer.Sign(claims) + if err != nil { + return nil, fmt.Errorf("could not sign jwt: %s", err) + } + + req.Header.Set("Authorization", "Bearer "+ss) + req.Header.Add("Accept", acceptHeader) + + resp, err := t.tr.RoundTrip(req) + return resp, err +} + +// Transport provides a http.RoundTripper by wrapping an existing +// http.RoundTripper and provides GitHub Apps authentication as an installation. +// +// Client can also be overwritten, and is useful to change to one which +// provides retry logic if you do experience retryable errors. +// +// See https://developer.github.com/apps/building-integrations/setting-up-and-registering-github-apps/about-authentication-options-for-github-apps/ +type Transport struct { + BaseURL string // BaseURL is the scheme and host for GitHub API, defaults to https://api.github.com + Client Client // Client to use to refresh tokens, defaults to http.Client with provided transport + tr http.RoundTripper // tr is the underlying roundtripper being wrapped + appID int64 // appID is the GitHub App's ID + installationID int64 // installationID is the GitHub App Installation ID + InstallationTokenOptions *github.InstallationTokenOptions // parameters restrict a token's access + appsTransport *AppsTransport + + mu *sync.Mutex + token *accessToken // the installation's access token +} + +// accessToken is an installation access token response from GitHub +type accessToken struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + Permissions github.InstallationPermissions `json:"permissions,omitempty"` + Repositories []github.Repository `json:"repositories,omitempty"` +} + +var _ http.RoundTripper = &Transport{} + +// Client is a HTTP client which sends a http.Request and returns a http.Response +// or an error. +type Client interface { + Do(*http.Request) (*http.Response, error) +} + +// RoundTrip implements http.RoundTripper interface. +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + reqBodyClosed := false + if req.Body != nil { + defer func() { + if !reqBodyClosed { + req.Body.Close() + } + }() + } + + token, err := t.Token(req.Context()) + if err != nil { + return nil, err + } + + creq := cloneRequest(req) + creq.Header.Set("Authorization", "token "+token) + + if creq.Header.Get("Accept") == "" { + creq.Header.Add("Accept", acceptHeader) + } + + reqBodyClosed = true + resp, err := t.tr.RoundTrip(creq) + return resp, err +} + +// getRefreshTime returns the time when the token should be refreshed. +func (at *accessToken) getRefreshTime() time.Time { + return at.ExpiresAt.Add(-time.Minute) +} + +// isExpired checks if the access token is expired. +func (at *accessToken) isExpired() bool { + return at == nil || at.getRefreshTime().Before(time.Now()) +} + +// Token checks the active token expiration and renews if necessary. Token returns +// a valid access token. If renewal fails an error is returned. +func (t *Transport) Token(ctx context.Context) (string, error) { + t.mu.Lock() + defer t.mu.Unlock() + if t.token.isExpired() { + // token is not set or expired/nearly expired, so refresh + if err := t.refreshToken(ctx); err != nil { + return "", fmt.Errorf("could not refresh installation id %v's token: %w", t.installationID, err) + } + } + + return t.token.Token, nil +} + +// Expiry returns a transport token's expiration time and refresh time. There is a small grace period +// built in where a token will be refreshed before it expires. expiresAt is the actual token expiry, +// and refreshAt is when a call to Token() will cause it to be refreshed. +func (t *Transport) Expiry() (expiresAt time.Time, refreshAt time.Time, err error) { + if t.token == nil { + return time.Time{}, time.Time{}, errors.New("Expiry() = unknown, err: nil token") + } + return t.token.ExpiresAt, t.token.getRefreshTime(), nil +} + +func (t *Transport) refreshToken(ctx context.Context) error { + // convert InstallationTokenOptions into a ReadWriter to pass as an argument to http.NewRequest + body, err := GetReadWriter(t.InstallationTokenOptions) + if err != nil { + return fmt.Errorf("could not convert installation token parameters into json: %s", err) + } + + requestURL := fmt.Sprintf("%s/app/installations/%v/access_tokens", strings.TrimRight(t.BaseURL, "/"), t.installationID) + req, err := http.NewRequest("POST", requestURL, body) + if err != nil { + return fmt.Errorf("could not create request: %s", err) + } + + // set Content and Accept headers + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + req.Header.Set("Accept", acceptHeader) + + if ctx != nil { + req = req.WithContext(ctx) + } + + t.appsTransport.BaseURL = t.BaseURL + t.appsTransport.Client = t.Client + resp, err := t.appsTransport.RoundTrip(req) + if err != nil { + return fmt.Errorf("could not get access_tokens from GitHub API for installation ID %v: %v", t.installationID, err) + } + + if resp.StatusCode/100 != 2 { + return fmt.Errorf("received non 2xx response status %q when fetching %v", resp.Status, req.URL) + } + + // closing body late, to provide caller a chance to inspect body in an error / non-200 response status situation + defer resp.Body.Close() + + return json.NewDecoder(resp.Body).Decode(&t.token) +} + +// GetReadWriter converts a body interface into an io.ReadWriter object. +func GetReadWriter(i interface{}) (io.ReadWriter, error) { + var buf io.ReadWriter + if i != nil { + buf = new(bytes.Buffer) + enc := json.NewEncoder(buf) + err := enc.Encode(i) + if err != nil { + return nil, err + } + } + return buf, nil +} + +// cloneRequest returns a clone of the provided *http.Request. +// The clone is a shallow copy of the struct and its Header map. +func cloneRequest(r *http.Request) *http.Request { + // shallow copy of the struct + r2 := new(http.Request) + *r2 = *r + // deep copy of the Header + r2.Header = make(http.Header, len(r.Header)) + for k, s := range r.Header { + r2.Header[k] = append([]string(nil), s...) + } + return r2 +} + +// Signer is a JWT token signer. This is a wrapper around [jwt.SigningMethod] with predetermined +// key material. +type Signer interface { + // sign the given claims and returns a JWT token string, as specified + // by [jwt.Token.SignedString] + Sign(claims jwt.Claims) (string, error) +} + +// RSASigner signs JWT tokens using RSA keys. +type RSASigner struct { + method *jwt.SigningMethodRSA + key *rsa.PrivateKey +} + +// NewRSASigner creates a new RSASigner with the given RSA key. +func NewRSASigner(method *jwt.SigningMethodRSA, key *rsa.PrivateKey) *RSASigner { + return &RSASigner{ + method: method, + key: key, + } +} + +// Sign signs the JWT claims with the RSA key. +func (s *RSASigner) Sign(claims jwt.Claims) (string, error) { + return jwt.NewWithClaims(s.method, claims).SignedString(s.key) +} + +// AppsTransportOption is a func option for configuring an AppsTransport. +type AppsTransportOption func(*AppsTransport) + +// WithSigner configures the AppsTransport to use the given Signer for generating JWT tokens. +func WithSigner(signer Signer) AppsTransportOption { + return func(at *AppsTransport) { + at.signer = signer + } +} diff --git a/scm/github/github.go b/scm/github/github.go index 265d05367..a42c72487 100644 --- a/scm/github/github.go +++ b/scm/github/github.go @@ -13,7 +13,6 @@ import ( "net/url" "strings" - "github.com/bradleyfalzon/ghinstallation/v2" "github.com/google/go-github/v65/github" "github.com/sirupsen/logrus" "go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace" @@ -67,7 +66,7 @@ type client struct { OAuth *oauth2.Config AuthReq *github.AuthorizationRequest Tracing *tracing.Client - AppsTransport *ghinstallation.AppsTransport + AppsTransport *AppsTransport // https://pkg.go.dev/github.com/sirupsen/logrus#Entry Logger *logrus.Entry } @@ -145,7 +144,8 @@ func New(opts ...ClientOpt) (*client, error) { return nil, fmt.Errorf("failed to parse RSA private key: %w", err) } - transport := ghinstallation.NewAppsTransportFromPrivateKey(http.DefaultTransport, c.config.AppID, privateKey) + fmt.Println("using custom round tripper") + transport := NewAppsTransportFromPrivateKey(http.DefaultTransport, c.config.AppID, privateKey) transport.BaseURL = c.config.API c.AppsTransport = transport @@ -242,6 +242,7 @@ func (c *client) newGithubAppInstallationRepoToken(ctx context.Context, r *api.R // todo: we want to support passing nothing to get the full permission set // so move this outside of this function // make the yaml provide a default when not provided, not the function + // but also, only if the repo.InstallID is non-empty, for UX on /expand // convert raw permissions to GitHub InstallationPermissions perms := &github.InstallationPermissions{