-
Notifications
You must be signed in to change notification settings - Fork 0
/
oauth.go
310 lines (275 loc) · 11 KB
/
oauth.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
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
// Copyright 2017 The LUCI 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 auth
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"time"
"golang.org/x/oauth2"
"go.chromium.org/luci/auth/identity"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/gcloud/googleoauth"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/common/retry"
"go.chromium.org/luci/common/retry/transient"
"go.chromium.org/luci/grpc/grpcutil"
"go.chromium.org/luci/server/caching"
"go.chromium.org/luci/server/caching/layered"
)
var (
// ErrBadOAuthToken is returned by GoogleOAuth2Method if the access token it
// checks either totally invalid, expired or has a wrong list of scopes.
ErrBadOAuthToken = errors.New("oauth: bad access token", grpcutil.UnauthenticatedTag)
// ErrBadAuthorizationHeader is returned by GoogleOAuth2Method if it doesn't
// recognize the format of Authorization header.
ErrBadAuthorizationHeader = errors.New("oauth: bad Authorization header", grpcutil.UnauthenticatedTag)
)
// tokenValidationOutcome is returned by validateAccessToken and cached in
// oauthValidationCache.
//
// It either contains an info extracted from the token or an error message if
// the token is invalid.
type tokenValidationOutcome struct {
Email string `json:"email,omitempty"`
ClientID string `json:"client_id,omitempty"`
Scopes []string `json:"scopes,omitempty"` // sorted
Expiry int64 `json:"expiry,omitempty"` // unix timestamp
Error string `json:"error,omitempty"`
}
// SHA256(access token) => JSON-marshalled *tokenValidationOutcome.
var oauthValidationCache = layered.Cache{
ProcessLRUCache: caching.RegisterLRUCache(65536),
GlobalNamespace: "oauth_validation_v1",
Marshal: func(item interface{}) ([]byte, error) {
return json.Marshal(item.(*tokenValidationOutcome))
},
Unmarshal: func(blob []byte) (interface{}, error) {
tok := &tokenValidationOutcome{}
if err := json.Unmarshal(blob, tok); err != nil {
return nil, err
}
return tok, nil
},
}
// GoogleOAuth2Method implements Method via Google's OAuth2 token info endpoint.
//
// Note that it uses the endpoint which "has no SLA and is not intended for
// production use". The closest alternative is /userinfo endpoint, but it
// doesn't return the token expiration time (so we can't cache the result of
// the check) nor the list of OAuth scopes the token has, nor the client ID to
// check against a whitelist.
//
// The general Google's recommendation is to use access tokens only for
// accessing Google APIs and use OpenID Connect Identity tokens for
// authentication in your own services instead (they are locally verifiable
// JWTs).
//
// Unfortunately, using OpenID tokens for LUCI services and OAuth2 access token
// for Google services significantly complicates clients, especially in
// non-trivial cases (like authenticating from a Swarming job): they now must
// support two token kinds and know which one to use when.
//
// There's no solution currently that preserves all of correctness, performance,
// usability and availability:
// * Using /tokeninfo (like is done currently) sacrifices availability.
// * Using /userinfo sacrifices correctness (no client ID or scopes check).
// * Using OpenID ID tokens scarifies usability for the clients.
type GoogleOAuth2Method struct {
// Scopes is a list of OAuth scopes to check when authenticating the token.
Scopes []string
// tokenInfoEndpoint is used in unit test to mock production endpoint.
tokenInfoEndpoint string
}
var _ UserCredentialsGetter = (*GoogleOAuth2Method)(nil)
// Authenticate implements Method.
func (m *GoogleOAuth2Method) Authenticate(ctx context.Context, r *http.Request) (*User, error) {
// Extract the access token from the Authorization header.
header := r.Header.Get("Authorization")
if header == "" || len(m.Scopes) == 0 {
return nil, nil // this method is not applicable
}
accessToken, err := accessTokenFromHeader(header)
if err != nil {
return nil, err
}
// Store only the token hash in the cache, so that if a memory or cache dump
// ever occurs, the tokens themselves aren't included in it.
h := sha256.Sum256([]byte(accessToken))
cacheKey := hex.EncodeToString(h[:])
// Verify the token using /tokeninfo endpoint or grab a result of the previous
// verification. We cache both good and bad tokens for extra 10 min to avoid
// uselessly rechecking them all the time. Note that a bad token can't turn
// into a good one with the passage of time, so its OK to cache it. And a good
// token can turn into a bad one only when it expires (we check it below), so
// it is also OK to cache it.
//
// TODO(vadimsh): Strictly speaking we need to store bad tokens in a separate
// cache, so a flood of bad tokens (which are very easy to produce, compared
// to good tokens) doesn't evict good tokens from the process cache.
cached, err := oauthValidationCache.GetOrCreate(ctx, cacheKey, func() (interface{}, time.Duration, error) {
logging.Infof(ctx, "oauth: validating access token SHA256=%q", cacheKey)
outcome, expiresIn, err := validateAccessToken(ctx, accessToken, m.tokenInfoEndpoint)
if err != nil {
return nil, 0, err
}
return outcome, 10*time.Minute + expiresIn, nil
})
if err != nil {
return nil, err // the check itself failed
}
outcome := cached.(*tokenValidationOutcome)
// Fail if the token was never valid.
if outcome.Error != "" {
logging.Warningf(ctx, "oauth: access token SHA256=%q: %s", cacheKey, outcome.Error)
return nil, ErrBadOAuthToken
}
// Fail if the token was once valid but has expired since.
if expired := clock.Now(ctx).Unix() - outcome.Expiry; expired > 0 {
logging.Warningf(ctx, "oauth: access token SHA256=%q from %s expired %d sec ago",
cacheKey, outcome.Email, expired)
return nil, ErrBadOAuthToken
}
// Fail if the token doesn't have all required scopes.
var missingScopes []string
for _, s := range m.Scopes {
idx := sort.SearchStrings(outcome.Scopes, s)
if idx == len(outcome.Scopes) || outcome.Scopes[idx] != s {
missingScopes = append(missingScopes, s)
}
}
if len(missingScopes) != 0 {
logging.Warningf(ctx, "oauth: access token SHA256=%q from %s doesn't have scopes %q, it has %q",
cacheKey, outcome.Email, missingScopes, outcome.Scopes)
return nil, ErrBadOAuthToken
}
return &User{
Identity: identity.Identity("user:" + outcome.Email),
Email: outcome.Email,
ClientID: outcome.ClientID,
}, nil
}
// GetUserCredentials implements UserCredentialsGetter.
func (m *GoogleOAuth2Method) GetUserCredentials(c context.Context, r *http.Request) (*oauth2.Token, error) {
accessToken, err := accessTokenFromHeader(r.Header.Get("Authorization"))
if err != nil {
return nil, err
}
return &oauth2.Token{
AccessToken: accessToken,
TokenType: "Bearer",
}, nil
}
// accessTokenFromHeader parses Authorization header.
func accessTokenFromHeader(header string) (string, error) {
chunks := strings.SplitN(header, " ", 2)
if len(chunks) != 2 || (chunks[0] != "OAuth" && chunks[0] != "Bearer") {
return "", ErrBadAuthorizationHeader
}
return chunks[1], nil
}
// validateAccessToken uses OAuth2 tokeninfo endpoint to validate an access
// token.
//
// Returns its outcome as tokenValidationOutcome. It either contains a token
// info or an error message if the token is invalid. If the token is valid,
// also returns the duration until it expires.
//
// Returns an error if the check itself fails, e.g. we couldn't make the
// request. Such errors may be transient (network flakes) or fatal
// (auth library misconfiguration).
func validateAccessToken(ctx context.Context, accessToken, tokenInfoEndpoint string) (*tokenValidationOutcome, time.Duration, error) {
tr, err := GetRPCTransport(ctx, NoAuth)
if err != nil {
return nil, 0, err
}
tokenInfo, err := queryTokenInfoEndpoint(ctx, googleoauth.TokenInfoParams{
AccessToken: accessToken,
Client: &http.Client{Transport: tr},
Endpoint: tokenInfoEndpoint, // "" means "use default"
})
if err != nil {
if err == googleoauth.ErrBadToken {
return &tokenValidationOutcome{Error: err.Error()}, 0, nil
}
return nil, 0, errors.Annotate(err, "oauth: transient error when validating the token").Tag(transient.Tag).Err()
}
// Verify the token contains all necessary fields.
errorMsg := ""
switch {
case tokenInfo.Email == "":
errorMsg = "the token is not associated with an email"
case !tokenInfo.EmailVerified:
errorMsg = fmt.Sprintf("the email %s in the token is not verified", tokenInfo.Email)
case tokenInfo.ExpiresIn <= 0:
errorMsg = fmt.Sprintf("in a token from %s 'expires_in' %d is not a positive integer", tokenInfo.Email, tokenInfo.ExpiresIn)
case tokenInfo.Aud == "":
errorMsg = fmt.Sprintf("in a token from %s 'aud' field is empty", tokenInfo.Email)
case tokenInfo.Scope == "":
errorMsg = fmt.Sprintf("in a token from %s 'scope' field is empty", tokenInfo.Scope)
}
if errorMsg != "" {
return &tokenValidationOutcome{Error: errorMsg}, 0, nil
}
// Verify the email passes our regexp check.
if _, err := identity.MakeIdentity("user:" + tokenInfo.Email); err != nil {
return &tokenValidationOutcome{Error: err.Error()}, 0, nil
}
// Sort scopes alphabetically to speed up lookups in Authenticate.
scopes := strings.Split(tokenInfo.Scope, " ")
sort.Strings(scopes)
// The token is good.
expiresIn := time.Duration(tokenInfo.ExpiresIn) * time.Second
return &tokenValidationOutcome{
Email: tokenInfo.Email,
ClientID: tokenInfo.Aud,
Scopes: scopes,
Expiry: clock.Now(ctx).Add(expiresIn).Unix(),
}, expiresIn, nil
}
// queryTokenInfoEndpoint calls the token info endpoint with retries.
func queryTokenInfoEndpoint(ctx context.Context, params googleoauth.TokenInfoParams) (info *googleoauth.TokenInfo, err error) {
ctx = clock.Tag(ctx, "oauth-tokeninfo-retry")
retryParams := func() retry.Iterator {
return &retry.ExponentialBackoff{
Limited: retry.Limited{
Delay: 10 * time.Millisecond,
Retries: 5,
},
}
}
err = retry.Retry(ctx, transient.Only(retryParams), func() (err error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
start := clock.Now(ctx)
outcome := "ERROR"
switch info, err = googleoauth.GetTokenInfo(ctx, params); {
case err == nil:
outcome = "OK"
case err == googleoauth.ErrBadToken:
outcome = "BAD_TOKEN"
case errors.Unwrap(err) == context.DeadlineExceeded:
outcome = "DEADLINE"
}
tokenInfoCallDuration.Add(ctx, float64(clock.Since(ctx, start).Nanoseconds()/1000), outcome)
return err
}, retry.LogCallback(ctx, "tokeninfo"))
return info, err
}