go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/auth/openid/gcevm.go (about)

     1  // Copyright 2022 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package openid
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"strings"
    21  
    22  	"go.chromium.org/luci/auth/identity"
    23  	"go.chromium.org/luci/common/errors"
    24  	"go.chromium.org/luci/common/logging"
    25  
    26  	"go.chromium.org/luci/server/auth"
    27  	"go.chromium.org/luci/server/auth/signing"
    28  )
    29  
    30  // GoogleComputeAuthMethod implements auth.Method by checking a header which is
    31  // expected to have an OpenID Connect ID token generated via GCE VM identity
    32  // metadata endpoint.
    33  //
    34  // Such tokens identify a particular VM via `google.compute_engine` claim. ID
    35  // tokens without that claim (even if they pass the signature checks) are
    36  // rejected.
    37  //
    38  // The authenticated identity has form "bot:<instance-name>@gce.<project>", but
    39  // instead of parsing it, better to use GetGoogleComputeTokenInfo to get the
    40  // information extracted from the token in a structured form.
    41  type GoogleComputeAuthMethod struct {
    42  	// Header is a HTTP header to read the token from. Required.
    43  	Header string
    44  	// AudienceCheck is a callback to use to check tokens audience. Required.
    45  	AudienceCheck func(ctx context.Context, r auth.RequestMetadata, aud string) (valid bool, err error)
    46  
    47  	// certs are used in tests in place of Google certificates.
    48  	certs *signing.PublicCertificates
    49  }
    50  
    51  // Make sure all extra interfaces are implemented.
    52  var _ interface {
    53  	auth.Method
    54  	auth.Warmable
    55  } = (*GoogleComputeAuthMethod)(nil)
    56  
    57  // GoogleComputeTokenInfo contains information extracted from the GCM VM token.
    58  type GoogleComputeTokenInfo struct {
    59  	// Audience is the audience in the token as it was checked by AudienceCheck.
    60  	Audience string
    61  	// ServiceAccount is the service account email the GCE VM runs under.
    62  	ServiceAccount string
    63  	// Instance is a GCE VM instance name asserted in the token.
    64  	Instance string
    65  	// Zone is the GCE zone with the VM.
    66  	Zone string
    67  	// Project is a GCP project name the VM belong to.
    68  	Project string
    69  }
    70  
    71  // GetGoogleComputeTokenInfo returns GCE VM info as asserted by the VM token.
    72  //
    73  // Works only from within a request handler and only if the call was
    74  // authenticated via a GCE VM token. In all other cases (anonymous calls, calls
    75  // authenticated via some other mechanism, etc.) returns nil.
    76  func GetGoogleComputeTokenInfo(ctx context.Context) *GoogleComputeTokenInfo {
    77  	info, _ := auth.CurrentUser(ctx).Extra.(*GoogleComputeTokenInfo)
    78  	return info
    79  }
    80  
    81  // Authenticate extracts user information from the incoming request.
    82  //
    83  // It returns:
    84  //   - (*User, nil, nil) on success.
    85  //   - (nil, nil, nil) if the method is not applicable.
    86  //   - (nil, nil, error) if the method is applicable, but credentials are bad.
    87  func (m *GoogleComputeAuthMethod) Authenticate(ctx context.Context, r auth.RequestMetadata) (*auth.User, auth.Session, error) {
    88  	token := strings.TrimSpace(strings.TrimPrefix(r.Header(m.Header), "Bearer "))
    89  	if token == "" {
    90  		return nil, nil, nil // skip this auth method
    91  	}
    92  
    93  	// Grab root Google OAuth2 keys to verify JWT signature. They are most likely
    94  	// already cached in the process memory.
    95  	certs := m.certs
    96  	if certs == nil {
    97  		var err error
    98  		if certs, err = signing.FetchGoogleOAuth2Certificates(ctx); err != nil {
    99  			return nil, nil, err
   100  		}
   101  	}
   102  
   103  	// Verify and deserialize the token. GCE VM tokens are always issued by
   104  	// accounts.google.com.
   105  	verifiedToken, err := VerifyIDToken(ctx, token, certs, "https://accounts.google.com")
   106  	if err != nil {
   107  		return nil, nil, err
   108  	}
   109  
   110  	// Tokens can either be in "full" or "standard" format. We want "full", since
   111  	// "standard" doesn't have details about the VM.
   112  	if verifiedToken.Google.ComputeEngine.ProjectID == "" {
   113  		return nil, nil, errors.Reason("no google.compute_engine in the GCE VM token, use 'full' format").Err()
   114  	}
   115  
   116  	// Convert "<realm>:<project>" to "<project>.<realm>" for "bot:..." string.
   117  	domain := verifiedToken.Google.ComputeEngine.ProjectID
   118  	if chunks := strings.SplitN(domain, ":", 2); len(chunks) == 2 {
   119  		domain = fmt.Sprintf("%s.%s", chunks[1], chunks[0])
   120  	}
   121  
   122  	// Generate some "bot" identity just to have something representative in the
   123  	// context for e.g. logs. This also verifies there are no funky characters
   124  	// in the instance and project names. Full information about the token will be
   125  	// exposed via GoogleComputeTokenInfo in auth.User.Extra.
   126  	ident, err := identity.MakeIdentity(fmt.Sprintf("bot:%s@gce.%s",
   127  		verifiedToken.Google.ComputeEngine.InstanceName, domain))
   128  	if err != nil {
   129  		return nil, nil, err
   130  	}
   131  
   132  	// Check the audience in the token.
   133  	if m.AudienceCheck == nil {
   134  		return nil, nil, errors.Reason("GoogleComputeAuthMethod has no AudienceCheck").Err()
   135  	}
   136  	switch valid, err := m.AudienceCheck(ctx, r, verifiedToken.Aud); {
   137  	case err != nil:
   138  		return nil, nil, err
   139  	case !valid:
   140  		logging.Errorf(ctx, "openid: GCE VM token from %s has unrecognized audience %q", ident, verifiedToken.Aud)
   141  		return nil, nil, auth.ErrBadAudience
   142  	}
   143  
   144  	// Success.
   145  	return &auth.User{
   146  		Identity: ident,
   147  		Extra: &GoogleComputeTokenInfo{
   148  			Audience:       verifiedToken.Aud,
   149  			ServiceAccount: verifiedToken.Email,
   150  			Instance:       verifiedToken.Google.ComputeEngine.InstanceName,
   151  			Zone:           verifiedToken.Google.ComputeEngine.Zone,
   152  			Project:        verifiedToken.Google.ComputeEngine.ProjectID,
   153  		},
   154  	}, nil, nil
   155  }
   156  
   157  // Warmup prepares local caches. It's optional.
   158  //
   159  // Implements auth.Warmable.
   160  func (m *GoogleComputeAuthMethod) Warmup(ctx context.Context) error {
   161  	_, err := signing.FetchGoogleOAuth2Certificates(ctx)
   162  	return err
   163  }