lib/ic/ic.go (1,203 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 is identity concentrator for GA4GH Passports. package ic import ( "context" "fmt" "html/template" "io" "net/http" "net/url" "os" "regexp" "sort" "strconv" "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 */ "golang.org/x/oauth2" /* copybara-comment */ "github.com/golang/protobuf/jsonpb" /* copybara-comment */ "github.com/golang/protobuf/proto" /* copybara-comment */ "github.com/pborman/uuid" /* copybara-comment */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/auditlogsapi" /* copybara-comment: auditlogsapi */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/auth" /* copybara-comment: auth */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/check" /* copybara-comment: check */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/cli" /* copybara-comment: cli */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/consentsapi" /* copybara-comment: consentsapi */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/ga4gh" /* copybara-comment: ga4gh */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/handlerfactory" /* copybara-comment: handlerfactory */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/httputils" /* copybara-comment: httputils */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/hydraproxy" /* copybara-comment: hydraproxy */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/kms" /* copybara-comment: kms */ "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/scim" /* copybara-comment: scim */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/srcutil" /* copybara-comment: srcutil */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/storage" /* copybara-comment: storage */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/strutil" /* copybara-comment: strutil */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/timeutil" /* copybara-comment: timeutil */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/tokensapi" /* copybara-comment: tokensapi */ "github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/translator" /* copybara-comment: translator */ glog "github.com/golang/glog" /* copybara-comment */ lgrpcpb "google.golang.org/genproto/googleapis/logging/v2" /* copybara-comment: logging_go_grpc */ 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 ( keyID = "visa" maxClaimsLength = 1900 linkedIdentitiesMaxLifepan = time.Hour loginPageFile = "pages/login.html" loginPageInfoFile = "pages/ic/login_info.html" clientLoginPageFile = "pages/ic/client_login.html" informationReleasePageFile = "pages/ic/info_release.html" staticDirectory = "assets/serve/" serviceTitle = "Identity Concentrator" loginInfoTitle = "Data Discovery and Access Platform" noClientID = "" noScope = "" noNonce = "" scopeOpenID = "openid" matchFullScope = false matchPrefixScope = true generateRefreshToken = true noRefreshToken = false noDuration = 0 * time.Second minResetClientFrequency = 2 * time.Minute ) func defaultPath(path string) string { return strings.Replace(path, "{realm}", storage.DefaultRealm, -1) } var ( secretParams = map[string]bool{ "clientSecret": true, "client_secret": true, } pageVariableRE = regexp.MustCompile(`\$\{[-A-Z_]*\}`) passportScope = "ga4gh_passport_v1" ga4ghScope = "ga4gh" defaultIdpScopes = []string{"openid", "profile", "email"} filterAccessTokScope = map[string]bool{ "openid": true, ga4ghScope: true, "identities": true, "link": true, "account_admin": true, "email": true, passportScope: true, } filterIDTokScope = map[string]bool{ "openid": true, "profile": true, // TODO: remove these once DDAP BFF switches to use access token. ga4ghScope: true, passportScope: true, "identities": true, "email": true, } descReadOnlyMasterRealm = &cpb.Descriptor{ Label: "Read Only Master Realm", Description: "When 'true', the master realm becomes read-only and updates to the configuration must be performed via updating a config file", Type: "bool", DefaultValue: "false", } descClaimTtlCap = &cpb.Descriptor{ Label: "Claim TTL Cap", Description: "A maximum duration of how long individual claims can be cached and used before requiring them to be refreshed from the authority issuing the claim", Type: "string:duration", Regexp: timeutil.DurationREStr, Min: "10s", Max: "9125d", DefaultValue: "90d", } shortNameRE = regexp.MustCompile(`^[A-Za-z][-_A-Za-z0-9\.]{0,30}[A-Za-z0-9]$`) tagField = "tag" tagNameCheck = map[string]*regexp.Regexp{ tagField: shortNameRE, } // skipURLValidationInTokenURL is for skipping URL validation for TokenUrl in format "FOO_BAR=https://...". skipURLValidationInTokenURL = regexp.MustCompile("^[A-Z_]*=https://.*$") ) type Service struct { store storage.Store Handler *ServiceHandler httpClient *http.Client loginPageTmpl *template.Template clientLoginPageTmpl *template.Template infomationReleasePageTmpl *template.Template startTime int64 domain string serviceName string accountDomain string hydraAdminURL string hydraPublicURL string hydraPublicURLProxy *hydraproxy.Service translators sync.Map encryption kms.Encryption signer kms.Signer logger *logging.Client skipInformationReleasePage bool useHydra bool hydraSyncFreq time.Duration scim *scim.Scim cliAcceptHandler *cli.AcceptHandler consentDashboardURL string tokenProviders []tokensapi.TokenProvider auditlogs *auditlogsapi.AuditLogs checker *auth.Checker } type ServiceHandler struct { Handler *mux.Router s *Service } // Options contains parameters to New IC Service. type Options struct { // HTTPClient: http client for making http request. HTTPClient *http.Client // Domain: domain used to host ic service. Domain string // ServiceName: name of the service including environment (example: "ic-staging") ServiceName string // AccountDomain: domain used to host service account warehouse. AccountDomain string // Store: data storage and configuration storage. Store storage.Store // Encryption: the encryption use for storing tokens safely in database. Encryption kms.Encryption // Signer: the signer use for signing jwt. Signer kms.Signer // Logger: audit log logger Logger *logging.Client // SDLC: gRPC client to StackDriver Logging. SDLC lgrpcpb.LoggingServiceV2Client // AuditLogProject is the GCP project id where audit logs are written to. AuditLogProject string // SkipInformationReleasePage: set true if want to skip the information release page. SkipInformationReleasePage bool // UseHydra: service use hydra integrated OIDC. UseHydra bool // HydraAdminURL: hydra admin endpoints url. HydraAdminURL string // HydraPublicURL: hydra public endpoints url. HydraPublicURL string // HydraPublicProxy: proxy for hydra public endpoint. HydraPublicProxy *hydraproxy.Service // HydraSyncFreq: how often to allow clients:sync to be called HydraSyncFreq time.Duration // ConsentDashboardURL is url to frontend consent dashboard, will replace // ${USER_ID} with userID. ConsentDashboardURL string } // NewService create new IC service. func NewService(params *Options) *Service { r := mux.NewRouter() return New(r, params) } // New creats a new IC and registers it on r. func New(r *mux.Router, params *Options) *Service { sh := &ServiceHandler{} loginPageTmpl, err := httputils.TemplateFromFiles(loginPageFile, loginPageInfoFile) if err != nil { glog.Exitf("cannot create template for login page: %v", err) } clientLoginPageTmpl, err := httputils.TemplateFromFiles(clientLoginPageFile) if err != nil { glog.Exitf("cannot create template for client login page: %v", err) } infomationReleasePageTmpl, err := httputils.TemplateFromFiles(informationReleasePageFile) if err != nil { glog.Exitf("cannot create template for information release page: %v", err) } syncFreq := time.Minute if params.HydraSyncFreq > 0 { syncFreq = params.HydraSyncFreq } cliAcceptHandler, err := cli.NewAcceptHandler(params.Store, params.Encryption, "/identity") if err != nil { glog.Exitf("cli.NewAcceptHandler() failed: %v", err) } s := &Service{ store: params.Store, Handler: sh, httpClient: params.HTTPClient, loginPageTmpl: loginPageTmpl, clientLoginPageTmpl: clientLoginPageTmpl, infomationReleasePageTmpl: infomationReleasePageTmpl, startTime: time.Now().Unix(), domain: params.Domain, serviceName: params.ServiceName, accountDomain: params.AccountDomain, hydraAdminURL: params.HydraAdminURL, hydraPublicURL: params.HydraPublicURL, hydraPublicURLProxy: params.HydraPublicProxy, encryption: params.Encryption, signer: params.Signer, logger: params.Logger, skipInformationReleasePage: params.SkipInformationReleasePage, useHydra: params.UseHydra, hydraSyncFreq: syncFreq, scim: scim.New(params.Store), cliAcceptHandler: cliAcceptHandler, consentDashboardURL: params.ConsentDashboardURL, auditlogs: auditlogsapi.NewAuditLogs(params.SDLC, params.AuditLogProject, params.ServiceName), } if s.httpClient == nil { s.httpClient = http.DefaultClient } if err := validateURLs(map[string]string{ "DOMAIN as URL": "https://" + params.Domain, "ACCOUNT_DOMAIN as URL": "https://" + params.AccountDomain, }); err != nil { glog.Exitf(err.Error()) } exists, err := configExists(params.Store) if err != nil { glog.Exitf("cannot use storage layer: %v", err) } if !exists { if err = ImportConfig(params.Store, params.ServiceName, nil, true, true, true); err != nil { glog.Exitf("cannot import configs to service %q: %v", params.ServiceName, err) } } cfg, err := s.loadConfig(nil, storage.DefaultRealm) if err != nil { glog.Exitf("cannot load config: %v", err) } if err = s.checkConfigIntegrity(cfg); err != nil { glog.Exitf("invalid config: %v", err) } secrets, err := s.loadSecrets(nil) if err != nil { glog.Exitf("cannot load client secrets: %v", err) } ctx := context.WithValue(context.Background(), oauth2.HTTPClient, s.httpClient) for name, cfgIdp := range cfg.IdentityProviders { _, err = s.getIssuerTranslator(ctx, cfgIdp.Issuer, cfg, secrets) if err != nil { glog.Infof("failed to create translator for issuer %q: %v", name, err) } } if s.useHydra { s.tokenProviders = append(s.tokenProviders, tokensapi.NewHydraTokenManager(s.hydraAdminURL, s.getIssuerString(), s.clients)) } s.syncToHydra(cfg.Clients, secrets.ClientSecrets, 30*time.Second, nil) a := authChecker{s: s} checker := auth.NewChecker(s.logger, s.getIssuerString(), permissions.New(s.store), a.fetchClientSecrets, a.transformIdentity, false, nil) s.checker = checker sh.s = s sh.Handler = r registerHandlers(r, s) return s } func getClientID(r *http.Request) string { cid := httputils.QueryParam(r, "client_id") if len(cid) > 0 { return cid } return httputils.QueryParam(r, "clientId") } func getClientSecret(r *http.Request) string { cs := httputils.QueryParam(r, "client_secret") if len(cs) > 0 { return cs } return httputils.QueryParam(r, "clientSecret") } func getNonce(r *http.Request) (string, error) { n := httputils.QueryParam(r, "nonce") if len(n) > 0 { return n, nil } // TODO: should return error after front end supports nonce field. // return "", fmt.Errorf("request must include 'nonce'") return "no-nonce", nil } func extractState(r *http.Request) (string, error) { n := httputils.QueryParam(r, "state") if len(n) > 0 { return n, nil } // TODO: should return error after front end supports state field. // return "", fmt.Errorf("request must include 'state'") return "no-state", nil } func (sh *ServiceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { httputils.WriteCorsHeaders(w) w.WriteHeader(http.StatusOK) return } // Inject http client for oauth lib. r = r.WithContext(context.WithValue(r.Context(), oauth2.HTTPClient, sh.s.httpClient)) sh.Handler.ServeHTTP(w, r) } type loginPageArgs struct { ProviderList *pb.LoginPageProviders AssetDir string ServiceTitle string LoginInfoTitle string } func (s *Service) renderLoginPage(cfg *pb.IcConfig, pathVars map[string]string, queryParams string) (string, error) { list := &pb.LoginPageProviders{ Idps: make(map[string]*pb.LoginPageProviders_ProviderEntry), Personas: make(map[string]*pb.LoginPageProviders_ProviderEntry), } for name, idp := range cfg.IdentityProviders { list.Idps[name] = &pb.LoginPageProviders_ProviderEntry{ Url: buildPath(loginPath, name, pathVars) + queryParams, Ui: idp.Ui, } } args := &loginPageArgs{ ProviderList: list, AssetDir: assetPath, ServiceTitle: serviceTitle, LoginInfoTitle: loginInfoTitle, } sb := &strings.Builder{} if err := s.loginPageTmpl.Execute(sb, args); err != nil { return "", err } return sb.String(), nil } func (s *Service) idpAuthorize(in loginIn, idp *cpb.IdentityProvider, cfg *pb.IcConfig, tx storage.Tx) (*oauth2.Config, string, error) { stateID, err := s.buildState(in.provider, in.realm, in.challenge, tx) if err != nil { return nil, "", err } return idpConfig(idp, s.getDomainURL(), nil), stateID, nil } func idpConfig(idp *cpb.IdentityProvider, domainURL string, secrets *pb.IcSecrets) *oauth2.Config { scopes := idp.Scopes if scopes == nil || len(scopes) == 0 { scopes = defaultIdpScopes } secret := "" if secrets != nil { var ok bool if secret, ok = secrets.IdProviderSecrets[idp.ClientId]; !ok { secret = "" } } return &oauth2.Config{ ClientID: idp.ClientId, ClientSecret: secret, Endpoint: oauth2.Endpoint{ AuthURL: idp.AuthorizeUrl, TokenURL: idp.TokenUrl, }, RedirectURL: domainURL + acceptLoginPath, Scopes: scopes, } } func (s *Service) buildState(idpName, realm, challenge string, tx storage.Tx) (string, error) { login := &cpb.LoginState{ Provider: idpName, Realm: realm, LoginChallenge: challenge, Step: cpb.LoginState_LOGIN, } id := uuid.New() err := s.store.WriteTx(storage.LoginStateDatatype, storage.DefaultRealm, storage.DefaultUser, id, storage.LatestRev, login, nil, tx) if err != nil { return "", err } return id, nil } func buildPath(muxPath string, name string, vars map[string]string) string { out := strings.Replace(muxPath, "{name}", name, -1) for k, v := range vars { out = strings.Replace(out, "{"+k+"}", v, -1) } return out } func buildRedirectNonOIDC(idp *cpb.IdentityProvider, idpc *oauth2.Config, state string) string { url, err := url.Parse(idpc.RedirectURL) if err != nil { return idpc.RedirectURL } q := url.Query() q.Set("state", state) url.RawQuery = q.Encode() return url.String() } func (s *Service) idpUsesClientLoginPage(idpName, realm string, cfg *pb.IcConfig) bool { idp, ok := cfg.IdentityProviders[idpName] if !ok { return false } return idp.TranslateUsing == translator.DbGapTranslatorName } type loginIn struct { provider string loginHint string realm string challenge string scope []string } // login returns redirect and status error. func (s *Service) login(in loginIn, cfg *pb.IcConfig) (string, error) { var err error idp, ok := cfg.IdentityProviders[in.provider] if !ok { return "", status.Errorf(codes.NotFound, "login service %q not found", in.provider) } idpc, state, err := s.idpAuthorize(in, idp, cfg, nil) if err != nil { return "", status.Errorf(codes.InvalidArgument, "%v", err) } resType := idp.ResponseType if len(resType) == 0 { resType = "code" } options := []oauth2.AuthCodeOption{ oauth2.SetAuthURLParam("response_type", resType), oauth2.SetAuthURLParam("prompt", "login consent"), } if len(in.loginHint) > 0 { options = append(options, oauth2.SetAuthURLParam("login_hint", in.loginHint)) } url := idpc.AuthCodeURL(state, options...) url = strings.Replace(url, "${CLIENT_ID}", idp.ClientId, -1) url = strings.Replace(url, "${REDIRECT_URI}", buildRedirectNonOIDC(idp, idpc, state), -1) return url, nil } func getStateRedirect(r *http.Request) (string, error) { redirect, err := url.Parse(httputils.QueryParam(r, "redirect_uri")) if err != nil { return "", fmt.Errorf("redirect_uri missing or invalid: %v", err) } q := redirect.Query() if clientState := httputils.QueryParam(r, "state"); len(clientState) > 0 { q.Set("state", clientState) } redirect.RawQuery = q.Encode() return redirect.String(), nil } func (s *Service) getAndValidateStateRedirect(r *http.Request, cfg *pb.IcConfig) (string, error) { redirect, err := getStateRedirect(r) if err != nil { return "", err } if len(redirect) == 0 { return "", fmt.Errorf("missing %q parameter", "redirect_uri") } if !matchRedirect(getClient(cfg, r), redirect) { return "", fmt.Errorf("redirect not registered") } return redirect, nil } func extractClientName(cfg *pb.IcConfig, clientID string) string { clientName := "the application" for name, cli := range cfg.Clients { if cli.ClientId == clientID { if cli.Ui != nil && len("label") > 0 { clientName = cli.Ui["label"] } else { clientName = name } break } } return clientName } ////////////////////////////////////////////////////////////////// func (s *Service) GetStore() storage.Store { return s.store } ////////////////////////////////////////////////////////////////// func getRealm(r *http.Request) string { if r == nil { return storage.DefaultRealm } if realm, ok := mux.Vars(r)["realm"]; ok && len(realm) > 0 { return realm } return storage.DefaultRealm } func getName(r *http.Request) string { if name, ok := mux.Vars(r)["name"]; ok && len(name) > 0 { return name } return "" } func (s *Service) handlerSetupNoAuth(tx storage.Tx, r *http.Request, item proto.Message) (*pb.IcConfig, int, error) { r.ParseForm() if item != nil { if err := jsonpb.Unmarshal(r.Body, item); err != nil && err != io.EOF { return nil, http.StatusBadRequest, status.Errorf(codes.InvalidArgument, "%v", err) } } cfg, err := s.loadConfig(tx, getRealm(r)) if err != nil { return nil, http.StatusServiceUnavailable, status.Errorf(codes.Unavailable, "%v", err) } return cfg, http.StatusOK, nil } func (s *Service) handlerSetup(tx storage.Tx, r *http.Request, scope string, item proto.Message) (*pb.IcConfig, *pb.IcSecrets, *ga4gh.Identity, int, error) { cfg, st, err := s.handlerSetupNoAuth(tx, r, item) if err != nil { return nil, nil, nil, st, err } secrets, err := s.loadSecrets(tx) if err != nil { return nil, nil, nil, http.StatusServiceUnavailable, status.Errorf(codes.Unavailable, "%v", err) } c, err := auth.FromContext(r.Context()) if err != nil { return nil, nil, nil, httputils.FromError(err), err } return cfg, secrets, c.ID, st, status.Errorf(httputils.RPCCode(st), "%v", err) } func (s *Service) accountToIdentity(ctx context.Context, acct *cpb.Account, cfg *pb.IcConfig, secrets *pb.IcSecrets) (*ga4gh.Identity, error) { email := acct.Properties.Subject + "@" + s.accountDomain id := &ga4gh.Identity{ Subject: acct.Properties.Subject, Issuer: s.getIssuerString(), GA4GH: make(map[string][]ga4gh.OldClaim), Email: email, } if acct.Profile != nil { id.Username = acct.Profile.Username id.Name = acct.Profile.Name id.GivenName = acct.Profile.GivenName id.FamilyName = acct.Profile.FamilyName id.MiddleName = acct.Profile.MiddleName id.Profile = acct.Profile.Profile id.Picture = acct.Profile.Picture id.ZoneInfo = acct.Profile.ZoneInfo id.Locale = acct.Profile.Locale } ttl := getDurationOption(cfg.Options.ClaimTtlCap, descClaimTtlCap) identities := make(map[string][]string) for _, link := range acct.ConnectedAccounts { subject := link.Properties.Subject email := link.Properties.Email if len(email) == 0 { email = subject } identities[email] = []string{"IC"} // TODO: consider skipping claims if idp=cfg.IdProvider[link.Provider] is missing (not <persona>) or idp.State != "ACTIVE". if err := s.populateLinkVisas(ctx, id, link, ttl, cfg, secrets); err != nil { return nil, err } } if len(identities) > 0 { id.Identities = identities } return id, nil } func (s *Service) loginTokenToIdentity(acTok, idTok string, idp *cpb.IdentityProvider, r *http.Request, cfg *pb.IcConfig, secrets *pb.IcSecrets) (*ga4gh.Identity, int, error) { t, err := s.getIssuerTranslator(r.Context(), idp.Issuer, cfg, secrets) if err != nil { return nil, http.StatusUnauthorized, err } if len(acTok) > 0 && s.idpProvidesPassports(idp) { tid, err := t.TranslateToken(r.Context(), acTok) if err != nil { return nil, http.StatusUnauthorized, fmt.Errorf("translating access token from issuer %q: %v", idp.Issuer, err) } if !ga4gh.HasUserinfoClaims(tid) { return tid, http.StatusOK, nil } id, err := translator.FetchUserinfoClaims(r.Context(), s.httpClient, tid, acTok, t) if err != nil { return nil, http.StatusUnauthorized, fmt.Errorf("fetching user info from issuer %q: %v", idp.Issuer, err) } return id, http.StatusOK, nil } if len(idTok) > 0 { // Assumes the login ID token is a JWT containing standard claims. tid, err := t.TranslateToken(r.Context(), idTok) if err != nil { return nil, http.StatusUnauthorized, fmt.Errorf("translating ID token from issuer %q: %v", idp.Issuer, err) } return tid, http.StatusOK, nil } return nil, http.StatusBadRequest, fmt.Errorf("fetching identity: the IdP is not configured to fetch passports and the IdP did not provide an ID token") } // syncToHydra pushes the configuration of clients and secrets to Hydra. // Use minFrequency of 0 if you always want the sync to proceed immediately after // the last one (if it doesn't time out), or non-zero to indicate that a recent sync // is good enough. Note there are some race conditions with several client changes // overlapping in flight that could still have the two services be out of sync. func (s *Service) syncToHydra(clients map[string]*cpb.Client, secrets map[string]string, minFrequency time.Duration, tx storage.Tx) (*cpb.ClientState, error) { if !s.useHydra { return nil, nil } ltx := s.store.LockTx("hydra_"+s.serviceName, minFrequency, tx) if ltx == nil { return nil, fmt.Errorf("hydra sync has completed recently or is active") } if tx == nil { // Is a new tx (i.e. ltx didn't override tx) defer ltx.Finish() } state, err := oathclients.SyncClients(s.httpClient, s.hydraAdminURL, clients, secrets) if err != nil { glog.Errorf("failed to sync hydra clients: %v", err) return nil, err } return state, nil } func (s *Service) idpProvidesPassports(idp *cpb.IdentityProvider) bool { if len(idp.TranslateUsing) > 0 { return true } for _, scope := range idp.Scopes { if scope == passportScope { return true } } return false } func linkedIdentityValue(sub, iss string) string { sub = url.QueryEscape(sub) iss = url.QueryEscape(iss) return fmt.Sprintf("%s,%s", sub, iss) } func (s *Service) addLinkedIdentities(ctx context.Context, id *ga4gh.Identity, link *cpb.ConnectedAccount, cfg *pb.IcConfig) error { if len(id.Subject) == 0 { return nil } subjectIssuers := map[string]bool{} now := time.Now() // TODO: add config option for LinkedIdentities expiry. exp := now.Add(linkedIdentitiesMaxLifepan).Unix() idp, ok := cfg.IdentityProviders[link.Provider] if !ok { // admin has removed the IdP (temp or permanent) but the linked identity is still maintained, so ignore it. return nil } iss := idp.Issuer // Add ConnectedAccount identity to linked identities. if len(link.Properties.Subject) != 0 { subjectIssuers[linkedIdentityValue(link.Properties.Subject, iss)] = true } // Add email to linked identities. if len(link.Properties.Email) != 0 { subjectIssuers[linkedIdentityValue(link.Properties.Email, iss)] = true } var linked []string for k := range subjectIssuers { linked = append(linked, k) } sort.Strings(linked) d := &ga4gh.VisaData{ StdClaims: ga4gh.StdClaims{ Subject: id.Subject, Issuer: s.getVisaIssuerString(), IssuedAt: now.Unix(), ExpiresAt: exp, }, Assertion: ga4gh.Assertion{ Type: ga4gh.LinkedIdentities, Asserted: int64(link.Refreshed), Value: ga4gh.Value(strings.Join(linked, ";")), Source: ga4gh.Source(s.getVisaIssuerString()), }, } v, err := ga4gh.NewVisaFromData(ctx, d, s.visaIssuerJKU(), s.signer) if err != nil { return status.Errorf(codes.Unavailable, "ga4gh.NewVisaFromData(_) failed: %v", err) } id.VisaJWTs = append(id.VisaJWTs, string(v.JWT())) return nil } func (s *Service) populateLinkVisas(ctx context.Context, id *ga4gh.Identity, link *cpb.ConnectedAccount, ttl time.Duration, cfg *pb.IcConfig, secrets *pb.IcSecrets) error { passport := link.Passport if passport == nil { passport = &cpb.Passport{} } jwts, err := s.decryptEmbeddedTokens(ctx, passport.InternalEncryptedVisas) if err != nil { return err } id.VisaJWTs = append(id.VisaJWTs, jwts...) if err = s.addLinkedIdentities(ctx, id, link, cfg); err != nil { return fmt.Errorf("add linked identities to visas failed: %v", err) } return nil } func getScope(r *http.Request) (string, error) { s := httputils.QueryParam(r, "scope") if !hasScopes(scopeOpenID, s, matchFullScope) { return "", fmt.Errorf("scope must include 'openid'") } return s, nil } func hasScopes(want, got string, matchPrefix bool) bool { wanted := strings.Split(want, " ") gotten := strings.Split(got, " ") for _, w := range wanted { proceed := false for _, g := range gotten { if g == w || (matchPrefix && strings.HasPrefix(g, w+":")) { proceed = true break } } if !proceed { return false } } return true } func (s *Service) visaIssuerJKU() string { return strings.TrimSuffix(s.getDomainURL(), "/") + "/visas/jwks" } func (s *Service) getVisaIssuerString() string { return strings.TrimSuffix(s.getDomainURL(), "/") + "/visas" } func (s *Service) getIssuerString() string { if s.useHydra { return strings.TrimRight(s.hydraPublicURL, "/") + "/" } return "" } func (s *Service) getDomainURL() string { domain := os.Getenv("SERVICE_DOMAIN") if len(domain) == 0 { domain = s.accountDomain } if strings.HasPrefix(domain, "localhost:") { return "http://" + domain } return "https://" + domain } func getClient(cfg *pb.IcConfig, r *http.Request) *cpb.Client { cid := getClientID(r) if cid == "" { return nil } for _, c := range cfg.Clients { if c.ClientId == cid { return c } } return nil } func matchRedirect(client *cpb.Client, redirect string) bool { if client == nil || len(redirect) == 0 { return false } redir, err := url.Parse(redirect) if err != nil { return false } for _, v := range client.RedirectUris { prefix, err := url.Parse(v) if err == nil && redir.Host == prefix.Host && strings.HasPrefix(redir.Path, prefix.Path) && redir.Scheme == prefix.Scheme { return true } } return false } func (s *Service) encryptEmbeddedTokens(ctx context.Context, tokens []string) ([][]byte, error) { var res [][]byte for _, tok := range tokens { encrypted, err := s.encryption.Encrypt(ctx, []byte(tok), "") if err != nil { return nil, err } res = append(res, encrypted) } return res, nil } func (s *Service) decryptEmbeddedTokens(ctx context.Context, tokens [][]byte) ([]string, error) { var res []string for _, t := range tokens { tok, err := s.encryption.Decrypt(ctx, t, "") if err != nil { return nil, err } res = append(res, string(tok)) } return res, nil } func findLinkedAccount(acct *cpb.Account, subject, provider string) (*cpb.ConnectedAccount, int) { if acct.ConnectedAccounts == nil { return nil, -1 } for i, link := range acct.ConnectedAccounts { if link.Provider == provider && link.Properties.Subject == subject { return link, i } } return nil, -1 } func validateTranslator(translateUsing, iss string) error { if translateUsing == "" { return nil } t, ok := translator.PassportTranslators()[translateUsing] if !ok { return fmt.Errorf("invalid translator: %q", translateUsing) } validIss := false for _, ci := range t.CompatibleIssuers { if iss == ci { validIss = true break } } if !validIss { return fmt.Errorf("invalid issuer for translator %q: %q", translateUsing, iss) } return nil } func (s *Service) getIssuerTranslator(ctx context.Context, issuer string, cfg *pb.IcConfig, secrets *pb.IcSecrets) (translator.Translator, error) { v, ok := s.translators.Load(issuer) var t translator.Translator var err error if ok { t, ok = v.(translator.Translator) if !ok { return nil, fmt.Errorf("passport issuer %q with wrong type", issuer) } return t, nil } var cfgIdp *cpb.IdentityProvider for _, idp := range cfg.IdentityProviders { if idp.Issuer == issuer { cfgIdp = idp break } } if cfgIdp == nil { return nil, fmt.Errorf("passport issuer not found %q", issuer) } t, err = s.createIssuerTranslator(ctx, cfgIdp, secrets) if err != nil { return nil, fmt.Errorf("failed to create translator for issuer %q: %v", issuer, err) } s.translators.Store(issuer, t) return t, err } func (s *Service) createIssuerTranslator(ctx context.Context, cfgIdp *cpb.IdentityProvider, secrets *pb.IcSecrets) (translator.Translator, error) { iss := cfgIdp.Issuer publicKey := "" k, ok := secrets.TokenKeys[iss] if ok { publicKey = k.PublicKey } selfIssuer := s.getIssuerString() return translator.CreateTranslator(ctx, iss, cfgIdp.TranslateUsing, cfgIdp.ClientId, publicKey, selfIssuer, s.signer) } func (s *Service) checkConfigIntegrity(cfg *pb.IcConfig) error { // Check Id Providers. for name, idp := range cfg.IdentityProviders { if err := httputils.CheckName("name", name, nil); err != nil { return fmt.Errorf("invalid idProvider name %q: %v", name, err) } if len(idp.Issuer) == 0 { return fmt.Errorf("invalid idProvider %q: missing 'issuer' field", name) } m := map[string]string{ "issuer": idp.Issuer, "authorizeUrl": idp.AuthorizeUrl, } if !skipURLValidationInTokenURL.MatchString(idp.TokenUrl) { m["tokenUrl"] = idp.TokenUrl } if err := validateURLs(m); err != nil { return err } if err := validateTranslator(idp.TranslateUsing, idp.Issuer); err != nil { return fmt.Errorf("identity provider %q: %v", name, err) } if _, err := check.CheckUI(idp.Ui, true); err != nil { return fmt.Errorf("identity provider %q: %v", name, err) } } // Check Clients. for name, client := range cfg.Clients { if err := oathclients.CheckClientIntegrity(name, client); err != nil { return err } } // Check Options. opts := makeConfigOptions(cfg.Options) descs := opts.ComputedDescriptors if err := check.CheckStringOption(opts.ClaimTtlCap, "claimTtlCap", descs); err != nil { return err } if _, err := check.CheckUI(cfg.Ui, true); err != nil { return fmt.Errorf("config root: %v", err) } return nil } func makeConfig(cfg *pb.IcConfig) *pb.IcConfig { out := &pb.IcConfig{} proto.Merge(out, cfg) out.Options = makeConfigOptions(cfg.Options) return out } func makeConfigOptions(opts *pb.ConfigOptions) *pb.ConfigOptions { out := &pb.ConfigOptions{} if opts != nil { proto.Merge(out, opts) } out.ComputedDescriptors = map[string]*cpb.Descriptor{ "readOnlyMasterRealm": descReadOnlyMasterRealm, "claimTtlCap": descClaimTtlCap, } return out } func receiveConfigOptions(opts *pb.ConfigOptions) *pb.ConfigOptions { out := &pb.ConfigOptions{} if opts != nil { proto.Merge(out, opts) out.ComputedDescriptors = nil } return out } func makeIdentityProvider(idp *cpb.IdentityProvider) *cpb.IdentityProvider { return &cpb.IdentityProvider{ Issuer: idp.Issuer, Ui: idp.Ui, } } func (s *Service) makeAccount(ctx context.Context, acct *cpb.Account, cfg *pb.IcConfig, secrets *pb.IcSecrets) *cpb.Account { out := &cpb.Account{} proto.Merge(out, acct) out.State = "" out.ConnectedAccounts = []*cpb.ConnectedAccount{} for _, ca := range acct.ConnectedAccounts { out.ConnectedAccounts = append(out.ConnectedAccounts, s.makeConnectedAccount(ctx, ca, cfg, secrets)) } return out } func (s *Service) makeConnectedAccount(ctx context.Context, ca *cpb.ConnectedAccount, cfg *pb.IcConfig, secrets *pb.IcSecrets) *cpb.ConnectedAccount { out := &cpb.ConnectedAccount{} proto.Merge(out, ca) if out.Passport == nil { out.Passport = &cpb.Passport{} } out.Passport.InternalEncryptedVisas = nil jwts, err := s.decryptEmbeddedTokens(ctx, ca.Passport.InternalEncryptedVisas) if err == nil { for _, jwt := range jwts { visa, err := ga4gh.NewVisaFromJWT(ga4gh.VisaJWT(jwt)) if err != nil { continue } out.Passport.Ga4GhAssertions = append(out.Passport.Ga4GhAssertions, visa.AssertionProto()) } } if idp, ok := cfg.IdentityProviders[ca.Provider]; ok { out.ComputedIdentityProvider = makeIdentityProvider(idp) } if len(out.Provider) > 0 && out.Properties != nil && len(out.Properties.Subject) > 0 { out.ComputedLoginHint = makeLoginHint(out.Provider, out.Properties.Subject) } return out } func makeLoginHint(provider, subject string) string { return provider + ":" + subject } func (s *Service) saveNewLinkedAccount(newAcct *cpb.Account, id *ga4gh.Identity, realm, desc string, r *http.Request, tx storage.Tx, lookup *cpb.AccountLookup) error { if err := s.scim.SaveAccount(nil, newAcct, desc, id.Subject, realm, r, tx); err != nil { return fmt.Errorf("service dependencies not available; try again later") } if err := s.scim.SaveAccountLookup(lookup, realm, id.Subject, r, id, tx); err != nil { return fmt.Errorf("service dependencies not available; try again later") } return nil } func validateURLs(input map[string]string) error { for k, v := range input { if !strutil.IsURL(v) { return fmt.Errorf("%q value %q is not a URL", k, v) } } return nil } func getDurationOption(d string, desc *cpb.Descriptor) time.Duration { if desc == nil || len(desc.DefaultValue) == 0 { return timeutil.ParseDurationWithDefault(d, noDuration) } defVal, err := timeutil.ParseDuration(desc.DefaultValue) if err != nil || defVal == 0 { return timeutil.ParseDurationWithDefault(d, noDuration) } return timeutil.ParseDurationWithDefault(d, defVal) } func getIntOption(val int32, desc *cpb.Descriptor) int { if val != 0 || desc == nil || len(desc.DefaultValue) == 0 { return int(val) } defVal, _ := strconv.ParseInt(desc.DefaultValue, 10, 64) return int(defVal) } func configRevision(mod *pb.ConfigModification, cfg *pb.IcConfig) error { if mod != nil && mod.Revision > 0 && mod.Revision != cfg.Revision { return fmt.Errorf("request revision %d is out of date with current config revision %d", mod.Revision, cfg.Revision) } return nil } ////////////////////////////////////////////////////////////////// func (s *Service) loadConfig(tx storage.Tx, realm string) (*pb.IcConfig, error) { cfg := &pb.IcConfig{} _, err := s.realmReadTx(storage.ConfigDatatype, realm, storage.DefaultUser, storage.DefaultID, storage.LatestRev, cfg, tx) if err != nil { return nil, fmt.Errorf("cannot load %q file: %v", storage.ConfigDatatype, err) } if err := normalizeConfig(cfg); err != nil { return nil, fmt.Errorf("invalid %q file: %v", storage.ConfigDatatype, err) } // glog.Infof("loaded IC config: %+v", cfg) return cfg, nil } func (s *Service) saveConfig(cfg *pb.IcConfig, desc, resType string, r *http.Request, id *ga4gh.Identity, orig, update proto.Message, modification *pb.ConfigModification, tx storage.Tx) error { if modification != nil && modification.DryRun { return nil } cfg.Revision++ cfg.CommitTime = float64(time.Now().UnixNano()) / 1e9 if err := s.store.WriteTx(storage.ConfigDatatype, getRealm(r), storage.DefaultUser, storage.DefaultID, cfg.Revision, cfg, storage.MakeConfigHistory(desc, resType, cfg.Revision, cfg.CommitTime, r, id.Subject, orig, update), tx); err != nil { return fmt.Errorf("service storage unavailable: %v, retry later", err) } return nil } func (s *Service) loadSecrets(tx storage.Tx) (*pb.IcSecrets, error) { secrets := &pb.IcSecrets{} _, err := s.realmReadTx(storage.SecretsDatatype, storage.DefaultRealm, storage.DefaultUser, storage.DefaultID, storage.LatestRev, secrets, tx) if err != nil { return nil, err } return secrets, nil } func (s *Service) saveSecrets(secrets *pb.IcSecrets, desc, resType string, r *http.Request, id *ga4gh.Identity, tx storage.Tx) error { secrets.Revision++ secrets.CommitTime = float64(time.Now().UnixNano()) / 1e9 if err := s.store.WriteTx(storage.SecretsDatatype, storage.DefaultRealm, storage.DefaultUser, storage.DefaultID, secrets.Revision, secrets, storage.MakeConfigHistory(desc, resType, secrets.Revision, secrets.CommitTime, r, id.Subject, nil, nil), tx); err != nil { return fmt.Errorf("service storage unavailable: %v, retry later", err) } return nil } func (s *Service) singleRealmReadTx(datatype, realm, user, id string, rev int64, item proto.Message, tx storage.Tx) (int, error) { err := s.store.ReadTx(datatype, realm, user, id, rev, item, tx) if err == nil { return http.StatusOK, nil } if storage.ErrNotFound(err) { if len(id) > 0 && id != storage.DefaultID { return http.StatusNotFound, fmt.Errorf("%s %q not found", datatype, id) } return http.StatusNotFound, fmt.Errorf("%s not found", datatype) } return http.StatusServiceUnavailable, fmt.Errorf("service storage unavailable: %v, retry later", err) } func (s *Service) realmReadTx(datatype, realm, user, id string, rev int64, item proto.Message, tx storage.Tx) (int, error) { err := s.store.ReadTx(datatype, realm, user, id, rev, item, tx) if err == nil { return http.StatusOK, nil } if storage.ErrNotFound(err) && realm != storage.DefaultRealm { err = s.store.ReadTx(datatype, storage.DefaultRealm, user, id, rev, item, tx) if err == nil { return http.StatusOK, nil } } if storage.ErrNotFound(err) { if len(id) > 0 && id != storage.DefaultID { return http.StatusNotFound, fmt.Errorf("%s %q not found", datatype, id) } return http.StatusNotFound, fmt.Errorf("%s not found", datatype) } return http.StatusServiceUnavailable, fmt.Errorf("service storage unavailable: %v, retry later", err) } func isLookupActive(lookup *cpb.AccountLookup) bool { return lookup != nil && lookup.State == storage.StateActive } func normalizeConfig(cfg *pb.IcConfig) error { return nil } type damArgs struct { clientId string clientSecret string persona string } // ImportConfig ingests bootstrap configuration files to the IC's storage sytem. func ImportConfig(store storage.Store, service string, cfgVars map[string]string, importConfig, importSecrets, importPermission bool) (ferr error) { tx, err := store.Tx(true) if err != nil { return err } defer func() { err := tx.Finish() if ferr == nil { ferr = err } }() glog.Infof("import IC config into data store") history := &cpb.HistoryEntry{ Revision: 1, User: "admin", CommitTime: float64(time.Now().Unix()), Desc: "Inital config", } info := store.Info() path := info["path"] if service == "" || path == "" { return nil } fs := storage.NewFileStorage(service, path) if importConfig { cfg := &pb.IcConfig{} if err = fs.Read(storage.ConfigDatatype, storage.DefaultRealm, storage.DefaultUser, storage.DefaultID, storage.LatestRev, cfg); err != nil { return err } history.Revision = cfg.Revision if err = storage.ReplaceContentVariables(cfg, cfgVars); err != nil { return fmt.Errorf("replacing variables on config file: %v", err) } if err = store.WriteTx(storage.ConfigDatatype, storage.DefaultRealm, storage.DefaultUser, storage.DefaultID, cfg.Revision, cfg, history, tx); err != nil { return err } } if importSecrets { secrets := &pb.IcSecrets{} if err = fs.Read(storage.SecretsDatatype, storage.DefaultRealm, storage.DefaultUser, storage.DefaultID, storage.LatestRev, secrets); err != nil { return err } history.Revision = secrets.Revision if err = storage.ReplaceContentVariables(secrets, cfgVars); err != nil { return fmt.Errorf("replacing variables on secrets file: %v", err) } if err = store.WriteTx(storage.SecretsDatatype, storage.DefaultRealm, storage.DefaultUser, storage.DefaultID, secrets.Revision, secrets, history, tx); err != nil { return err } } if importPermission { perm := &cpb.Permissions{} if err = fs.Read(storage.PermissionsDatatype, storage.DefaultRealm, storage.DefaultUser, storage.DefaultID, storage.LatestRev, perm); err != nil { return err } history.Revision = perm.Revision if err = storage.ReplaceContentVariables(perm, cfgVars); err != nil { return fmt.Errorf("replacing variables on permissions file: %v", err) } if err = store.WriteTx(storage.PermissionsDatatype, storage.DefaultRealm, storage.DefaultUser, storage.DefaultID, perm.Revision, perm, history, tx); err != nil { return err } } return nil } func configExists(store storage.Store) (bool, error) { return store.Exists(storage.SecretsDatatype, storage.DefaultRealm, storage.DefaultUser, storage.DefaultID, storage.LatestRev) } // TODO: move registeration of endpoints to main package. func registerHandlers(r *mux.Router, s *Service) { // static files sfs := http.StripPrefix(staticFilePath, http.FileServer(http.Dir(srcutil.Path(staticDirectory)))) r.PathPrefix(staticFilePath).Handler(sfs) // oidc login flow endpoints r.HandleFunc(loginPath, auth.MustWithAuth(s.Login, s.checker, auth.RequireNone)).Methods(http.MethodGet) r.HandleFunc(finishLoginPath, auth.MustWithAuth(s.FinishLogin, s.checker, auth.RequireNone)).Methods(http.MethodGet) r.HandleFunc(acceptInformationReleasePath, auth.MustWithAuth(s.AcceptInformationRelease, s.checker, auth.RequireNone)).Methods(http.MethodPost) r.HandleFunc(rejectInformationReleasePath, auth.MustWithAuth(s.RejectInformationRelease, s.checker, auth.RequireNone)).Methods(http.MethodPost) r.HandleFunc(acceptLoginPath, auth.MustWithAuth(s.AcceptLogin, s.checker, auth.RequireNone)).Methods(http.MethodGet) // hydra related oidc endpoints r.HandleFunc(hydraLoginPath, auth.MustWithAuth(s.HydraLogin, s.checker, auth.RequireNone)).Methods(http.MethodGet) r.HandleFunc(hydraConsentPath, auth.MustWithAuth(s.HydraConsent, s.checker, auth.RequireNone)).Methods(http.MethodGet) // CLI login endpoints cliAuthURL := urlPathJoin(s.getDomainURL(), cliAuthPath) hydraAuthURL := urlPathJoin(s.hydraPublicURL, oauthAuthPath) hydraTokenURL := urlPathJoin(s.hydraPublicURL, oauthTokenPath) cliAcceptURL := urlPathJoin(s.getDomainURL(), cliAcceptPath) r.HandleFunc(cliRegisterPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.GetStore(), cli.RegisterFactory(s.GetStore(), cliRegisterPath, s.encryption, cliAuthURL, s.hydraPublicURL, hydraAuthURL, hydraTokenURL, cliAcceptURL, http.DefaultClient)), s.checker, auth.RequireClientIDAndSecret)) r.HandleFunc(cliAuthPath, auth.MustWithAuth(cli.NewAuthHandler(s.GetStore()).Handle, s.checker, auth.RequireNone)).Methods(http.MethodGet) r.HandleFunc(cliAcceptPath, auth.MustWithAuth(s.cliAcceptHandler.Handle, s.checker, auth.RequireNone)).Methods(http.MethodGet) // info endpoints r.HandleFunc(infoPath, auth.MustWithAuth(s.Status, s.checker, auth.RequireNone)).Methods(http.MethodGet) r.HandleFunc(jwksPath, auth.MustWithAuth(s.JWKS, s.checker, auth.RequireNone)).Methods(http.MethodGet) // administration endpoints r.HandleFunc(realmPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.GetStore(), s.realmFactory()), s.checker, auth.RequireAdminTokenClientCredential)) r.HandleFunc(configPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.GetStore(), s.configFactory()), s.checker, auth.RequireAdminTokenClientCredential)) r.HandleFunc(configIdentityProvidersPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.GetStore(), s.configIdpFactory()), s.checker, auth.RequireAdminTokenClientCredential)) r.HandleFunc(configClientsPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.GetStore(), s.configClientFactory()), s.checker, auth.RequireAdminTokenClientCredential)) r.HandleFunc(configOptionsPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.GetStore(), s.configOptionsFactory()), s.checker, auth.RequireAdminTokenClientCredential)) r.HandleFunc(configResetPath, auth.MustWithAuth(s.ConfigReset, s.checker, auth.RequireAdminTokenClientCredential)).Methods(http.MethodGet) r.HandleFunc(configHistoryPath, auth.MustWithAuth(s.ConfigHistory, s.checker, auth.RequireAdminTokenClientCredential)).Methods(http.MethodGet) r.HandleFunc(configHistoryRevisionPath, auth.MustWithAuth(s.ConfigHistoryRevision, s.checker, auth.RequireAdminTokenClientCredential)).Methods(http.MethodGet) // light-weight admin functions using client_id, client_secret and client scope to limit use r.HandleFunc(syncClientsPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.GetStore(), s.syncClientsFactory()), s.checker, auth.RequireClientIDAndSecret)) // readonly config endpoints r.HandleFunc(identityProvidersPath, auth.MustWithAuth(s.IdentityProviders, s.checker, auth.RequireClientIDAndSecret)).Methods(http.MethodGet) r.HandleFunc(localeMetadataPath, auth.MustWithAuth(s.LocaleMetadata, s.checker, auth.RequireClientIDAndSecret)).Methods(http.MethodGet) r.HandleFunc(translatorsPath, auth.MustWithAuth(s.PassportTranslators, s.checker, auth.RequireClientIDAndSecret)).Methods(http.MethodGet) r.HandleFunc(clientPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.GetStore(), s.clientFactory()), s.checker, auth.RequireClientIDAndSecret)) // scim service endpoints r.HandleFunc(scimGroupPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.GetStore(), scim.GroupFactory(s.GetStore(), scimGroupPath)), s.checker, auth.RequireAdminTokenClientCredential)) r.HandleFunc(scimGroupsPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.GetStore(), scim.GroupsFactory(s.GetStore(), scimGroupsPath)), s.checker, auth.RequireAdminTokenClientCredential)) r.HandleFunc(scimMePath, auth.MustWithAuth(handlerfactory.MakeHandler(s.GetStore(), scim.MeFactory(s.GetStore(), s.getDomainURL(), scimMePath)), s.checker, auth.RequireAccountAdminUserTokenCredential)) r.HandleFunc(scimUserPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.GetStore(), scim.UserFactory(s.GetStore(), s.getDomainURL(), scimUserPath)), s.checker, auth.RequireAccountAdminUserTokenCredential)) r.HandleFunc(scimUsersPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.GetStore(), scim.UsersFactory(s.GetStore(), s.getDomainURL(), scimUsersPath)), s.checker, auth.RequireAdminTokenClientCredential)) // token service endpoints r.HandleFunc(tokensPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.store, tokensapi.ListTokensFactory(tokensPath, s.tokenProviders, s.store)), s.checker, auth.RequireUserTokenClientCredential)).Methods(http.MethodGet) r.HandleFunc(tokenPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.store, tokensapi.DeleteTokenFactory(tokenPath, s.tokenProviders, s.store)), s.checker, auth.RequireUserTokenClientCredential)).Methods(http.MethodDelete) // consents service endpoints consentService := s.consentService() r.HandleFunc(listConsentPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.GetStore(), consentsapi.ListConsentsFactory(consentService, listConsentPath)), s.checker, auth.RequireUserTokenClientCredential)).Methods(http.MethodGet) r.HandleFunc(deleteConsentPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.GetStore(), consentsapi.DeleteConsentFactory(consentService, deleteConsentPath, true)), s.checker, auth.RequireUserTokenClientCredential)).Methods(http.MethodDelete) // audit logs endpoints r.HandleFunc(auditlogsPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.store, auditlogsapi.ListAuditlogsPathFactory(auditlogsPath, s.auditlogs)), s.checker, auth.RequireUserTokenClientCredential)).Methods(http.MethodGet) // legacy endpoints r.HandleFunc(adminClaimsPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.GetStore(), s.adminClaimsFactory()), s.checker, auth.RequireAdminTokenClientCredential)) r.HandleFunc(adminTokenMetadataPath, auth.MustWithAuth(handlerfactory.MakeHandler(s.GetStore(), s.adminTokenMetadataFactory()), s.checker, auth.RequireAdminTokenClientCredential)) // proxy hydra oauth token endpoint if s.hydraPublicURLProxy != nil { r.HandleFunc(oauthTokenPath, s.hydraPublicURLProxy.HydraOAuthToken).Methods(http.MethodPost) } } func urlPathJoin(urlStr, pathStr string) string { // Niether path.Join nor url.Parse()...String() does the right thing. // Just append. s1 := strings.HasSuffix(urlStr, "/") s2 := strings.HasPrefix(pathStr, "/") if !s1 && !s2 { return urlStr + "/" + pathStr } if s1 && s2 { return urlStr + pathStr[1:] } return urlStr + pathStr }