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

     1  // Copyright 2020 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  	"strings"
    20  
    21  	"go.chromium.org/luci/auth/jwt"
    22  	"go.chromium.org/luci/common/errors"
    23  	"go.chromium.org/luci/common/logging"
    24  
    25  	"go.chromium.org/luci/server/auth"
    26  	"go.chromium.org/luci/server/auth/internal"
    27  )
    28  
    29  // GoogleIDTokenAuthMethod implements auth.Method by checking `Authorization`
    30  // header which is expected to have an OpenID Connect ID token signed by Google.
    31  //
    32  // The header value should have form "Bearer <base64 JWT>".
    33  //
    34  // There are two variants of tokens signed by Google:
    35  //   - ID tokens identifying end users. They always have an OAuth2 Client ID as
    36  //     an audience (`aud` field). Their `aud` is placed into User.ClientID, so
    37  //     it is later checked against an allowlist of client IDs by the LUCI auth
    38  //     stack.
    39  //   - ID tokens identifying service accounts. They generally can have anything
    40  //     at all as an audience, but usually have an URL of the service being
    41  //     called. Their `aud` is first checked against Audience list and
    42  //     AudienceCheck callback below. If after these check the audience is still
    43  //     not recognized, but it looks like a Google OAuth2 Client ID, it is placed
    44  //     into User.ClientID, to be subjected to the regular check against an
    45  //     allowlist of OAuth2 Client IDs.
    46  type GoogleIDTokenAuthMethod struct {
    47  	// Audience is a list of allowed audiences for tokens that identify Google
    48  	// service accounts ("*.gserviceaccount.com" emails).
    49  	Audience []string
    50  
    51  	// AudienceCheck is an optional callback to use to check tokens audience in
    52  	// case enumerating all expected audiences is not viable.
    53  	//
    54  	// Works in conjunction with Audience. Also, just like Audience, this check is
    55  	// used only for tokens that identify service accounts.
    56  	AudienceCheck func(ctx context.Context, r auth.RequestMetadata, aud string) (valid bool, err error)
    57  
    58  	// SkipNonJWT indicates to ignore tokens that don't look like JWTs.
    59  	//
    60  	// This is useful when chaining together multiple auth methods that all search
    61  	// for tokens in the `Authorization` header.
    62  	//
    63  	// If the `Authorization` header contains a malformed JWT and SkipNonJWT is
    64  	// false, Authenticate would return an error, which eventually would result in
    65  	// Unauthenticated response code (e.g. HTTP 401). But If SkipNonJWT is true,
    66  	// Authenticate would return (nil, nil, nil) instead, which (per auth.Method
    67  	// API) instructs the auth stack to try the next registered authentication
    68  	// method (or treat the request as anonymous if there are no more methods to
    69  	// try).
    70  	SkipNonJWT bool
    71  
    72  	// discoveryURL is used in tests to override GoogleDiscoveryURL.
    73  	discoveryURL string
    74  }
    75  
    76  // Make sure all extra interfaces are implemented.
    77  var _ interface {
    78  	auth.Method
    79  	auth.Warmable
    80  } = (*GoogleIDTokenAuthMethod)(nil)
    81  
    82  // AudienceMatchesHost can be used as a AudienceCheck callback.
    83  //
    84  // It verifies token's audience matches "Host" request header. Suitable for
    85  // environments where "Host" header can be trusted.
    86  func AudienceMatchesHost(ctx context.Context, r auth.RequestMetadata, aud string) (valid bool, err error) {
    87  	if host := r.Host(); host != "" {
    88  		return aud == "https://"+host || strings.HasPrefix(aud, "https://"+host+"/"), nil
    89  	}
    90  	return false, nil
    91  }
    92  
    93  // Authenticate extracts user information from the incoming request.
    94  //
    95  // It returns:
    96  //   - (*User, nil, nil) on success.
    97  //   - (nil, nil, nil) if the method is not applicable.
    98  //   - (nil, nil, error) if the method is applicable, but credentials are bad.
    99  func (m *GoogleIDTokenAuthMethod) Authenticate(ctx context.Context, r auth.RequestMetadata) (*auth.User, auth.Session, error) {
   100  	typ, token := internal.SplitAuthHeader(r.Header("Authorization"))
   101  	if typ != "bearer" {
   102  		return nil, nil, nil // this auth method is not applicable
   103  	}
   104  
   105  	// Grab (usually already cached) discovery document.
   106  	doc, err := m.discoveryDoc(ctx)
   107  	if err != nil {
   108  		return nil, nil, errors.Annotate(err, "openid: failed to fetch the OpenID discovery doc").Err()
   109  	}
   110  
   111  	// Validate token's signature and expiration. Extract user info from it.
   112  	tok, user, err := UserFromIDToken(ctx, token, doc)
   113  	if err != nil {
   114  		if m.SkipNonJWT && jwt.NotJWT.In(err) {
   115  			return nil, nil, nil
   116  		}
   117  		return nil, nil, err
   118  	}
   119  
   120  	// For tokens identifying end users, populate user.ClientID to let the LUCI
   121  	// auth stack check it against an allowlist of OAuth2 Client IDs in the
   122  	// AuthDB. Tokens identifying end users always have OAuth2 Client ID as an
   123  	// audience.
   124  	if !strings.HasSuffix(user.Email, ".gserviceaccount.com") {
   125  		user.ClientID = tok.Aud
   126  		return user, nil, nil
   127  	}
   128  
   129  	// For service accounts we want to check `aud` right here first, since it is
   130  	// generally not an OAuth2 Client ID and can be anything at all.
   131  	for _, aud := range m.Audience {
   132  		if tok.Aud == aud {
   133  			return user, nil, nil
   134  		}
   135  	}
   136  	if m.AudienceCheck != nil {
   137  		switch valid, err := m.AudienceCheck(ctx, r, tok.Aud); {
   138  		case err != nil:
   139  			return nil, nil, err
   140  		case valid:
   141  			return user, nil, nil
   142  		}
   143  	}
   144  
   145  	// If unrecognized `aud` looks like Google OAuth2 Client ID, put it into the
   146  	// returned `user`. This will trigger a check against an allowlist of OAuth2
   147  	// client IDs.
   148  	if strings.HasSuffix(tok.Aud, ".apps.googleusercontent.com") {
   149  		user.ClientID = tok.Aud
   150  		return user, nil, nil
   151  	}
   152  
   153  	logging.Errorf(ctx, "openid: token from %s has unrecognized audience %q", user.Email, tok.Aud)
   154  	return nil, nil, auth.ErrBadAudience
   155  }
   156  
   157  // Warmup prepares local caches. It's optional.
   158  //
   159  // Implements auth.Warmable.
   160  func (m *GoogleIDTokenAuthMethod) Warmup(ctx context.Context) error {
   161  	_, err := m.discoveryDoc(ctx)
   162  	return err
   163  }
   164  
   165  // discoveryDoc fetches (and caches) the discovery document.
   166  func (m *GoogleIDTokenAuthMethod) discoveryDoc(ctx context.Context) (*DiscoveryDoc, error) {
   167  	url := GoogleDiscoveryURL
   168  	if m.discoveryURL != "" {
   169  		url = m.discoveryURL
   170  	}
   171  	return FetchDiscoveryDoc(ctx, url)
   172  }