blob: 689c11df2c7811e2750a0908a52be2c7a1fcf2df [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"
"sort"
"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
// AsSessionUser is used for outbound RPCs that inherit the authority of
// an end-user by using credentials stored in the current auth session.
//
// Works only if the method used to authenticate the incoming request supports
// this mechanism. Currently this is only go.chromium.org/luci/server/encryptedcookies.
//
// Unlike deprecated AsUser, which uses LUCI delegation tokens, AsSessionUser
// authenticates outbound RPCs using standard OAuth2 or ID tokens, making this
// mechanism more widely applicable.
//
// On a flip side, the implementation relies on OpenID Connect refresh tokens,
// which limits it only to real human accounts that can click buttons in the
// browser to go through the OpenID Connect sign in flow to get a refresh
// token and establish a session (i.e. service accounts are not supported).
// Thus this mechanism is primarily useful when implementing Web UIs that use
// session cookies for authentication and want to call other services on
// user's behalf from the backend side.
//
// By default RPCs performed with AsSessionUser use email-scoped OAuth2 access
// tokens with the client ID matching the current service OAuth2 client ID.
// There's no way to ask for more scopes (using WithScopes option would result
// in an error).
//
// If WithIDToken option is specified, RPCs use ID tokens with the audience
// matching the current service OAuth2 client ID. There's no way to customize
// the audience.
AsSessionUser
// 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) }
// WithIDToken indicates to use ID tokens instead of OAuth2 tokens.
//
// If no audience is given via WithIDTokenAudience, uses "https://${host}"
// by default.
func WithIDToken() RPCOption {
return rpcOption(func(opts *rpcOptions) {
opts.idToken = true
})
}
// WithIDTokenAudience indicates to use ID tokens with a specific audience
// instead of OAuth2 tokens.
//
// Implies WithIDToken.
//
// 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.idToken = true
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)
isRootStateBackground := isBackgroundState(rootState)
return auth.NewModifyingTransport(baseTransport, func(req *http.Request) error {
// Use the request context as the base to inherit its fields and deadlines,
// but substitute the auth state there with the state from the root context,
// if necessary. This allows to create an AsSelf RPC transport during
// the server startup and then share it from RPCs that have some non-trivial
// auth state.
reqCtx := req.Context()
if reqCtx == context.Background() {
reqCtx = ctx
} else {
reqState := GetState(reqCtx)
isReqStateBackground := isBackgroundState(reqState)
switch {
case reqState == rootState:
// Good, the exact same state (background or not), no need to create
// a new context.
case isRootStateBackground && isReqStateBackground:
// Good, both are background states and therefore equivalent, no need
// to create a new context.
case isRootStateBackground && !isReqStateBackground:
// Transports created from the background state can be used from any
// other state. Inherit `reqCtx` deadlines, but inject the background
// state there.
reqCtx = WithState(reqCtx, rootState)
case !isRootStateBackground && isReqStateBackground:
// A transport created from a user-authenticated state is attempted to
// be used from a background state, this smells like a bug.
panic("an RPC transport created from a user context is used from a background server context, this is not allowed")
case !isRootStateBackground && !isReqStateBackground:
// A transport created from inside one request handler is attempted to
// be used from another request handler, this also smells like a bug.
panic("a non-background RPC transport is shared between different request contexts, this is not allowed")
}
}
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.
ri, _ := credentials.RequestInfoFromContext(ctx)
if err := credentials.CheckSecurityLevel(ri.AuthInfo, 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:
return nil, errors.Reason("using non-OAuth2 based credentials in TokenSource").Err()
case len(extra) != 0:
keys := make([]string, 0, len(extra))
for k := range extra {
keys = append(keys, k)
}
sort.Strings(keys)
return nil, errors.Reason("extra headers %q with credentials are not supported in TokenSource", keys).Err()
}
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
idToken bool // for AsSelf, AsProject, AsActor and AsSessionUser
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.idToken && len(options.scopes) == 0 {
options.scopes = defaultOAuthScopes
}
// Set the default audience.
if options.kind != AsSessionUser && options.idToken && options.idTokenAud == "" {
options.idTokenAud = "https://${host}"
}
// Validate options.
if !asSelfOrActorOrProject && options.kind != AsSessionUser && options.idToken {
return nil, errors.Reason("WithIDToken can only be used with AsSelf, AsActor, AsProject or AsSessionUser authority kind").Err()
}
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.idToken && len(options.scopes) != 0 {
return nil, errors.Reason("WithIDToken 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.idToken && (options.kind == AsActor || options.kind == AsProject) {
return nil, errors.Reason("WithIDToken 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 AsSessionUser:
options.checkCtx = func(ctx context.Context) error {
_, err := currentSession(ctx)
return err
}
options.getRPCHeaders = asSessionUserHeaders
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) {
return forwardedCreds(ctx)
}
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
}
// 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 {
var err error
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
}
// First try the environment-specific method of getting an ID token (e.g.
// querying it from the GCE metadata server). It may not be available (e.g.
// on GAE v1). We'll fall back to a more expensive generic method below.
if cfg.IDTokenProvider != nil {
tok, err := cfg.IDTokenProvider(ctx, aud)
return tok, nil, err
}
// 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.
// 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()
}
// 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 and any extra authentication
// headers as they were received by the service.
//
// Returns (nil, 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, map[string]string, error) {
switch s := GetState(ctx); {
case s == nil:
return nil, nil, ErrNotConfigured
case s.User().Identity == identity.AnonymousIdentity:
return nil, 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
}
// currentSession either returns the current session or ErrNotConfigured.
func currentSession(ctx context.Context) (Session, error) {
if state := GetState(ctx); state != nil {
if session := state.Session(); session != nil {
return session, nil
}
}
return nil, ErrNotConfigured
}
// asSessionUserHeaders returns a map of authentication headers to add to
// outbound RPC requests done in AsSessionUser mode.
//
// This will be called by the transport layer on each request.
func asSessionUserHeaders(ctx context.Context, opts *rpcOptions, _ *http.Request) (tok *oauth2.Token, _ map[string]string, err error) {
s, err := currentSession(ctx)
if err != nil {
return nil, nil, err
}
if opts.idToken {
tok, err = s.IDToken(ctx)
} else {
tok, err = s.AccessToken(ctx)
}
return
}
// 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
}