blob: 94289b94c61096a44b4538e446f7824d9058aba5 [file] [log] [blame]
// Copyright 2015 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 internal contains code used internally by auth/integration.
package internal
import (
"bytes"
"context"
"reflect"
"strings"
"sync"
"time"
"golang.org/x/oauth2"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/data/rand/mathrand"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/retry/transient"
)
// expiryRandInterval is used by TokenExpiresInRnd.
const expiryRandInterval = 30 * time.Second
const (
// NoEmail indicates an OAuth2 token is not associated with an email.
//
// See Token below. We need this special value to distinguish "an email can
// not possibly be fetched ever" from "the cached token doesn't have an email
// yet" cases.
NoEmail = "-"
// UnknownEmail indicates an OAuth2 token may potentially be associated with
// an email, but we haven't tried to fetch the email yet.
UnknownEmail = ""
// NoIDToken indicates it was impossible to obtain an ID token, e.g. no
// "openid" scope in the refresh token or the provider doesn't support ID
// tokens at all.
NoIDToken = "-"
// NoAccessToken indicates the access token was not returned by the provider.
//
// This can happen with providers that support only ID tokens.
NoAccessToken = "-"
)
var (
// ErrInsufficientAccess is returned by MintToken() if token can't be minted
// for given OAuth scopes. For example, if GCE instance wasn't granted access
// to requested scopes when it was created.
ErrInsufficientAccess = errors.New("can't get access token for the given account and scopes")
// ErrBadRefreshToken is returned by RefreshToken if refresh token was revoked
// or otherwise invalid. It means MintToken must be used to get a new refresh
// token.
ErrBadRefreshToken = errors.New("refresh_token is not valid")
// ErrBadCredentials is returned by MintToken or RefreshToken if provided
// offline credentials (like service account key) are invalid.
ErrBadCredentials = errors.New("invalid or unavailable service account credentials")
)
// Token is an oauth2.Token with an email and ID token that correspond to it.
//
// Email may be an empty string, in which case we assume the email hasn't been
// fetched yet. It can also be a special NoEmail string, which means the token
// is not associated with an email (happens for tokens without 'userinfo.email'
// scope).
type Token struct {
oauth2.Token
IDToken string // an ID token derived directly from the access token or NoIDToken
Email string // an email or NoEmail or empty string (aka UnknownEmail)
}
// TokenProvider knows how to mint new tokens or refresh existing ones.
type TokenProvider interface {
// RequiresInteraction is true if provider may start user interaction
// in MintToken.
RequiresInteraction() bool
// Lightweight is true if MintToken is very cheap to call.
//
// In this case the token is not being cached on disk (only in memory), since
// it's easy to get a new one each time the process starts.
//
// By avoiding the disk cache, we reduce the chance of a leak.
Lightweight() bool
// Email is email associated with tokens produced by the provider, if known.
//
// May return UnknownEmail, which means the provider doesn't know the email
// in advance and RefreshToken must be used to get the token and the email.
// This happens, for example, for interactive providers before user has
// logged in.
//
// It can also be NoEmail which means the email is not available, even if
// caller is using RefreshToken.
Email() string
// CacheKey identifies a slot in the token cache to store the token in.
//
// Note: CacheKey MAY change during lifetime of a TokenProvider. It happens,
// for example, for ServiceAccount token provider if the underlying service
// account key is replaced while the process is still running.
CacheKey(ctx context.Context) (*CacheKey, error)
// MintToken launches authentication flow (possibly interactive) and returns
// a new refreshable token (or error). It must never return (nil, nil).
//
// In actor mode 'base' is an IAM-scoped sufficiently fresh oauth token. It's
// nil otherwise. Used by IAM-based token provider.
MintToken(ctx context.Context, base *Token) (*Token, error)
// RefreshToken takes existing token (probably expired, but not necessarily)
// and returns a new refreshed token. It should never do any user interaction.
// If a user interaction is required, a error should be returned instead.
//
// In actor mode 'base' is an IAM-scoped sufficiently fresh oauth token. It's
// nil otherwise. Used by IAM-based token provider.
RefreshToken(ctx context.Context, prev, base *Token) (*Token, error)
}
// TokenCache stores access and refresh tokens to avoid requesting them all
// the time.
type TokenCache interface {
// GetToken reads the token from cache.
//
// Returns (nil, nil) if requested token is not in the cache.
GetToken(key *CacheKey) (*Token, error)
// PutToken writes the token to cache.
PutToken(key *CacheKey, tok *Token) error
// DeleteToken removes the token from cache.
DeleteToken(key *CacheKey) error
}
// CacheKey identifies a slot in the token cache to store the token in.
type CacheKey struct {
// Key identifies an auth method being used to get the token and its
// parameters.
//
// Its exact form is not important, since it is used only for string matching
// when searching for a token inside the cache.
//
// The following forms are being used currently:
// * user/<client_id> when using UserCredentialsMethod with some ClientID.
// * service_account/<email>/<key_id> when using ServiceAccountMethod.
// * gce/<account> when using GCEMetadataMethod.
// * iam/<account> when using IAM actor mode.
// * luci_ts/<account>/<host>/<realm> when using Token Server actor mode.
// * luci_ctx/<digest> when using LUCIContextMethod.
Key string `json:"key"`
// Scopes is the list of requested OAuth scopes or an ID token audience.
//
// The token audience is indicated by a fake scope that looks like
// "audience:<value>". Cache keys are used only for map indexing, their exact
// content doesn't matter. Adding a separate field (like `Audience`) to the
// key causes complication with older binaries that read the token cache and
// don't know about the new field, so we abuse `Scopes` field instead.
Scopes []string `json:"scopes,omitempty"`
}
var bufPool = sync.Pool{}
// ToMapKey returns a string that can be used as map[string] key.
//
// This string IS NOT PRINTABLE. It's a merely a string-looking []byte.
func (k *CacheKey) ToMapKey() string {
b, _ := bufPool.Get().(*bytes.Buffer)
if b == nil {
b = &bytes.Buffer{}
} else {
b.Reset()
}
defer bufPool.Put(b)
b.WriteString(k.Key)
b.WriteByte(0)
for _, s := range k.Scopes {
b.WriteString(s)
b.WriteByte(0)
}
return b.String()
}
// Function equalCacheKeys returns true if keys are equal.
func equalCacheKeys(a, b *CacheKey) bool {
return reflect.DeepEqual(a, b)
}
// TokenExpiresIn returns True if the token is not valid or expires within given
// duration.
//
// The function returns True in any of the following conditions:
// - The token is not valid.
// - The token expires before now+lifetime.
//
// In all other cases it returns False.
func TokenExpiresIn(ctx context.Context, t *Token, lifetime time.Duration) bool {
if t == nil || t.AccessToken == "" {
return true
}
if t.Expiry.IsZero() {
return false
}
return t.Expiry.Round(0).Before(clock.Now(ctx).Add(lifetime))
}
// TokenExpiresInRnd is like TokenExpiresIn, except it slightly randomizes the
// token expiration time.
//
// If the function returns False, the token expires past now+lifetime. In other
// words, it is totally safe to use the token until now+lifetime. The inverse of
// this statement is not correct though: if the function returns True, it
// doesn't necessarily imply the token will expire before now+lifetime.
//
// The function returns True in any of the following conditions:
// - The token is not valid.
// - The token expires before now+lifetime.
// - The token expiration time is between (now+lifetime, now+lifetime+rnd),
// where rnd is a uniformly distributed random number between 0 and
// expiryRandInterval sec (which is set to 30 sec).
//
// This is useful for processes that use multiple service account keys at
// around the same time. Without randomization, access tokens for such keys
// expire at the same time (strictly 1h after process startup, where 1h is
// the default token lifetime). This causes unnecessary contention on the token
// cache file.
func TokenExpiresInRnd(ctx context.Context, t *Token, lifetime time.Duration) bool {
if t == nil || t.AccessToken == "" {
return true
}
if t.Expiry.IsZero() {
return false
}
expiry := t.Expiry.Round(0) // force to use wall clock time
deadline := clock.Now(ctx).Add(lifetime)
if expiry.Before(deadline) {
// Definitely expires within 'lifetime'.
return true
}
if expiry.After(deadline.Add(expiryRandInterval)) {
// Definitely expires much later than 'lifetime', no need to involve RNG.
return false
}
// Semi-randomly declare it as expired.
rnd := time.Duration(mathrand.Int63n(ctx, int64(expiryRandInterval)))
return expiry.Before(deadline.Add(rnd))
}
// EqualTokens returns true if tokens are equal.
//
// 'nil' token corresponds to an empty access token.
func EqualTokens(a, b *Token) bool {
if a == b {
return true
}
if a == nil {
a = &Token{}
}
if b == nil {
b = &Token{}
}
return a.AccessToken == b.AccessToken &&
a.Expiry.Equal(b.Expiry) &&
a.IDToken == b.IDToken &&
a.Email == b.Email
}
// isBadTokenError sniffs out HTTP 400/401 from token source errors.
func isBadTokenError(err error) bool {
if rerr, _ := err.(*oauth2.RetrieveError); rerr != nil {
return rerr.Response.StatusCode == 400 || rerr.Response.StatusCode == 401
}
return false
}
// isBadKeyError sniffs out errors related to malformed private keys.
func isBadKeyError(err error) bool {
if err == nil {
return false
}
// See https://go.googlesource.com/oauth2.git/+/197281d4/internal/oauth2.go#32
// Unfortunately, if uses fmt.Errorf.
s := err.Error()
return strings.Contains(s, "private key should be a PEM") ||
s == "private key is invalid"
}
// grabToken uses token source to create a new *oauth2.Token.
//
// It recognizes transient errors.
func grabToken(src oauth2.TokenSource) (*oauth2.Token, error) {
switch tok, err := src.Token(); {
case isBadTokenError(err):
return nil, err
case isBadKeyError(err):
return nil, err
case err != nil:
// More often than not errors here are transient (network connectivity
// errors, HTTP 500 responses, etc). Retrying a fatal error a bunch of times
// is not very bad, so pick safer approach and assume any error is
// transient. Revoked refresh token or bad credentials (most common source
// of fatal errors) is already handled above.
return nil, transient.Tag.Apply(err)
default:
return tok, nil
}
}