go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/appengine/gaeauth/client/client.go (about) 1 // Copyright 2015 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package client implements OAuth2 authentication for outbound connections 16 // from Appengine using the application services account. 17 // 18 // Deprecated: use go.chromium.org/luci/server/auth APIs instead. 19 package client 20 21 import ( 22 "context" 23 "fmt" 24 "sort" 25 "strings" 26 "time" 27 28 "golang.org/x/oauth2" 29 30 "go.chromium.org/luci/gae/service/info" 31 32 "go.chromium.org/luci/auth" 33 "go.chromium.org/luci/common/clock" 34 "go.chromium.org/luci/common/data/rand/mathrand" 35 "go.chromium.org/luci/common/data/stringset" 36 "go.chromium.org/luci/common/logging" 37 "go.chromium.org/luci/common/retry/transient" 38 "go.chromium.org/luci/server/caching" 39 ) 40 41 // GetAccessToken returns an OAuth access token representing app's service 42 // account. 43 // 44 // If scopes is empty, uses auth.OAuthScopeEmail scope. 45 // 46 // Implements a caching layer on top of GAE's GetAccessToken RPC. May return 47 // transient errors. 48 func GetAccessToken(ctx context.Context, scopes []string) (*oauth2.Token, error) { 49 scopes, cacheKey := normalizeScopes(scopes) 50 51 // Try to find the token in the local memory first. If it expires soon, 52 // refresh it earlier with some probability. That avoids a situation when 53 // parallel requests that use access tokens suddenly see the cache expired 54 // and rush to refresh the token all at once. 55 lru := tokensCache.LRU(ctx) 56 if tok, ok := lru.Get(ctx, cacheKey); ok { 57 if !closeToExpRandomized(ctx, tok.Expiry) { 58 return tok, nil 59 } 60 } 61 62 return lru.Create(ctx, cacheKey, func() (*oauth2.Token, time.Duration, error) { 63 // The token needs to be refreshed. 64 logging.Debugf(ctx, "Getting an access token for scopes %q", strings.Join(scopes, ", ")) 65 accessToken, exp, err := info.AccessToken(ctx, scopes...) 66 if err != nil { 67 return nil, 0, transient.Tag.Apply(err) 68 } 69 now := clock.Now(ctx) 70 logging.Debugf(ctx, "The token expires in %s", exp.Sub(now)) 71 72 // Prematurely expire it to guarantee all returned token live for at least 73 // 'expirationMinLifetime'. 74 tok := &oauth2.Token{ 75 AccessToken: accessToken, 76 Expiry: exp.Add(-expirationMinLifetime), 77 TokenType: "Bearer", 78 } 79 80 return tok, now.Sub(tok.Expiry), nil 81 }) 82 } 83 84 // NewTokenSource makes oauth2.TokenSource implemented on top of GetAccessToken. 85 // 86 // It is bound to the given context. 87 func NewTokenSource(ctx context.Context, scopes []string) oauth2.TokenSource { 88 return &tokenSource{ctx, scopes} 89 } 90 91 type tokenSource struct { 92 ctx context.Context 93 scopes []string 94 } 95 96 func (ts *tokenSource) Token() (*oauth2.Token, error) { 97 return GetAccessToken(ts.ctx, ts.scopes) 98 } 99 100 //// Internal stuff. 101 102 // normalized scopes string => *oauth2.Token. 103 var tokensCache = caching.RegisterLRUCache[string, *oauth2.Token](100) 104 105 const ( 106 // expirationMinLifetime is minimal possible lifetime of a returned token. 107 expirationMinLifetime = 2 * time.Minute 108 // expirationRandomization defines how much to randomize expiration time. 109 expirationRandomization = 3 * time.Minute 110 ) 111 112 func normalizeScopes(scopes []string) (normalized []string, cacheKey string) { 113 if len(scopes) == 0 { 114 scopes = []string{auth.OAuthScopeEmail} 115 } else { 116 set := stringset.New(len(scopes)) 117 for _, s := range scopes { 118 if strings.ContainsRune(s, '\n') { 119 panic(fmt.Errorf("invalid scope %q", s)) 120 } 121 set.Add(s) 122 } 123 scopes = set.ToSlice() 124 sort.Strings(scopes) 125 } 126 return scopes, strings.Join(scopes, "\n") 127 } 128 129 func closeToExpRandomized(ctx context.Context, exp time.Time) bool { 130 switch now := clock.Now(ctx); { 131 case now.After(exp): 132 return true // expired already 133 case now.Add(expirationRandomization).Before(exp): 134 return false // far from expiration 135 default: 136 // The expiration is close enough. Do the randomization. 137 rnd := time.Duration(mathrand.Int63n(ctx, int64(expirationRandomization))) 138 return now.Add(rnd).After(exp) 139 } 140 }