blob: 2a03c1e00bd104d899ad2158d1c5b96e1a0b2700 [file] [log] [blame]
// Copyright 2019 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"
"fmt"
"net/http"
"strings"
"time"
"go.opentelemetry.io/otel/attribute"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"go.chromium.org/luci/common/clock"
"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/grpc/prpc"
"go.chromium.org/luci/tokenserver/api/minter/v1"
"go.chromium.org/luci/server/auth/internal/tracing"
)
const (
// MaxScopedTokenTTL is maximum allowed token lifetime that can be
// requested via MintScopedToken.
MaxScopedTokenTTL = 15 * time.Minute
)
// scopedTokenMinterClient is subset of minter.TokenMinterClient we use.
type scopedTokenMinterClient interface {
MintProjectToken(context.Context, *minter.MintProjectTokenRequest, ...grpc.CallOption) (*minter.MintProjectTokenResponse, error)
}
// ProjectTokenParams defines the parameters to create project scoped service account OAuth2 tokens.
type ProjectTokenParams struct {
// LuciProject is the name of the LUCI project for which a token will be obtained.
LuciProject string
// OAuthScopes resemble the requested OAuth scopes for which the token is valid.
OAuthScopes []string
// MinTTL defines a minimally acceptable token lifetime.
//
// The returned token will be valid for at least MinTTL, but no longer than
// MaxScopedTokenTTL (which is 15min).
//
// Default is 2 min.
MinTTL time.Duration
// rpcClient is token server RPC client to use.
//
// Mocked in tests.
rpcClient scopedTokenMinterClient
}
// scopedTokenCache is used to store project scoped tokens in the cache.
//
// The token is stored in OAuth2Token field.
var scopedTokenCache = newTokenCache(tokenCacheConfig{
Kind: "scoped",
Version: 2,
ProcessCacheCapacity: 8192,
ExpiryRandomizationThreshold: MaxScopedTokenTTL / 10, // 10%
})
// MintProjectToken returns a LUCI project-scoped OAuth2 token that can be used
// to access external resources on behalf of the project.
//
// It protects against accidental cross-project resource access. A token
// is targeted to some single specific LUCI project. The token is cached
// internally. Same token may be returned by multiple calls, if its lifetime
// allows.
func MintProjectToken(ctx context.Context, p ProjectTokenParams) (_ *Token, err error) {
ctx, span := tracing.Start(ctx, "go.chromium.org/luci/server/auth.MintProjectToken",
attribute.String("cr.dev.project", p.LuciProject),
)
defer func() { tracing.End(span, err) }()
report := durationReporter(ctx, mintProjectTokenDuration)
// Validate TTL is sane.
if p.MinTTL == 0 {
p.MinTTL = 2 * time.Minute
}
if p.MinTTL < 30*time.Second || p.MinTTL > MaxScopedTokenTTL {
report(ErrBadTokenTTL, "ERROR_BAD_TTL")
return nil, ErrBadTokenTTL
}
// Config contains the cache implementation.
cfg := getConfig(ctx)
if cfg == nil {
report(ErrNotConfigured, "ERROR_NOT_CONFIGURED")
return nil, ErrNotConfigured
}
// The state carries ID of the current user and URL of the token service.
state := GetState(ctx)
if state == nil {
report(ErrNotConfigured, "ERROR_NO_AUTH_STATE")
return nil, ErrNotConfigured
}
// Grab hostname of the token service we received from the auth service.
tokenServiceURL, err := state.DB().GetTokenServiceURL(ctx)
switch {
case err != nil:
report(err, "ERROR_AUTH_DB")
return nil, err
case tokenServiceURL == "":
report(ErrTokenServiceNotConfigured, "ERROR_NO_TOKEN_SERVICE")
return nil, ErrTokenServiceNotConfigured
case !strings.HasPrefix(tokenServiceURL, "https://"):
// Note: this never actually happens.
logging.Errorf(ctx, "Bad token service URL: %s", tokenServiceURL)
report(ErrTokenServiceNotConfigured, "ERROR_NOT_HTTPS_TOKEN_SERVICE")
return nil, ErrTokenServiceNotConfigured
}
tokenServiceHost := tokenServiceURL[len("https://"):]
ctx = logging.SetFields(ctx, logging.Fields{
"token": "scoped",
"project": p.LuciProject,
})
cacheKey := fmt.Sprintf("%s\n%s\n",
p.LuciProject, strings.Join(p.OAuthScopes, "\n"))
cached, err, label := scopedTokenCache.fetchOrMintToken(ctx, &fetchOrMintTokenOp{
CacheKey: cacheKey,
MinTTL: p.MinTTL,
MintTimeout: cfg.adjustedTimeout(10 * time.Second),
// Mint is called on cache miss, under the lock.
Mint: func(ctx context.Context) (t *cachedToken, err error, label string) {
// Grab a token server client (or its mock).
rpcClient := p.rpcClient
if rpcClient == nil {
transport, err := GetRPCTransport(ctx, AsSelf)
if err != nil {
return nil, err, "ERROR_NO_TRANSPORT"
}
rpcClient = minter.NewTokenMinterClient(&prpc.Client{
C: &http.Client{Transport: transport},
Host: tokenServiceHost,
Options: &prpc.Options{
Retry: func() retry.Iterator {
return &retry.ExponentialBackoff{
Limited: retry.Limited{
Delay: 50 * time.Millisecond,
Retries: 5,
},
}
},
},
})
}
// The actual RPC call.
resp, err := rpcClient.MintProjectToken(ctx, &minter.MintProjectTokenRequest{
LuciProject: p.LuciProject,
OauthScope: p.OAuthScopes,
MinValidityDuration: int64(MaxScopedTokenTTL.Seconds()),
})
// TODO(fmatenaar): This is valid during scoped-account migration and
// should be removed eventually after migration is finished for all
// projects.
//
// Cache the "NotFound" response and indicate it in the cached token.
now := clock.Now(ctx).UTC()
if err != nil && status.Code(err) == codes.NotFound {
logging.Warningf(ctx, "Received NOT_FOUND from token-server, caching")
exp := now.Add(5 * time.Minute).UTC()
return &cachedToken{
Created: now,
Expiry: exp,
ProjectScopeFallback: true,
OAuth2Token: "",
}, nil, "FALLBACK_PROJECT_NOT_FOUND"
}
if err != nil {
err = grpcutil.WrapIfTransient(err)
if transient.Tag.In(err) {
return nil, err, "ERROR_TRANSIENT_IN_MINTING"
}
return nil, err, "ERROR_MINTING"
}
// Sanity checks. A correctly working token server should not trigger
// them.
good := false
switch {
case resp.AccessToken == "":
logging.Errorf(ctx, "No access token in the response")
case resp.ServiceAccountEmail == "":
logging.Errorf(ctx, "No service account email in the response")
case resp.Expiry == nil:
logging.Errorf(ctx, "No expiration in the response")
default:
good = true
}
if !good {
return nil, ErrBrokenTokenService, "ERROR_BROKEN_TOKEN_SERVICE"
}
exp := time.Unix(resp.Expiry.Seconds, 0).UTC()
// Log details about the new token.
logging.Fields{
"service_account": resp.ServiceAccountEmail,
"expiry": exp.Sub(now),
"fingerprint": tokenFingerprint(resp.AccessToken),
}.Debugf(ctx, "Minted new project scoped service account token")
return &cachedToken{
Created: now,
Expiry: exp,
OAuth2Token: resp.AccessToken,
}, nil, "SUCCESS_CACHE_MISS"
},
})
report(err, label)
if err != nil {
return nil, err
}
// TODO(fmatenaar): Remove this when scoped service accounts have been
// migrated.
if cached.OAuth2Token == "" {
return nil, nil
}
return &Token{
Token: cached.OAuth2Token,
Expiry: cached.Expiry,
}, nil
}