Skip to content

Commit

Permalink
chore: gut required code from ghinstallation
Browse files Browse the repository at this point in the history
  • Loading branch information
plyr4 committed Oct 24, 2024
1 parent 8587d2c commit cc81d1b
Show file tree
Hide file tree
Showing 5 changed files with 290 additions and 17 deletions.
3 changes: 0 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 0 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand All @@ -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=
Expand Down
9 changes: 4 additions & 5 deletions scm/github/installation.go → scm/github/app_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
282 changes: 282 additions & 0 deletions scm/github/app_transport.go
Original file line number Diff line number Diff line change
@@ -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
}
}
7 changes: 4 additions & 3 deletions scm/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{
Expand Down

0 comments on commit cc81d1b

Please sign in to comment.