lib/ic/hydra_ic.go (262 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 ( "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "google.golang.org/grpc/codes" /* copybara-comment */ "google.golang.org/grpc/status" /* copybara-comment */ "github.com/pborman/uuid" /* copybara-comment */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/apis/hydraapi" /* copybara-comment: hydraapi */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/auth" /* copybara-comment: auth */ "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/hydra" /* copybara-comment: hydra */ "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 */ ) // HydraLogin handles login request from hydra. func (s *Service) HydraLogin(w http.ResponseWriter, r *http.Request) { r.ParseForm() // Use login_challenge fetch information from hydra. challenge, sts := hydra.ExtractLoginChallenge(r) if sts != nil { httputils.WriteError(w, sts.Err()) return } login, err := hydra.GetLoginRequest(s.httpClient, s.hydraAdminURL, challenge) if err != nil { httputils.WriteError(w, err) return } res, err := s.hydraLogin(challenge, login) if err != nil { hydra.SendLoginReject(w, r, s.httpClient, s.hydraAdminURL, challenge, err) } else { res.writeResp(w, r) } } type htmlPageOrRedirectURL struct { page string redirect string } func (h *htmlPageOrRedirectURL) writeResp(w http.ResponseWriter, r *http.Request) { if len(h.page) > 0 { httputils.WriteHTMLResp(w, h.page, nil) } else { httputils.WriteRedirect(w, r, h.redirect) } } // hydraLogin returns htmlpage, redirect and status error func (s *Service) hydraLogin(challenge string, login *hydraapi.LoginRequest) (*htmlPageOrRedirectURL, error) { u, err := url.Parse(login.RequestURL) if err != nil { return nil, status.Errorf(codes.InvalidArgument, err.Error()) } realm := u.Query().Get("realm") if len(realm) == 0 { realm = storage.DefaultRealm } cfg, err := s.loadConfig(nil, realm) if err != nil { return nil, status.Errorf(codes.Unavailable, "%v", err.Error()) } scopes := login.RequestedScope if len(scopes) == 0 { scopes = defaultIdpScopes } // Return login page if no login hint. loginHint := u.Query().Get("login_hint") if !strings.Contains(loginHint, ":") { // Return Login page. query := fmt.Sprintf("?scope=%s&login_challenge=%s", url.QueryEscape(strings.Join(scopes, " ")), url.QueryEscape(challenge)) page, err := s.renderLoginPage(cfg, map[string]string{"realm": realm}, query) if err != nil { return nil, status.Errorf(codes.Unavailable, "%v", err.Error()) } return &htmlPageOrRedirectURL{page: page}, nil } // Skip login page and select the given idp when if contains login hint. hint := strings.SplitN(loginHint, ":", 2) loginHintProvider := hint[0] loginHintAccount := hint[1] // Idp login in := loginIn{ realm: realm, scope: scopes, loginHint: loginHintAccount, provider: loginHintProvider, challenge: challenge, } redirect, err := s.login(in, cfg) return &htmlPageOrRedirectURL{redirect: redirect}, err } // HydraConsent handles consent request from hydra. func (s *Service) HydraConsent(w http.ResponseWriter, r *http.Request) { r.ParseForm() // Use consent_challenge fetch information from hydra. challenge, sts := hydra.ExtractConsentChallenge(r) if sts != nil { httputils.WriteError(w, sts.Err()) return } consent, err := hydra.GetConsentRequest(s.httpClient, s.hydraAdminURL, challenge) if err != nil { httputils.WriteError(w, err) return } res, err := s.hydraConsent(r, challenge, consent) if err != nil { hydra.SendConsentReject(w, r, s.httpClient, s.hydraAdminURL, challenge, err) } else { res.writeResp(w, r) } } func (s *Service) hydraConsent(r *http.Request, challenge string, consent *hydraapi.ConsentRequest) (_ *htmlPageOrRedirectURL, ferr error) { stateID, sts := hydra.ExtractStateIDInConsent(consent) if sts != nil { return nil, sts.Err() } tx, err := s.store.Tx(true) if err != nil { return nil, status.Errorf(codes.Unavailable, "%v", err) } defer func() { err := tx.Finish() if ferr == nil && err != nil { ferr = status.Errorf(codes.Internal, "%v", err) } }() state := &cpb.LoginState{} err = s.store.ReadTx(storage.LoginStateDatatype, storage.DefaultRealm, storage.DefaultUser, stateID, storage.LatestRev, state, tx) if err != nil { return nil, status.Errorf(codes.Internal, "%v", err) } state.ConsentChallenge = challenge state.Scope = strings.Join(consent.RequestedScope, " ") state.Audience = append(consent.RequestedAudience, consent.Client.ClientID) state.ClientName = consent.Client.Name cfg, err := s.loadConfig(tx, state.Realm) if err != nil { return nil, status.Errorf(codes.Unavailable, "%v", err) } if s.skipInformationReleasePage { redirect, err := s.hydraConsentSkipInformationReleasePage(r, stateID, state, cfg, tx) return &htmlPageOrRedirectURL{redirect: redirect}, err } return s.hydraConsentRememberConsentOrInformationReleasePage(r, consent, stateID, state, cfg, tx) } func (s *Service) hydraConsentSkipInformationReleasePage(r *http.Request, stateID string, state *cpb.LoginState, cfg *pb.IcConfig, tx storage.Tx) (string, error) { err := s.store.DeleteTx(storage.LoginStateDatatype, storage.DefaultRealm, storage.DefaultUser, stateID, storage.LatestRev, tx) if err != nil { return "", status.Errorf(codes.Internal, "skip information release page delete state failed: %v", err) } secrets, err := s.loadSecrets(tx) if err != nil { return "", status.Errorf(codes.Internal, "skip information release page loadSecrets() failed: %v", err) } acct, st, err := s.scim.LoadAccount(state.Subject, state.Realm, false, tx) if err != nil { return "", status.Errorf(httputils.RPCCode(st), "skip information release page LoadAccount() failed: %v", err) } id, err := s.accountToIdentity(r.Context(), acct, cfg, secrets) if err != nil { return "", status.Errorf(codes.Internal, "skip information release page accountToIdentity() failed: %v", err) } redirect, err := s.hydraAcceptConsent(id, state) if err != nil { return "", err } return redirect, nil } func (s *Service) hydraConsentRememberConsentOrInformationReleasePage(r *http.Request, consent *hydraapi.ConsentRequest, stateID string, state *cpb.LoginState, cfg *pb.IcConfig, tx storage.Tx) (*htmlPageOrRedirectURL, error) { clientName := consent.Client.Name if len(clientName) == 0 { clientName = consent.Client.ClientID } if len(clientName) == 0 { return nil, status.Errorf(codes.Unavailable, "consent.Client.Name empty") } sub := consent.Subject if len(sub) == 0 { return nil, status.Errorf(codes.Unavailable, "consent.Subject empty") } 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) } a, err := auth.FromContext(r.Context()) if err != nil { return nil, status.Errorf(codes.Internal, "%v", err) } acct, st, err := s.scim.LoadAccount(sub, state.Realm, a.IsAdmin, tx) if err != nil { return nil, status.Errorf(httputils.RPCCode(st), "%v", err) } secrets, err := s.loadSecrets(tx) if err != nil { return nil, status.Errorf(codes.Unavailable, "%v", err) } id, err := s.accountToIdentity(r.Context(), acct, cfg, secrets) if err != nil { return nil, status.Errorf(codes.Internal, "%v", err) } id.Scope = strings.Join(consent.RequestedScope, " ") rcp, err := findRememberedConsent(s.store, consent.RequestedScope, id.Subject, state.Realm, state.ClientName, tx) if err != nil { return nil, err } if rcp != nil { scoped, err := scopedIdentity(id, rcp, id.Scope, s.getIssuerString(), id.Subject, time.Now().Unix(), id.NotBefore, id.Expiry) if err != nil { return nil, status.Errorf(codes.Internal, "accept info release scopedIdentity() failed: %v", err) } redirect, err := s.hydraAcceptConsent(scoped, state) if err != nil { return nil, err } return &htmlPageOrRedirectURL{redirect: redirect}, nil } page := s.informationReleasePage(id, stateID, clientName, id.Scope) return &htmlPageOrRedirectURL{page: page}, nil } func identityToHydraMap(id *ga4gh.Identity) (map[string]interface{}, error) { b, err := json.Marshal(id) if err != nil { return nil, err } m := map[string]interface{}{} err = json.Unmarshal(b, &m) if err != nil { return nil, err } // Remove all standard claims which already included in hydra. claims := []string{"sub", "iss", "iat", "nbf", "exp", "scope", "aud", "azp", "jti", "nonce"} for _, n := range claims { delete(m, n) } return m, err } func (s *Service) hydraAcceptConsent(id *ga4gh.Identity, state *cpb.LoginState) (string, error) { m, err := identityToHydraMap(id) if err != nil { return "", status.Errorf(codes.Internal, "hydraAcceptConsent identityToHydraMap() failed: %v", err) } tokenID := uuid.New() m["tid"] = tokenID req := &hydraapi.HandledConsentRequest{ GrantedAudience: state.Audience, GrantedScope: strings.Split(state.Scope, " "), Session: &hydraapi.ConsentRequestSessionData{ IDToken: m, AccessToken: map[string]interface{}{ "tid": tokenID, }, }, } if len(id.Identities) != 0 { var identities []string for k := range id.Identities { identities = append(identities, k) } req.Session.AccessToken["identities"] = identities } resp, err := hydra.AcceptConsent(s.httpClient, s.hydraAdminURL, state.ConsentChallenge, req) if err != nil { return "", err } return resp.RedirectTo, nil }