blob: 0fa15ea2c9926211abd6f4b1c4ecd383a3d6c665 [file] [log] [blame]
// Copyright 2016 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"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"golang.org/x/oauth2"
"google.golang.org/grpc/credentials"
"go.chromium.org/luci/auth"
"go.chromium.org/luci/auth/identity"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/common/trace"
"go.chromium.org/luci/common/tsmon/metric"
"go.chromium.org/luci/server/auth/delegation"
"go.chromium.org/luci/server/auth/internal"
)
// CloudOAuthScopes is a list of OAuth scopes recommended to use when
// authenticating to Google Cloud services.
//
// Besides the actual cloud-platform scope also includes userinfo.email scope,
// so that it is possible to examine the token email.
//
// Note that it is preferable to use the exact same list of scopes in all
// Cloud API clients. That way when the server runs locally in a development
// mode, we need to go through the login flow only once. Using different scopes
// for different clients would require to "login" for each unique set of scopes.
var CloudOAuthScopes = []string{
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
}
// RPCAuthorityKind defines under whose authority RPCs are made.
type RPCAuthorityKind int
const (
// NoAuth is used for outbound RPCs that don't have any implicit auth headers.
NoAuth RPCAuthorityKind = iota
// AsSelf is used for outbound RPCs sent with the authority of the current
// service itself.
//
// RPC requests done in this mode will have 'Authorization' header set to
// either an OAuth2 access token or an ID token, depending on a presence of
// WithIDTokenAudience option.
//
// If WithIDTokenAudience is not given, RPCs will be authenticated with
// an OAuth2 access token of the service's own service account. The set of
// OAuth scopes can be customized via WithScopes option, and by default it
// is ["https://www.googleapis.com/auth/userinfo.email"].
//
// If WithIDTokenAudience is given, RPCs will be authenticated with an ID
// token that has `aud` claim set to the supplied value. WithScopes can't be
// used in this case, providing it will cause an error.
//
// In LUCI services AsSelf should be used very sparingly, only for internal
// "maintenance" RPCs that happen outside of the context of any LUCI project.
// Using AsSelf to authorize RPCs that touch project data leads to "confused
// deputy" problems. Prefer to use AsProject when possible.
AsSelf
// AsUser is used for outbound RPCs that inherit the authority of a user
// that initiated the request that is currently being handled, regardless of
// how exactly the user was authenticated.
//
// DEPRECATED.
//
// The implementation is based on LUCI-specific protocol that uses special
// delegation tokens. Only LUCI backends can understand them.
//
// If you need to call non-LUCI services, and incoming requests are
// authenticated via OAuth access tokens, use AsCredentialsForwarder instead.
//
// If the current request was initiated by an anonymous caller, the RPC will
// have no auth headers (just like in NoAuth mode).
//
// Can also be used together with MintDelegationToken to make requests on
// user behalf asynchronously. For example, to associate end-user authority
// with some delayed task, call MintDelegationToken (in a context of a user
// initiated request) when this task is created and store the resulting token
// along with the task. Then, to make an RPC on behalf of the user from the
// task use GetRPCTransport(ctx, AsUser, WithDelegationToken(token)).
AsUser
// AsCredentialsForwarder is used for outbound RPCs that just forward the
// user credentials, exactly as they were received by the service.
//
// For authenticated calls, works only if the current request was
// authenticated via a forwardable token, e.g. an OAuth2 access token.
//
// If the current request was initiated by an anonymous caller, the RPC will
// have no auth headers (just like in NoAuth mode).
//
// An attempt to use GetRPCTransport(ctx, AsCredentialsForwarder) with
// unsupported credentials results in an error.
AsCredentialsForwarder
// AsActor is used for outbound RPCs sent with the authority of some service
// account that the current service has "iam.serviceAccountTokenCreator" role
// in.
//
// RPC requests done in this mode will have 'Authorization' header set to
// either an OAuth2 access token or an ID token of the service account
// specified by WithServiceAccount option.
//
// What kind of token is used depends on a presence of WithIDTokenAudience
// option and it follows the rules described in AsSelf comment.
//
// TODO(crbug.com/1081932): Implement WithIDTokenAudience mode.
AsActor
// AsProject is used for outbounds RPCs sent with the authority of some LUCI
// project (specified via WithProject option).
//
// When used to call external services (anything that is not a part of the
// current LUCI deployment), uses 'Authorization' header with either an OAuth2
// access token or an ID token of the project-specific service account
// (specified in the LUCI project definition in 'projects.cfg' deployment
// configuration file).
//
// What kind of token is used in this case depends on a presence of
// WithIDTokenAudience option and it follows the rules described in AsSelf
// comment.
//
// When used to call LUCI services belonging the same LUCI deployment (per
// 'internal_service_regexp' setting in 'security.cfg' deployment
// configuration file) uses the current service's OAuth2 access token plus
// 'X-Luci-Project' header with the project name. Such calls are authenticated
// by the peer as coming from 'project:<name>' identity. Options WithScopes
// and WithIDTokenAudience are ignored in this case.
//
// TODO(crbug.com/1081932): Implement WithIDTokenAudience mode.
AsProject
)
// XLUCIProjectHeader is a header with the current project for internal LUCI
// RPCs done via AsProject authority.
const XLUCIProjectHeader = "X-Luci-Project"
// RPCOption is an option for GetRPCTransport, GetPerRPCCredentials and
// GetTokenSource functions.
type RPCOption interface {
apply(opts *rpcOptions)
}
type rpcOption func(opts *rpcOptions)
func (o rpcOption) apply(opts *rpcOptions) { o(opts) }
// WithIDTokenAudience indicates to use ID tokens instead of OAuth2 tokens.
//
// The token's `aud` claim will be set to the given value. It can be customized
// per-request by using `${host}` which will be substituted with a host name of
// the request URI.
//
// Usage example:
//
// tr, err := auth.GetRPCTransport(ctx,
// auth.AsSelf,
// auth.WithIDTokenAudience("https://${host}"),
// )
// if err != nil {
// return err
// }
// client := &http.Client{Transport: tr}
// ...
//
// Not compatible with WithScopes.
func WithIDTokenAudience(aud string) RPCOption {
return rpcOption(func(opts *rpcOptions) {
opts.idTokenAud = aud
})
}
// WithScopes can be used to customize OAuth scopes for outbound RPC requests.
//
// Not compatible with WithIDTokenAudience.
func WithScopes(scopes ...string) RPCOption {
return rpcOption(func(opts *rpcOptions) {
opts.scopes = append(opts.scopes, scopes...)
})
}
// WithProject can be used to generate an OAuth token with an identity of that
// particular LUCI project.
//
// See AsProject for more info.
func WithProject(project string) RPCOption {
return rpcOption(func(opts *rpcOptions) {
opts.project = project
})
}
// WithServiceAccount option must be used with AsActor authority kind to specify
// what service account to act as.
func WithServiceAccount(email string) RPCOption {
return rpcOption(func(opts *rpcOptions) {
opts.serviceAccount = email
})
}
// WithDelegationToken can be used to attach an existing delegation token to
// requests made in AsUser mode.
//
// DEPRECATED.
//
// The token can be obtained earlier via MintDelegationToken call. The transport
// doesn't attempt to validate it and just blindly sends it to the other side.
func WithDelegationToken(token string) RPCOption {
return rpcOption(func(opts *rpcOptions) {
opts.delegationToken = token
})
}
// WithDelegationTags can be used to attach tags to the delegation token used
// internally in AsUser mode.
//
// DEPRECATED.
//
// The recipient of the RPC that uses the delegation will be able to extract
// them, if necessary. They are also logged in the token server logs.
//
// Each tag is a key:value string.
//
// Note that any delegation tags are ignored if the current request was
// initiated by an anonymous caller, since delegation protocol is not actually
// used in this case.
func WithDelegationTags(tags ...string) RPCOption {
return rpcOption(func(opts *rpcOptions) {
opts.delegationTags = tags
})
}
// WithMonitoringClient allows to override 'client' field that goes into HTTP
// client monitoring metrics (such as 'http/response_status').
//
// The default value of the field is "luci-go-server".
//
// Note that the metrics also include hostname of the target service (in 'name'
// field), so in most cases it is fine to use the default client name.
// Overriding it may be useful if you want to differentiate between requests
// made to the same host from a bunch of different places in the code.
//
// This option has absolutely no effect when passed to GetPerRPCCredentials() or
// GetTokenSource(). It applies only to GetRPCTransport().
func WithMonitoringClient(client string) RPCOption {
return rpcOption(func(opts *rpcOptions) {
opts.monitoringClient = client
})
}
// GetRPCTransport returns http.RoundTripper to use for outbound HTTP RPC
// requests.
//
// Usage:
// tr, err := auth.GetRPCTransport(c, auth.AsSelf, auth.WithScopes("..."))
// if err != nil {
// return err
// }
// client := &http.Client{Transport: tr}
// ...
func GetRPCTransport(ctx context.Context, kind RPCAuthorityKind, opts ...RPCOption) (http.RoundTripper, error) {
options, err := makeRPCOptions(kind, opts)
if err != nil {
return nil, err
}
config := getConfig(ctx)
if config == nil || config.AnonymousTransport == nil {
return nil, ErrNotConfigured
}
if options.checkCtx != nil {
if err := options.checkCtx(ctx); err != nil {
return nil, err
}
}
baseTransport := trace.InstrumentTransport(ctx,
metric.InstrumentTransport(ctx,
config.AnonymousTransport(ctx), options.monitoringClient,
),
)
if options.kind == NoAuth {
return baseTransport, nil
}
rootState := GetState(ctx)
return auth.NewModifyingTransport(baseTransport, func(req *http.Request) error {
// Prefer to use the request context to get authentication headers, if it is
// set, to inherit its deadline and cancellation. Assert the request context
// carries the same auth state (perhaps the background one) as the transport
// context, otherwise there can be very weird side effects. Constructing
// the transport with one auth state and then using it with another is not
// allowed.
reqCtx := req.Context()
if reqCtx == context.Background() {
reqCtx = ctx
} else {
switch reqState := GetState(reqCtx); {
case reqState == rootState:
// good, exact same state
case isBackgroundState(reqState) && isBackgroundState(rootState):
// good, both are background states
default:
panic(
"the transport is shared between different auth contexts, this is not allowed: " +
"requests passed to a transport created via GetRPCTransport(ctx, ...) " +
"should either not have any context at all, or have a context that carries the same " +
"authentication state as `ctx` (i.e. be derived from `ctx` itself or be derived from " +
"an appropriate parent of `ctx`, like the root context associated with the incoming request)")
}
}
tok, extra, err := options.getRPCHeaders(reqCtx, options, req)
if err != nil {
return err
}
if tok != nil {
req.Header.Set("Authorization", tok.TokenType+" "+tok.AccessToken)
}
for k, v := range extra {
req.Header.Set(k, v)
}
return nil
}), nil
}
// GetPerRPCCredentials returns gRPC's PerRPCCredentials implementation.
//
// It can be used to authenticate outbound gPRC RPC's.
func GetPerRPCCredentials(ctx context.Context, kind RPCAuthorityKind, opts ...RPCOption) (credentials.PerRPCCredentials, error) {
options, err := makeRPCOptions(kind, opts)
if err != nil {
return nil, err
}
if options.checkCtx != nil {
if err := options.checkCtx(ctx); err != nil {
return nil, err
}
}
return perRPCCreds{ctx, options}, nil
}
type perRPCCreds struct {
ctx context.Context
options *rpcOptions
}
func (creds perRPCCreds) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
// Don't transfer tokens in clear text.
if err := credentials.CheckSecurityLevel(ctx, credentials.PrivacyAndIntegrity); err != nil {
return nil, errors.Annotate(err, "can't use per RPC credentials").Err()
}
// URI is needed for some auth modes to "lock" tokens to a concrete audience.
if len(uri) == 0 {
panic("perRPCCreds: no URI given")
}
u, err := url.Parse(uri[0])
if err != nil {
return nil, errors.Annotate(err, "malformed URI %q", uri[0]).Err()
}
// Some libraries (in particular Spanner), pass very bare bones `ctx` here
// (essentially context.Background() with gRPC metadata on top). Such contexts
// are not sufficient to call getRPCHeaders, so we merge it with creds.ctx
// to get a full-featured LUCI context that at the same time has the same
// deadline and cancellation as `ctx`.
ctx = &internal.MergedContext{
Root: ctx,
Fallback: creds.ctx,
}
tok, extra, err := creds.options.getRPCHeaders(ctx, creds.options, &http.Request{URL: u})
switch {
case err != nil:
return nil, err
case tok == nil && len(extra) == 0:
return nil, nil
}
// gRPC metadata uses lower case keys by convention.
metadata := make(map[string]string, 1+len(extra))
if tok != nil {
metadata["authorization"] = tok.TokenType + " " + tok.AccessToken
}
for k, v := range extra {
metadata[strings.ToLower(k)] = v
}
return metadata, nil
}
func (creds perRPCCreds) RequireTransportSecurity() bool {
return true
}
// GetTokenSource returns an oauth2.TokenSource bound to the supplied Context.
//
// Supports only AsSelf, AsCredentialsForwarder and AsActor authority kinds,
// since they are the only ones that exclusively use only Authorization header.
//
// While GetPerRPCCredentials is preferred, this can be used by packages that
// cannot or do not properly handle this gRPC option.
func GetTokenSource(ctx context.Context, kind RPCAuthorityKind, opts ...RPCOption) (oauth2.TokenSource, error) {
if kind != AsSelf && kind != AsCredentialsForwarder && kind != AsActor {
return nil, errors.Reason("GetTokenSource can only be used with AsSelf, AsCredentialsForwarder or AsActor authority kind").Err()
}
options, err := makeRPCOptions(kind, opts)
if err != nil {
return nil, err
}
if options.checkCtx != nil {
if err := options.checkCtx(ctx); err != nil {
return nil, err
}
}
if options.idTokenAudGen != nil {
// There's no access to an URI in oauth2.TokenSource.Token() method, can't
// use patterned audiences there.
return nil, errors.Reason("WithIDTokenAudience with patterned audience is not supported by GetTokenSource, " +
"use GetRPCTransport or GetPerRPCCredentials instead").Err()
}
return &tokenSource{ctx, options}, nil
}
type tokenSource struct {
ctx context.Context
options *rpcOptions
}
func (ts *tokenSource) Token() (*oauth2.Token, error) {
tok, extra, err := ts.options.getRPCHeaders(ts.ctx, ts.options, nil)
switch {
case err != nil:
return nil, err
case tok == nil:
panic("oauth2.Token is unexpectedly nil")
case extra != nil:
keys := make([]string, 0, len(extra))
for k := range extra {
keys = append(keys, k)
}
panic(keys)
}
return tok, nil
}
////////////////////////////////////////////////////////////////////////////////
// Internal stuff.
func init() {
// This is needed to allow packages imported by 'server/auth' to make
// authenticated calls. They can't use GetRPCTransport directly, since they
// can't import 'server/auth' (it creates an import cycle).
internal.RegisterClientFactory(func(ctx context.Context, scopes []string) (*http.Client, error) {
var t http.RoundTripper
var err error
if len(scopes) == 0 {
t, err = GetRPCTransport(ctx, NoAuth)
} else {
t, err = GetRPCTransport(ctx, AsSelf, WithScopes(scopes...))
}
if err != nil {
return nil, err
}
return &http.Client{Transport: t}, nil
})
}
// tokenFingerprint returns first 16 bytes of SHA256 of the token, as hex.
//
// Token fingerprints can be used to identify tokens without parsing them.
func tokenFingerprint(tok string) string {
digest := sha256.Sum256([]byte(tok))
return hex.EncodeToString(digest[:16])
}
// rpcMocks are used exclusively in unit tests.
type rpcMocks struct {
MintDelegationToken func(context.Context, DelegationTokenParams) (*Token, error)
MintAccessTokenForServiceAccount func(context.Context, MintAccessTokenParams) (*Token, error)
MintIDTokenForServiceAccount func(context.Context, MintIDTokenParams) (*Token, error)
MintProjectToken func(context.Context, ProjectTokenParams) (*Token, error)
}
// apply implements RPCOption interface.
func (o *rpcMocks) apply(opts *rpcOptions) {
opts.rpcMocks = o
}
var defaultOAuthScopes = []string{auth.OAuthScopeEmail}
// headersGetter returns a main Authorization token and optional additional
// headers.
//
// `req` is an outbound request if known. May be nil. May not be fully
// initialized for the gRPC case.
type headersGetter func(ctx context.Context, opts *rpcOptions, req *http.Request) (*oauth2.Token, map[string]string, error)
// audGenerator takes a request and returns an audience string derived from it.
type audGenerator func(r *http.Request) (string, error)
type rpcOptions struct {
kind RPCAuthorityKind
project string // for AsProject
idTokenAud string // for AsSelf, AsProject and AsActor
idTokenAudGen audGenerator // non-nil iff idTokenAud is a pattern
scopes []string // for AsSelf, AsProject and AsActor
serviceAccount string // for AsActor
delegationToken string // for AsUser
delegationTags []string // for AsUser
monitoringClient string
checkCtx func(ctx context.Context) error // optional, may be skipped
getRPCHeaders headersGetter
rpcMocks *rpcMocks
}
// makeRPCOptions applies all options and validates them.
func makeRPCOptions(kind RPCAuthorityKind, opts []RPCOption) (*rpcOptions, error) {
options := &rpcOptions{kind: kind}
for _, o := range opts {
o.apply(options)
}
asSelfOrActorOrProject := options.kind == AsSelf ||
options.kind == AsActor ||
options.kind == AsProject
// Set default scopes.
if asSelfOrActorOrProject && options.idTokenAud == "" && len(options.scopes) == 0 {
options.scopes = defaultOAuthScopes
}
// Validate options.
if !asSelfOrActorOrProject && options.idTokenAud != "" {
return nil, errors.Reason("WithIDTokenAudience can only be used with AsSelf, AsActor or AsProject authority kind").Err()
}
if !asSelfOrActorOrProject && len(options.scopes) != 0 {
return nil, errors.Reason("WithScopes can only be used with AsSelf, AsActor or AsProject authority kind").Err()
}
if options.idTokenAud != "" && len(options.scopes) != 0 {
return nil, errors.Reason("WithIDTokenAudience and WithScopes cannot be used together").Err()
}
if options.serviceAccount != "" && options.kind != AsActor {
return nil, errors.Reason("WithServiceAccount can only be used with AsActor authority kind").Err()
}
if options.serviceAccount == "" && options.kind == AsActor {
return nil, errors.Reason("AsActor authority kind requires WithServiceAccount option").Err()
}
if options.delegationToken != "" && options.kind != AsUser {
return nil, errors.Reason("WithDelegationToken can only be used with AsUser authority kind").Err()
}
if len(options.delegationTags) != 0 && options.kind != AsUser {
return nil, errors.Reason("WithDelegationTags can only be used with AsUser authority kind").Err()
}
if len(options.delegationTags) != 0 && options.delegationToken != "" {
return nil, errors.Reason("WithDelegationTags and WithDelegationToken cannot be used together").Err()
}
if options.project == "" && options.kind == AsProject {
return nil, errors.Reason("AsProject authority kind requires WithProject option").Err()
}
// Temporarily not supported combinations of options.
//
// TODO(crbug.com/1081932): Support.
if options.idTokenAud != "" && (options.kind == AsActor || options.kind == AsProject) {
return nil, errors.Reason("WithIDTokenAudience is not supported here yet").Err()
}
// Convert `idTokenAud` into a callback {http.Request => aud}. This is needed
// to support "${host}" substitution.
if options.idTokenAud != "" {
gen, err := parseAudPattern(options.idTokenAud)
if err != nil {
return nil, errors.Annotate(err, "bad WithIDTokenAudience value").Err()
}
options.idTokenAudGen = gen // this is nil if idTokenAud is not a pattern
}
// Validate 'kind' and pick correct implementation of getRPCHeaders.
switch options.kind {
case NoAuth:
options.getRPCHeaders = noAuthHeaders
case AsSelf:
if options.idTokenAud != "" {
options.getRPCHeaders = asSelfIDTokenHeaders
} else {
options.getRPCHeaders = asSelfOAuthHeaders
}
case AsUser:
options.getRPCHeaders = asUserHeaders
case AsCredentialsForwarder:
options.checkCtx = func(ctx context.Context) error {
_, err := forwardedCreds(ctx)
return err
}
options.getRPCHeaders = func(ctx context.Context, _ *rpcOptions, _ *http.Request) (*oauth2.Token, map[string]string, error) {
tok, err := forwardedCreds(ctx)
return tok, nil, err
}
case AsActor:
options.getRPCHeaders = asActorHeaders
case AsProject:
options.getRPCHeaders = asProjectHeaders
default:
return nil, errors.Reason("unknown RPCAuthorityKind %d", options.kind).Err()
}
// Default value for "client" field in monitoring metrics.
if options.monitoringClient == "" {
options.monitoringClient = "luci-go-server"
}
return options, nil
}
// noAuthHeaders is getRPCHeaders for NoAuth mode.
func noAuthHeaders(ctx context.Context, opts *rpcOptions, req *http.Request) (*oauth2.Token, map[string]string, error) {
return nil, nil, nil
}
// asSelfOAuthHeaders returns a map of authentication headers to add to outbound
// RPC requests done in AsSelf mode when using OAuth2 access tokens.
//
// This will be called by the transport layer on each request.
func asSelfOAuthHeaders(ctx context.Context, opts *rpcOptions, req *http.Request) (*oauth2.Token, map[string]string, error) {
cfg := getConfig(ctx)
if cfg == nil || cfg.AccessTokenProvider == nil {
return nil, nil, ErrNotConfigured
}
tok, err := cfg.AccessTokenProvider(ctx, opts.scopes)
if err != nil {
return nil, nil, errors.Annotate(err, "failed to get AsSelf access token").Err()
}
return tok, nil, nil
}
// asSelfIDTokenHeaders returns a map of authentication headers to add to
// outbound RPC requests done in AsSelf mode when using ID tokens.
//
// This will be called by the transport layer on each request.
func asSelfIDTokenHeaders(ctx context.Context, opts *rpcOptions, req *http.Request) (*oauth2.Token, map[string]string, error) {
cfg := getConfig(ctx)
if cfg == nil || cfg.Signer == nil {
return nil, nil, ErrNotConfigured
}
// TODO(crbug.com/1081932): Pick environment-specific methods when available.
//
// The method below works almost everywhere, but it requires the service
// account to have iam.serviceAccountTokenCreator role on itself, which is
// a bit weird and not default. On GAE/Flex/Cloud Run/GKE we should use GCE
// metadata server instead.
// Discover our own service account name to use it as a target.
info, err := cfg.Signer.ServiceInfo(ctx)
switch {
case err != nil:
return nil, nil, errors.Annotate(err, "failed to get our own service info").Err()
case info.ServiceAccountName == "":
return nil, nil, errors.Reason("no service account name in our own service info").Err()
}
// Derive the audience string. It may have "${host}" var that is replaced
// based on the hostname in the `req`.
var aud string
if opts.idTokenAudGen != nil {
if aud, err = opts.idTokenAudGen(req); err != nil {
return nil, nil, errors.Annotate(err, "can't derive audience for ID token").Err()
}
} else {
// Using a static audience, not a pattern.
aud = opts.idTokenAud
}
// Grab ID token for our own account. This uses our own IAM-scoped access
// token internally and also implements heavy caching of the result, so its
// fine to call it often.
mintTokenCall := MintIDTokenForServiceAccount
if opts.rpcMocks != nil && opts.rpcMocks.MintIDTokenForServiceAccount != nil {
mintTokenCall = opts.rpcMocks.MintIDTokenForServiceAccount
}
tok, err := mintTokenCall(ctx, MintIDTokenParams{
ServiceAccount: info.ServiceAccountName,
Audience: aud,
MinTTL: 2 * time.Minute,
})
if err != nil {
return nil, nil, errors.Annotate(err, "failed to get our own ID token for %q with aud %q", info.ServiceAccountName, aud).Err()
}
return &oauth2.Token{
AccessToken: tok.Token,
TokenType: "Bearer",
Expiry: tok.Expiry,
}, nil, nil
}
// asUserHeaders returns a map of authentication headers to add to outbound
// RPC requests done in AsUser mode.
//
// This will be called by the transport layer on each request.
func asUserHeaders(ctx context.Context, opts *rpcOptions, req *http.Request) (*oauth2.Token, map[string]string, error) {
cfg := getConfig(ctx)
if cfg == nil || cfg.AccessTokenProvider == nil {
return nil, nil, ErrNotConfigured
}
delegationToken := ""
if opts.delegationToken != "" {
delegationToken = opts.delegationToken // WithDelegationToken was used
} else {
// Outbound RPC calls in the context of a request from anonymous caller are
// anonymous too. No need to use any authentication headers.
userIdent := CurrentIdentity(ctx)
if userIdent == identity.AnonymousIdentity {
return nil, nil, nil
}
// Only https:// are allowed, can't send bearer tokens in clear text.
if req.URL.Scheme != "https" {
return nil, nil, errors.Reason("refusing to use delegation tokens with non-https URL").Err()
}
// Grab a token that's good enough for at least 10 min. Outbound RPCs
// shouldn't last longer than that.
mintTokenCall := MintDelegationToken
if opts.rpcMocks != nil && opts.rpcMocks.MintDelegationToken != nil {
mintTokenCall = opts.rpcMocks.MintDelegationToken
}
tok, err := mintTokenCall(ctx, DelegationTokenParams{
TargetHost: req.URL.Hostname(),
Tags: opts.delegationTags,
MinTTL: 10 * time.Minute,
})
if err != nil {
return nil, nil, errors.Annotate(err, "failed to mint AsUser delegation token").Err()
}
delegationToken = tok.Token
}
// Use our own OAuth token too, since the delegation token is bound to us.
oauthTok, err := cfg.AccessTokenProvider(ctx, []string{auth.OAuthScopeEmail})
if err != nil {
return nil, nil, errors.Annotate(err, "failed to get own access token").Err()
}
logging.Fields{
"fingerprint": tokenFingerprint(delegationToken),
}.Debugf(ctx, "auth: Sending delegation token")
return oauthTok, map[string]string{delegation.HTTPHeaderName: delegationToken}, nil
}
// forwardedCreds returns the end user token, as it was received by the service.
//
// Returns (nil, nil) if the incoming call was anonymous. Returns an error if
// the incoming call was authenticated by non-forwardable credentials.
func forwardedCreds(ctx context.Context) (*oauth2.Token, error) {
switch s := GetState(ctx); {
case s == nil:
return nil, ErrNotConfigured
case s.User().Identity == identity.AnonymousIdentity:
return nil, nil // nothing to forward if the call is anonymous
default:
// Grab the end user credentials (or an error) from the auth state, as
// put there by Authenticate(...).
return s.UserCredentials()
}
}
// asActorHeaders returns a map of authentication headers to add to outbound
// RPC requests done in AsActor mode.
//
// This will be called by the transport layer on each request.
func asActorHeaders(ctx context.Context, opts *rpcOptions, req *http.Request) (*oauth2.Token, map[string]string, error) {
mintTokenCall := MintAccessTokenForServiceAccount
if opts.rpcMocks != nil && opts.rpcMocks.MintAccessTokenForServiceAccount != nil {
mintTokenCall = opts.rpcMocks.MintAccessTokenForServiceAccount
}
tok, err := mintTokenCall(ctx, MintAccessTokenParams{
ServiceAccount: opts.serviceAccount,
Scopes: opts.scopes,
MinTTL: 2 * time.Minute,
})
if err != nil {
return nil, nil, errors.Annotate(err, "failed to mint AsActor access token").Err()
}
return &oauth2.Token{
AccessToken: tok.Token,
TokenType: "Bearer",
Expiry: tok.Expiry,
}, nil, nil
}
// asProjectHeaders returns a map of authentication headers to add to outbound
// RPC requests done in AsProject mode.
//
// This will be called by the transport layer on each request.
func asProjectHeaders(ctx context.Context, opts *rpcOptions, req *http.Request) (*oauth2.Token, map[string]string, error) {
internal, err := isInternalURL(ctx, req.URL)
if err != nil {
return nil, nil, err
}
// For calls within a single LUCI deployment use the service's own OAuth2
// token and 'X-Luci-Project' header to convey the project identity to the
// peer.
if internal {
// TODO(vadimsh): Always use userinfo.email scope here, not the original
// one. The target of the call is a LUCI service, it generally doesn't care
// about non-email scopes, but *requires* userinfo.email.
tok, _, err := asSelfOAuthHeaders(ctx, opts, req)
return tok, map[string]string{XLUCIProjectHeader: opts.project}, err
}
// For calls to external (non-LUCI) services get an OAuth2 token of a project
// scoped service account.
mintTokenCall := MintProjectToken
if opts.rpcMocks != nil && opts.rpcMocks.MintProjectToken != nil {
mintTokenCall = opts.rpcMocks.MintProjectToken
}
mintParams := ProjectTokenParams{
MinTTL: 2 * time.Minute,
LuciProject: opts.project,
OAuthScopes: opts.scopes,
}
tok, err := mintTokenCall(ctx, mintParams)
if err != nil {
return nil, nil, errors.Annotate(err, "failed to mint AsProject access token").Err()
}
// TODO(fmatenaar): This is only during migration and needs to be removed
// eventually.
if tok == nil {
logging.Infof(ctx, "Project %s not found, fallback to service identity", opts.project)
return asSelfOAuthHeaders(ctx, opts, req)
}
return &oauth2.Token{
AccessToken: tok.Token,
TokenType: "Bearer",
Expiry: tok.Expiry,
}, nil, nil
}
// isInternalURL returns true if the URL points to a LUCI microservice belonging
// to the same LUCI deployment as us.
//
// Returns an error if the URL is not https:// or there were errors accessing
// the AuthDB to compare the URL against the list of LUCI services.
func isInternalURL(ctx context.Context, u *url.URL) (bool, error) {
if u.Scheme != "https" {
return false, errors.Reason("AsProject can be used only with https:// targets, got %s", u).Err()
}
state := GetState(ctx)
if state == nil {
return false, ErrNotConfigured
}
return state.DB().IsInternalService(ctx, u.Hostname())
}
var placeholderRe = regexp.MustCompile(`\${[^}]*}`)
// parseAudPattern takes a pattern like "https://${host}" and produces
// a callback that knows how to fill it in given a *http.Request.
//
// Returns (nil, nil) if `pat` is not really a pattern but just a static string.
// Returns an error if `pat` looks like a malformed or unsupported pattern.
func parseAudPattern(pat string) (audGenerator, error) {
// Recognized static string, use a cheesy check for mismatched curly braces.
if !placeholderRe.MatchString(pat) {
if strings.Contains(pat, "${") {
return nil, errors.Reason("%q looks like a malformed pattern", pat).Err()
}
return nil, nil
}
renderPat := func(req *http.Request) (out string, err error) {
out = placeholderRe.ReplaceAllStringFunc(pat, func(match string) string {
if err == nil {
switch match {
case "${host}":
// Prefer a value of `Host` header when given.
if req.Host != "" {
return req.Host
}
return req.URL.Host
default:
err = errors.Reason("unknown var %s", match).Err()
}
}
return ""
})
return
}
// Verify all referenced vars are known by interpreting a phony request. That
// way a set of supported vars is neatly referenced only in `renderPat`.
_, err := renderPat(&http.Request{
URL: &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/example",
},
})
if err != nil {
return nil, errors.Annotate(err, "bad pattern %q", pat).Err()
}
return renderPat, nil
}