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 }