blob: c2d4a36f8794c69bcb985f33f0ffb8dff73fbdab [file] [log] [blame]
// 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 (
"encoding/gob"
"fmt"
"net/http"
"sort"
"strings"
"time"
"golang.org/x/net/context"
"golang.org/x/oauth2"
"google.golang.org/api/googleapi"
"github.com/luci/luci-go/common/clock"
"github.com/luci/luci-go/common/gcloud/googleoauth"
"github.com/luci/luci-go/common/gcloud/iam"
"github.com/luci/luci-go/common/logging"
"github.com/luci/luci-go/common/retry/transient"
)
// MintAccessTokenParams is passed to MintAccessTokenForServiceAccount.
type MintAccessTokenParams struct {
// ServiceAccount is an email of a service account to mint a token for.
ServiceAccount string
// Scopes is a list of OAuth scopes the token should have.
Scopes []string
// MinTTL defines an acceptable token lifetime.
//
// The returned token will be valid for at least MinTTL, but no longer than
// one hour.
//
// Default is 2 min.
MinTTL time.Duration
}
// actorTokenCache is used to store access tokens of a service accounts the
// current service has "iam.serviceAccountActor" role in.
//
// The underlying token type is cachedOAuth2Token.
var actorTokenCache = tokenCache{
Kind: "as_actor_tokens",
Version: 1,
ExpRandPercent: 10,
MinAcceptedLifetime: 5 * time.Minute,
}
// cachedOAuth2Token is gob-serializable representation of the oauth2.Token.
//
// It explicitly contains only stuff we want to be in the cache. Storing
// oauth2.Token directly is dangerous because we don't control what oauth2 lib
// has in the Token struct (it may be non-serializable).
type cachedOAuth2Token struct {
AccessToken string
TokenType string
Expiry time.Time
}
func makeCachedOAuth2Token(tok *oauth2.Token) cachedOAuth2Token {
return cachedOAuth2Token{
AccessToken: tok.AccessToken,
TokenType: tok.TokenType,
Expiry: tok.Expiry,
}
}
func (c *cachedOAuth2Token) toToken() *oauth2.Token {
return &oauth2.Token{
AccessToken: c.AccessToken,
TokenType: c.TokenType,
Expiry: c.Expiry,
}
}
func init() {
gob.Register(cachedOAuth2Token{})
}
// MintAccessTokenForServiceAccount produces an access token for some service
// account that the current service has "iam.serviceAccountActor" role in.
//
// Used to implement AsActor authorization kind, but can also be used directly,
// if needed. The token is cached internally. Same token may be returned by
// multiple calls, if its lifetime allows.
//
// Recognizes transient errors and marks them, but does not automatically
// retry. Has internal timeout of 10 sec.
func MintAccessTokenForServiceAccount(ctx context.Context, params MintAccessTokenParams) (*oauth2.Token, error) {
report := durationReporter(ctx, mintAccessTokenDuration)
cfg := getConfig(ctx)
if cfg == nil || cfg.AccessTokenProvider == nil || cfg.Cache == nil {
report(ErrNotConfigured, "ERROR_NOT_CONFIGURED")
return nil, ErrNotConfigured
}
if params.ServiceAccount == "" || len(params.Scopes) == 0 {
err := fmt.Errorf("invalid parameters")
report(err, "ERROR_BAD_ARGUMENTS")
return nil, err
}
if params.MinTTL == 0 {
params.MinTTL = 2 * time.Minute
}
sortedScopes := append([]string(nil), params.Scopes...)
sort.Strings(sortedScopes)
// Construct the cache key. Note that it is hashed by 'actorTokenCache' and
// thus can be as long as necessary. Double check there's no malicious input.
parts := append([]string{params.ServiceAccount}, sortedScopes...)
for _, p := range parts {
if strings.ContainsRune(p, '\n') {
err := fmt.Errorf("forbidding character in a service account or scope name: %q", p)
report(err, "ERROR_BAD_ARGUMENTS")
return nil, err
}
}
ctx = logging.SetFields(ctx, logging.Fields{
"token": "actor",
"account": params.ServiceAccount,
"scopes": strings.Join(sortedScopes, " "),
})
cached, err, label := fetchOrMintToken(ctx, &fetchOrMintTokenOp{
Config: cfg,
Cache: &actorTokenCache,
CacheKey: strings.Join(parts, "\n"),
MinTTL: params.MinTTL,
// Mint is called on cache miss, under the lock.
Mint: func(ctx context.Context) (*cachedToken, error, string) {
// Need an authenticating transport to talk to IAM.
asSelf, err := GetRPCTransport(ctx, AsSelf, WithScopes(iam.OAuthScope))
if err != nil {
return nil, err, "ERROR_NO_TRANSPORT"
}
// This will do two HTTP calls: one to 'signBytes' IAM API, another to the
// token exchange endpoint.
tok, err := googleoauth.GetAccessToken(ctx, googleoauth.JwtFlowParams{
ServiceAccount: params.ServiceAccount,
Signer: &iam.Client{
Client: &http.Client{Transport: asSelf},
},
Scopes: sortedScopes,
Client: &http.Client{Transport: cfg.AnonymousTransport(ctx)},
})
// Both iam.Signer and googleoauth.GetAccessToken return googleapi.Error
// on HTTP-level responses. Recognize fatal HTTP errors. Everything else
// (stuff like connection timeouts, deadlines, etc) are transient errors.
if err != nil {
if apiErr, ok := err.(*googleapi.Error); ok && apiErr.Code < 500 {
return nil, err, fmt.Sprintf("ERROR_MINTING_HTTP_%d", apiErr.Code)
}
return nil, transient.Tag.Apply(err), "ERROR_TRANSIENT_IN_MINTING"
}
// Log details about the new token.
now := clock.Now(ctx).UTC()
logging.Fields{
"fingerprint": tokenFingerprint(tok.AccessToken),
"validity": tok.Expiry.Sub(now),
}.Debugf(ctx, "Minted new actor OAuth token")
return &cachedToken{
Token: makeCachedOAuth2Token(tok),
Created: now,
Expiry: tok.Expiry,
}, nil, "SUCCESS_CACHE_MISS"
},
})
if err != nil {
report(err, label)
return nil, err
}
t := cached.Token.(cachedOAuth2Token) // let it panic on type mismatch
report(nil, label)
return t.toToken(), nil
}