@@ -2,16 +2,24 @@ package connector
22
33import (
44 "context"
5+ "crypto/rsa"
6+ "crypto/x509"
7+ "encoding/json"
8+ "encoding/pem"
9+ "errors"
510 "fmt"
11+ "io"
612 "net/http"
713 "net/url"
814 "strings"
15+ "time"
916
1017 cfg "github.com/conductorone/baton-github/pkg/config"
1118 v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
1219 "github.com/conductorone/baton-sdk/pkg/annotations"
1320 "github.com/conductorone/baton-sdk/pkg/connectorbuilder"
1421 "github.com/conductorone/baton-sdk/pkg/uhttp"
22+ jwtv5 "github.com/golang-jwt/jwt/v5"
1523 "github.com/google/go-github/v63/github"
1624 "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
1725 "github.com/shurcooL/githubv4"
5462type GitHub struct {
5563 orgs []string
5664 client * github.Client
65+ appClient * github.Client
5766 instanceURL string
5867 graphqlClient * githubv4.Client
5968 hasSAMLEnabled * bool
@@ -62,7 +71,7 @@ type GitHub struct {
6271
6372func (gh * GitHub ) ResourceSyncers (ctx context.Context ) []connectorbuilder.ResourceSyncer {
6473 return []connectorbuilder.ResourceSyncer {
65- orgBuilder (gh .client , gh .orgCache , gh .orgs ),
74+ orgBuilder (gh .client , gh .appClient , gh . orgCache , gh .orgs ),
6675 teamBuilder (gh .client , gh .orgCache ),
6776 userBuilder (gh .client , gh .hasSAMLEnabled , gh .graphqlClient , gh .orgCache ),
6877 repositoryBuilder (gh .client , gh .orgCache ),
@@ -78,6 +87,10 @@ func (gh *GitHub) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) {
7887
7988// Validate hits the GitHub API to validate that the configured credentials are still valid.
8089func (gh * GitHub ) Validate (ctx context.Context ) (annotations.Annotations , error ) {
90+ if gh .appClient != nil {
91+ return gh .validateAppCredentials (ctx )
92+ }
93+
8194 page := 0
8295 orgLogins := gh .orgs
8396 filterOrgs := true
@@ -99,7 +112,6 @@ func (gh *GitHub) Validate(ctx context.Context) (annotations.Annotations, error)
99112 if resp .NextPage == 0 {
100113 break
101114 }
102-
103115 page = resp .NextPage
104116 }
105117 }
@@ -132,6 +144,40 @@ func (gh *GitHub) Validate(ctx context.Context) (annotations.Annotations, error)
132144 return nil , nil
133145}
134146
147+ func (gh * GitHub ) validateAppCredentials (ctx context.Context ) (annotations.Annotations , error ) {
148+ page := 0
149+ orgLogins := gh .orgs
150+
151+ installationsMap := make (map [string ]struct {})
152+ for {
153+ installations , resp , err := gh .appClient .Apps .ListInstallations (ctx , & github.ListOptions {Page : page })
154+ if err != nil {
155+ return nil , fmt .Errorf ("github-connector: failed to retrieve org: %w" , err )
156+ }
157+ if resp .StatusCode == http .StatusUnauthorized {
158+ return nil , status .Error (codes .Unauthenticated , "github token is not authorized" )
159+ }
160+ for _ , installation := range installations {
161+ if installation .Account == nil {
162+ continue
163+ }
164+ installationsMap [installation .Account .GetLogin ()] = struct {}{}
165+ }
166+
167+ if resp .NextPage == 0 {
168+ break
169+ }
170+ page = resp .NextPage
171+ }
172+
173+ for _ , o := range orgLogins {
174+ if _ , ok := installationsMap [o ]; ! ok {
175+ return nil , fmt .Errorf ("access token must be an admin on the %s organization" , o )
176+ }
177+ }
178+ return nil , nil
179+ }
180+
135181// newGitHubClient returns a new GitHub API client authenticated with an access token via oauth2.
136182func newGitHubClient (ctx context.Context , instanceURL string , accessToken string ) (* github.Client , error ) {
137183 httpClient , err := uhttp .NewClient (ctx , uhttp .WithLogger (true , ctxzap .Extract (ctx )))
@@ -157,22 +203,35 @@ func newGitHubClient(ctx context.Context, instanceURL string, accessToken string
157203
158204// New returns the GitHub connector configured to sync against the instance URL.
159205func New (ctx context.Context , ghc * cfg.Github ) (* GitHub , error ) {
160- client , err := newGitHubClient (ctx , ghc .InstanceUrl , ghc .Token )
206+ jwttoken , token , err := getClientToken (ghc )
207+ if err != nil {
208+ return nil , err
209+ }
210+
211+ client , err := newGitHubClient (ctx , ghc .InstanceUrl , token )
161212 if err != nil {
162213 return nil , err
163214 }
164- graphqlClient , err := newGitHubGraphqlClient (ctx , ghc .InstanceUrl , ghc . Token )
215+ graphqlClient , err := newGitHubGraphqlClient (ctx , ghc .InstanceUrl , token )
165216 if err != nil {
166217 return nil , err
167218 }
219+
220+ var appClient * github.Client
221+ if jwttoken != "" {
222+ appClient , err = newGitHubClient (ctx , ghc .InstanceUrl , jwttoken )
223+ if err != nil {
224+ return nil , err
225+ }
226+ }
168227 gh := & GitHub {
169228 client : client ,
229+ appClient : appClient ,
170230 instanceURL : ghc .InstanceUrl ,
171231 orgs : ghc .Orgs ,
172232 graphqlClient : graphqlClient ,
173233 orgCache : newOrgNameCache (client ),
174234 }
175-
176235 return gh , nil
177236}
178237
@@ -203,3 +262,72 @@ func newGitHubGraphqlClient(ctx context.Context, instanceURL string, accessToken
203262
204263 return githubv4 .NewClient (tc ), nil
205264}
265+
266+ func loadPrivateKeyFromString (p string ) (* rsa.PrivateKey , error ) {
267+ block , _ := pem .Decode ([]byte (p ))
268+ if block == nil || (block .Type != "PRIVATE KEY" && block .Type != "RSA PRIVATE KEY" ) {
269+ return nil , errors .New ("invalid private key PEM format" )
270+ }
271+
272+ // PKCS8 format
273+ if block .Type == "PRIVATE KEY" {
274+ key , err := x509 .ParsePKCS8PrivateKey (block .Bytes )
275+ if err != nil {
276+ return nil , err
277+ }
278+ rsaKey , ok := key .(* rsa.PrivateKey )
279+ if ! ok {
280+ return nil , errors .New ("not an RSA private key" )
281+ }
282+ return rsaKey , nil
283+ }
284+
285+ // PKCS1 format
286+ return x509 .ParsePKCS1PrivateKey (block .Bytes )
287+ }
288+
289+ // getClientToken returns
290+ // 1. fine-grained personal access tokens if any.
291+ // 2. (JWT token, installation access tokens) if using github app.
292+ func getClientToken (ghc * cfg.Github ) (string , string , error ) {
293+ if ghc .Token != "" {
294+ return "" , ghc .Token , nil
295+ }
296+
297+ key , err := loadPrivateKeyFromString (ghc .AppPrivatekey )
298+ if err != nil {
299+ return "" , "" , err
300+ }
301+ now := time .Now ()
302+ token , err := jwtv5 .NewWithClaims (jwtv5 .SigningMethodRS256 , jwtv5.MapClaims {
303+ "iat" : now .Unix () - 60 , // issued at
304+ "exp" : now .Add (time .Minute * 10 ).Unix (), // expires
305+ "iss" : ghc .AppId , // GitHub App ID
306+ }).SignedString (key )
307+ if err != nil {
308+ return "" , "" , err
309+ }
310+
311+ url := fmt .Sprintf ("https://api.github.com/app/installations/%s/access_tokens" , "67110013" )
312+
313+ req , _ := http .NewRequest ("POST" , url , nil )
314+ req .Header .Set ("Authorization" , "Bearer " + token )
315+ req .Header .Set ("Accept" , "application/vnd.github+json" )
316+
317+ resp , err := http .DefaultClient .Do (req )
318+ if err != nil {
319+ return "" , "" , err
320+ }
321+ defer resp .Body .Close ()
322+
323+ if resp .StatusCode != 201 {
324+ body , _ := io .ReadAll (resp .Body )
325+ return "" , "" , fmt .Errorf ("GitHub API error: %s" , body )
326+ }
327+
328+ var result struct {
329+ Token string `json:"token"`
330+ }
331+ err = json .NewDecoder (resp .Body ).Decode (& result )
332+ return token , result .Token , err
333+ }
0 commit comments