package vcs import ( "context" "fmt" "net/http" "net/url" "os" "strings" "github.com/bradleyfalzon/ghinstallation/v2" "github.com/google/go-github/v71/github" "github.com/pkg/errors" ) //go:generate pegomock generate --package mocks -o mocks/mock_github_credentials.go GithubCredentials // GithubCredentials handles creating http.Clients that authenticate. type GithubCredentials interface { Client() (*http.Client, error) GetToken() (string, error) GetUser() (string, error) } // GithubAnonymousCredentials expose no credentials. type GithubAnonymousCredentials struct{} // Client returns a client with no credentials. func (c *GithubAnonymousCredentials) Client() (*http.Client, error) { tr := http.DefaultTransport return &http.Client{Transport: tr}, nil } // GetUser returns the username for these credentials. func (c *GithubAnonymousCredentials) GetUser() (string, error) { return "anonymous", nil } // GetToken returns an empty token. func (c *GithubAnonymousCredentials) GetToken() (string, error) { return "", nil } // GithubUserCredentials implements GithubCredentials for the personal auth token flow. type GithubUserCredentials struct { User string Token string TokenFile string } type GitHubUserTransport struct { Credentials *GithubUserCredentials Transport *github.BasicAuthTransport } func (t *GitHubUserTransport) RoundTrip(req *http.Request) (*http.Response, error) { // update token token, err := t.Credentials.GetToken() if err != nil { return nil, err } t.Transport.Password = token // defer to the underlying transport return t.Transport.RoundTrip(req) } // Client returns a client for basic auth user credentials. func (c *GithubUserCredentials) Client() (*http.Client, error) { password, err := c.GetToken() if err != nil { return nil, err } client := &http.Client{ Transport: &GitHubUserTransport{ Credentials: c, Transport: &github.BasicAuthTransport{ Username: strings.TrimSpace(c.User), Password: strings.TrimSpace(password), }, }, } return client, nil } // GetUser returns the username for these credentials. func (c *GithubUserCredentials) GetUser() (string, error) { return c.User, nil } // GetToken returns the user token. func (c *GithubUserCredentials) GetToken() (string, error) { if c.TokenFile != "" { content, err := os.ReadFile(c.TokenFile) if err != nil { return "", fmt.Errorf("failed reading github token file: %w", err) } return string(content), nil } return c.Token, nil } // GithubAppCredentials implements GithubCredentials for github app installation token flow. type GithubAppCredentials struct { AppID int64 Key []byte Hostname string apiURL *url.URL InstallationID int64 tr *ghinstallation.Transport AppSlug string } // Client returns a github app installation client. func (c *GithubAppCredentials) Client() (*http.Client, error) { itr, err := c.transport() if err != nil { return nil, err } return &http.Client{Transport: itr}, nil } // GetUser returns the username for these credentials. func (c *GithubAppCredentials) GetUser() (string, error) { // Keeping backwards compatibility since this flag is optional if c.AppSlug == "" { return "", nil } client, err := c.Client() if err != nil { return "", errors.Wrap(err, "initializing client") } ghClient := github.NewClient(client) ghClient.BaseURL = c.getAPIURL() ctx := context.Background() app, _, err := ghClient.Apps.Get(ctx, c.AppSlug) if err != nil { return "", errors.Wrap(err, "getting app details") } // Currently there is no way to get the bot's login info, so this is a // hack until Github exposes that. return fmt.Sprintf("%s[bot]", app.GetSlug()), nil } // GetToken returns a fresh installation token. func (c *GithubAppCredentials) GetToken() (string, error) { tr, err := c.transport() if err != nil { return "", errors.Wrap(err, "transport failed") } return tr.Token(context.Background()) } func (c *GithubAppCredentials) getInstallationID() (int64, error) { if c.InstallationID != 0 { return c.InstallationID, nil } tr := http.DefaultTransport // A non-installation transport t, err := ghinstallation.NewAppsTransport(tr, c.AppID, c.Key) if err != nil { return 0, err } t.BaseURL = c.getAPIURL().String() // Query github with the app's JWT client := github.NewClient(&http.Client{Transport: t}) client.BaseURL = c.getAPIURL() ctx := context.Background() installations, _, err := client.Apps.ListInstallations(ctx, nil) if err != nil { return 0, err } if len(installations) != 1 { return 0, fmt.Errorf("wrong number of installations, expected 1, found %d", len(installations)) } c.InstallationID = installations[0].GetID() return c.InstallationID, nil } func (c *GithubAppCredentials) transport() (*ghinstallation.Transport, error) { if c.tr != nil { return c.tr, nil } installationID, err := c.getInstallationID() if err != nil { return nil, err } tr := http.DefaultTransport itr, err := ghinstallation.New(tr, c.AppID, installationID, c.Key) if err == nil { apiURL := c.getAPIURL() itr.BaseURL = strings.TrimSuffix(apiURL.String(), "/") c.tr = itr } return itr, err } func (c *GithubAppCredentials) getAPIURL() *url.URL { if c.apiURL != nil { return c.apiURL } c.apiURL = resolveGithubAPIURL(c.Hostname) return c.apiURL } func resolveGithubAPIURL(hostname string) *url.URL { // If we're using github.com then we don't need to do any additional configuration // for the client. It we're using Github Enterprise, then we need to manually // set the base url for the API. baseURL := &url.URL{ Scheme: "https", Host: "api.github.com", Path: "/", } if hostname != "github.com" { baseURL.Host = hostname baseURL.Path = "/api/v3/" } return baseURL }