github.com/quickfeed/quickfeed@v0.0.0-20240507093252-ed8ca812a09c/scm/github_app.go (about) 1 package scm 2 3 import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "io" 9 "strconv" 10 "time" 11 12 "github.com/google/go-github/v45/github" 13 "github.com/shurcooL/githubv4" 14 "go.uber.org/zap" 15 ) 16 17 func newGithubAppClient(ctx context.Context, logger *zap.SugaredLogger, cfg *Config, organization string) (*GithubSCM, error) { 18 inst, err := cfg.fetchInstallation(organization) 19 if err != nil { 20 return nil, err 21 } 22 installCfg, err := cfg.InstallationConfig(strconv.Itoa(int(inst.GetID()))) 23 if err != nil { 24 return nil, fmt.Errorf("error configuring github client for installation: %w", err) 25 } 26 httpClient := installCfg.Client(ctx) 27 return &GithubSCM{ 28 logger: logger, 29 client: github.NewClient(httpClient), 30 clientV4: githubv4.NewClient(httpClient), 31 config: cfg, 32 providerURL: "github.com", 33 tokenURL: fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", inst.GetID()), 34 }, nil 35 } 36 37 func (cfg *Config) fetchInstallation(organization string) (*github.Installation, error) { 38 const installationURL = "https://api.github.com/app/installations" 39 resp, err := cfg.Client().Get(installationURL) 40 if err != nil { 41 return nil, fmt.Errorf("error fetching GitHub app installation for organization %s: %w", organization, err) 42 } 43 defer resp.Body.Close() 44 body, err := io.ReadAll(resp.Body) 45 if err != nil { 46 return nil, fmt.Errorf("error reading installation response: %w", err) 47 } 48 var installations []*github.Installation 49 if err := json.Unmarshal(body, &installations); err != nil { 50 return nil, fmt.Errorf("error unmarshalling installation response: %s: %w", body, err) 51 } 52 for _, inst := range installations { 53 if *inst.Account.Login == organization { 54 return inst, nil 55 } 56 } 57 return nil, fmt.Errorf("could not find GitHub app installation for organization %s", organization) 58 } 59 60 type ExchangeToken struct { 61 AccessToken string `json:"access_token"` 62 RefreshToken string `json:"refresh_token"` 63 } 64 65 // ExchangeToken exchanges a refresh token for an access token. 66 func (cfg *Config) ExchangeToken(refreshToken string) (*ExchangeToken, error) { 67 if cfg == nil { 68 return nil, errors.New("cannot exchange refresh token without config") 69 } 70 form := map[string][]string{ 71 "client_id": {cfg.ClientID}, 72 "client_secret": {cfg.ClientSecret}, 73 "refresh_token": {refreshToken}, 74 "grant_type": {"refresh_token"}, 75 } 76 resp, err := cfg.Client().PostForm("https://github.com/login/oauth/access_token", form) 77 if err != nil { 78 return nil, err 79 } 80 defer resp.Body.Close() 81 if resp.StatusCode != 200 { 82 return nil, fmt.Errorf("failed to get access token: %s", resp.Status) 83 } 84 var token ExchangeToken 85 if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { 86 return nil, err 87 } 88 if token.AccessToken == "" || token.RefreshToken == "" { 89 return nil, fmt.Errorf("tokens are empty") 90 } 91 return &token, nil 92 } 93 94 func (s *GithubSCM) refreshToken(organization string) error { 95 if s.config == nil { 96 return errors.New("cannot refresh token without config") 97 } 98 resp, err := s.config.Client().Post(s.tokenURL, "application/vnd.github.v3+json", nil) 99 if err != nil { 100 // Note: If the installation was deleted on GitHub, the installation ID will be invalid. 101 return err 102 } 103 defer resp.Body.Close() 104 var tokenResponse struct { 105 Token string `json:"token"` 106 ExpiresAt time.Time `json:"expires_at"` 107 Permissions struct { 108 Contents string `json:"contents"` 109 Metadata string `json:"metadata"` 110 PullRequests string `json:"pull_requests"` 111 } `json:"permissions"` 112 RepositorySelection string `json:"repository_selection"` 113 } 114 body, err := io.ReadAll(resp.Body) 115 if err != nil { 116 return err 117 } 118 if resp.StatusCode < 200 || resp.StatusCode > 300 { 119 return fmt.Errorf("failed to fetch installation access token for %s (response status %s): %s", organization, resp.Status, string(body)) 120 } 121 if err := json.Unmarshal(body, &tokenResponse); err != nil { 122 return err 123 } 124 s.token = tokenResponse.Token 125 return nil 126 }