github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/github/app_auth_roundtripper.go (about)

     1  /*
     2  Copyright 2020 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package github
    18  
    19  import (
    20  	"context"
    21  	"crypto/rsa"
    22  	"fmt"
    23  	"net/http"
    24  	"net/url"
    25  	"reflect"
    26  	"regexp"
    27  	"runtime/debug"
    28  	"strings"
    29  	"sync"
    30  	"time"
    31  
    32  	jwt "github.com/dgrijalva/jwt-go/v4"
    33  
    34  	"sigs.k8s.io/prow/pkg/ghcache"
    35  )
    36  
    37  type appGitHubClient interface {
    38  	ListAppInstallations() ([]AppInstallation, error)
    39  	getAppInstallationToken(installationId int64) (*AppInstallationToken, error)
    40  	GetApp() (*App, error)
    41  }
    42  
    43  func newAppsRoundTripper(appID string, privateKey func() *rsa.PrivateKey, upstream http.RoundTripper, githubClient appGitHubClient, v3BaseURLs []string) (*appsRoundTripper, error) {
    44  	roundTripper := &appsRoundTripper{
    45  		appID:             appID,
    46  		privateKey:        privateKey,
    47  		upstream:          upstream,
    48  		githubClient:      githubClient,
    49  		hostPrefixMapping: make(map[string]string, len(v3BaseURLs)),
    50  	}
    51  	for _, baseURL := range v3BaseURLs {
    52  		url, err := url.Parse(baseURL)
    53  		if err != nil {
    54  			return nil, fmt.Errorf("failed to parse github-endpoint %s as URL: %w", baseURL, err)
    55  		}
    56  		roundTripper.hostPrefixMapping[url.Host] = url.Path
    57  	}
    58  
    59  	return roundTripper, nil
    60  }
    61  
    62  type appsRoundTripper struct {
    63  	appID             string
    64  	appSlug           string
    65  	appSlugLock       sync.Mutex
    66  	privateKey        func() *rsa.PrivateKey
    67  	installationLock  sync.RWMutex
    68  	installations     map[string]AppInstallation
    69  	tokenLock         sync.RWMutex
    70  	tokens            map[int64]*AppInstallationToken
    71  	upstream          http.RoundTripper
    72  	githubClient      appGitHubClient
    73  	hostPrefixMapping map[string]string
    74  }
    75  
    76  // appsAuthError is returned by the appsRoundTripper if any issues were encountered
    77  // trying to authorize the request. It signals the client to not retry.
    78  type appsAuthError struct {
    79  	error
    80  }
    81  
    82  func (*appsAuthError) Is(target error) bool {
    83  	_, ok := target.(*appsAuthError)
    84  	return ok
    85  }
    86  
    87  func (arr *appsRoundTripper) canonicalizedPath(url *url.URL) string {
    88  	return strings.TrimPrefix(url.Path, arr.hostPrefixMapping[url.Host])
    89  }
    90  
    91  var installationPath = regexp.MustCompile(`^/repos/[^/]+/[^/]+/installation$`)
    92  
    93  func (arr *appsRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
    94  	path := arr.canonicalizedPath(r.URL)
    95  	// We need to use a JWT when we are getting /app/* endpoints or installation information for a particular repo
    96  	if strings.HasPrefix(path, "/app") || installationPath.MatchString(path) {
    97  		if err := arr.addAppAuth(r); err != nil {
    98  			return nil, err
    99  		}
   100  	} else if err := arr.addAppInstallationAuth(r); err != nil {
   101  		return nil, err
   102  	}
   103  
   104  	return arr.upstream.RoundTrip(r)
   105  }
   106  
   107  // TimeNow is exposed so that it can be mocked by unit test, to ensure that
   108  // addAppAuth always return consistent token when needed.
   109  // DO NOT use it in prod
   110  var TimeNow = func() time.Time {
   111  	return time.Now().UTC()
   112  }
   113  
   114  func (arr *appsRoundTripper) addAppAuth(r *http.Request) *appsAuthError {
   115  	now := TimeNow()
   116  	// GitHub's clock may lag a few seconds, so we do not use 10min here.
   117  	expiresAt := now.Add(9 * time.Minute)
   118  	token, err := jwt.NewWithClaims(jwt.SigningMethodRS256, &jwt.StandardClaims{
   119  		IssuedAt:  jwt.NewTime(float64(now.Unix())),
   120  		ExpiresAt: jwt.NewTime(float64(expiresAt.Unix())),
   121  		Issuer:    arr.appID,
   122  	}).SignedString(arr.privateKey())
   123  	if err != nil {
   124  		return &appsAuthError{fmt.Errorf("failed to generate jwt: %w", err)}
   125  	}
   126  
   127  	r.Header.Set("Authorization", "Bearer "+token)
   128  	r.Header.Set(ghcache.TokenExpiryAtHeader, expiresAt.Format(time.RFC3339))
   129  
   130  	// We call the /app endpoint to resolve the slug, so we can't set it there
   131  	if arr.canonicalizedPath(r.URL) == "/app" {
   132  		r.Header.Set(ghcache.TokenBudgetIdentifierHeader, arr.appID)
   133  	} else {
   134  		slug, err := arr.getSlug()
   135  		if err != nil {
   136  			return &appsAuthError{err}
   137  		}
   138  		r.Header.Set(ghcache.TokenBudgetIdentifierHeader, slug)
   139  	}
   140  	return nil
   141  }
   142  
   143  func extractOrgFromContext(ctx context.Context) string {
   144  	var org string
   145  	if v := ctx.Value(githubOrgContextKey); v != nil {
   146  		org = v.(string)
   147  	}
   148  	return org
   149  }
   150  
   151  func (arr *appsRoundTripper) addAppInstallationAuth(r *http.Request) *appsAuthError {
   152  	org := extractOrgFromContext(r.Context())
   153  	if org == "" {
   154  		return &appsAuthError{fmt.Errorf("BUG apps auth requested but empty org, please report this to the test-infra repo. Stack: %s", string(debug.Stack()))}
   155  	}
   156  
   157  	token, expiresAt, err := arr.installationTokenFor(org)
   158  	if err != nil {
   159  		return &appsAuthError{err}
   160  	}
   161  
   162  	r.Header.Set("Authorization", "Bearer "+token)
   163  	r.Header.Set(ghcache.TokenExpiryAtHeader, expiresAt.Format(time.RFC3339))
   164  	slug, err := arr.getSlug()
   165  	if err != nil {
   166  		return &appsAuthError{err}
   167  	}
   168  
   169  	// Token budgets are set on organization level, so include it in the identifier
   170  	// to not mess up metrics.
   171  	r.Header.Set(ghcache.TokenBudgetIdentifierHeader, slug+" - "+org)
   172  
   173  	return nil
   174  }
   175  
   176  func (arr *appsRoundTripper) installationTokenFor(org string) (string, time.Time, error) {
   177  	installationID, err := arr.installationIDFor(org)
   178  	if err != nil {
   179  		return "", time.Time{}, fmt.Errorf("failed to get installation id for org %s: %w", org, err)
   180  	}
   181  
   182  	token, expiresAt, err := arr.getTokenForInstallation(installationID)
   183  	if err != nil {
   184  		return "", time.Time{}, fmt.Errorf("failed to get an installation token for org %s: %w", org, err)
   185  	}
   186  
   187  	return token, expiresAt, nil
   188  }
   189  
   190  // installationIDFor returns the installation id for the given org. Unfortunately,
   191  // GitHub does not expose what repos in that org the app is installed in, it
   192  // only tells us if its all repos or a subset via the repository_selection
   193  // property.
   194  // Ref: https://docs.github.com/en/free-pro-team@latest/rest/reference/apps#list-installations-for-the-authenticated-app
   195  func (arr *appsRoundTripper) installationIDFor(org string) (int64, error) {
   196  	arr.installationLock.RLock()
   197  	id, found := arr.installations[org]
   198  	arr.installationLock.RUnlock()
   199  	if found {
   200  		return id.ID, nil
   201  	}
   202  
   203  	arr.installationLock.Lock()
   204  	defer arr.installationLock.Unlock()
   205  
   206  	// Check again in case a concurrent routine updated it while we waited for the lock
   207  	id, found = arr.installations[org]
   208  	if found {
   209  		return id.ID, nil
   210  	}
   211  
   212  	installations, err := arr.githubClient.ListAppInstallations()
   213  	if err != nil {
   214  		return 0, fmt.Errorf("failed to list app installations: %w", err)
   215  	}
   216  
   217  	installationsMap := make(map[string]AppInstallation, len(installations))
   218  	for _, installation := range installations {
   219  		installationsMap[installation.Account.Login] = installation
   220  	}
   221  
   222  	if equal := reflect.DeepEqual(arr.installations, installationsMap); equal {
   223  		return 0, fmt.Errorf("the github app is not installed in organization %s", org)
   224  	}
   225  	arr.installations = installationsMap
   226  
   227  	id, found = installationsMap[org]
   228  	if !found {
   229  		return 0, fmt.Errorf("the github app is not installed in organization %s", org)
   230  	}
   231  
   232  	return id.ID, nil
   233  }
   234  
   235  func (arr *appsRoundTripper) getTokenForInstallation(installation int64) (string, time.Time, error) {
   236  	arr.tokenLock.RLock()
   237  	token, found := arr.tokens[installation]
   238  	arr.tokenLock.RUnlock()
   239  
   240  	if found && token.ExpiresAt.Add(-time.Minute).After(time.Now()) {
   241  		return token.Token, token.ExpiresAt, nil
   242  	}
   243  
   244  	arr.tokenLock.Lock()
   245  	defer arr.tokenLock.Unlock()
   246  
   247  	// Check again in case a concurrent routine got a token while we waited for the lock
   248  	token, found = arr.tokens[installation]
   249  	if found && token.ExpiresAt.Add(-time.Minute).After(time.Now()) {
   250  		return token.Token, token.ExpiresAt, nil
   251  	}
   252  
   253  	token, err := arr.githubClient.getAppInstallationToken(installation)
   254  	if err != nil {
   255  		return "", time.Time{}, fmt.Errorf("failed to get installation token from GitHub: %w", err)
   256  	}
   257  
   258  	if arr.tokens == nil {
   259  		arr.tokens = map[int64]*AppInstallationToken{}
   260  	}
   261  	arr.tokens[installation] = token
   262  
   263  	return token.Token, token.ExpiresAt, nil
   264  }
   265  
   266  func (arr *appsRoundTripper) getSlug() (string, error) {
   267  	arr.appSlugLock.Lock()
   268  	defer arr.appSlugLock.Unlock()
   269  
   270  	if arr.appSlug != "" {
   271  		return arr.appSlug, nil
   272  	}
   273  	response, err := arr.githubClient.GetApp()
   274  	if err != nil {
   275  		return "", err
   276  	}
   277  
   278  	arr.appSlug = response.Slug
   279  	return arr.appSlug, nil
   280  }