go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/auth/internal/common.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 internal contains code used internally by auth/integration.
    16  package internal
    17  
    18  import (
    19  	"bytes"
    20  	"context"
    21  	"reflect"
    22  	"strings"
    23  	"sync"
    24  	"time"
    25  
    26  	"golang.org/x/oauth2"
    27  
    28  	"go.chromium.org/luci/common/clock"
    29  	"go.chromium.org/luci/common/data/rand/mathrand"
    30  	"go.chromium.org/luci/common/errors"
    31  	"go.chromium.org/luci/common/retry/transient"
    32  )
    33  
    34  // expiryRandInterval is used by TokenExpiresInRnd.
    35  const expiryRandInterval = 30 * time.Second
    36  
    37  const (
    38  	// NoEmail indicates an OAuth2 token is not associated with an email.
    39  	//
    40  	// See Token below. We need this special value to distinguish "an email can
    41  	// not possibly be fetched ever" from "the cached token doesn't have an email
    42  	// yet" cases.
    43  	NoEmail = "-"
    44  
    45  	// UnknownEmail indicates an OAuth2 token may potentially be associated with
    46  	// an email, but we haven't tried to fetch the email yet.
    47  	UnknownEmail = ""
    48  
    49  	// NoIDToken indicates it was impossible to obtain an ID token, e.g. no
    50  	// "openid" scope in the refresh token or the provider doesn't support ID
    51  	// tokens at all.
    52  	NoIDToken = "-"
    53  
    54  	// NoAccessToken indicates the access token was not returned by the provider.
    55  	//
    56  	// This can happen with providers that support only ID tokens.
    57  	NoAccessToken = "-"
    58  )
    59  
    60  var (
    61  	// ErrInsufficientAccess is returned by MintToken() if token can't be minted
    62  	// for given OAuth scopes. For example, if GCE instance wasn't granted access
    63  	// to requested scopes when it was created.
    64  	ErrInsufficientAccess = errors.New("can't get access token for the given account and scopes")
    65  
    66  	// ErrBadRefreshToken is returned by RefreshToken if refresh token was revoked
    67  	// or otherwise invalid. It means MintToken must be used to get a new refresh
    68  	// token.
    69  	ErrBadRefreshToken = errors.New("refresh_token is not valid")
    70  
    71  	// ErrBadCredentials is returned by MintToken or RefreshToken if provided
    72  	// offline credentials (like service account key) are invalid.
    73  	ErrBadCredentials = errors.New("invalid or unavailable service account credentials")
    74  )
    75  
    76  // Token is an oauth2.Token with an email and ID token that correspond to it.
    77  //
    78  // Email may be an empty string, in which case we assume the email hasn't been
    79  // fetched yet. It can also be a special NoEmail string, which means the token
    80  // is not associated with an email (happens for tokens without 'userinfo.email'
    81  // scope).
    82  type Token struct {
    83  	oauth2.Token
    84  
    85  	IDToken string // an ID token derived directly from the access token or NoIDToken
    86  	Email   string // an email or NoEmail or empty string (aka UnknownEmail)
    87  }
    88  
    89  // TokenProvider knows how to mint new tokens or refresh existing ones.
    90  type TokenProvider interface {
    91  	// RequiresInteraction is true if provider may start user interaction
    92  	// in MintToken.
    93  	RequiresInteraction() bool
    94  
    95  	// Lightweight is true if MintToken is very cheap to call.
    96  	//
    97  	// In this case the token is not being cached on disk (only in memory), since
    98  	// it's easy to get a new one each time the process starts.
    99  	//
   100  	// By avoiding the disk cache, we reduce the chance of a leak.
   101  	Lightweight() bool
   102  
   103  	// Email is email associated with tokens produced by the provider, if known.
   104  	//
   105  	// May return UnknownEmail, which means the provider doesn't know the email
   106  	// in advance and RefreshToken must be used to get the token and the email.
   107  	// This happens, for example, for interactive providers before user has
   108  	// logged in.
   109  	//
   110  	// It can also be NoEmail which means the email is not available, even if
   111  	// caller is using RefreshToken.
   112  	Email() string
   113  
   114  	// CacheKey identifies a slot in the token cache to store the token in.
   115  	//
   116  	// Note: CacheKey MAY change during lifetime of a TokenProvider. It happens,
   117  	// for example, for ServiceAccount token provider if the underlying service
   118  	// account key is replaced while the process is still running.
   119  	CacheKey(ctx context.Context) (*CacheKey, error)
   120  
   121  	// MintToken launches authentication flow (possibly interactive) and returns
   122  	// a new refreshable token (or error). It must never return (nil, nil).
   123  	//
   124  	// In actor mode 'base' is an IAM-scoped sufficiently fresh oauth token. It's
   125  	// nil otherwise. Used by IAM-based token provider.
   126  	MintToken(ctx context.Context, base *Token) (*Token, error)
   127  
   128  	// RefreshToken takes existing token (probably expired, but not necessarily)
   129  	// and returns a new refreshed token. It should never do any user interaction.
   130  	// If a user interaction is required, a error should be returned instead.
   131  	//
   132  	// In actor mode 'base' is an IAM-scoped sufficiently fresh oauth token. It's
   133  	// nil otherwise. Used by IAM-based token provider.
   134  	RefreshToken(ctx context.Context, prev, base *Token) (*Token, error)
   135  }
   136  
   137  // TokenCache stores access and refresh tokens to avoid requesting them all
   138  // the time.
   139  type TokenCache interface {
   140  	// GetToken reads the token from cache.
   141  	//
   142  	// Returns (nil, nil) if requested token is not in the cache.
   143  	GetToken(key *CacheKey) (*Token, error)
   144  
   145  	// PutToken writes the token to cache.
   146  	PutToken(key *CacheKey, tok *Token) error
   147  
   148  	// DeleteToken removes the token from cache.
   149  	DeleteToken(key *CacheKey) error
   150  }
   151  
   152  // CacheKey identifies a slot in the token cache to store the token in.
   153  type CacheKey struct {
   154  	// Key identifies an auth method being used to get the token and its
   155  	// parameters.
   156  	//
   157  	// Its exact form is not important, since it is used only for string matching
   158  	// when searching for a token inside the cache.
   159  	//
   160  	// The following forms are being used currently:
   161  	//  * user/<client_id> when using UserCredentialsMethod with some ClientID.
   162  	//  * service_account/<email>/<key_id> when using ServiceAccountMethod.
   163  	//  * gce/<account> when using GCEMetadataMethod.
   164  	//  * iam/<account> when using IAM actor mode.
   165  	//  * luci_ts/<account>/<host>/<realm> when using Token Server actor mode.
   166  	//  * luci_ctx/<digest> when using LUCIContextMethod.
   167  	Key string `json:"key"`
   168  
   169  	// Scopes is the list of requested OAuth scopes or an ID token audience.
   170  	//
   171  	// The token audience is indicated by a fake scope that looks like
   172  	// "audience:<value>". Cache keys are used only for map indexing, their exact
   173  	// content doesn't matter. Adding a separate field (like `Audience`) to the
   174  	// key causes complication with older binaries that read the token cache and
   175  	// don't know about the new field, so we abuse `Scopes` field instead.
   176  	Scopes []string `json:"scopes,omitempty"`
   177  }
   178  
   179  var bufPool = sync.Pool{}
   180  
   181  // ToMapKey returns a string that can be used as map[string] key.
   182  //
   183  // This string IS NOT PRINTABLE. It's a merely a string-looking []byte.
   184  func (k *CacheKey) ToMapKey() string {
   185  	b, _ := bufPool.Get().(*bytes.Buffer)
   186  	if b == nil {
   187  		b = &bytes.Buffer{}
   188  	} else {
   189  		b.Reset()
   190  	}
   191  	defer bufPool.Put(b)
   192  	b.WriteString(k.Key)
   193  	b.WriteByte(0)
   194  	for _, s := range k.Scopes {
   195  		b.WriteString(s)
   196  		b.WriteByte(0)
   197  	}
   198  	return b.String()
   199  }
   200  
   201  // EqualCacheKeys returns true if keys are equal.
   202  func EqualCacheKeys(a, b *CacheKey) bool {
   203  	return reflect.DeepEqual(a, b)
   204  }
   205  
   206  // TokenExpiresIn returns True if the token is not valid or expires within given
   207  // duration.
   208  //
   209  // The function returns True in any of the following conditions:
   210  //   - The token is not valid.
   211  //   - The token expires before now+lifetime.
   212  //
   213  // In all other cases it returns False.
   214  func TokenExpiresIn(ctx context.Context, t *Token, lifetime time.Duration) bool {
   215  	if t == nil || t.AccessToken == "" {
   216  		return true
   217  	}
   218  	if t.Expiry.IsZero() {
   219  		return false
   220  	}
   221  	return t.Expiry.Round(0).Before(clock.Now(ctx).Add(lifetime))
   222  }
   223  
   224  // TokenExpiresInRnd is like TokenExpiresIn, except it slightly randomizes the
   225  // token expiration time.
   226  //
   227  // If the function returns False, the token expires past now+lifetime. In other
   228  // words, it is totally safe to use the token until now+lifetime. The inverse of
   229  // this statement is not correct though: if the function returns True, it
   230  // doesn't necessarily imply the token will expire before now+lifetime.
   231  //
   232  // The function returns True in any of the following conditions:
   233  //   - The token is not valid.
   234  //   - The token expires before now+lifetime.
   235  //   - The token expiration time is between (now+lifetime, now+lifetime+rnd),
   236  //     where rnd is a uniformly distributed random number between 0 and
   237  //     expiryRandInterval sec (which is set to 30 sec).
   238  //
   239  // This is useful for processes that use multiple service account keys at
   240  // around the same time. Without randomization, access tokens for such keys
   241  // expire at the same time (strictly 1h after process startup, where 1h is
   242  // the default token lifetime). This causes unnecessary contention on the token
   243  // cache file.
   244  func TokenExpiresInRnd(ctx context.Context, t *Token, lifetime time.Duration) bool {
   245  	if t == nil || t.AccessToken == "" {
   246  		return true
   247  	}
   248  	if t.Expiry.IsZero() {
   249  		return false
   250  	}
   251  	expiry := t.Expiry.Round(0) // force to use wall clock time
   252  	deadline := clock.Now(ctx).Add(lifetime)
   253  	if expiry.Before(deadline) {
   254  		// Definitely expires within 'lifetime'.
   255  		return true
   256  	}
   257  	if expiry.After(deadline.Add(expiryRandInterval)) {
   258  		// Definitely expires much later than 'lifetime', no need to involve RNG.
   259  		return false
   260  	}
   261  	// Semi-randomly declare it as expired.
   262  	rnd := time.Duration(mathrand.Int63n(ctx, int64(expiryRandInterval)))
   263  	return expiry.Before(deadline.Add(rnd))
   264  }
   265  
   266  // EqualTokens returns true if tokens are equal.
   267  //
   268  // 'nil' token corresponds to an empty access token.
   269  func EqualTokens(a, b *Token) bool {
   270  	if a == b {
   271  		return true
   272  	}
   273  	if a == nil {
   274  		a = &Token{}
   275  	}
   276  	if b == nil {
   277  		b = &Token{}
   278  	}
   279  	return a.AccessToken == b.AccessToken &&
   280  		a.Expiry.Equal(b.Expiry) &&
   281  		a.IDToken == b.IDToken &&
   282  		a.Email == b.Email
   283  }
   284  
   285  // isBadTokenError sniffs out HTTP 400/401 from token source errors.
   286  func isBadTokenError(err error) bool {
   287  	if rerr, _ := err.(*oauth2.RetrieveError); rerr != nil {
   288  		return rerr.Response.StatusCode == 400 || rerr.Response.StatusCode == 401
   289  	}
   290  	return false
   291  }
   292  
   293  // isBadKeyError sniffs out errors related to malformed private keys.
   294  func isBadKeyError(err error) bool {
   295  	if err == nil {
   296  		return false
   297  	}
   298  	// See https://go.googlesource.com/oauth2.git/+/197281d4/internal/oauth2.go#32
   299  	// Unfortunately, if uses fmt.Errorf.
   300  	s := err.Error()
   301  	return strings.Contains(s, "private key should be a PEM") ||
   302  		s == "private key is invalid"
   303  }
   304  
   305  // grabToken uses token source to create a new *oauth2.Token.
   306  //
   307  // It recognizes transient errors.
   308  func grabToken(src oauth2.TokenSource) (*oauth2.Token, error) {
   309  	switch tok, err := src.Token(); {
   310  	case isBadTokenError(err):
   311  		return nil, err
   312  	case isBadKeyError(err):
   313  		return nil, err
   314  	case err != nil:
   315  		// More often than not errors here are transient (network connectivity
   316  		// errors, HTTP 500 responses, etc). Retrying a fatal error a bunch of times
   317  		// is not very bad, so pick safer approach and assume any error is
   318  		// transient. Revoked refresh token or bad credentials (most common source
   319  		// of fatal errors) is already handled above.
   320  		return nil, transient.Tag.Apply(err)
   321  	default:
   322  		return tok, nil
   323  	}
   324  }