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