blob: 4380257b726f90e1ec4faaf8a16d310acb831b6d [file] [log] [blame]
// Copyright 2020 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 openid
import (
"context"
"strings"
"go.chromium.org/luci/auth/jwt"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/server/auth"
"go.chromium.org/luci/server/auth/internal"
)
// GoogleIDTokenAuthMethod implements auth.Method by checking `Authorization`
// header which is expected to have an OpenID Connect ID token signed by Google.
//
// The header value should have form "Bearer <base64 JWT>".
//
// There are two variants of tokens signed by Google:
// - ID tokens identifying end users. They always have an OAuth2 Client ID as
// an audience (`aud` field). Their `aud` is placed into User.ClientID, so
// it is later checked against an allowlist of client IDs by the LUCI auth
// stack.
// - ID tokens identifying service accounts. They generally can have anything
// at all as an audience, but usually have an URL of the service being
// called. Their `aud` is first checked against Audience list and
// AudienceCheck callback below. If after these check the audience is still
// not recognized, but it looks like a Google OAuth2 Client ID, it is placed
// into User.ClientID, to be subjected to the regular check against an
// allowlist of OAuth2 Client IDs.
type GoogleIDTokenAuthMethod struct {
// Audience is a list of allowed audiences for tokens that identify Google
// service accounts ("*.gserviceaccount.com" emails).
Audience []string
// AudienceCheck is an optional callback to use to check tokens audience in
// case enumerating all expected audiences is not viable.
//
// Works in conjunction with Audience. Also, just like Audience, this check is
// used only for tokens that identify service accounts.
AudienceCheck func(ctx context.Context, r auth.RequestMetadata, aud string) (valid bool, err error)
// SkipNonJWT indicates to ignore tokens that don't look like JWTs.
//
// This is useful when chaining together multiple auth methods that all search
// for tokens in the `Authorization` header.
//
// If the `Authorization` header contains a malformed JWT and SkipNonJWT is
// false, Authenticate would return an error, which eventually would result in
// Unauthenticated response code (e.g. HTTP 401). But If SkipNonJWT is true,
// Authenticate would return (nil, nil, nil) instead, which (per auth.Method
// API) instructs the auth stack to try the next registered authentication
// method (or treat the request as anonymous if there are no more methods to
// try).
SkipNonJWT bool
// discoveryURL is used in tests to override GoogleDiscoveryURL.
discoveryURL string
}
// Make sure all extra interfaces are implemented.
var _ interface {
auth.Method
auth.Warmable
} = (*GoogleIDTokenAuthMethod)(nil)
// AudienceMatchesHost can be used as a AudienceCheck callback.
//
// It verifies token's audience matches "Host" request header. Suitable for
// environments where "Host" header can be trusted.
func AudienceMatchesHost(ctx context.Context, r auth.RequestMetadata, aud string) (valid bool, err error) {
if host := r.Host(); host != "" {
return aud == "https://"+host || strings.HasPrefix(aud, "https://"+host+"/"), nil
}
return false, nil
}
// Authenticate extracts user information from the incoming request.
//
// It returns:
// - (*User, nil, nil) on success.
// - (nil, nil, nil) if the method is not applicable.
// - (nil, nil, error) if the method is applicable, but credentials are bad.
func (m *GoogleIDTokenAuthMethod) Authenticate(ctx context.Context, r auth.RequestMetadata) (*auth.User, auth.Session, error) {
typ, token := internal.SplitAuthHeader(r.Header("Authorization"))
if typ != "bearer" {
return nil, nil, nil // this auth method is not applicable
}
// Grab (usually already cached) discovery document.
doc, err := m.discoveryDoc(ctx)
if err != nil {
return nil, nil, errors.Annotate(err, "openid: failed to fetch the OpenID discovery doc").Err()
}
// Validate token's signature and expiration. Extract user info from it.
tok, user, err := UserFromIDToken(ctx, token, doc)
if err != nil {
if m.SkipNonJWT && jwt.NotJWT.In(err) {
return nil, nil, nil
}
return nil, nil, err
}
// For tokens identifying end users, populate user.ClientID to let the LUCI
// auth stack check it against an allowlist of OAuth2 Client IDs in the
// AuthDB. Tokens identifying end users always have OAuth2 Client ID as an
// audience.
if !strings.HasSuffix(user.Email, ".gserviceaccount.com") {
user.ClientID = tok.Aud
return user, nil, nil
}
// For service accounts we want to check `aud` right here first, since it is
// generally not an OAuth2 Client ID and can be anything at all.
for _, aud := range m.Audience {
if tok.Aud == aud {
return user, nil, nil
}
}
if m.AudienceCheck != nil {
switch valid, err := m.AudienceCheck(ctx, r, tok.Aud); {
case err != nil:
return nil, nil, err
case valid:
return user, nil, nil
}
}
// If unrecognized `aud` looks like Google OAuth2 Client ID, put it into the
// returned `user`. This will trigger a check against an allowlist of OAuth2
// client IDs.
if strings.HasSuffix(tok.Aud, ".apps.googleusercontent.com") {
user.ClientID = tok.Aud
return user, nil, nil
}
logging.Errorf(ctx, "openid: token from %s has unrecognized audience %q", user.Email, tok.Aud)
return nil, nil, auth.ErrBadAudience
}
// Warmup prepares local caches. It's optional.
//
// Implements auth.Warmable.
func (m *GoogleIDTokenAuthMethod) Warmup(ctx context.Context) error {
_, err := m.discoveryDoc(ctx)
return err
}
// discoveryDoc fetches (and caches) the discovery document.
func (m *GoogleIDTokenAuthMethod) discoveryDoc(ctx context.Context) (*DiscoveryDoc, error) {
url := GoogleDiscoveryURL
if m.discoveryURL != "" {
url = m.discoveryURL
}
return FetchDiscoveryDoc(ctx, url)
}