lib/ic/broker.go (298 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 ic
import (
"net/http"
"net/url"
"strings"
"google.golang.org/grpc/codes" /* copybara-comment */
"google.golang.org/grpc/status" /* copybara-comment */
"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/globalflags" /* copybara-comment: globalflags */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/httputils" /* copybara-comment: httputils */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/hydra" /* copybara-comment: hydra */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/scim" /* copybara-comment: scim */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/storage" /* copybara-comment: storage */
cpb "github.com/GoogleCloudPlatform/healthcare-federated-access-services/proto/common/v1" /* copybara-comment: go_proto */
pb "github.com/GoogleCloudPlatform/healthcare-federated-access-services/proto/ic/v1" /* copybara-comment: go_proto */
)
const (
accountNameLength = 30
)
type clientLoginPageArgs struct {
AssetDir string
Instructions string
}
// Login is the HTTP handler for ".../login/{name}" endpoint.
func (s *Service) Login(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
challenge := httputils.QueryParam(r, "login_challenge")
if s.useHydra {
if len(challenge) == 0 {
httputils.WriteError(w, status.Errorf(codes.InvalidArgument, "Query login_challenge missing"))
return
}
} else {
httputils.WriteError(w, status.Errorf(codes.Unimplemented, "Unimplemented oidc provider"))
}
scope, err := getScope(r)
if err != nil {
httputils.WriteError(w, status.Errorf(codes.InvalidArgument, "%v", err))
return
}
realm := getRealm(r)
cfg, err := s.loadConfig(nil, realm)
if err != nil {
httputils.WriteError(w, status.Errorf(codes.Unavailable, "%v", err))
return
}
in := loginIn{
realm: realm,
scope: strings.Split(scope, " "),
loginHint: httputils.QueryParam(r, "login_hint"),
provider: getName(r),
challenge: challenge,
}
redirect, err := s.login(in, cfg)
if err == nil {
httputils.WriteRedirect(w, r, redirect)
return
}
if s.useHydra {
hydra.SendLoginReject(w, r, s.httpClient, s.hydraAdminURL, challenge, err)
} else {
httputils.WriteError(w, err)
}
}
// AcceptLogin is the HTTP handler for ".../loggedin/{name}" endpoint.
func (s *Service) AcceptLogin(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
stateParam := httputils.QueryParam(r, "state")
errStr := httputils.QueryParam(r, "error")
errDesc := httputils.QueryParam(r, "error_description")
// Experimental allows non OIDC auth code flow which may need state extracted from html anchor.
if globalflags.Experimental {
extract := httputils.QueryParam(r, "client_extract") // makes sure we only grab state from client once
// Some IdPs need state extracted from html anchor.
if len(stateParam) == 0 && len(extract) == 0 {
args := &clientLoginPageArgs{
AssetDir: assetPath,
Instructions: "",
}
if err := s.clientLoginPageTmpl.Execute(w, args); err != nil {
httputils.WriteError(w, status.Errorf(codes.Internal, "render client login page failed: %v", err))
}
return
}
}
if len(stateParam) == 0 {
httputils.WriteError(w, status.Errorf(codes.PermissionDenied, "query params state missing"))
return
}
var loginState cpb.LoginState
err := s.store.Read(storage.LoginStateDatatype, storage.DefaultRealm, storage.DefaultUser, stateParam, storage.LatestRev, &loginState)
if err != nil {
httputils.WriteError(w, status.Errorf(codes.Internal, "read login state failed, %q", err))
return
}
if s.useHydra && len(loginState.LoginChallenge) == 0 {
httputils.WriteError(w, status.Errorf(codes.Internal, "invalid login state parameter"))
return
}
redirect, err := s.acceptLogin(r, &loginState, errStr, errDesc)
if err == nil {
httputils.WriteRedirect(w, r, redirect)
return
}
if s.useHydra {
hydra.SendLoginReject(w, r, s.httpClient, s.hydraAdminURL, loginState.LoginChallenge, err)
} else {
httputils.WriteError(w, err)
}
}
// acceptLogin returns redirect and status error
func (s *Service) acceptLogin(r *http.Request, state *cpb.LoginState, errStr, errDesc string) (string, error) {
if len(errStr) > 0 || len(errDesc) > 0 {
return "", errutil.WithErrorReason(errStr, status.Errorf(codes.Unauthenticated, errDesc))
}
if len(state.Provider) == 0 || len(state.Realm) == 0 {
return "", status.Errorf(codes.PermissionDenied, "invalid login state parameter")
}
// For the purposes of simplifying OIDC redirect_uri registrations, this handler is on a path without
// realms or other query param context. To make the handling of these requests compatible with the
// rest of the code, this request will be forwarded to a standard path at "finishLoginPath" and state
// parameters received from the OIDC call flow will be normalized into query parameters.
path := strings.Replace(finishLoginPath, "{realm}", state.Realm, -1)
path = strings.Replace(path, "{name}", state.Provider, -1)
u, err := url.Parse(path)
if err != nil {
return "", status.Errorf(codes.Internal, "bad redirect format: %v", err)
}
u.RawQuery = r.URL.RawQuery
return u.String(), nil
}
// FinishLogin is the HTTP handler for ".../loggedin" endpoint.
func (s *Service) FinishLogin(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
challenge, res, err := s.doFinishLogin(r)
if err == nil {
res.writeResp(w, r)
return
}
if !s.useHydra {
httputils.WriteError(w, err)
} else if challenge == nil {
httputils.WriteError(w, err)
} else if len(challenge.consent) > 0 {
hydra.SendConsentReject(w, r, s.httpClient, s.hydraAdminURL, challenge.consent, err)
} else {
hydra.SendLoginReject(w, r, s.httpClient, s.hydraAdminURL, challenge.login, err)
}
}
type challenge struct {
login string
consent string
}
// doFinishLogin returns challenge, redirect or html page and status error.
func (s *Service) doFinishLogin(r *http.Request) (_ *challenge, _ *htmlPageOrRedirectURL, ferr error) {
r.ParseForm()
tx, err := s.store.Tx(true)
if err != nil {
return nil, nil, status.Errorf(codes.Unavailable, "%v", err)
}
defer func() {
err := tx.Finish()
if ferr == nil && err != nil {
ferr = status.Errorf(codes.Internal, "%v", err)
}
}()
cfg, err := s.loadConfig(tx, getRealm(r))
if err != nil {
return nil, nil, status.Errorf(codes.Unavailable, "%v", err)
}
provider := getName(r)
idp, ok := cfg.IdentityProviders[provider]
if !ok {
return nil, nil, status.Errorf(codes.Unauthenticated, "invalid identity provider %q", provider)
}
code := httputils.QueryParam(r, "code")
stateParam := httputils.QueryParam(r, "state")
idToken := ""
accessToken := ""
extract := ""
// Experimental allows reading tokens from non-OIDC.
if globalflags.Experimental {
idToken = httputils.QueryParam(r, "id_token")
accessToken = httputils.QueryParam(r, "access_token")
extract = httputils.QueryParam(r, "client_extract") // makes sure we only grab state from client once
if len(extract) == 0 && len(code) == 0 && len(idToken) == 0 && len(accessToken) == 0 {
instructions := ""
if len(idp.TokenUrl) > 0 && !strings.HasPrefix(idp.TokenUrl, "http") {
// Allow the client login page to follow instructions encoded in the TokenUrl.
// This enables support for some non-OIDC clients.
instructions = idp.TokenUrl
}
args := &clientLoginPageArgs{
AssetDir: assetPath,
Instructions: instructions,
}
sb := &strings.Builder{}
if err := s.clientLoginPageTmpl.Execute(sb, args); err != nil {
return nil, nil, status.Errorf(codes.Internal, "render client login page failed: %v", err)
}
return nil, &htmlPageOrRedirectURL{page: sb.String()}, nil
}
} else {
// Experimental allows non OIDC auth code flow which code or stateParam can be empty.
if len(code) == 0 || len(stateParam) == 0 {
return nil, nil, status.Errorf(codes.Unauthenticated, "query params code or state missing")
}
}
loginState := &cpb.LoginState{}
err = s.store.ReadTx(storage.LoginStateDatatype, storage.DefaultRealm, storage.DefaultUser, stateParam, storage.LatestRev, loginState, tx)
if err != nil {
return nil, nil, status.Errorf(codes.Internal, "read login state failed, %q", err)
}
if s.useHydra {
if len(loginState.LoginChallenge) == 0 {
return nil, nil, status.Errorf(codes.Unauthenticated, "invalid login state parameter")
}
} else {
return nil, nil, status.Errorf(codes.Unimplemented, "Unimplemented oidc provider")
}
challenge := &challenge{
login: loginState.LoginChallenge,
consent: loginState.ConsentChallenge,
}
if loginState.Step != cpb.LoginState_LOGIN {
return challenge, nil, status.Errorf(codes.Unauthenticated, "login state not in login step")
}
if len(loginState.Provider) == 0 || len(loginState.Realm) == 0 {
return challenge, nil, status.Errorf(codes.Unauthenticated, "invalid login state parameter")
}
if len(code) == 0 && len(idToken) == 0 && !s.idpUsesClientLoginPage(loginState.Provider, loginState.Realm, cfg) {
return challenge, nil, status.Errorf(codes.Unauthenticated, "missing auth code")
}
if provider != loginState.Provider {
return challenge, nil, status.Errorf(codes.Unauthenticated, "request idp does not match login state, want %q, got %q", loginState.Provider, provider)
}
secrets, err := s.loadSecrets(tx)
if err != nil {
return challenge, nil, status.Errorf(codes.Unavailable, "%v", err)
}
if len(accessToken) == 0 {
idpc := idpConfig(idp, s.getDomainURL(), secrets)
tok, err := idpc.Exchange(r.Context(), code)
if err != nil {
return challenge, nil, status.Errorf(codes.Unauthenticated, "invalid code: %v", err)
}
accessToken = tok.AccessToken
if len(idToken) == 0 {
idToken, ok = tok.Extra("id_token").(string)
if !ok && len(accessToken) == 0 {
return challenge, nil, status.Errorf(codes.Unauthenticated, "identity provider response does not contain an access_token nor id_token token")
}
}
}
login, st, err := s.loginTokenToIdentity(accessToken, idToken, idp, r, cfg, secrets)
if err != nil {
return challenge, nil, status.Errorf(httputils.RPCCode(st), "%v", err)
}
res, err := s.finishLogin(login, stateParam, loginState, tx, cfg, secrets, r)
return challenge, res, err
}
// finishLogin returns html page or redirect url and status error
func (s *Service) finishLogin(id *ga4gh.Identity, stateID string, state *cpb.LoginState, tx storage.Tx, cfg *pb.IcConfig, secrets *pb.IcSecrets, r *http.Request) (*htmlPageOrRedirectURL, error) {
realm := getRealm(r)
lookup, err := s.scim.LoadAccountLookup(realm, id.Subject, tx)
if err != nil {
return nil, status.Errorf(codes.Unavailable, "%v", err)
}
var subject string
if isLookupActive(lookup) {
subject = lookup.Subject
acct, _, err := s.scim.LoadAccount(subject, realm, true, tx)
if err != nil {
return nil, status.Errorf(codes.Unavailable, "%v", err)
}
if acct.State == storage.StateDisabled {
// Reject using a DISABLED account.
return nil, status.Errorf(codes.PermissionDenied, "this account has been disabled, please contact the system administrator")
}
acct, err = scim.UpdateIdentityInAccount(r.Context(), id, state.Provider, acct, s.encryption)
if err != nil {
return nil, err
}
if err := s.scim.SaveAccount(nil, acct, "REFRESH claims "+id.Subject, id.Subject, state.Realm, r, tx); err != nil {
return nil, status.Errorf(codes.Unavailable, "%v", err)
}
} else {
// Create an account for the identity automatically.
accountPrefix := "ic_"
acct, lookup, err := scim.NewAccount(r.Context(), s.encryption, id, state.Provider, accountPrefix, accountNameLength)
if err != nil {
return nil, err
}
if err = s.saveNewLinkedAccount(acct, id, state.Realm, "New Account", r, tx, lookup); err != nil {
return nil, status.Errorf(codes.Unavailable, "%v", err)
}
subject = acct.Properties.Subject
}
loginHint := makeLoginHint(state.Provider, id.Subject)
// redirect to information release page.
state.Subject = subject
state.LoginHint = loginHint
state.Step = cpb.LoginState_CONSENT
err = s.store.WriteTx(storage.LoginStateDatatype, storage.DefaultRealm, storage.DefaultUser, stateID, storage.LatestRev, state, nil, tx)
if err != nil {
return nil, status.Errorf(codes.Internal, "%v", err)
}
if s.useHydra {
// send login success to hydra and redirect to hydra, hydra will come back to /identity/consent for information release.
redirect, err := hydra.LoginSuccess(r, s.httpClient, s.hydraAdminURL, state.LoginChallenge, subject, stateID, nil)
if err != nil {
return nil, status.Errorf(codes.Unavailable, "%v", err)
}
return &htmlPageOrRedirectURL{redirect: redirect}, nil
}
return nil, status.Errorf(codes.Unimplemented, "Unimplemented oidc provider")
}