blob: ec4966bc99e51899b2b7972005f44832660d0da5 [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"
"net/http"
"strings"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/server/auth"
)
// googleDiscoveryURL is URL of Google OpenID Connect discovery document.
const googleDiscoveryURL = "https://accounts.google.com/.well-known/openid-configuration"
// 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 a whitelist 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 checked against Audience list below.
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 *http.Request, aud string) (valid bool, err error)
// 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 *http.Request, aud string) (valid bool, err error) {
valid = aud == "https://"+r.Host || strings.HasPrefix(aud, "https://"+r.Host+"/")
return
}
// Authenticate extracts user information from the incoming request.
//
// It returns:
// * (*User, nil) on success.
// * (nil, nil) if the method is not applicable.
// * (nil, error) if the method is applicable, but credentials are invalid.
func (m *GoogleIDTokenAuthMethod) Authenticate(ctx context.Context, r *http.Request) (*auth.User, error) {
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
return nil, nil // this auth method is not applicable
}
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
// Grab (usually already cached) discovery document.
doc, err := m.discoveryDoc(ctx)
if err != nil {
return 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 {
return nil, err
}
// For tokens identifying end users, populate user.ClientID to let the LUCI
// auth stack check it against a whitelist in the AuthDB (same way it does for
// OAuth2 access tokens).
if !strings.HasSuffix(user.Email, ".gserviceaccount.com") {
user.ClientID = tok.Aud
return user, nil
}
// For service accounts we want to check `aud` right here, since it is
// generally not an OAuth Client ID and can be anything at all.
for _, aud := range m.Audience {
if tok.Aud == aud {
return user, nil
}
}
if m.AudienceCheck != nil {
switch valid, err := m.AudienceCheck(ctx, r, tok.Aud); {
case err != nil:
return nil, err
case valid:
return user, nil
}
}
logging.Errorf(ctx, "openid: token from %s has unrecognized audience %q", user.Email, tok.Aud)
return 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)
}