Skip to content
Open
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
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ module github.com/conductorone/baton-github
go 1.25.2

require (
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0
github.com/conductorone/baton-sdk v0.7.14
github.com/deckarep/golang-set/v2 v2.8.0
github.com/ennyjfrick/ruleguard-logfatal v0.0.2
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/go-github/v69 v69.2.0
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0
github.com/migueleliasweb/go-github-mock v1.1.0
Expand Down Expand Up @@ -62,8 +62,10 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-github/v64 v64.0.0 // indirect
github.com/google/go-github/v75 v75.0.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/mux v1.8.0 // indirect
Expand Down
8 changes: 6 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4p
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 h1:SmbUK/GxpAspRjSQbB6ARvH+ArzlNzTtHydNyXUQ6zg=
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0/go.mod h1:vuD/xvJT9Y+ZVZRv4HQ42cMyPFIYqpc7AbB4Gvt/DlY=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
Expand Down Expand Up @@ -118,8 +120,8 @@ github.com/go-toolsmith/astequal v1.0.3 h1:+LVdyRatFS+XO78SGV4I3TCEA0AC7fKEGma+f
github.com/go-toolsmith/astequal v1.0.3/go.mod h1:9Ai4UglvtR+4up+bAD4+hCj7iTo4m/OXVTSLnCyTAx4=
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/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
Expand All @@ -137,6 +139,8 @@ github.com/google/go-github/v64 v64.0.0 h1:4G61sozmY3eiPAjjoOHponXDBONm+utovTKby
github.com/google/go-github/v64 v64.0.0/go.mod h1:xB3vqMQNdHzilXBiO2I+M7iEFtHf+DP/omBOv6tQzVo=
github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE=
github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM=
github.com/google/go-github/v75 v75.0.0 h1:k7q8Bvg+W5KxRl9Tjq16a9XEgVY1pwuiG5sIL7435Ic=
github.com/google/go-github/v75 v75.0.0/go.mod h1:H3LUJEA1TCrzuUqtdAQniBNwuKiQIqdGKgBo1/M/uqI=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
Expand Down
212 changes: 73 additions & 139 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,21 @@ package connector

import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"

"github.com/bradleyfalzon/ghinstallation/v2"
cfg "github.com/conductorone/baton-github/pkg/config"
"github.com/conductorone/baton-github/pkg/customclient"
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
"github.com/conductorone/baton-sdk/pkg/annotations"
"github.com/conductorone/baton-sdk/pkg/cli"
"github.com/conductorone/baton-sdk/pkg/connectorbuilder"
"github.com/conductorone/baton-sdk/pkg/uhttp"
jwtv5 "github.com/golang-jwt/jwt/v5"
"github.com/google/go-github/v69/github"
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
"github.com/shurcooL/githubv4"
Expand All @@ -31,9 +27,6 @@ import (

const githubDotCom = "https://github.com"

// JWT token expires in 10 minutes, so we set it to 9 minutes to leave some buffer.
const jwtExpiryTime = 9 * time.Minute

var (
ValidAssetDomains = []string{"avatars.githubusercontent.com"}
maxPageSize int = 100 // maximum page size github supported.
Expand Down Expand Up @@ -297,75 +290,92 @@ func newWithGithubPAT(ctx context.Context, ghc *cfg.Github) (*GitHub, error) {
}

func newWithGithubApp(ctx context.Context, ghc *cfg.Github) (*GitHub, error) {
jwttoken, err := getJWTToken(ghc.AppId, string(ghc.AppPrivatekeyPath))
if len(ghc.Orgs) != 1 {
return nil, fmt.Errorf("github-connector: only one org should be specified for GitHub App authentication")
}

// Parse App ID
appID, err := strconv.ParseInt(ghc.AppId, 10, 64)
if err != nil {
return nil, err
return nil, fmt.Errorf("github-connector: invalid app-id: %w", err)
}

appClient, err := newGitHubClient(ctx,
ghc.InstanceUrl,
oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: jwttoken},
),
)
// Get the private key content
appKey := string(ghc.AppPrivatekeyPath)

// Create app transport for finding installation
appTransport, err := ghinstallation.NewAppsTransport(
http.DefaultTransport,
appID,
[]byte(appKey),
)
if err != nil {
return nil, err
return nil, fmt.Errorf("github-connector: failed to create app transport: %w", err)
}
installation, err := findInstallation(ctx, appClient, ghc.Org)
if err != nil {
return nil, err

// Set base URL for GitHub Enterprise
instanceURL := strings.TrimSuffix(ghc.InstanceUrl, "/")
if instanceURL != "" && instanceURL != githubDotCom {
appTransport.BaseURL = instanceURL + "/api/v3"
}

token, err := getInstallationToken(ctx, appClient, installation.GetID())
appClient := github.NewClient(&http.Client{Transport: appTransport})
if instanceURL != "" && instanceURL != githubDotCom {
appClient, err = appClient.WithEnterpriseURLs(instanceURL, instanceURL)
if err != nil {
return nil, fmt.Errorf("github-connector: failed to set enterprise URLs for app client: %w", err)
}
}

// Find installation for the org
installation, err := findInstallation(ctx, appClient, ghc.Orgs[0])
if err != nil {
return nil, err
}

ts := oauth2.ReuseTokenSource(
&oauth2.Token{
AccessToken: token.GetToken(),
Expiry: token.GetExpiresAt().Time,
},
&appTokenRefresher{
ctx: ctx,
instanceURL: ghc.InstanceUrl,
installationID: installation.GetID(),
jwtTokenSource: oauth2.ReuseTokenSource(
&oauth2.Token{
AccessToken: jwttoken,
Expiry: time.Now().Add(jwtExpiryTime),
},
&appJWTTokenRefresher{
appID: ghc.AppId,
privateKey: string(ghc.AppPrivatekeyPath),
},
),
},
// Create installation transport - handles all token refresh automatically
installTransport, err := ghinstallation.New(
http.DefaultTransport,
appID,
installation.GetID(),
[]byte(appKey),
)

ghClient, err := newGitHubClient(ctx, ghc.InstanceUrl, ts)
if err != nil {
return nil, err
return nil, fmt.Errorf("github-connector: failed to create installation transport: %w", err)
}

// Set base URL for GitHub Enterprise
if instanceURL != "" && instanceURL != githubDotCom {
installTransport.BaseURL = instanceURL + "/api/v3"
}

ghClient := github.NewClient(&http.Client{Transport: installTransport})
if instanceURL != "" && instanceURL != githubDotCom {
ghClient, err = ghClient.WithEnterpriseURLs(instanceURL, instanceURL)
if err != nil {
return nil, fmt.Errorf("github-connector: failed to set enterprise URLs for install client: %w", err)
}
}

// Wrap for GraphQL client which needs oauth2.TokenSource
ts := &ghinstallationTokenSource{transport: installTransport}
graphqlClient, err := newGitHubGraphqlClient(ctx, ghc.InstanceUrl, ts)
if err != nil {
return nil, err
}

gh := &GitHub{
return &GitHub{
client: ghClient,
appClient: appClient,
customClient: customclient.New(ghClient),
instanceURL: ghc.InstanceUrl,
orgs: []string{ghc.Org},
orgs: ghc.Orgs,
enterprises: ghc.Enterprises,
graphqlClient: graphqlClient,
orgCache: newOrgNameCache(ghClient),
syncSecrets: ghc.SyncSecrets,
omitArchivedRepositories: ghc.OmitArchivedRepositories,
}
return gh, nil
}, nil
}

func newGitHubGraphqlClient(ctx context.Context, instanceURL string, ts oauth2.TokenSource) (*githubv4.Client, error) {
Expand Down Expand Up @@ -393,46 +403,6 @@ func newGitHubGraphqlClient(ctx context.Context, instanceURL string, ts oauth2.T
return githubv4.NewClient(tc), nil
}

func loadPrivateKeyFromString(p string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(p))
if block == nil || (block.Type != "PRIVATE KEY" && block.Type != "RSA PRIVATE KEY") {
return nil, errors.New("invalid private key PEM format")
}

// PKCS8 format
if block.Type == "PRIVATE KEY" {
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("not an RSA private key")
}
return rsaKey, nil
}

// PKCS1 format
return x509.ParsePKCS1PrivateKey(block.Bytes)
}

func getJWTToken(appID string, privateKey string) (string, error) {
key, err := loadPrivateKeyFromString(privateKey)
if err != nil {
return "", err
}
now := time.Now()
token, err := jwtv5.NewWithClaims(jwtv5.SigningMethodRS256, jwtv5.MapClaims{
"iat": now.Unix() - 60, // issued at
"exp": now.Add(time.Minute * 10).Unix(), // expires
"iss": appID, // GitHub App ID
}).SignedString(key)
if err != nil {
return "", err
}
return token, nil
}

func findInstallation(ctx context.Context, c *github.Client, orgName string) (*github.Installation, error) {
installation, resp, err := c.Apps.FindOrganizationInstallation(ctx, orgName)
if err != nil {
Expand All @@ -441,62 +411,26 @@ func findInstallation(ctx context.Context, c *github.Client, orgName string) (*g
return installation, nil
}

func getInstallationToken(ctx context.Context, c *github.Client, id int64) (*github.InstallationToken, error) {
token, resp, err := c.Apps.CreateInstallationToken(ctx, id, &github.InstallationTokenOptions{})
if err != nil {
return nil, err
}

if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("GitHub API error: %s", body)
}

return token, nil
}

// appJWTTokenRefresher is used to refresh the app jwt token when it expires.
type appJWTTokenRefresher struct {
appID string
privateKey string
}

func (r *appJWTTokenRefresher) Token() (*oauth2.Token, error) {
token, err := getJWTToken(r.appID, r.privateKey)
if err != nil {
return nil, err
}

return &oauth2.Token{
AccessToken: token,
Expiry: time.Now().Add(jwtExpiryTime),
}, nil
}

type appTokenRefresher struct {
ctx context.Context
jwtTokenSource oauth2.TokenSource
instanceURL string
installationID int64
// ghinstallationTokenSource wraps ghinstallation.Transport to implement oauth2.TokenSource
// for use with the GraphQL client.
type ghinstallationTokenSource struct {
transport *ghinstallation.Transport
}

func (r *appTokenRefresher) Token() (*oauth2.Token, error) {
appClient, err := newGitHubClient(r.ctx,
r.instanceURL,
r.jwtTokenSource,
)
func (g *ghinstallationTokenSource) Token() (*oauth2.Token, error) {
token, err := g.transport.Token(context.Background())
if err != nil {
return nil, err
}

token, err := getInstallationToken(r.ctx, appClient, r.installationID)
if err != nil {
return nil, err
// Use actual token expiry from ghinstallation transport.
// If Expiry() fails, fallback to forcing re-evaluation by returning
// time.Now() which will cause oauth2 to refresh the token.
expiresAt, _, expiryErr := g.transport.Expiry()
if expiryErr != nil {
//nolint:nilerr // Intentional: gracefully degrade when expiry unavailable
return &oauth2.Token{AccessToken: token, Expiry: time.Now()}, nil
}
return &oauth2.Token{
AccessToken: token.GetToken(),
Expiry: token.GetExpiresAt().Time,
}, nil
return &oauth2.Token{AccessToken: token, Expiry: expiresAt}, nil
}

func getOrgs(ctx context.Context, client *github.Client, orgs []string) ([]string, error) {
Expand Down
Loading