lib/auth/auth.go (368 lines of code) (raw):
// Copyright 2019 Google LLC
//
// 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 contains authorization check wrapper for handlers.
// Example:
// h, err := auth.WithAuth(handler, checker, Requirement{ClientID: true, ClientSecret: true, Role: Admin}
// if err != nil { ... }
// r.HandleFunc("/path", h)
package auth
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"cloud.google.com/go/logging" /* copybara-comment */
"github.com/gorilla/mux" /* copybara-comment */
"google.golang.org/grpc/codes" /* copybara-comment */
"google.golang.org/grpc/status" /* copybara-comment */
"github.com/coreos/go-oidc" /* copybara-comment */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/auditlog" /* copybara-comment: auditlog */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/cache" /* copybara-comment: cache */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/errutil" /* copybara-comment: errutil */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/ga4gh" /* copybara-comment: ga4gh */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/httputils" /* copybara-comment: httputils */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/oathclients" /* copybara-comment: oathclients */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/permissions" /* copybara-comment: permissions */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/strutil" /* copybara-comment: strutil */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/verifier" /* copybara-comment: verifier */
glog "github.com/golang/glog" /* copybara-comment */
)
const (
// maxHTTPBody = 2M
maxHTTPBody = 2 * 1000 * 1000
// UserAuthorizationHeader is the standard user authorization request header as a bearer token.
UserAuthorizationHeader = "Authorization"
// LinkAuthorizationHeader is an additional auth token in the request header for linking accounts.
LinkAuthorizationHeader = "X-Link-Authorization"
)
var (
// HTTPClient used for external calls.
HTTPClient *http.Client = nil
// cacheMaxExpiry, use cacheMaxExpiry if token does not have expiry info or expiry longer than cacheMaxExpiry.
cacheMaxExpiry = int64(10 * time.Minute.Seconds())
)
// Role requirement of access.
type Role string
const (
// None -> no bearer token required
None Role = ""
// User -> requires any valid bearer token, need to match {user} in path
User Role = "user"
// Admin -> requires bearer token with admin permission
Admin Role = "admin"
)
// Require defines the Authorization Requirement.
type Require struct {
Nothing bool
ClientID bool
ClientSecret bool
// Roles current supports "user" and "admin". Check will check the role inside the bearer token.
// not requirement bearer token if "Role" is empty.
Role Role
EditScopes []string
// client id of self
SelfClientID string
// allow using issuer as aud or azp
AllowIssuerInAudOrAzp bool
// allow azp
AllowAzp bool
}
var (
// RequireNone -> requires nothing for authorization
RequireNone = Require{Nothing: true}
// RequireClientID -> only require client id
RequireClientID = Require{ClientID: true, ClientSecret: false, Role: None}
// RequireClientIDAndSecret -> require client id and matched secret
RequireClientIDAndSecret = Require{ClientID: true, ClientSecret: true, Role: None}
// RequireAdminTokenClientCredential -> require an admin token, also the client id and secret
RequireAdminTokenClientCredential = Require{ClientID: true, ClientSecret: true, Role: Admin, AllowIssuerInAudOrAzp: true, AllowAzp: true}
// RequireUserTokenClientCredential -> require an user token, also the client id and secret
RequireUserTokenClientCredential = Require{ClientID: true, ClientSecret: true, Role: User, AllowIssuerInAudOrAzp: true, AllowAzp: true}
// RequireAccountAdminUserTokenCredential -> require a user token, client id & secret, and non-admins require "account_admin" scope for edits methods
RequireAccountAdminUserTokenCredential = Require{ClientID: true, ClientSecret: true, Role: User, EditScopes: []string{"account_admin"}, AllowIssuerInAudOrAzp: true, AllowAzp: true}
)
// Checker stores information and functions for authorization check.
type Checker struct {
// Audit log logger.
logger *logging.Client
// Accepted oidc issuer url.
issuer string
// permissions contains methor to check if user admin permission.
permissions *permissions.Permissions
// fetchClientSecrets fetchs client id and client secret.
fetchClientSecrets func() (map[string]string, error)
// transformIdentity transform as needed, will run just after token convert to identity.
// eg. hydra stores custom claims in "ext" fields for access token. need to move to top
// level field.
transformIdentity func(*ga4gh.Identity) *ga4gh.Identity
// init the verifier.AccessTokenVerifier
mutex sync.Mutex
// access token verifier
verifier verifier.AccessTokenVerifier
// use userinfo instead of the token itself to verify access token.
useUserinfoVerifyToken bool
// use cache to cache auth result for opaque token, eg. token verifies via userinfo
// Need to set useUserinfoVerifyToken true to enable cache.
cache func() cache.Client
}
func (s *Checker) getVerifier(ctx context.Context) (verifier.AccessTokenVerifier, error) {
// TODO: We use lazy load for verifier creation since now Hydra
// and IC/DAM are deployed in a same container. Hydra is not available before
// IC/DAM startup completed. We should separate Hydra and IC/DAM to 2
// containers after that we should fetch oidc public key when service
// starts and exit with error.
if s.verifier != nil {
return s.verifier, nil
}
s.mutex.Lock()
defer s.mutex.Unlock()
// s.verifier maybe available after the waiting.
if s.verifier != nil {
return s.verifier, nil
}
var err error
s.verifier, err = verifier.NewAccessTokenVerifier(ctx, s.issuer, s.useUserinfoVerifyToken)
return s.verifier, err
}
// NewChecker creates checker for authorization check.
// ctx: used to creates oidc token verifier, may store httpclient for mock.
// logger: audit log logger.
// issuer: accepted oidc issuer url.
// permissions: contains method to check if user admin permission.
// fetchClientSecrets: fetches client id and client secret.
// transformIdentity: transform as needed, will run just after token convert to identity.
func NewChecker(logger *logging.Client, issuer string, permissions *permissions.Permissions, fetchClientSecrets func() (map[string]string, error), transformIdentity func(*ga4gh.Identity) *ga4gh.Identity, useUserinfoVerifyToken bool, cache func() cache.Client) *Checker {
return &Checker{
logger: logger,
issuer: issuer,
permissions: permissions,
fetchClientSecrets: fetchClientSecrets,
transformIdentity: transformIdentity,
useUserinfoVerifyToken: useUserinfoVerifyToken,
cache: cache,
}
}
// Context (i.e. auth.Context) is authorization information that is stored within the request context.
type Context struct {
ID *ga4gh.Identity
LinkedID *ga4gh.Identity
ClientID string
ClientSecret string
IsAdmin bool
}
type authContextType struct{}
var authContextKey = &authContextType{}
// MustWithAuth wraps the handler func with authorization check includes client credentials, bearer token validation and role in token.
// function will cause fatal if passed in invalid requirement. This is cleaner when calling in main.
func MustWithAuth(handler func(http.ResponseWriter, *http.Request), checker *Checker, require Require) func(http.ResponseWriter, *http.Request) {
h, err := WithAuth(handler, checker, require)
if err != nil {
glog.Fatalf("WithAuth(): %v", err)
}
return h
}
// WithAuth wraps the handler func with authorization check includes client credentials, bearer token validation and role in token.
// function will return error if passed in invalid requirement.
func WithAuth(handler func(http.ResponseWriter, *http.Request), checker *Checker, require Require) (func(http.ResponseWriter, *http.Request), error) {
switch require.Role {
case None, User, Admin:
default:
return nil, status.Errorf(codes.Internal, "undefined role: %s", require.Role)
}
return func(w http.ResponseWriter, r *http.Request) {
if HTTPClient != nil {
r = r.WithContext(oidc.ClientContext(r.Context(), HTTPClient))
}
var linkedID *ga4gh.Identity
log, id, isAdmin, err := checker.check(r, require)
if err == nil && len(r.Header.Get(LinkAuthorizationHeader)) > 0 {
linkedID, err = checker.verifiedBearerToken(r, LinkAuthorizationHeader, oathclients.ExtractClientID(r), require.AllowIssuerInAudOrAzp, require.AllowAzp)
if err == nil && !strutil.ContainsWord(linkedID.Scope, "link") {
err = errutil.WithErrorReason(errScopeMissing, status.Errorf(codes.Unauthenticated, "linked auth bearer token missing required 'link' scope"))
}
}
if err != nil {
log.ErrorType = errutil.ErrorReason(err)
}
writeRequestLog(checker.logger, log, err, r)
if err != nil {
httputils.WriteError(w, err)
return
}
a := &Context{
ID: id,
LinkedID: linkedID,
ClientID: oathclients.ExtractClientID(r),
ClientSecret: oathclients.ExtractClientSecret(r),
IsAdmin: isAdmin,
}
r = r.WithContext(context.WithValue(r.Context(), authContextKey, a))
handler(w, r)
}, nil
}
// FromContext (i.e. auth.FromContext) returns auth information from the request context.
// Example within a request handler: a, err := auth.FromContext(r.Context())
func FromContext(ctx context.Context) (*Context, error) {
v := ctx.Value(authContextKey)
if v == nil {
return nil, status.Errorf(codes.PermissionDenied, "unauthorized: identity not provided")
}
if a, ok := v.(*Context); ok {
return a, nil
}
return nil, status.Errorf(codes.PermissionDenied, "unauthorized: invalid identity format")
}
// checkRequest need to validate the request before actually read data from it.
func checkRequest(r *http.Request) error {
// TODO: maybe should also cover content-length = -1
if r.ContentLength > maxHTTPBody {
return errutil.WithErrorReason(errBodyTooLarge, status.Error(codes.FailedPrecondition, "body too large"))
}
return nil
}
// Check checks request meet all authorization requirements for this framework.
func (s *Checker) check(r *http.Request, require Require) (*auditlog.RequestLog, *ga4gh.Identity, bool, error) {
log := &auditlog.RequestLog{}
if err := checkRequest(r); err != nil {
return log, nil, false, err
}
r.ParseForm()
if require.Nothing {
return log, nil, false, nil
}
cID := oathclients.ExtractClientID(r)
if require.ClientID {
cSec := oathclients.ExtractClientSecret(r)
if err := s.verifyClientCredentials(cID, cSec, require); err != nil {
return log, nil, false, err
}
} else {
cID = require.SelfClientID
}
if len(cID) == 0 {
return log, nil, false, errutil.WithErrorReason(errClientIDMissing, status.Error(codes.Internal, "endpoint requirement does not setup correct: does not have a client id to verify the token"))
}
id, isAdmin, err := s.verifyAccessToken(r, cID, require)
log.TokenID = tokenID(id)
log.TokenSubject = id.Subject
log.TokenIssuer = id.Issuer
if err != nil {
return log, id, isAdmin, err
}
// EditScopes are required for some operations, unless the user is an administrator.
if len(require.EditScopes) > 0 && isEditMethod(r.Method) && !isAdmin {
for _, scope := range require.EditScopes {
if !strutil.ContainsWord(id.Scope, scope) {
return log, id, isAdmin, errutil.WithErrorReason(errScopeMissing, status.Errorf(codes.Unauthenticated, "scope %q required for this method (%q)", scope, id.Scope))
}
}
}
return log, id, isAdmin, err
}
// verifyClientCredentials based on the provided requirement, the function
// checks if the client is known and the provided secret matches the secret
// for that client.
func (s *Checker) verifyClientCredentials(client, secret string, require Require) error {
if s.fetchClientSecrets == nil {
return errutil.WithErrorReason(errFetchClientSecretsMissing, status.Error(codes.Internal, "endpoint setup is incorrect: no fetchClientSecrets function but require client id"))
}
secrets, err := s.fetchClientSecrets()
if err != nil {
return errutil.WithErrorReason(errClientUnavailable, err)
}
// Check that the client ID exists and it is a known.
if len(client) == 0 {
return errutil.WithErrorReason(errClientMissing, status.Error(codes.Unauthenticated, "requires a valid client ID"))
}
want, ok := secrets[client]
if !ok {
return errutil.WithErrorReason(errClientInvalid, status.Errorf(codes.Unauthenticated, "client ID %q is unrecognized", client))
}
if !require.ClientSecret {
return nil
}
// Check that the client secret match the client ID.
if want != secret {
return errutil.WithErrorReason(errSecretMismatch, status.Error(codes.Unauthenticated, "requires a valid client secret"))
}
return nil
}
// verifyAccessToken verify the access token meet the given requirement.
// The returned identity will not be nil even in error cases.
func (s *Checker) verifyAccessToken(r *http.Request, clientID string, require Require) (*ga4gh.Identity, bool, error) {
if require.Role == None {
return &ga4gh.Identity{}, false, nil
}
id, err := s.verifiedBearerToken(r, UserAuthorizationHeader, clientID, require.AllowIssuerInAudOrAzp, require.AllowAzp)
if err != nil {
return &ga4gh.Identity{}, false, err
}
isAdmin := false
if s.permissions != nil {
isAdmin, err = s.permissions.CheckAdmin(id)
if err != nil {
return id, false, errutil.WithErrorReason(errCheckAdminFailed, status.Errorf(codes.Unavailable, "loadPermissions failed: %v", err))
}
}
switch require.Role {
case Admin:
if !isAdmin {
// TODO: token maybe leaked at this point, consider auto revoke or contact user/admin.
return id, isAdmin, errutil.WithErrorReason(errNotAdmin, status.Errorf(codes.Unauthenticated, "requires admin permission %v", err))
}
return id, isAdmin, nil
case User:
if isAdmin {
// Token is for an administrator, who is able to act on behalf of any user, so short-circuit remaining checks.
return id, isAdmin, nil
}
if user := mux.Vars(r)["user"]; len(user) != 0 && user != id.Subject {
// TODO: token maybe leaked at this point, consider auto revoke or contact user/admin.
return id, isAdmin, errutil.WithErrorReason(errUserMismatch, status.Errorf(codes.Unauthenticated, "user in path does not match token"))
}
return id, isAdmin, nil
default:
return id, isAdmin, errutil.WithErrorReason(errUnknownRole, status.Errorf(codes.Unauthenticated, "unknown role %q", require.Role))
}
}
// verifiedBearerToken extracts the bearer token from the request and verifies it.
// Returns the identity for the token, token information, and error type, and error.
func (s *Checker) verifiedBearerToken(r *http.Request, authHeader, clientID string, allowIssuerInAudAndAzp, allowAzp bool) (*ga4gh.Identity, error) {
parts := strings.SplitN(r.Header.Get(authHeader), " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
return nil, errutil.WithErrorReason(errIDVerifyFailed, status.Errorf(codes.Unauthenticated, "invalid brearer token"))
}
tok := parts[1]
v, err := s.getVerifier(r.Context())
if err != nil {
return nil, err
}
var cache cache.Client
if s.cache != nil {
cache = s.cache()
}
id, err := verifyToken(r.Context(), v, cache, tok, s.issuer, clientID, s.useUserinfoVerifyToken, allowIssuerInAudAndAzp, allowAzp)
if err != nil {
return nil, err
}
id.Issuer = normalize(id.Issuer)
if s.transformIdentity != nil {
return s.transformIdentity(id), nil
}
return id, nil
}
// verifyToken oidc spec verfiy token.
func verifyToken(ctx context.Context, v verifier.AccessTokenVerifier, cache cache.Client, tok, iss, clientID string, opaqueToken, allowIssuerInAudAndAzp, allowAzp bool) (*ga4gh.Identity, error) {
issuerInAudAndAzp := iss
if !allowIssuerInAudAndAzp {
issuerInAudAndAzp = ""
}
id := &ga4gh.Identity{}
if opaqueToken && cache != nil {
key := AccessTokenCacheKey(iss, tok)
bytes, err := cache.Get(key)
if err == nil {
err := json.Unmarshal(bytes, id)
if err != nil {
return nil, errutil.WithErrorReason(errCacheDecodeFailed, status.Errorf(codes.Internal, "decode json failed: %v", err))
}
return id, nil
}
}
// cache not enabled or not available or token not found in cache, fallback to verify token via /userinfo or other introspect endpoints.
err := v.Verify(ctx, tok, id, verifier.AccessTokenOption(clientID, issuerInAudAndAzp, allowAzp))
if err == nil {
if opaqueToken && cache != nil {
now := time.Now().Unix()
key := AccessTokenCacheKey(iss, tok)
var exp int64
if id.Expiry == 0 || id.Expiry > now+cacheMaxExpiry {
exp = cacheMaxExpiry
} else {
exp = id.Expiry - now
}
bytes, err := json.Marshal(id)
if err != nil {
return nil, errutil.WithErrorReason(errCacheEncodeFailed, status.Errorf(codes.Internal, "encode json failed: %v", err))
}
if err := cache.SetWithExpiry(key, bytes, exp); err != nil {
// if cache unavailable, just skip and fallback.
glog.Errorf("cache.SetWithExpiry() failed: %v", err)
}
}
return id, nil
}
reason := errutil.ErrorReason(err)
if len(reason) == 0 {
reason = errIDVerifyFailed
}
return nil, errutil.WithErrorReason(reason, status.Errorf(codes.Unauthenticated, "token verify failed: %v", err))
}
// AccessTokenCacheKey creates the caching key of access token.
func AccessTokenCacheKey(issuer, token string) string {
b := sha256.Sum256([]byte(token))
// use StdEncoding instead of URLEncoding. Because StdEncoding uses [0-9a-zA-Z+/] charset.
s := base64.StdEncoding.EncodeToString(b[:])
return fmt.Sprintf("accesstoken_%s_%s", issuer, s)
}
// normalize ensure the issuer string and tailling slash.
func normalize(issuer string) string {
return strings.TrimSuffix(issuer, "/")
}
func isEditMethod(method string) bool {
if method == http.MethodGet || method == http.MethodOptions {
return false
}
return true
}
func writeRequestLog(client *logging.Client, entry *auditlog.RequestLog, err error, r *http.Request) {
entry.RequestMethod = r.Method
entry.RequestEndpoint = httputils.AbsolutePath(r)
entry.RequestPath = r.URL.Path
entry.RequestIP = httputils.RequesterIP(r)
entry.TracingID = httputils.TracingID(r)
entry.PassAuthCheck = true
if err != nil {
if st, ok := status.FromError(err); ok {
entry.ResponseCode = httputils.HTTPStatus(st.Code())
}
entry.Payload = err.Error()
entry.PassAuthCheck = false
}
entry.Request = r
auditlog.WriteRequestLog(r.Context(), client, entry)
}
func tokenID(id *ga4gh.Identity) string {
v, ok := id.Extra["tid"]
if ok {
if tid, ok := v.(string); ok {
return tid
}
}
if len(id.TokenID) > 0 {
return id.TokenID
}
return id.ID
}