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
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.33.10 // indirect
github.com/aws/smithy-go v1.22.2 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/conductorone/dpop v0.2.3 // indirect
github.com/conductorone/dpop/integrations/dpop_grpc v0.2.3 // indirect
Expand All @@ -61,8 +62,10 @@ require (
github.com/go-logr/logr v1.4.2 // 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
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxY
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/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
Expand Down Expand Up @@ -116,6 +118,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/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-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
Expand All @@ -135,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
221 changes: 95 additions & 126 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,20 @@ 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/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 Down Expand Up @@ -248,62 +244,102 @@ func newGitHubClient(ctx context.Context, instanceURL string, ts oauth2.TokenSou

// New returns the GitHub connector configured to sync against the instance URL.
func New(ctx context.Context, ghc *cfg.Github, appKey string) (*GitHub, error) {
jwttoken, patToken, err := getClientToken(ghc, appKey)
if err != nil {
return nil, err
}

var (
appClient *github.Client
ts = oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: patToken},
)
appClient *github.Client
ghClient *github.Client
graphqlClient *githubv4.Client
ts oauth2.TokenSource
err error
)
if jwttoken != "" {

// GitHub App authentication using ghinstallation for automatic token refresh
//nolint:gocritic // ifElseChain: conditions check different variables, switch not appropriate
if appKey != "" && ghc.AppId != "" {
if len(ghc.Orgs) != 1 {
return nil, fmt.Errorf("github-connector: only one org should be specified")
}

appClient, err = newGitHubClient(ctx,
ghc.InstanceUrl,
oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: jwttoken},
),
// Parse App ID
appID, err := strconv.ParseInt(ghc.AppId, 10, 64)
if err != nil {
return nil, fmt.Errorf("github-connector: invalid app-id: %w", err)
}

// 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)
}

// Set base URL for GitHub Enterprise
instanceURL := strings.TrimSuffix(ghc.InstanceUrl, "/")
if instanceURL != "" && instanceURL != githubDotCom {
appTransport.BaseURL = instanceURL + "/api/v3"
}
installation, resp, err := findInstallation(ctx, appClient, ghc.Orgs[0])

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, resp, err := appClient.Apps.FindOrganizationInstallation(ctx, ghc.Orgs[0])
if err != nil {
return nil, wrapGitHubError(err, resp, "github-connector: failed to find app installation")
}

token, err := getInstallationToken(ctx, appClient, installation.GetID())
// Create installation transport - handles all token refresh automatically
installTransport, err := ghinstallation.New(
http.DefaultTransport,
appID,
installation.GetID(),
[]byte(appKey),
)
if err != nil {
return nil, err
return nil, fmt.Errorf("github-connector: failed to create installation transport: %w", err)
}

ts = oauth2.ReuseTokenSource(
&oauth2.Token{
AccessToken: token.GetToken(),
Expiry: token.GetExpiresAt().Time,
},
&appTokenRefresher{
ctx: ctx,
instanceURL: ghc.InstanceUrl,
installationID: installation.GetID(),
jwttoken: jwttoken,
},
)
}
// Set base URL for GitHub Enterprise
if instanceURL != "" && instanceURL != githubDotCom {
installTransport.BaseURL = instanceURL + "/api/v3"
}

ghClient, err := newGitHubClient(ctx, ghc.InstanceUrl, ts)
if err != nil {
return nil, err
}
graphqlClient, err := newGitHubGraphqlClient(ctx, ghc.InstanceUrl, ts)
if err != nil {
return nil, err
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
}
} else if ghc.Token != "" {
// PAT token authentication
ts = oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: ghc.Token},
)
ghClient, err = newGitHubClient(ctx, ghc.InstanceUrl, ts)
if err != nil {
return nil, err
}
graphqlClient, err = newGitHubGraphqlClient(ctx, ghc.InstanceUrl, ts)
if err != nil {
return nil, err
}
} else {
return nil, fmt.Errorf("github-connector: no authentication method provided")
}

gh := &GitHub{
Expand Down Expand Up @@ -346,53 +382,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)
}

// getClientToken returns
// 1. fine-grained personal access tokens if any.
// 2. JWT token if using github app.
func getClientToken(ghc *cfg.Github, privateKey string) (string, string, error) {
if ghc.Token != "" {
return "", ghc.Token, nil
}

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": ghc.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, *github.Response, error) {
installation, resp, err := c.Apps.FindOrganizationInstallation(ctx, orgName)
if err != nil {
Expand All @@ -401,46 +390,26 @@ func findInstallation(ctx context.Context, c *github.Client, orgName string) (*g
return installation, resp, 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
}

type appTokenRefresher struct {
ctx context.Context
jwttoken string
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,
oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: r.jwttoken},
),
)
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
Loading