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  }