From 73a44a6bc1598b039dd6b2e265af69e918f853f5 Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Mon, 19 Dec 2022 22:36:56 +0000 Subject: [PATCH 001/954] Enables resubmissions for the k8s runner --- lib/galaxy/jobs/runners/kubernetes.py | 31 +++++++++++++++++++-------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/lib/galaxy/jobs/runners/kubernetes.py b/lib/galaxy/jobs/runners/kubernetes.py index 1f9e984605c3..87a28d815b8c 100644 --- a/lib/galaxy/jobs/runners/kubernetes.py +++ b/lib/galaxy/jobs/runners/kubernetes.py @@ -95,7 +95,8 @@ def __init__(self, app, nworkers, **kwargs): map=str, valid=lambda s: s == "$gid" or isinstance(s, int) or not s or s.isdigit(), default=None ), k8s_cleanup_job=dict(map=str, valid=lambda s: s in {"onsuccess", "always", "never"}, default="always"), - k8s_pod_retries=dict(map=int, valid=lambda x: int(x) >= 0, default=3), + k8s_pod_retries=dict(map=int, valid=lambda x: int(x) >= 0, default=1), # note that if the backOffLimit is lower, this paramer will have not effect. + k8s_job_spec_back_off_limit=dict(map=int, valid=lambda x: int(x) >= 0, default=0), # this means that it will stop retrying after 1 failure. k8s_walltime_limit=dict(map=int, valid=lambda x: int(x) >= 0, default=172800), k8s_unschedulable_walltime_limit=dict(map=int, valid=lambda x: not x or int(x) >= 0, default=None), k8s_interactivetools_use_ssl=dict(map=bool, default=False), @@ -323,6 +324,7 @@ def __get_k8s_job_spec(self, ajs): job_ttl = self.runner_params["k8s_job_ttl_secs_after_finished"] if self.runner_params["k8s_cleanup_job"] != "never" and job_ttl is not None: k8s_job_spec["ttlSecondsAfterFinished"] = job_ttl + k8s_job_spec["backoffLimit"] = self.runner_params["k8s_job_spec_back_off_limit"] return k8s_job_spec def __force_label_conformity(self, value): @@ -526,7 +528,7 @@ def __get_k8s_containers(self, ajs): # command line execution, separated by ;, which is what Galaxy does # to assemble the command. "command": [ajs.job_wrapper.shell], - "args": ["-c", ajs.job_file], + "args": ["-c", f"{ajs.job_file}; exit $(cat {ajs.exit_code_file})"], "workingDir": ajs.job_wrapper.working_directory, "volumeMounts": deduplicate_entries(mounts), } @@ -715,6 +717,10 @@ def check_watched_item(self, job_state): else: max_pod_retries = 1 + # make sure that we don't have any conditions by which the runner + # would wait forever for a pod that never gets sent. + max_pod_retries = min(max_pod_retries, self.runner_params["k8s_job_spec_back_off_limit"]) + # Check if job.obj['status'] is empty, # return job_state unchanged if this is the case # as probably this means that the k8s API server hasn't @@ -736,7 +742,7 @@ def check_watched_item(self, job_state): job_state.running = False self.mark_as_finished(job_state) return None - elif active > 0 and failed <= max_pod_retries: + elif active > 0 and failed < max_pod_retries + 1: if not job_state.running: if self.__job_pending_due_to_unschedulable_pod(job_state): if self.runner_params.get("k8s_unschedulable_walltime_limit"): @@ -760,7 +766,10 @@ def check_watched_item(self, job_state): job_state.job_wrapper.cleanup() return None else: - return self._handle_job_failure(job, job_state) + self._handle_job_failure(job, job_state) + # changes for resubmission (removed self.mark_as_failed from handle_job_failure) + self.work_queue.put((self.mark_as_failed, job_state)) + return None elif len(jobs.response["items"]) == 0: if job_state.job_wrapper.get_job().state == model.Job.states.DELETED: @@ -798,6 +807,8 @@ def _handle_unschedulable_job(self, job, job_state): def _handle_job_failure(self, job, job_state): # Figure out why job has failed with open(job_state.error_file, "a") as error_file: + # TODO we need to remove probably these error_file.writes, as they remove the stderr / stdout capture + # from failed Galaxy k8s jobs. if self.__job_failed_due_to_low_memory(job_state): error_file.write("Job killed after running out of memory. Try with more memory.\n") job_state.fail_message = "Tool failed due to insufficient memory. Try with more memory." @@ -809,8 +820,9 @@ def _handle_job_failure(self, job, job_state): else: error_file.write("Exceeded max number of Kubernetes pod retries allowed for job\n") job_state.fail_message = "More pods failed than allowed. See stdout for pods details." - job_state.running = False - self.mark_as_failed(job_state) + # changes for resubmission, to mimick what happens in the LSF-cli runner + # job_state.running = False + # self.mark_as_failed(job_state) try: if self.__has_guest_ports(job_state.job_wrapper): self.__cleanup_k8s_guest_ports(job_state.job_wrapper, job) @@ -855,11 +867,12 @@ def __job_failed_due_to_low_memory(self, job_state): if not pods.response["items"]: return False - pod = self._get_pod_for_job(job_state) + # pod = self._get_pod_for_job(job_state) # this was always None + pod = pods.response["items"][0] if ( pod - and pod.obj["status"]["phase"] == "Failed" - and pod.obj["status"]["containerStatuses"][0]["state"]["terminated"]["reason"] == "OOMKilled" + and "terminated" in pod["status"]["containerStatuses"][0]["state"] + and pod["status"]["containerStatuses"][0]["state"]["terminated"]["reason"] == "OOMKilled" ): return True From 4ed002540cafc6ef7411cf4ca9838f2a4680d24f Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Mon, 27 Feb 2023 11:38:05 +0000 Subject: [PATCH 002/954] Fix detection of stderr / stdout and placement on UI --- lib/galaxy/jobs/runners/kubernetes.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/lib/galaxy/jobs/runners/kubernetes.py b/lib/galaxy/jobs/runners/kubernetes.py index 87a28d815b8c..4cb85284bfc7 100644 --- a/lib/galaxy/jobs/runners/kubernetes.py +++ b/lib/galaxy/jobs/runners/kubernetes.py @@ -741,10 +741,12 @@ def check_watched_item(self, job_state): if succeeded > 0 or job_state == model.Job.states.STOPPED: job_state.running = False self.mark_as_finished(job_state) + log.debug("k8s job succeeded") return None elif active > 0 and failed < max_pod_retries + 1: if not job_state.running: if self.__job_pending_due_to_unschedulable_pod(job_state): + log.debug("k8s job pending..") if self.runner_params.get("k8s_unschedulable_walltime_limit"): creation_time_str = job.obj["metadata"].get("creationTimestamp") creation_time = datetime.strptime(creation_time_str, "%Y-%m-%dT%H:%M:%SZ") @@ -756,19 +758,28 @@ def check_watched_item(self, job_state): else: pass else: + log.debug("k8s job is running..") job_state.running = True job_state.job_wrapper.change_state(model.Job.states.RUNNING) return job_state elif job_persisted_state == model.Job.states.DELETED: # Job has been deleted via stop_job and job has not been deleted, # remove from watched_jobs by returning `None` + log.debug("PP Job is DELETED..") if job_state.job_wrapper.cleanup_job in ("always", "onsuccess"): job_state.job_wrapper.cleanup() return None else: + log.debug("k8s job is failed and not deleted, looking at failure") self._handle_job_failure(job, job_state) # changes for resubmission (removed self.mark_as_failed from handle_job_failure) self.work_queue.put((self.mark_as_failed, job_state)) + # If the job was not resubmitted after being put in the failed queue, + # we mark it as finished as well for stderr / stdout detection. + # Otherwise, the user doesn't see any stdout/stderr in the UI. + if job_state.job_wrapper.get_state() != model.Job.states.RESUBMITTED: + self.mark_as_finished(job_state) + return None elif len(jobs.response["items"]) == 0: @@ -807,22 +818,21 @@ def _handle_unschedulable_job(self, job, job_state): def _handle_job_failure(self, job, job_state): # Figure out why job has failed with open(job_state.error_file, "a") as error_file: - # TODO we need to remove probably these error_file.writes, as they remove the stderr / stdout capture - # from failed Galaxy k8s jobs. + log.debug("Trying with error file in _handle_job_failure") if self.__job_failed_due_to_low_memory(job_state): + log.debug("OOM condition reached") error_file.write("Job killed after running out of memory. Try with more memory.\n") job_state.fail_message = "Tool failed due to insufficient memory. Try with more memory." job_state.runner_state = JobState.runner_states.MEMORY_LIMIT_REACHED elif self.__job_failed_due_to_walltime_limit(job): + log.debug("Walltime condition reached") error_file.write("DeadlineExceeded") job_state.fail_message = "Job was active longer than specified deadline" job_state.runner_state = JobState.runner_states.WALLTIME_REACHED else: - error_file.write("Exceeded max number of Kubernetes pod retries allowed for job\n") - job_state.fail_message = "More pods failed than allowed. See stdout for pods details." - # changes for resubmission, to mimick what happens in the LSF-cli runner - # job_state.running = False - # self.mark_as_failed(job_state) + log.debug("Runner cannot detect a specific reason for failure, must be a tool failure.") + error_file.write("Exceeded max number of job retries allowed for job\n") + job_state.fail_message = "More job retries failed than allowed. See standard output and standard error within the info section for details." try: if self.__has_guest_ports(job_state.job_wrapper): self.__cleanup_k8s_guest_ports(job_state.job_wrapper, job) @@ -962,6 +972,7 @@ def recover(self, job, job_wrapper): ajs.old_state = model.Job.states.QUEUED ajs.running = False self.monitor_queue.put(ajs) + def finish_job(self, job_state): self._handle_metadata_externally(job_state.job_wrapper, resolve_requirements=True) From e4aeae0892ad88858ed228861ad99f295e74557e Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Wed, 8 Mar 2023 18:06:44 +0000 Subject: [PATCH 003/954] Everything working --- lib/galaxy/jobs/runners/kubernetes.py | 196 ++++++++++++++++++++------ 1 file changed, 152 insertions(+), 44 deletions(-) diff --git a/lib/galaxy/jobs/runners/kubernetes.py b/lib/galaxy/jobs/runners/kubernetes.py index 4cb85284bfc7..da212d2a68e6 100644 --- a/lib/galaxy/jobs/runners/kubernetes.py +++ b/lib/galaxy/jobs/runners/kubernetes.py @@ -3,14 +3,19 @@ """ import logging +import json # for debugging of API objects import math import os import re +import time from datetime import datetime import yaml from galaxy import model +from galaxy.util import ( + unicodify, +) from galaxy.jobs.runners import ( AsynchronousJobRunner, AsynchronousJobState, @@ -214,7 +219,11 @@ def queue_job(self, job_wrapper): self.monitor_queue.put(ajs) def __has_guest_ports(self, job_wrapper): - return bool(job_wrapper.guest_ports) + # Check if job has guest ports or interactive tool entry points that would signal that + # this is an interactive tool. + log.debug(f"Checking if job {job_wrapper.get_id_tag()} has guest ports: {job_wrapper.guest_ports}") + log.debug(f"Checking if job {job_wrapper.get_id_tag()} has interactive entry points: {job_wrapper.guest_ports}") + return bool(job_wrapper.guest_ports) or bool(job_wrapper.get_job().interactivetool_entry_points) def __configure_port_routing(self, ajs): # Configure interactive tool entry points first @@ -231,9 +240,19 @@ def __configure_port_routing(self, ajs): k8s_service_obj = service_object_dict(self.runner_params, k8s_job_name, self.__get_k8s_service_spec(ajs)) k8s_ingress_obj = ingress_object_dict(self.runner_params, k8s_job_name, self.__get_k8s_ingress_spec(ajs)) + # pretty print the objects for debugging + log.debug(f"Kubernetes service object: {json.dumps(k8s_service_obj, indent=4)}") + log.debug(f"Kubernetes ingress object: {json.dumps(k8s_ingress_obj, indent=4)}") + # We avoid creating service and ingress if they already exist (e.g. when Galaxy is restarted or resubmitting a job) service = Service(self._pykube_api, k8s_service_obj) + # if service.exists(): + # log.debug(f"Service {k8s_job_name} already exists, skipping creation") + # else: service.create() ingress = Ingress(self._pykube_api, k8s_ingress_obj) + # if ingress.exists(): + # log.debug(f"Ingress {k8s_job_name} already exists, skipping creation") + # else: ingress.create() def __get_overridable_params(self, job_wrapper, param_key): @@ -456,26 +475,27 @@ def __get_k8s_ingress_spec(self, ajs): "annotations": {"app.galaxyproject.org/tool_id": ajs.job_wrapper.tool.id}, }, "spec": { - "rules": [ - { - "host": ep["domain"], - "http": { - "paths": [ - { - "backend": { - "serviceName": self.__get_k8s_job_name( - self.__produce_k8s_job_prefix(), ajs.job_wrapper - ), - "servicePort": int(ep["tool_port"]), - }, - "path": ep.get("entry_path", "/"), - "pathType": "Prefix", - } - ] - }, - } - for ep in entry_points - ] + "ingressClassName": "nginx", + "rules":[ { + "host": ep["domain"], + "http": { + "paths": [ { + "backend": { + "service": { + "name": self.__get_k8s_job_name( + self.__produce_k8s_job_prefix(), ajs.job_wrapper + ), + "port": { "number": int(ep["tool_port"])} + } + }, + "path": ep.get("entry_path", "/"), + "pathType": "Prefix" + } + ] + }, + } + for ep in entry_points + ] }, } if self.runner_params.get("k8s_interactivetools_use_ssl"): @@ -741,12 +761,12 @@ def check_watched_item(self, job_state): if succeeded > 0 or job_state == model.Job.states.STOPPED: job_state.running = False self.mark_as_finished(job_state) - log.debug("k8s job succeeded") + log.debug("Job succeeded") return None elif active > 0 and failed < max_pod_retries + 1: if not job_state.running: if self.__job_pending_due_to_unschedulable_pod(job_state): - log.debug("k8s job pending..") + log.debug("PP Job pending..") if self.runner_params.get("k8s_unschedulable_walltime_limit"): creation_time_str = job.obj["metadata"].get("creationTimestamp") creation_time = datetime.strptime(creation_time_str, "%Y-%m-%dT%H:%M:%SZ") @@ -758,39 +778,39 @@ def check_watched_item(self, job_state): else: pass else: - log.debug("k8s job is running..") + log.debug("Job set to running..") job_state.running = True job_state.job_wrapper.change_state(model.Job.states.RUNNING) return job_state elif job_persisted_state == model.Job.states.DELETED: # Job has been deleted via stop_job and job has not been deleted, # remove from watched_jobs by returning `None` - log.debug("PP Job is DELETED..") + log.debug("Job is DELETED..") if job_state.job_wrapper.cleanup_job in ("always", "onsuccess"): job_state.job_wrapper.cleanup() return None else: - log.debug("k8s job is failed and not deleted, looking at failure") + log.debug(f"Job is failed and not deleted, looking at failure") + log.debug(f"Job state before handle job failure: {job_state.job_wrapper.get_state()}") self._handle_job_failure(job, job_state) # changes for resubmission (removed self.mark_as_failed from handle_job_failure) self.work_queue.put((self.mark_as_failed, job_state)) - # If the job was not resubmitted after being put in the failed queue, - # we mark it as finished as well for stderr / stdout detection. - # Otherwise, the user doesn't see any stdout/stderr in the UI. - if job_state.job_wrapper.get_state() != model.Job.states.RESUBMITTED: - self.mark_as_finished(job_state) return None elif len(jobs.response["items"]) == 0: if job_state.job_wrapper.get_job().state == model.Job.states.DELETED: - # Job has been deleted via stop_job and job has been deleted, - # cleanup and remove from watched_jobs by returning `None` if job_state.job_wrapper.cleanup_job in ("always", "onsuccess"): job_state.job_wrapper.cleanup() return None + if job_state.job_wrapper.get_job().state == model.Job.states.STOPPED and self.__has_guest_ports(job_state.job_wrapper): + # Interactive job has been stopped via stop_job (most likely by the user), + # cleanup and remove from watched_jobs by returning `None`. STOPPED jobs are cleaned up elsewhere. + # Marking as finished makes sure that the interactive job output is available in the UI. + self.mark_as_finished(job_state) + return None # there is no job responding to this job_id, it is either lost or something happened. - log.error("No Jobs are available under expected selector app=%s", job_state.job_id) + log.error(f"No Jobs are available under expected selector app={job_state.job_id} and they are not deleted or stopped either.") self.mark_as_failed(job_state) # job is no longer viable - remove from watched jobs return None @@ -818,21 +838,24 @@ def _handle_unschedulable_job(self, job, job_state): def _handle_job_failure(self, job, job_state): # Figure out why job has failed with open(job_state.error_file, "a") as error_file: - log.debug("Trying with error file in _handle_job_failure") + log.debug("PP Trying with error file in _handle_job_failure") if self.__job_failed_due_to_low_memory(job_state): - log.debug("OOM condition reached") + log.debug("PP OOM reached!") error_file.write("Job killed after running out of memory. Try with more memory.\n") job_state.fail_message = "Tool failed due to insufficient memory. Try with more memory." job_state.runner_state = JobState.runner_states.MEMORY_LIMIT_REACHED elif self.__job_failed_due_to_walltime_limit(job): - log.debug("Walltime condition reached") + log.debug("PP checking for walltime") error_file.write("DeadlineExceeded") job_state.fail_message = "Job was active longer than specified deadline" job_state.runner_state = JobState.runner_states.WALLTIME_REACHED else: - log.debug("Runner cannot detect a specific reason for failure, must be a tool failure.") + log.debug("PP no idea!") error_file.write("Exceeded max number of job retries allowed for job\n") - job_state.fail_message = "More job retries failed than allowed. See standard output and standard error within the info section for details." + job_state.fail_message = "More job retries failed than allowed. See standard output within the info section for details." + # changes for resubmission + # job_state.running = False + # self.mark_as_failed(job_state) try: if self.__has_guest_ports(job_state.job_wrapper): self.__cleanup_k8s_guest_ports(job_state.job_wrapper, job) @@ -845,11 +868,11 @@ def __cleanup_k8s_job(self, job): k8s_cleanup_job = self.runner_params["k8s_cleanup_job"] delete_job(job, k8s_cleanup_job) - def __cleanup_k8s_ingress(self, ingress, job_failed): + def __cleanup_k8s_ingress(self, ingress, job_failed=False): k8s_cleanup_job = self.runner_params["k8s_cleanup_job"] delete_ingress(ingress, k8s_cleanup_job, job_failed) - def __cleanup_k8s_service(self, service, job_failed): + def __cleanup_k8s_service(self, service, job_failed=False): k8s_cleanup_job = self.runner_params["k8s_cleanup_job"] delete_service(service, k8s_cleanup_job, job_failed) @@ -903,32 +926,48 @@ def __cleanup_k8s_guest_ports(self, job_wrapper, k8s_job): k8s_job_prefix = self.__produce_k8s_job_prefix() k8s_job_name = f"{k8s_job_prefix}-{self.__force_label_conformity(job_wrapper.get_id_tag())}" log.debug(f"Deleting service/ingress for job with ID {job_wrapper.get_id_tag()}") - job_failed = k8s_job.obj["status"]["failed"] > 0 if "failed" in k8s_job.obj["status"] else False ingress_to_delete = find_ingress_object_by_name( self._pykube_api, k8s_job_name, self.runner_params["k8s_namespace"] ) if ingress_to_delete and len(ingress_to_delete.response["items"]) > 0: k8s_ingress = Ingress(self._pykube_api, ingress_to_delete.response["items"][0]) - self.__cleanup_k8s_ingress(k8s_ingress, job_failed) + self.__cleanup_k8s_ingress(k8s_ingress) + else: + log.debug(f"No ingress found for job with k8s_job_name {k8s_job_name}") service_to_delete = find_service_object_by_name( self._pykube_api, k8s_job_name, self.runner_params["k8s_namespace"] ) if service_to_delete and len(service_to_delete.response["items"]) > 0: k8s_service = Service(self._pykube_api, service_to_delete.response["items"][0]) - self.__cleanup_k8s_service(k8s_service, job_failed) + self.__cleanup_k8s_service(k8s_service) + else: + log.debug(f"No service found for job with k8s_job_name {k8s_job_name}") + # remove the interactive environment entrypoints + eps = job_wrapper.get_job().interactivetool_entry_points + if eps: + log.debug(f"Removing entry points for job with ID {job_wrapper.get_id_tag()}") + self.app.interactivetool_manager.remove_entry_points(eps) def stop_job(self, job_wrapper): """Attempts to delete a dispatched job to the k8s cluster""" job = job_wrapper.get_job() try: + log.debug(f"Attempting to stop job {job.id} ({job.job_runner_external_id})") job_to_delete = find_job_object_by_name( self._pykube_api, job.get_job_runner_external_id(), self.runner_params["k8s_namespace"] ) if job_to_delete and len(job_to_delete.response["items"]) > 0: k8s_job = Job(self._pykube_api, job_to_delete.response["items"][0]) + log.debug(f"Found job with id {job.get_job_runner_external_id()} to delete") + # For interactive jobs, at this point because the job stopping has been partly handled by the + # interactive tool manager, the job wrapper no longer shows any guest ports. We need another way + # to check if the job is an interactive job. if self.__has_guest_ports(job_wrapper): + log.debug(f"Job {job.id} ({job.job_runner_external_id}) has guest ports, cleaning them up") self.__cleanup_k8s_guest_ports(job_wrapper, k8s_job) self.__cleanup_k8s_job(k8s_job) + else: + log.debug(f"Could not find job with id {job.get_job_runner_external_id()} to delete") # TODO assert whether job parallelism == 0 # assert not job_to_delete.exists(), "Could not delete job,"+job.job_runner_external_id+" it still exists" log.debug(f"({job.id}/{job.job_runner_external_id}) Terminated at user's request") @@ -972,6 +1011,75 @@ def recover(self, job, job_wrapper): ajs.old_state = model.Job.states.QUEUED ajs.running = False self.monitor_queue.put(ajs) + + # added to make sure that stdout and stderr is captured for Kubernetes + def fail_job(self, job_state: "JobState", exception=False, message="Job failed", full_status=None): + log.debug("PP Getting into fail_job in k8s runner") + job = job_state.job_wrapper.get_job() + + #### Get STDOUT and STDERR from the job and tool to be stored in the database #### + #### This is needed because when calling finish_job on a failed job, the check_output method + #### overrides the job error state and tries to figure it out from the job output files + #### breaking OOM resubmissions. + # To ensure that files below are readable, ownership must be reclaimed first + job_state.job_wrapper.reclaim_ownership() + + # wait for the files to appear + which_try = 0 + while which_try < self.app.config.retry_job_output_collection + 1: + try: + with open(job_state.output_file, "rb") as stdout_file, open(job_state.error_file, "rb") as stderr_file: + job_stdout = self._job_io_for_db(stdout_file) + job_stderr = self._job_io_for_db(stderr_file) + break + except Exception as e: + if which_try == self.app.config.retry_job_output_collection: + job_stdout = "" + job_stderr = job_state.runner_states.JOB_OUTPUT_NOT_RETURNED_FROM_CLUSTER + log.error(f"{job.id}/{job.job_runner_external_id} {job_stderr}: {unicodify(e)}") + else: + time.sleep(1) + which_try += 1 + + # get stderr and stdout to database + outputs_directory = os.path.join(job_state.job_wrapper.working_directory, "outputs") + if not os.path.exists(outputs_directory): + outputs_directory = job_state.job_wrapper.working_directory + + tool_stdout_path = os.path.join(outputs_directory, "tool_stdout") + tool_stderr_path = os.path.join(outputs_directory, "tool_stderr") + + # TODO: These might not exist for running jobs at the upgrade to 19.XX, remove that + # assumption in 20.XX. + tool_stderr = None + if os.path.exists(tool_stdout_path): + with open(tool_stdout_path, "rb") as stdout_file: + tool_stdout = self._job_io_for_db(stdout_file) + else: + # Legacy job, were getting a merged output - assume it is mostly tool output. + tool_stdout = job_stdout + job_stdout = None + + tool_stdout = None + if os.path.exists(tool_stderr_path): + with open(tool_stderr_path, "rb") as stdout_file: + tool_stderr = self._job_io_for_db(stdout_file) + else: + # Legacy job, were getting a merged output - assume it is mostly tool output. + tool_stderr = job_stderr + job_stderr = None + + #### END Get STDOUT and STDERR from the job and tool to be stored in the database #### + + # full status empty leaves the UI without stderr/stdout + full_status = { "stderr" : tool_stderr, "stdout" : tool_stdout} + log.debug(f"({job.id}/{job.job_runner_external_id}) tool_stdout: {tool_stdout}") + log.debug(f"({job.id}/{job.job_runner_external_id}) tool_stderr: {tool_stderr}") + log.debug(f"({job.id}/{job.job_runner_external_id}) job_stdout: {job_stdout}") + log.debug(f"({job.id}/{job.job_runner_external_id}) job_stderr: {job_stderr}") + + # run super method + super().fail_job(job_state, exception, message, full_status) def finish_job(self, job_state): From 8100e6966f3709c886c2d51f72af758d83f71027 Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Tue, 3 Jan 2023 12:56:44 +0000 Subject: [PATCH 004/954] Move to pykube-ng (cherry picked from commit 652c04f315e45626570da6420606f74541f0117b) (cherry picked from commit 7d4b0b4b4e15654323aa30835ff307b12d4b40d1) --- lib/galaxy/dependencies/conditional-requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/dependencies/conditional-requirements.txt b/lib/galaxy/dependencies/conditional-requirements.txt index 17b16118f5a8..392cbb23f7c2 100644 --- a/lib/galaxy/dependencies/conditional-requirements.txt +++ b/lib/galaxy/dependencies/conditional-requirements.txt @@ -35,7 +35,8 @@ custos-sdk chronos-python==1.2.1 # Kubernetes job runner -pykube==0.15.0 +# pykube==0.15.0 +pykube-ng==22.9.0 # Synnefo / Pithos+ object store client kamaki From 8ba802083dd12647bbbd92ca8b2cc392672fccaf Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Tue, 3 Jan 2023 22:41:12 +0000 Subject: [PATCH 005/954] Change ingress API version to current one (cherry picked from commit f1b92d911827c897cb8e65a060e99b44f9d4ebf5) --- lib/galaxy/jobs/runners/util/pykube_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/jobs/runners/util/pykube_util.py b/lib/galaxy/jobs/runners/util/pykube_util.py index 7c3f32d87b09..28020a8c8dde 100644 --- a/lib/galaxy/jobs/runners/util/pykube_util.py +++ b/lib/galaxy/jobs/runners/util/pykube_util.py @@ -31,7 +31,7 @@ DEFAULT_JOB_API_VERSION = "batch/v1" DEFAULT_SERVICE_API_VERSION = "v1" -DEFAULT_INGRESS_API_VERSION = "extensions/v1beta1" +DEFAULT_INGRESS_API_VERSION = "networking.k8s.io/v1" DEFAULT_NAMESPACE = "default" INSTANCE_ID_INVALID_MESSAGE = ( "Galaxy instance [%s] is either too long " From c75b8123f7223c3ed9c6bf4ec521f14980ae9743 Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Thu, 9 Mar 2023 08:58:03 +0000 Subject: [PATCH 006/954] Missing stdout (cherry picked from commit a201abbb08bd855ecf85fe8250384e972077cb9b) --- lib/galaxy/jobs/runners/kubernetes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/jobs/runners/kubernetes.py b/lib/galaxy/jobs/runners/kubernetes.py index da212d2a68e6..7c05d094635b 100644 --- a/lib/galaxy/jobs/runners/kubernetes.py +++ b/lib/galaxy/jobs/runners/kubernetes.py @@ -1051,7 +1051,8 @@ def fail_job(self, job_state: "JobState", exception=False, message="Job failed", # TODO: These might not exist for running jobs at the upgrade to 19.XX, remove that # assumption in 20.XX. - tool_stderr = None + tool_stderr = "Galaxy issue: Stderr failed to be retrieved from the job working directory." + tool_stdout = "Galaxy issue: Stdout failed to be retrieved from the job working directory." if os.path.exists(tool_stdout_path): with open(tool_stdout_path, "rb") as stdout_file: tool_stdout = self._job_io_for_db(stdout_file) @@ -1060,7 +1061,6 @@ def fail_job(self, job_state: "JobState", exception=False, message="Job failed", tool_stdout = job_stdout job_stdout = None - tool_stdout = None if os.path.exists(tool_stderr_path): with open(tool_stderr_path, "rb") as stdout_file: tool_stderr = self._job_io_for_db(stdout_file) From 63360bd5690bbf28d371e96be533da2a22a63e3a Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Wed, 15 Mar 2023 09:12:10 +0000 Subject: [PATCH 007/954] Apply suggestions from code review Mostly cleanups from Nuwan and Pablo. Co-authored-by: Nuwan Goonasekera <2070605+nuwang@users.noreply.github.com> --- .../dependencies/conditional-requirements.txt | 1 - lib/galaxy/jobs/runners/kubernetes.py | 32 +++++++------------ 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/lib/galaxy/dependencies/conditional-requirements.txt b/lib/galaxy/dependencies/conditional-requirements.txt index 392cbb23f7c2..40861584fc09 100644 --- a/lib/galaxy/dependencies/conditional-requirements.txt +++ b/lib/galaxy/dependencies/conditional-requirements.txt @@ -35,7 +35,6 @@ custos-sdk chronos-python==1.2.1 # Kubernetes job runner -# pykube==0.15.0 pykube-ng==22.9.0 # Synnefo / Pithos+ object store client diff --git a/lib/galaxy/jobs/runners/kubernetes.py b/lib/galaxy/jobs/runners/kubernetes.py index 7c05d094635b..6d209a1b5b4d 100644 --- a/lib/galaxy/jobs/runners/kubernetes.py +++ b/lib/galaxy/jobs/runners/kubernetes.py @@ -100,7 +100,7 @@ def __init__(self, app, nworkers, **kwargs): map=str, valid=lambda s: s == "$gid" or isinstance(s, int) or not s or s.isdigit(), default=None ), k8s_cleanup_job=dict(map=str, valid=lambda s: s in {"onsuccess", "always", "never"}, default="always"), - k8s_pod_retries=dict(map=int, valid=lambda x: int(x) >= 0, default=1), # note that if the backOffLimit is lower, this paramer will have not effect. + k8s_pod_retries=dict(map=int, valid=lambda x: int(x) >= 0, default=1), # note that if the backOffLimit is lower, this paramer will have no effect. k8s_job_spec_back_off_limit=dict(map=int, valid=lambda x: int(x) >= 0, default=0), # this means that it will stop retrying after 1 failure. k8s_walltime_limit=dict(map=int, valid=lambda x: int(x) >= 0, default=172800), k8s_unschedulable_walltime_limit=dict(map=int, valid=lambda x: not x or int(x) >= 0, default=None), @@ -220,9 +220,7 @@ def queue_job(self, job_wrapper): def __has_guest_ports(self, job_wrapper): # Check if job has guest ports or interactive tool entry points that would signal that - # this is an interactive tool. - log.debug(f"Checking if job {job_wrapper.get_id_tag()} has guest ports: {job_wrapper.guest_ports}") - log.debug(f"Checking if job {job_wrapper.get_id_tag()} has interactive entry points: {job_wrapper.guest_ports}") + log.debug(f"Checking if job {job_wrapper.get_id_tag()} is an interactive tool. guest ports: {job_wrapper.guest_ports}. interactive entry points: {job_wrapper.interactivetool_entry_points}") return bool(job_wrapper.guest_ports) or bool(job_wrapper.get_job().interactivetool_entry_points) def __configure_port_routing(self, ajs): @@ -245,14 +243,8 @@ def __configure_port_routing(self, ajs): log.debug(f"Kubernetes ingress object: {json.dumps(k8s_ingress_obj, indent=4)}") # We avoid creating service and ingress if they already exist (e.g. when Galaxy is restarted or resubmitting a job) service = Service(self._pykube_api, k8s_service_obj) - # if service.exists(): - # log.debug(f"Service {k8s_job_name} already exists, skipping creation") - # else: service.create() ingress = Ingress(self._pykube_api, k8s_ingress_obj) - # if ingress.exists(): - # log.debug(f"Ingress {k8s_job_name} already exists, skipping creation") - # else: ingress.create() def __get_overridable_params(self, job_wrapper, param_key): @@ -766,7 +758,7 @@ def check_watched_item(self, job_state): elif active > 0 and failed < max_pod_retries + 1: if not job_state.running: if self.__job_pending_due_to_unschedulable_pod(job_state): - log.debug("PP Job pending..") + log.debug(f"Job id: {job_state.job_id} with k8s id: {job.name} pending...") if self.runner_params.get("k8s_unschedulable_walltime_limit"): creation_time_str = job.obj["metadata"].get("creationTimestamp") creation_time = datetime.strptime(creation_time_str, "%Y-%m-%dT%H:%M:%SZ") @@ -778,20 +770,20 @@ def check_watched_item(self, job_state): else: pass else: - log.debug("Job set to running..") + log.debug("Job set to running...") job_state.running = True job_state.job_wrapper.change_state(model.Job.states.RUNNING) return job_state elif job_persisted_state == model.Job.states.DELETED: # Job has been deleted via stop_job and job has not been deleted, # remove from watched_jobs by returning `None` - log.debug("Job is DELETED..") + log.debug(f"Job id: {job_state.job_id} has been already deleted...") if job_state.job_wrapper.cleanup_job in ("always", "onsuccess"): job_state.job_wrapper.cleanup() return None else: log.debug(f"Job is failed and not deleted, looking at failure") - log.debug(f"Job state before handle job failure: {job_state.job_wrapper.get_state()}") + log.debug(f"Job id: {job_state.job_id} failed but has not been deleted yet. Current state: {job_state.job_wrapper.get_state()}") self._handle_job_failure(job, job_state) # changes for resubmission (removed self.mark_as_failed from handle_job_failure) self.work_queue.put((self.mark_as_failed, job_state)) @@ -838,19 +830,19 @@ def _handle_unschedulable_job(self, job, job_state): def _handle_job_failure(self, job, job_state): # Figure out why job has failed with open(job_state.error_file, "a") as error_file: - log.debug("PP Trying with error file in _handle_job_failure") + log.debug("Trying with error file in _handle_job_failure") if self.__job_failed_due_to_low_memory(job_state): - log.debug("PP OOM reached!") + log.debug(f"OOM detected for job: {job_state.job_id}") error_file.write("Job killed after running out of memory. Try with more memory.\n") job_state.fail_message = "Tool failed due to insufficient memory. Try with more memory." job_state.runner_state = JobState.runner_states.MEMORY_LIMIT_REACHED elif self.__job_failed_due_to_walltime_limit(job): - log.debug("PP checking for walltime") + log.debug(f"Walltime limit reached for job: {job_state.job_id}") error_file.write("DeadlineExceeded") job_state.fail_message = "Job was active longer than specified deadline" job_state.runner_state = JobState.runner_states.WALLTIME_REACHED else: - log.debug("PP no idea!") + log.debug(f"Unknown error detected in job: {job_state.job_id}") error_file.write("Exceeded max number of job retries allowed for job\n") job_state.fail_message = "More job retries failed than allowed. See standard output within the info section for details." # changes for resubmission @@ -1051,8 +1043,8 @@ def fail_job(self, job_state: "JobState", exception=False, message="Job failed", # TODO: These might not exist for running jobs at the upgrade to 19.XX, remove that # assumption in 20.XX. - tool_stderr = "Galaxy issue: Stderr failed to be retrieved from the job working directory." - tool_stdout = "Galaxy issue: Stdout failed to be retrieved from the job working directory." + tool_stderr = "Galaxy issue: stderr could not be retrieved from the job working directory." + tool_stdout = "Galaxy issue: stdout could not be retrieved from the job working directory." if os.path.exists(tool_stdout_path): with open(tool_stdout_path, "rb") as stdout_file: tool_stdout = self._job_io_for_db(stdout_file) From 5c3ccb01191e6dd6378593ec36c79c07ff3decaa Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Wed, 15 Mar 2023 09:18:07 +0000 Subject: [PATCH 008/954] Please linter --- lib/galaxy/jobs/runners/kubernetes.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/galaxy/jobs/runners/kubernetes.py b/lib/galaxy/jobs/runners/kubernetes.py index 6d209a1b5b4d..af9d5551ec66 100644 --- a/lib/galaxy/jobs/runners/kubernetes.py +++ b/lib/galaxy/jobs/runners/kubernetes.py @@ -782,8 +782,7 @@ def check_watched_item(self, job_state): job_state.job_wrapper.cleanup() return None else: - log.debug(f"Job is failed and not deleted, looking at failure") - log.debug(f"Job id: {job_state.job_id} failed but has not been deleted yet. Current state: {job_state.job_wrapper.get_state()}") + log.debug(f"Job id: {job_state.job_id} failed and it is not a deletion case. Current state: {job_state.job_wrapper.get_state()}") self._handle_job_failure(job, job_state) # changes for resubmission (removed self.mark_as_failed from handle_job_failure) self.work_queue.put((self.mark_as_failed, job_state)) From bc2ac6b15004f8baa1d680027040f2415160d97d Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Wed, 15 Mar 2023 10:03:33 +0000 Subject: [PATCH 009/954] More linter pleasing --- lib/galaxy/jobs/runners/kubernetes.py | 59 +++++++++++++-------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/lib/galaxy/jobs/runners/kubernetes.py b/lib/galaxy/jobs/runners/kubernetes.py index af9d5551ec66..2f30496432b5 100644 --- a/lib/galaxy/jobs/runners/kubernetes.py +++ b/lib/galaxy/jobs/runners/kubernetes.py @@ -3,7 +3,7 @@ """ import logging -import json # for debugging of API objects +import json # for debugging of API objects import math import os import re @@ -100,8 +100,8 @@ def __init__(self, app, nworkers, **kwargs): map=str, valid=lambda s: s == "$gid" or isinstance(s, int) or not s or s.isdigit(), default=None ), k8s_cleanup_job=dict(map=str, valid=lambda s: s in {"onsuccess", "always", "never"}, default="always"), - k8s_pod_retries=dict(map=int, valid=lambda x: int(x) >= 0, default=1), # note that if the backOffLimit is lower, this paramer will have no effect. - k8s_job_spec_back_off_limit=dict(map=int, valid=lambda x: int(x) >= 0, default=0), # this means that it will stop retrying after 1 failure. + k8s_pod_retries=dict(map=int, valid=lambda x: int(x) >= 0, default=1), # note that if the backOffLimit is lower, this paramer will have no effect. + k8s_job_spec_back_off_limit=dict(map=int, valid=lambda x: int(x) >= 0, default=0), # this means that it will stop retrying after 1 failure. k8s_walltime_limit=dict(map=int, valid=lambda x: int(x) >= 0, default=172800), k8s_unschedulable_walltime_limit=dict(map=int, valid=lambda x: not x or int(x) >= 0, default=None), k8s_interactivetools_use_ssl=dict(map=bool, default=False), @@ -468,26 +468,26 @@ def __get_k8s_ingress_spec(self, ajs): }, "spec": { "ingressClassName": "nginx", - "rules":[ { - "host": ep["domain"], - "http": { - "paths": [ { - "backend": { - "service": { - "name": self.__get_k8s_job_name( - self.__produce_k8s_job_prefix(), ajs.job_wrapper - ), - "port": { "number": int(ep["tool_port"])} - } - }, - "path": ep.get("entry_path", "/"), - "pathType": "Prefix" - } - ] - }, + "rules": [ { + "host": ep["domain"], + "http": { + "paths": [ { + "backend": { + "service": { + "name": self.__get_k8s_job_name( + self.__produce_k8s_job_prefix(), ajs.job_wrapper + ), + "port": { "number": int(ep["tool_port"])} + } + }, + "path": ep.get("entry_path", "/"), + "pathType": "Prefix" + } + ] + }, } for ep in entry_points - ] + ] }, } if self.runner_params.get("k8s_interactivetools_use_ssl"): @@ -1007,11 +1007,11 @@ def recover(self, job, job_wrapper): def fail_job(self, job_state: "JobState", exception=False, message="Job failed", full_status=None): log.debug("PP Getting into fail_job in k8s runner") job = job_state.job_wrapper.get_job() - - #### Get STDOUT and STDERR from the job and tool to be stored in the database #### - #### This is needed because when calling finish_job on a failed job, the check_output method - #### overrides the job error state and tries to figure it out from the job output files - #### breaking OOM resubmissions. + + # Get STDOUT and STDERR from the job and tool to be stored in the database # + # This is needed because when calling finish_job on a failed job, the check_output method + # overrides the job error state and tries to figure it out from the job output files + # breaking OOM resubmissions. # To ensure that files below are readable, ownership must be reclaimed first job_state.job_wrapper.reclaim_ownership() @@ -1039,7 +1039,7 @@ def fail_job(self, job_state: "JobState", exception=False, message="Job failed", tool_stdout_path = os.path.join(outputs_directory, "tool_stdout") tool_stderr_path = os.path.join(outputs_directory, "tool_stderr") - + # TODO: These might not exist for running jobs at the upgrade to 19.XX, remove that # assumption in 20.XX. tool_stderr = "Galaxy issue: stderr could not be retrieved from the job working directory." @@ -1060,10 +1060,10 @@ def fail_job(self, job_state: "JobState", exception=False, message="Job failed", tool_stderr = job_stderr job_stderr = None - #### END Get STDOUT and STDERR from the job and tool to be stored in the database #### + # END Get STDOUT and STDERR from the job and tool to be stored in the database # # full status empty leaves the UI without stderr/stdout - full_status = { "stderr" : tool_stderr, "stdout" : tool_stdout} + full_status = {"stderr" : tool_stderr, "stdout" : tool_stdout} log.debug(f"({job.id}/{job.job_runner_external_id}) tool_stdout: {tool_stdout}") log.debug(f"({job.id}/{job.job_runner_external_id}) tool_stderr: {tool_stderr}") log.debug(f"({job.id}/{job.job_runner_external_id}) job_stdout: {job_stdout}") @@ -1071,7 +1071,6 @@ def fail_job(self, job_state: "JobState", exception=False, message="Job failed", # run super method super().fail_job(job_state, exception, message, full_status) - def finish_job(self, job_state): self._handle_metadata_externally(job_state.job_wrapper, resolve_requirements=True) From 80e254a4f2b7df22855a764a349804f55cd89ba7 Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Wed, 15 Mar 2023 10:25:18 +0000 Subject: [PATCH 010/954] Black + isort --- lib/galaxy/jobs/runners/kubernetes.py | 76 ++++++++++++++++----------- 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/lib/galaxy/jobs/runners/kubernetes.py b/lib/galaxy/jobs/runners/kubernetes.py index 2f30496432b5..a1281e7cea54 100644 --- a/lib/galaxy/jobs/runners/kubernetes.py +++ b/lib/galaxy/jobs/runners/kubernetes.py @@ -2,8 +2,8 @@ Offload jobs to a Kubernetes cluster. """ -import logging import json # for debugging of API objects +import logging import math import os import re @@ -13,9 +13,6 @@ import yaml from galaxy import model -from galaxy.util import ( - unicodify, -) from galaxy.jobs.runners import ( AsynchronousJobRunner, AsynchronousJobState, @@ -48,6 +45,7 @@ Service, service_object_dict, ) +from galaxy.util import unicodify from galaxy.util.bytesize import ByteSize log = logging.getLogger(__name__) @@ -100,8 +98,12 @@ def __init__(self, app, nworkers, **kwargs): map=str, valid=lambda s: s == "$gid" or isinstance(s, int) or not s or s.isdigit(), default=None ), k8s_cleanup_job=dict(map=str, valid=lambda s: s in {"onsuccess", "always", "never"}, default="always"), - k8s_pod_retries=dict(map=int, valid=lambda x: int(x) >= 0, default=1), # note that if the backOffLimit is lower, this paramer will have no effect. - k8s_job_spec_back_off_limit=dict(map=int, valid=lambda x: int(x) >= 0, default=0), # this means that it will stop retrying after 1 failure. + k8s_pod_retries=dict( + map=int, valid=lambda x: int(x) >= 0, default=1 + ), # note that if the backOffLimit is lower, this paramer will have no effect. + k8s_job_spec_back_off_limit=dict( + map=int, valid=lambda x: int(x) >= 0, default=0 + ), # this means that it will stop retrying after 1 failure. k8s_walltime_limit=dict(map=int, valid=lambda x: int(x) >= 0, default=172800), k8s_unschedulable_walltime_limit=dict(map=int, valid=lambda x: not x or int(x) >= 0, default=None), k8s_interactivetools_use_ssl=dict(map=bool, default=False), @@ -220,7 +222,9 @@ def queue_job(self, job_wrapper): def __has_guest_ports(self, job_wrapper): # Check if job has guest ports or interactive tool entry points that would signal that - log.debug(f"Checking if job {job_wrapper.get_id_tag()} is an interactive tool. guest ports: {job_wrapper.guest_ports}. interactive entry points: {job_wrapper.interactivetool_entry_points}") + log.debug( + f"Checking if job {job_wrapper.get_id_tag()} is an interactive tool. guest ports: {job_wrapper.guest_ports}. interactive entry points: {job_wrapper.interactivetool_entry_points}" + ) return bool(job_wrapper.guest_ports) or bool(job_wrapper.get_job().interactivetool_entry_points) def __configure_port_routing(self, ajs): @@ -468,26 +472,28 @@ def __get_k8s_ingress_spec(self, ajs): }, "spec": { "ingressClassName": "nginx", - "rules": [ { - "host": ep["domain"], - "http": { - "paths": [ { - "backend": { - "service": { - "name": self.__get_k8s_job_name( - self.__produce_k8s_job_prefix(), ajs.job_wrapper - ), - "port": { "number": int(ep["tool_port"])} - } - }, - "path": ep.get("entry_path", "/"), - "pathType": "Prefix" - } - ] + "rules": [ + { + "host": ep["domain"], + "http": { + "paths": [ + { + "backend": { + "service": { + "name": self.__get_k8s_job_name( + self.__produce_k8s_job_prefix(), ajs.job_wrapper + ), + "port": {"number": int(ep["tool_port"])}, + } }, - } - for ep in entry_points - ] + "path": ep.get("entry_path", "/"), + "pathType": "Prefix", + } + ] + }, + } + for ep in entry_points + ], }, } if self.runner_params.get("k8s_interactivetools_use_ssl"): @@ -782,7 +788,9 @@ def check_watched_item(self, job_state): job_state.job_wrapper.cleanup() return None else: - log.debug(f"Job id: {job_state.job_id} failed and it is not a deletion case. Current state: {job_state.job_wrapper.get_state()}") + log.debug( + f"Job id: {job_state.job_id} failed and it is not a deletion case. Current state: {job_state.job_wrapper.get_state()}" + ) self._handle_job_failure(job, job_state) # changes for resubmission (removed self.mark_as_failed from handle_job_failure) self.work_queue.put((self.mark_as_failed, job_state)) @@ -794,14 +802,18 @@ def check_watched_item(self, job_state): if job_state.job_wrapper.cleanup_job in ("always", "onsuccess"): job_state.job_wrapper.cleanup() return None - if job_state.job_wrapper.get_job().state == model.Job.states.STOPPED and self.__has_guest_ports(job_state.job_wrapper): + if job_state.job_wrapper.get_job().state == model.Job.states.STOPPED and self.__has_guest_ports( + job_state.job_wrapper + ): # Interactive job has been stopped via stop_job (most likely by the user), # cleanup and remove from watched_jobs by returning `None`. STOPPED jobs are cleaned up elsewhere. # Marking as finished makes sure that the interactive job output is available in the UI. self.mark_as_finished(job_state) return None # there is no job responding to this job_id, it is either lost or something happened. - log.error(f"No Jobs are available under expected selector app={job_state.job_id} and they are not deleted or stopped either.") + log.error( + f"No Jobs are available under expected selector app={job_state.job_id} and they are not deleted or stopped either." + ) self.mark_as_failed(job_state) # job is no longer viable - remove from watched jobs return None @@ -843,7 +855,9 @@ def _handle_job_failure(self, job, job_state): else: log.debug(f"Unknown error detected in job: {job_state.job_id}") error_file.write("Exceeded max number of job retries allowed for job\n") - job_state.fail_message = "More job retries failed than allowed. See standard output within the info section for details." + job_state.fail_message = ( + "More job retries failed than allowed. See standard output within the info section for details." + ) # changes for resubmission # job_state.running = False # self.mark_as_failed(job_state) @@ -1063,7 +1077,7 @@ def fail_job(self, job_state: "JobState", exception=False, message="Job failed", # END Get STDOUT and STDERR from the job and tool to be stored in the database # # full status empty leaves the UI without stderr/stdout - full_status = {"stderr" : tool_stderr, "stdout" : tool_stdout} + full_status = {"stderr": tool_stderr, "stdout": tool_stdout} log.debug(f"({job.id}/{job.job_runner_external_id}) tool_stdout: {tool_stdout}") log.debug(f"({job.id}/{job.job_runner_external_id}) tool_stderr: {tool_stderr}") log.debug(f"({job.id}/{job.job_runner_external_id}) job_stdout: {job_stdout}") From 5dd8751c0579199caa6ce45176123b439edd01f5 Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Tue, 28 Mar 2023 14:15:25 +0100 Subject: [PATCH 011/954] Fix its issue on logging (cherry picked from commit d5e73b8130ea29d3da9c073e1d74d295e2c4c03a) --- lib/galaxy/jobs/runners/kubernetes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/jobs/runners/kubernetes.py b/lib/galaxy/jobs/runners/kubernetes.py index a1281e7cea54..dab97fabacf3 100644 --- a/lib/galaxy/jobs/runners/kubernetes.py +++ b/lib/galaxy/jobs/runners/kubernetes.py @@ -223,7 +223,7 @@ def queue_job(self, job_wrapper): def __has_guest_ports(self, job_wrapper): # Check if job has guest ports or interactive tool entry points that would signal that log.debug( - f"Checking if job {job_wrapper.get_id_tag()} is an interactive tool. guest ports: {job_wrapper.guest_ports}. interactive entry points: {job_wrapper.interactivetool_entry_points}" + f"Checking if job {job_wrapper.get_id_tag()} is an interactive tool. guest ports: {job_wrapper.guest_ports}. interactive entry points: {job_wrapper.get_job().interactivetool_entry_points}" ) return bool(job_wrapper.guest_ports) or bool(job_wrapper.get_job().interactivetool_entry_points) From 5ca868703b394f7efbc2e9968cd6a29545d72e2f Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Tue, 28 Mar 2023 14:35:01 +0100 Subject: [PATCH 012/954] trace for larger json dump logs --- lib/galaxy/jobs/runners/kubernetes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/jobs/runners/kubernetes.py b/lib/galaxy/jobs/runners/kubernetes.py index dab97fabacf3..7c4a04972142 100644 --- a/lib/galaxy/jobs/runners/kubernetes.py +++ b/lib/galaxy/jobs/runners/kubernetes.py @@ -243,8 +243,8 @@ def __configure_port_routing(self, ajs): k8s_ingress_obj = ingress_object_dict(self.runner_params, k8s_job_name, self.__get_k8s_ingress_spec(ajs)) # pretty print the objects for debugging - log.debug(f"Kubernetes service object: {json.dumps(k8s_service_obj, indent=4)}") - log.debug(f"Kubernetes ingress object: {json.dumps(k8s_ingress_obj, indent=4)}") + log.trace(f"Kubernetes service object: {json.dumps(k8s_service_obj, indent=4)}") + log.trace(f"Kubernetes ingress object: {json.dumps(k8s_ingress_obj, indent=4)}") # We avoid creating service and ingress if they already exist (e.g. when Galaxy is restarted or resubmitting a job) service = Service(self._pykube_api, k8s_service_obj) service.create() From a9cc2ff450887dee825fddaec10fda3ce4119f00 Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Wed, 29 Mar 2023 21:37:13 +0100 Subject: [PATCH 013/954] Make ingress class configurable for ITs --- lib/galaxy/jobs/runners/kubernetes.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/jobs/runners/kubernetes.py b/lib/galaxy/jobs/runners/kubernetes.py index 7c4a04972142..3ee12585d071 100644 --- a/lib/galaxy/jobs/runners/kubernetes.py +++ b/lib/galaxy/jobs/runners/kubernetes.py @@ -108,6 +108,7 @@ def __init__(self, app, nworkers, **kwargs): k8s_unschedulable_walltime_limit=dict(map=int, valid=lambda x: not x or int(x) >= 0, default=None), k8s_interactivetools_use_ssl=dict(map=bool, default=False), k8s_interactivetools_ingress_annotations=dict(map=str), + k8s_interactivetools_ingress_class=dict(map=str, default=None), ) if "runner_param_specs" not in kwargs: @@ -471,7 +472,6 @@ def __get_k8s_ingress_spec(self, ajs): "annotations": {"app.galaxyproject.org/tool_id": ajs.job_wrapper.tool.id}, }, "spec": { - "ingressClassName": "nginx", "rules": [ { "host": ep["domain"], @@ -496,6 +496,9 @@ def __get_k8s_ingress_spec(self, ajs): ], }, } + default_ingress_class = self.runner_params.get("k8s_interactivetools_ingress_class") + if default_ingress_class: + k8s_spec_template["spec"]["ingressClassName"] = default_ingress_class if self.runner_params.get("k8s_interactivetools_use_ssl"): domains = list({e["domain"] for e in entry_points}) k8s_spec_template["spec"]["tls"] = [ From 6ca1a021f45ee9be07dc63c18124f3ae245a132c Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Tue, 6 Jun 2023 00:51:06 +0530 Subject: [PATCH 014/954] Remove extra comments and minor tweaks to debug logs --- lib/galaxy/jobs/runners/kubernetes.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/galaxy/jobs/runners/kubernetes.py b/lib/galaxy/jobs/runners/kubernetes.py index 3ee12585d071..f02608811cae 100644 --- a/lib/galaxy/jobs/runners/kubernetes.py +++ b/lib/galaxy/jobs/runners/kubernetes.py @@ -243,7 +243,6 @@ def __configure_port_routing(self, ajs): k8s_service_obj = service_object_dict(self.runner_params, k8s_job_name, self.__get_k8s_service_spec(ajs)) k8s_ingress_obj = ingress_object_dict(self.runner_params, k8s_job_name, self.__get_k8s_ingress_spec(ajs)) - # pretty print the objects for debugging log.trace(f"Kubernetes service object: {json.dumps(k8s_service_obj, indent=4)}") log.trace(f"Kubernetes ingress object: {json.dumps(k8s_ingress_obj, indent=4)}") # We avoid creating service and ingress if they already exist (e.g. when Galaxy is restarted or resubmitting a job) @@ -549,6 +548,7 @@ def __get_k8s_containers(self, ajs): # command line execution, separated by ;, which is what Galaxy does # to assemble the command. "command": [ajs.job_wrapper.shell], + # Make sure that the exit code is propagated to k8s, so k8s knows why the tool failed (e.g. OOM) "args": ["-c", f"{ajs.job_file}; exit $(cat {ajs.exit_code_file})"], "workingDir": ajs.job_wrapper.working_directory, "volumeMounts": deduplicate_entries(mounts), @@ -762,7 +762,7 @@ def check_watched_item(self, job_state): if succeeded > 0 or job_state == model.Job.states.STOPPED: job_state.running = False self.mark_as_finished(job_state) - log.debug("Job succeeded") + log.debug(f"Job id: {job_state.job_id} with k8s id: {job.name} succeeded") return None elif active > 0 and failed < max_pod_retries + 1: if not job_state.running: @@ -1077,8 +1077,6 @@ def fail_job(self, job_state: "JobState", exception=False, message="Job failed", tool_stderr = job_stderr job_stderr = None - # END Get STDOUT and STDERR from the job and tool to be stored in the database # - # full status empty leaves the UI without stderr/stdout full_status = {"stderr": tool_stderr, "stdout": tool_stdout} log.debug(f"({job.id}/{job.job_runner_external_id}) tool_stdout: {tool_stdout}") From 8276216fa8752718ab26517d8ae0ed02016e8cd3 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Tue, 6 Jun 2023 12:18:55 +0530 Subject: [PATCH 015/954] Change trace to debug --- lib/galaxy/jobs/runners/kubernetes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/jobs/runners/kubernetes.py b/lib/galaxy/jobs/runners/kubernetes.py index f02608811cae..6ef015f0d812 100644 --- a/lib/galaxy/jobs/runners/kubernetes.py +++ b/lib/galaxy/jobs/runners/kubernetes.py @@ -243,8 +243,8 @@ def __configure_port_routing(self, ajs): k8s_service_obj = service_object_dict(self.runner_params, k8s_job_name, self.__get_k8s_service_spec(ajs)) k8s_ingress_obj = ingress_object_dict(self.runner_params, k8s_job_name, self.__get_k8s_ingress_spec(ajs)) - log.trace(f"Kubernetes service object: {json.dumps(k8s_service_obj, indent=4)}") - log.trace(f"Kubernetes ingress object: {json.dumps(k8s_ingress_obj, indent=4)}") + log.debug(f"Kubernetes service object: {json.dumps(k8s_service_obj, indent=4)}") + log.debug(f"Kubernetes ingress object: {json.dumps(k8s_ingress_obj, indent=4)}") # We avoid creating service and ingress if they already exist (e.g. when Galaxy is restarted or resubmitting a job) service = Service(self._pykube_api, k8s_service_obj) service.create() From eeb4d25987a002fef154b66c2663751bfffefd0d Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Wed, 21 Jun 2023 22:17:53 +0530 Subject: [PATCH 016/954] Don't mark job as failed if unknown exit code --- lib/galaxy/authnz/custos_authnz.py | 2 +- lib/galaxy/jobs/runners/kubernetes.py | 46 +++++++++++++++++++++++---- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/lib/galaxy/authnz/custos_authnz.py b/lib/galaxy/authnz/custos_authnz.py index 4b4d40ec6860..75d3187cc508 100644 --- a/lib/galaxy/authnz/custos_authnz.py +++ b/lib/galaxy/authnz/custos_authnz.py @@ -204,7 +204,7 @@ def callback(self, state_token, authz_code, trans, login_redirect_url): ): user = existing_user else: - message = f"There already exists a user with email {email}. To associate this external login, you must first be logged in as that existing account." + message = f"There already exists a user with email {email}. To associate this external login, you must first be logged in as that existing account." log.info(message) login_redirect_url = ( f"{login_redirect_url}login/start" diff --git a/lib/galaxy/jobs/runners/kubernetes.py b/lib/galaxy/jobs/runners/kubernetes.py index 6ef015f0d812..6f033a1cd57f 100644 --- a/lib/galaxy/jobs/runners/kubernetes.py +++ b/lib/galaxy/jobs/runners/kubernetes.py @@ -794,9 +794,14 @@ def check_watched_item(self, job_state): log.debug( f"Job id: {job_state.job_id} failed and it is not a deletion case. Current state: {job_state.job_wrapper.get_state()}" ) - self._handle_job_failure(job, job_state) - # changes for resubmission (removed self.mark_as_failed from handle_job_failure) - self.work_queue.put((self.mark_as_failed, job_state)) + if self._handle_job_failure(job, job_state): + # changes for resubmission (removed self.mark_as_failed from handle_job_failure) + self.work_queue.put((self.mark_as_failed, job_state)) + else: + # Job failure was not due to a k8s issue or something that k8s can handle, so it's a tool error. + job_state.running = False + self.mark_as_finished(job_state) + return None return None @@ -843,6 +848,7 @@ def _handle_unschedulable_job(self, job, job_state): def _handle_job_failure(self, job, job_state): # Figure out why job has failed + mark_failed = True with open(job_state.error_file, "a") as error_file: log.debug("Trying with error file in _handle_job_failure") if self.__job_failed_due_to_low_memory(job_state): @@ -855,11 +861,18 @@ def _handle_job_failure(self, job, job_state): error_file.write("DeadlineExceeded") job_state.fail_message = "Job was active longer than specified deadline" job_state.runner_state = JobState.runner_states.WALLTIME_REACHED + elif self.__job_failed_due_to_unknown_exit_code(job_state): + msg = f"Job: {job_state.job_id} failed due to an unknown exit code from the tool." + log.debug(msg) + job_state.fail_message = msg + job_state.runner_state = JobState.runner_states.TOOL_DETECT_ERROR + mark_failed = False else: - log.debug(f"Unknown error detected in job: {job_state.job_id}") - error_file.write("Exceeded max number of job retries allowed for job\n") + msg = f"An unknown error occurred in this job and the maximum number of retries have been exceeded for job: {job_state.job_id}." + log.debug(msg) + error_file.write(msg) job_state.fail_message = ( - "More job retries failed than allowed. See standard output within the info section for details." + "An unknown error occurered with this job. See standard output within the info section for details." ) # changes for resubmission # job_state.running = False @@ -870,7 +883,7 @@ def _handle_job_failure(self, job, job_state): self.__cleanup_k8s_job(job) except Exception: log.exception("Could not clean up k8s batch job. Ignoring...") - return None + return mark_failed def __cleanup_k8s_job(self, job): k8s_cleanup_job = self.runner_params["k8s_cleanup_job"] @@ -930,6 +943,25 @@ def __job_pending_due_to_unschedulable_pod(self, job_state): pod = Pod(self._pykube_api, pods.response["items"][0]) return is_pod_unschedulable(self._pykube_api, pod, self.runner_params["k8s_namespace"]) + def __job_failed_due_to_unknown_exit_code(self, job_state): + """ + checks whether the pod exited prematurely due to an unknown exit code (i.e. not an exit code like OOM that + we can handle). This would mean that the tool failed, but the job should be considered to have succeeded. + """ + pods = find_pod_object_by_name(self._pykube_api, job_state.job_id, self.runner_params["k8s_namespace"]) + if not pods.response["items"]: + return False + + pod = pods.response["items"][0] + if ( + pod + and "terminated" in pod["status"]["containerStatuses"][0]["state"] + and pod["status"]["containerStatuses"][0]["state"].get("exitCode") + ): + return True + + return False + def __cleanup_k8s_guest_ports(self, job_wrapper, k8s_job): k8s_job_prefix = self.__produce_k8s_job_prefix() k8s_job_name = f"{k8s_job_prefix}-{self.__force_label_conformity(job_wrapper.get_id_tag())}" From 7724435df87af319351de967074e707290e1c1a0 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Wed, 21 Jun 2023 23:58:29 +0530 Subject: [PATCH 017/954] Get exitCode from correct dict entry --- lib/galaxy/jobs/runners/kubernetes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/jobs/runners/kubernetes.py b/lib/galaxy/jobs/runners/kubernetes.py index 6f033a1cd57f..7c6c6f153f84 100644 --- a/lib/galaxy/jobs/runners/kubernetes.py +++ b/lib/galaxy/jobs/runners/kubernetes.py @@ -956,7 +956,7 @@ def __job_failed_due_to_unknown_exit_code(self, job_state): if ( pod and "terminated" in pod["status"]["containerStatuses"][0]["state"] - and pod["status"]["containerStatuses"][0]["state"].get("exitCode") + and pod["status"]["containerStatuses"][0]["state"]["terminated"].get("exitCode") ): return True From c1b3b275344feaef63be73f0696b9d5dc4455c15 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Thu, 22 Jun 2023 02:49:37 +0530 Subject: [PATCH 018/954] Bump pykube version --- lib/galaxy/dependencies/conditional-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/dependencies/conditional-requirements.txt b/lib/galaxy/dependencies/conditional-requirements.txt index 40861584fc09..1bea9abfe5a2 100644 --- a/lib/galaxy/dependencies/conditional-requirements.txt +++ b/lib/galaxy/dependencies/conditional-requirements.txt @@ -35,7 +35,7 @@ custos-sdk chronos-python==1.2.1 # Kubernetes job runner -pykube-ng==22.9.0 +pykube-ng==23.6.0 # Synnefo / Pithos+ object store client kamaki From 452a042d672d11a76ab666eca8b52bb064b3f8a7 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Thu, 22 Jun 2023 17:41:31 +0530 Subject: [PATCH 019/954] Fix conditional requirement for pykube-ng --- lib/galaxy/dependencies/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/dependencies/__init__.py b/lib/galaxy/dependencies/__init__.py index e46c6fee60c9..091fb4da57dc 100644 --- a/lib/galaxy/dependencies/__init__.py +++ b/lib/galaxy/dependencies/__init__.py @@ -203,7 +203,7 @@ def check_total_perspective_vortex(self): def check_pbs_python(self): return "galaxy.jobs.runners.pbs:PBSJobRunner" in self.job_runners - def check_pykube(self): + def check_pykube_ng(self): return "galaxy.jobs.runners.kubernetes:KubernetesJobRunner" in self.job_runners or which("kubectl") def check_chronos_python(self): From 250be5db661ee7f8a6f43ca8086dacbbf6f7e7e1 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Thu, 22 Jun 2023 17:53:36 +0530 Subject: [PATCH 020/954] Set a pykube version that's available --- lib/galaxy/dependencies/conditional-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/dependencies/conditional-requirements.txt b/lib/galaxy/dependencies/conditional-requirements.txt index 1bea9abfe5a2..4d6eccc54007 100644 --- a/lib/galaxy/dependencies/conditional-requirements.txt +++ b/lib/galaxy/dependencies/conditional-requirements.txt @@ -35,7 +35,7 @@ custos-sdk chronos-python==1.2.1 # Kubernetes job runner -pykube-ng==23.6.0 +pykube-ng==21.3.0 # Synnefo / Pithos+ object store client kamaki From 566f3973ad419514bbd3ce4ec4c6c8a4093da7aa Mon Sep 17 00:00:00 2001 From: thepineapplepirate <91963008+thepineapplepirate@users.noreply.github.com> Date: Wed, 4 Oct 2023 15:57:32 -0400 Subject: [PATCH 021/954] new interactive tool for Qiskit in Galaxy --- ...nteractivetool_qiskit_jupyter_notebook.xml | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 tools/interactive/interactivetool_qiskit_jupyter_notebook.xml diff --git a/tools/interactive/interactivetool_qiskit_jupyter_notebook.xml b/tools/interactive/interactivetool_qiskit_jupyter_notebook.xml new file mode 100644 index 000000000000..99c1741aa9cf --- /dev/null +++ b/tools/interactive/interactivetool_qiskit_jupyter_notebook.xml @@ -0,0 +1,313 @@ + + interactive tool + + thepineapplepirate/qiskit_galaxy:latest + + + + 8888 + ipython/lab + + + + $__history_id__ + $__galaxy_url__ + 8080 + $__galaxy_url__ + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + preferences -> manage information). Instructions for + specifying the quantum backend can be found at https://qiskit.org/ecosystem/ibm-runtime/how_to/backends.html. + + The Jupyter Notebook is an open-source web application that allows you to create and share documents that contain live code, equations, + visualizations and narrative text. Uses include: data cleaning and transformation, numerical simulation, statistical modeling, data visualization, + machine learning, and much more. + + Galaxy offers you the use of Jupyter Notebooks directly in Galaxy accessing and interacting with Galaxy datasets as you like. A very common use-case is to + do the heavy lifting and data reduction steps in Galaxy and the plotting and more `interactive` part on smaller datasets in Jupyter. + + You can start with a new Jupyter notebook from scratch or load an already existing one, e.g. from your colleague and execute it on your dataset. + You can specify any number of user-defined inputs using the repeat input, providing `name` value, selecting the type of input, and then providing values. + + You can make the Qiskit JupyTool reusable in a workflow, by allowing the user to specify input values for the defined input blocks. + Inputs can be accessed by `name` from the automatically provided `GALAXY_INPUTS` dictionary. + Outputs can be written automatically to the user's history by writing to the `outputs` directory for one individual file or to the `outputs/collection` directory for multiple files. + Using collection tools, you can parse out the individual elements from the collection, as needed. + + For backwards compatibility, you can import data into the notebook via a predefined `get()` function and write results back to Galaxy with a `put()` function. + + .. image:: https://upload.wikimedia.org/wikipedia/commons/5/51/Qiskit-Logo.svg + ]]> + From 85f20770c1205e728740d14a3b1539a2f4d3a29d Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Thu, 19 Oct 2023 10:06:14 +0200 Subject: [PATCH 022/954] Add back 1.1.0 version of Filtering1 tool Which is the same as 1.1.1 but hard to explain to users why the run form shows a different version than what is shown in the user interface. --- lib/galaxy/config/sample/tool_conf.xml.sample | 1 + tools/stats/filtering_1_1_0.xml | 103 ++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 tools/stats/filtering_1_1_0.xml diff --git a/lib/galaxy/config/sample/tool_conf.xml.sample b/lib/galaxy/config/sample/tool_conf.xml.sample index f6f060739291..f3818f88f46c 100644 --- a/lib/galaxy/config/sample/tool_conf.xml.sample +++ b/lib/galaxy/config/sample/tool_conf.xml.sample @@ -79,6 +79,7 @@
+ diff --git a/tools/stats/filtering_1_1_0.xml b/tools/stats/filtering_1_1_0.xml new file mode 100644 index 000000000000..1a20cf40c947 --- /dev/null +++ b/tools/stats/filtering_1_1_0.xml @@ -0,0 +1,103 @@ + + data on any column using simple expressions + + operation_0335 + + + python '$__tool_directory__/filtering.py' '$input' '$out_file1' '$inputs' ${input.metadata.columns} "${input.metadata.column_types}" $header_lines + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +.. class:: warningmark + +Double equal signs, ==, must be used as *"equal to"* (e.g., **c1 == 'chr22'**) + +.. class:: infomark + +**TIP:** Attempting to apply a filtering condition may throw exceptions if the data type (e.g., string, integer) in every line of the columns being filtered is not appropriate for the condition (e.g., attempting certain numerical calculations on strings). If an exception is thrown when applying the condition to a line, that line is skipped as invalid for the filter condition. The number of invalid skipped lines is documented in the resulting history item as a "Condition/data issue". + +.. class:: infomark + +**TIP:** If your data is not TAB delimited, use *Text Manipulation->Convert* + +----- + +**Syntax** + +The filter tool allows you to restrict the dataset using simple conditional statements. + +- Columns are referenced with **c** and a **number**. For example, **c1** refers to the first column of a tab-delimited file +- Make sure that multi-character operators contain no white space ( e.g., **<=** is valid while **< =** is not valid ) +- When using 'equal-to' operator **double equal sign '==' must be used** ( e.g., **c1=='chr1'** ) +- Non-numerical values must be included in single or double quotes ( e.g., **c6=='+'** ) +- Filtering condition can include logical operators, but **make sure operators are all lower case** ( e.g., **(c1!='chrX' and c1!='chrY') or not c6=='+'** ) + +----- + +**Example** + +- **c1=='chr1'** selects lines in which the first column is chr1 +- **c3-c2<100*c4** selects lines where subtracting column 3 from column 2 is less than the value of column 4 times 100 +- **len(c2.split(',')) < 4** will select lines where the second column has less than four comma separated elements +- **c2>=1** selects lines in which the value of column 2 is greater than or equal to 1 +- Numbers should not contain commas - **c2<=44,554,350** will not work, but **c2<=44554350** will +- Some words in the data can be used, but must be single or double quoted ( e.g., **c3=='exon'** ) + + + + From 6182222f0bab7243b58470250857fe2c7f910dc8 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Mon, 9 Oct 2023 01:03:00 +0530 Subject: [PATCH 023/954] Add test harness for oidc login against keycloak --- .../integration/oidc/galaxy-realm-export.json | 1740 +++++++++++++++++ test/integration/oidc/keycloak-server.crt.pem | 20 + test/integration/oidc/keycloak-server.key.pem | 28 + test/integration/oidc/oidc_config.xml | 6 + test/integration/oidc/test_auth_oidc.py | 179 ++ 5 files changed, 1973 insertions(+) create mode 100644 test/integration/oidc/galaxy-realm-export.json create mode 100644 test/integration/oidc/keycloak-server.crt.pem create mode 100644 test/integration/oidc/keycloak-server.key.pem create mode 100644 test/integration/oidc/oidc_config.xml create mode 100644 test/integration/oidc/test_auth_oidc.py diff --git a/test/integration/oidc/galaxy-realm-export.json b/test/integration/oidc/galaxy-realm-export.json new file mode 100644 index 000000000000..925dd22c1d35 --- /dev/null +++ b/test/integration/oidc/galaxy-realm-export.json @@ -0,0 +1,1740 @@ +{ + "id" : "34fcece2-bf4c-4746-a3f0-3e96a2919e7b", + "realm" : "gxyrealm", + "notBefore" : 0, + "defaultSignatureAlgorithm" : "RS256", + "revokeRefreshToken" : false, + "refreshTokenMaxReuse" : 0, + "accessTokenLifespan" : 300, + "accessTokenLifespanForImplicitFlow" : 900, + "ssoSessionIdleTimeout" : 1800, + "ssoSessionMaxLifespan" : 36000, + "ssoSessionIdleTimeoutRememberMe" : 0, + "ssoSessionMaxLifespanRememberMe" : 0, + "offlineSessionIdleTimeout" : 2592000, + "offlineSessionMaxLifespanEnabled" : false, + "offlineSessionMaxLifespan" : 5184000, + "clientSessionIdleTimeout" : 0, + "clientSessionMaxLifespan" : 0, + "clientOfflineSessionIdleTimeout" : 0, + "clientOfflineSessionMaxLifespan" : 0, + "accessCodeLifespan" : 60, + "accessCodeLifespanUserAction" : 300, + "accessCodeLifespanLogin" : 1800, + "actionTokenGeneratedByAdminLifespan" : 43200, + "actionTokenGeneratedByUserLifespan" : 300, + "oauth2DeviceCodeLifespan" : 600, + "oauth2DevicePollingInterval" : 5, + "enabled" : true, + "sslRequired" : "external", + "registrationAllowed" : false, + "registrationEmailAsUsername" : false, + "rememberMe" : false, + "verifyEmail" : false, + "loginWithEmailAllowed" : true, + "duplicateEmailsAllowed" : false, + "resetPasswordAllowed" : false, + "editUsernameAllowed" : false, + "bruteForceProtected" : false, + "permanentLockout" : false, + "maxFailureWaitSeconds" : 900, + "minimumQuickLoginWaitSeconds" : 60, + "waitIncrementSeconds" : 60, + "quickLoginCheckMilliSeconds" : 1000, + "maxDeltaTimeSeconds" : 43200, + "failureFactor" : 30, + "roles" : { + "realm" : [ { + "id" : "657c4f1e-74b7-4c1f-911b-da083f4dff45", + "name" : "offline_access", + "description" : "${role_offline-access}", + "composite" : false, + "clientRole" : false, + "containerId" : "34fcece2-bf4c-4746-a3f0-3e96a2919e7b", + "attributes" : { } + }, { + "id" : "8fdc4fe0-9e7e-4af4-be60-6718864e503e", + "name" : "uma_authorization", + "description" : "${role_uma_authorization}", + "composite" : false, + "clientRole" : false, + "containerId" : "34fcece2-bf4c-4746-a3f0-3e96a2919e7b", + "attributes" : { } + }, { + "id" : "89dd96cd-2cc8-476d-be0c-c8b01a88cc8a", + "name" : "default-roles-gxyrealm", + "description" : "${role_default-roles}", + "composite" : true, + "composites" : { + "realm" : [ "offline_access", "uma_authorization" ] + }, + "clientRole" : false, + "containerId" : "34fcece2-bf4c-4746-a3f0-3e96a2919e7b", + "attributes" : { } + } ], + "client" : { + "realm-management" : [ { + "id" : "eb57dcd1-d46a-4ad9-8227-87dfef873591", + "name" : "manage-authorization", + "description" : "${role_manage-authorization}", + "composite" : false, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "38796997-4ded-4564-bdaf-477ea9f3c787", + "name" : "query-groups", + "description" : "${role_query-groups}", + "composite" : false, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "4ddc88cb-fbe5-45e2-8210-e250ace73eb7", + "name" : "realm-admin", + "description" : "${role_realm-admin}", + "composite" : true, + "composites" : { + "client" : { + "realm-management" : [ "manage-authorization", "query-groups", "view-authorization", "query-clients", "impersonation", "view-realm", "manage-identity-providers", "create-client", "view-identity-providers", "query-users", "view-users", "view-clients", "manage-events", "query-realms", "manage-realm", "manage-clients", "manage-users", "view-events" ] + } + }, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "9acd6d3a-a2cf-477d-8977-c39e0b3ec87e", + "name" : "view-authorization", + "description" : "${role_view-authorization}", + "composite" : false, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "a5049cc1-1291-49f2-8f6d-65611840f166", + "name" : "query-clients", + "description" : "${role_query-clients}", + "composite" : false, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "4bc5865c-56ce-4abc-8a6e-511f1067ffe9", + "name" : "impersonation", + "description" : "${role_impersonation}", + "composite" : false, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "8c8593e7-44d7-4199-8118-e7851303d020", + "name" : "view-realm", + "description" : "${role_view-realm}", + "composite" : false, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "22d9fa0d-87b9-470f-802a-722c020fb312", + "name" : "create-client", + "description" : "${role_create-client}", + "composite" : false, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "04330e15-5e79-4658-8350-8894abcae313", + "name" : "manage-identity-providers", + "description" : "${role_manage-identity-providers}", + "composite" : false, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "bad32860-b1bc-4a21-bd28-16409da0d24e", + "name" : "query-users", + "description" : "${role_query-users}", + "composite" : false, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "6ed42996-a8e6-46e3-8e90-138d4a20fd8f", + "name" : "view-identity-providers", + "description" : "${role_view-identity-providers}", + "composite" : false, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "d0f09613-6598-4fa9-873d-127d521abf2c", + "name" : "view-users", + "description" : "${role_view-users}", + "composite" : true, + "composites" : { + "client" : { + "realm-management" : [ "query-groups", "query-users" ] + } + }, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "b0144b22-e847-4b05-90fd-5dbbdf01a8ac", + "name" : "view-clients", + "description" : "${role_view-clients}", + "composite" : true, + "composites" : { + "client" : { + "realm-management" : [ "query-clients" ] + } + }, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "5c6d6d35-efff-4020-b679-eddc8a3dcb2a", + "name" : "manage-events", + "description" : "${role_manage-events}", + "composite" : false, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "d9b80d85-fd37-43cc-a3f6-7e562b00feef", + "name" : "query-realms", + "description" : "${role_query-realms}", + "composite" : false, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "fd21d7ac-3893-47a1-86ea-ea48844d8b5c", + "name" : "manage-clients", + "description" : "${role_manage-clients}", + "composite" : false, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "abcfbf06-02cd-4702-9641-309f1ea0e51e", + "name" : "manage-realm", + "description" : "${role_manage-realm}", + "composite" : false, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "5803766e-9316-4bb6-b3cc-a7ec8e6f3374", + "name" : "manage-users", + "description" : "${role_manage-users}", + "composite" : false, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + }, { + "id" : "fcfcff6a-561a-4f07-a631-f8c10ab25f43", + "name" : "view-events", + "description" : "${role_view-events}", + "composite" : false, + "clientRole" : true, + "containerId" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "attributes" : { } + } ], + "security-admin-console" : [ ], + "admin-cli" : [ ], + "gxyclient" : [ ], + "account-console" : [ ], + "broker" : [ ], + "account" : [ { + "id" : "16dd6df5-3072-417f-8a1f-6d6a482b9ec8", + "name" : "view-groups", + "composite" : false, + "clientRole" : true, + "containerId" : "49b6ddd3-efe7-4adc-9baf-7b473cb8704d", + "attributes" : { } + }, { + "id" : "3e5736e6-a6fc-4ba0-b8c2-79c0e00c808c", + "name" : "delete-account", + "description" : "${role_delete-account}", + "composite" : false, + "clientRole" : true, + "containerId" : "49b6ddd3-efe7-4adc-9baf-7b473cb8704d", + "attributes" : { } + }, { + "id" : "ccb05385-015b-44fb-9e2c-e588aaa5f778", + "name" : "manage-account", + "composite" : false, + "clientRole" : true, + "containerId" : "49b6ddd3-efe7-4adc-9baf-7b473cb8704d", + "attributes" : { } + } ] + } + }, + "groups" : [ ], + "defaultRole" : { + "id" : "89dd96cd-2cc8-476d-be0c-c8b01a88cc8a", + "name" : "default-roles-gxyrealm", + "description" : "${role_default-roles}", + "composite" : true, + "clientRole" : false, + "containerId" : "34fcece2-bf4c-4746-a3f0-3e96a2919e7b" + }, + "requiredCredentials" : [ "password" ], + "otpPolicyType" : "totp", + "otpPolicyAlgorithm" : "HmacSHA1", + "otpPolicyInitialCounter" : 0, + "otpPolicyDigits" : 6, + "otpPolicyLookAheadWindow" : 1, + "otpPolicyPeriod" : 30, + "otpPolicyCodeReusable" : false, + "otpSupportedApplications" : [ "totpAppMicrosoftAuthenticatorName", "totpAppFreeOTPName", "totpAppGoogleName" ], + "webAuthnPolicyRpEntityName" : "keycloak", + "webAuthnPolicySignatureAlgorithms" : [ "ES256" ], + "webAuthnPolicyRpId" : "", + "webAuthnPolicyAttestationConveyancePreference" : "not specified", + "webAuthnPolicyAuthenticatorAttachment" : "not specified", + "webAuthnPolicyRequireResidentKey" : "not specified", + "webAuthnPolicyUserVerificationRequirement" : "not specified", + "webAuthnPolicyCreateTimeout" : 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister" : false, + "webAuthnPolicyAcceptableAaguids" : [ ], + "webAuthnPolicyPasswordlessRpEntityName" : "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms" : [ "ES256" ], + "webAuthnPolicyPasswordlessRpId" : "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference" : "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment" : "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey" : "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement" : "not specified", + "webAuthnPolicyPasswordlessCreateTimeout" : 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister" : false, + "webAuthnPolicyPasswordlessAcceptableAaguids" : [ ], + "users" : [ { + "id" : "24ffa3ff-d351-4d5e-b10b-8d615082ec9c", + "createdTimestamp" : 1694376671733, + "username" : "gxyuser", + "enabled" : true, + "totp" : false, + "emailVerified" : true, + "firstName" : "Test", + "lastName" : "GalaxyUser", + "email" : "gxyuser@galaxy.org", + "credentials" : [ { + "id" : "00d87268-7e21-4d08-9f92-cfc06eca5148", + "type" : "password", + "userLabel" : "My password", + "createdDate" : 1694376754826, + "secretData" : "{\"value\":\"uNBI+UnpCLpXWHhm/tPSnnhuINiNw2MNt1XeDmImJaQ=\",\"salt\":\"fHS/FpnORylnSIco16UHwA==\",\"additionalParameters\":{}}", + "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" + } ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "default-roles-gxyrealm" ], + "notBefore" : 0, + "groups" : [ ] + } ], + "scopeMappings" : [ { + "clientScope" : "offline_access", + "roles" : [ "offline_access" ] + } ], + "clientScopeMappings" : { + "account" : [ { + "client" : "account-console", + "roles" : [ "manage-account", "view-groups" ] + } ] + }, + "clients" : [ { + "id" : "49b6ddd3-efe7-4adc-9baf-7b473cb8704d", + "clientId" : "account", + "name" : "${client_account}", + "rootUrl" : "${authBaseUrl}", + "baseUrl" : "/realms/gxyrealm/account/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ "/realms/gxyrealm/account/*" ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "post.logout.redirect.uris" : "+" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "f430e0ae-5017-4346-a8f5-13961d447307", + "clientId" : "account-console", + "name" : "${client_account-console}", + "rootUrl" : "${authBaseUrl}", + "baseUrl" : "/realms/gxyrealm/account/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ "/realms/gxyrealm/account/*" ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "post.logout.redirect.uris" : "+", + "pkce.code.challenge.method" : "S256" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "protocolMappers" : [ { + "id" : "4dd09d7e-d8d4-42a7-ad06-6058fd99e6b8", + "name" : "audience resolve", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-audience-resolve-mapper", + "consentRequired" : false, + "config" : { } + } ], + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "99d5c3ac-766a-4c02-8e51-713a11d4ac78", + "clientId" : "admin-cli", + "name" : "${client_admin-cli}", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : false, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : true, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "post.logout.redirect.uris" : "+" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "4a0e0a29-e407-4154-94f3-a82d85ceff04", + "clientId" : "broker", + "name" : "${client_broker}", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : true, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "post.logout.redirect.uris" : "+" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "52e25e17-fdb7-423a-83ab-90c27711d391", + "clientId" : "gxyclient", + "name" : "Galaxy Client", + "description" : "", + "rootUrl" : "http://localhost", + "adminUrl" : "http://localhost", + "baseUrl" : "http://localhost", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "gxytestclientsecret", + "redirectUris" : [ "http://*" ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : true, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : true, + "protocol" : "openid-connect", + "attributes" : { + "oidc.ciba.grant.enabled" : "false", + "client.secret.creation.time" : "1694354021", + "backchannel.logout.session.required" : "true", + "post.logout.redirect.uris" : "+", + "oauth2.device.authorization.grant.enabled" : "false", + "backchannel.logout.revoke.offline.tokens" : "false" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : true, + "nodeReRegistrationTimeout" : -1, + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "d27406eb-c929-4658-904f-f42f8bd2812c", + "clientId" : "realm-management", + "name" : "${client_realm-management}", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : true, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "post.logout.redirect.uris" : "+" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "df1d8b2c-b8f3-4fa9-aabb-a9d6be639cd6", + "clientId" : "security-admin-console", + "name" : "${client_security-admin-console}", + "rootUrl" : "${authAdminUrl}", + "baseUrl" : "/admin/gxyrealm/console/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ "/admin/gxyrealm/console/*" ], + "webOrigins" : [ "+" ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "post.logout.redirect.uris" : "+", + "pkce.code.challenge.method" : "S256" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "protocolMappers" : [ { + "id" : "c3ceb1e9-3950-4015-b2e2-793444a56b91", + "name" : "locale", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "locale", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "locale", + "jsonType.label" : "String" + } + } ], + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + } ], + "clientScopes" : [ { + "id" : "beae5e0b-8350-4d91-b4df-60cbb2410509", + "name" : "offline_access", + "description" : "OpenID Connect built-in scope: offline_access", + "protocol" : "openid-connect", + "attributes" : { + "consent.screen.text" : "${offlineAccessScopeConsentText}", + "display.on.consent.screen" : "true" + } + }, { + "id" : "f965bd9e-e42e-4bc7-97cb-ae48b77e40e6", + "name" : "profile", + "description" : "OpenID Connect built-in scope: profile", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${profileScopeConsentText}" + }, + "protocolMappers" : [ { + "id" : "d2c13e8a-3d1b-4623-87fe-3fd3bf6f8cb2", + "name" : "username", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "username", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "preferred_username", + "jsonType.label" : "String" + } + }, { + "id" : "4cb19904-3b4d-4720-a396-0491b5f502f0", + "name" : "family name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "lastName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "family_name", + "jsonType.label" : "String" + } + }, { + "id" : "2f646042-2302-4c6f-b4c7-274e8a0f9a77", + "name" : "birthdate", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "birthdate", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "birthdate", + "jsonType.label" : "String" + } + }, { + "id" : "3b78066d-0553-4dab-96d6-025d78bdffdb", + "name" : "updated at", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "updatedAt", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "updated_at", + "jsonType.label" : "long" + } + }, { + "id" : "3af4bdf5-335e-49d3-8740-ab4079a121e1", + "name" : "full name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-full-name-mapper", + "consentRequired" : false, + "config" : { + "id.token.claim" : "true", + "access.token.claim" : "true", + "userinfo.token.claim" : "true" + } + }, { + "id" : "198cbf78-7782-4605-a699-89678afd316b", + "name" : "website", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "website", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "website", + "jsonType.label" : "String" + } + }, { + "id" : "8ebcbb43-51ed-4904-b9ea-0cda360ab664", + "name" : "middle name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "middleName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "middle_name", + "jsonType.label" : "String" + } + }, { + "id" : "4c026991-9cba-4765-abe0-c1cac12ab875", + "name" : "given name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "firstName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "given_name", + "jsonType.label" : "String" + } + }, { + "id" : "c9d75fdc-40c9-49d9-b075-d166588e2895", + "name" : "gender", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "gender", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "gender", + "jsonType.label" : "String" + } + }, { + "id" : "32ac94cc-ca91-449e-9b68-c5fd8b2f00ea", + "name" : "zoneinfo", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "zoneinfo", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "zoneinfo", + "jsonType.label" : "String" + } + }, { + "id" : "93816c0c-0099-4a91-a6eb-5b2b8c1780c1", + "name" : "profile", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "profile", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "profile", + "jsonType.label" : "String" + } + }, { + "id" : "20aaf10f-7c81-465c-9533-4b15aa04dd56", + "name" : "nickname", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "nickname", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "nickname", + "jsonType.label" : "String" + } + }, { + "id" : "420194b1-9dca-4a2e-bff6-ffb047e9e7ca", + "name" : "picture", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "picture", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "picture", + "jsonType.label" : "String" + } + }, { + "id" : "cb42a8aa-e511-4233-9237-bab9ae006443", + "name" : "locale", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "locale", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "locale", + "jsonType.label" : "String" + } + } ] + }, { + "id" : "71705acc-29d2-4296-8c3e-aac92a8fdc85", + "name" : "web-origins", + "description" : "OpenID Connect scope for add allowed web origins to the access token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "display.on.consent.screen" : "false", + "consent.screen.text" : "" + }, + "protocolMappers" : [ { + "id" : "c87193aa-a56b-438d-a083-eccb43f3adca", + "name" : "allowed web origins", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-allowed-origins-mapper", + "consentRequired" : false, + "config" : { } + } ] + }, { + "id" : "4b7c9a4b-83de-40c3-9bb3-76f661aa8a65", + "name" : "email", + "description" : "OpenID Connect built-in scope: email", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${emailScopeConsentText}" + }, + "protocolMappers" : [ { + "id" : "da72c1fc-be6a-4368-8a60-e3e248f567bf", + "name" : "email verified", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-property-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "emailVerified", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "email_verified", + "jsonType.label" : "boolean" + } + }, { + "id" : "7f2c75b1-0c66-48fe-a83b-d210c696877b", + "name" : "email", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "email", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "email", + "jsonType.label" : "String" + } + } ] + }, { + "id" : "035ef185-68b8-479c-b2a8-d48fdbd0861d", + "name" : "address", + "description" : "OpenID Connect built-in scope: address", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${addressScopeConsentText}" + }, + "protocolMappers" : [ { + "id" : "4b08b663-f6da-4ae1-b51c-95d3c5da7135", + "name" : "address", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-address-mapper", + "consentRequired" : false, + "config" : { + "user.attribute.formatted" : "formatted", + "user.attribute.country" : "country", + "user.attribute.postal_code" : "postal_code", + "userinfo.token.claim" : "true", + "user.attribute.street" : "street", + "id.token.claim" : "true", + "user.attribute.region" : "region", + "access.token.claim" : "true", + "user.attribute.locality" : "locality" + } + } ] + }, { + "id" : "371b8bc3-9994-4139-b190-6ac66346d071", + "name" : "phone", + "description" : "OpenID Connect built-in scope: phone", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${phoneScopeConsentText}" + }, + "protocolMappers" : [ { + "id" : "de454cf9-21a7-4d4c-937a-161fc0a9f394", + "name" : "phone number verified", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "phoneNumberVerified", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "phone_number_verified", + "jsonType.label" : "boolean" + } + }, { + "id" : "9ff9ff9b-771a-4e4d-9927-4f4a41c12ef0", + "name" : "phone number", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "phoneNumber", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "phone_number", + "jsonType.label" : "String" + } + } ] + }, { + "id" : "c414175b-c48b-4ba8-affb-f7fcab2cd7e8", + "name" : "role_list", + "description" : "SAML role list", + "protocol" : "saml", + "attributes" : { + "consent.screen.text" : "${samlRoleListScopeConsentText}", + "display.on.consent.screen" : "true" + }, + "protocolMappers" : [ { + "id" : "71026609-b679-4f73-abc7-8cf8f3cc7c18", + "name" : "role list", + "protocol" : "saml", + "protocolMapper" : "saml-role-list-mapper", + "consentRequired" : false, + "config" : { + "single" : "false", + "attribute.nameformat" : "Basic", + "attribute.name" : "Role" + } + } ] + }, { + "id" : "251e1dfc-56f4-4b75-97de-5791f563af89", + "name" : "acr", + "description" : "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "display.on.consent.screen" : "false" + }, + "protocolMappers" : [ { + "id" : "eb8e0733-5595-4fc9-8b49-ba190913b91f", + "name" : "acr loa level", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-acr-mapper", + "consentRequired" : false, + "config" : { + "id.token.claim" : "true", + "access.token.claim" : "true", + "userinfo.token.claim" : "true" + } + } ] + }, { + "id" : "0a9c909d-f76e-4640-84f5-736cfbb43837", + "name" : "roles", + "description" : "OpenID Connect scope for add user roles to the access token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "display.on.consent.screen" : "true", + "consent.screen.text" : "${rolesScopeConsentText}" + }, + "protocolMappers" : [ { + "id" : "ebb49955-bc07-45df-b27e-1029dc2237e2", + "name" : "client roles", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-client-role-mapper", + "consentRequired" : false, + "config" : { + "user.attribute" : "foo", + "access.token.claim" : "true", + "claim.name" : "resource_access.${client_id}.roles", + "jsonType.label" : "String", + "multivalued" : "true" + } + }, { + "id" : "7204379b-fe03-4086-8be8-38d3c99c5972", + "name" : "audience resolve", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-audience-resolve-mapper", + "consentRequired" : false, + "config" : { } + }, { + "id" : "a3cd9334-75ce-47c4-8777-a08e40bce729", + "name" : "realm roles", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-realm-role-mapper", + "consentRequired" : false, + "config" : { + "user.attribute" : "foo", + "access.token.claim" : "true", + "claim.name" : "realm_access.roles", + "jsonType.label" : "String", + "multivalued" : "true" + } + } ] + }, { + "id" : "9fb76bef-4c14-4e2b-8c74-3b856333192d", + "name" : "microprofile-jwt", + "description" : "Microprofile - JWT built-in scope", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "false" + }, + "protocolMappers" : [ { + "id" : "89dbc45a-92c1-4118-b536-4da0c707442e", + "name" : "groups", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-realm-role-mapper", + "consentRequired" : false, + "config" : { + "multivalued" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "foo", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "groups", + "jsonType.label" : "String" + } + }, { + "id" : "d05b7c19-58b4-4e2e-87ac-96bb536fe2af", + "name" : "upn", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "userinfo.token.claim" : "true", + "user.attribute" : "username", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "upn", + "jsonType.label" : "String" + } + } ] + } ], + "defaultDefaultClientScopes" : [ "role_list", "profile", "email", "roles", "web-origins", "acr" ], + "defaultOptionalClientScopes" : [ "offline_access", "address", "phone", "microprofile-jwt" ], + "browserSecurityHeaders" : { + "contentSecurityPolicyReportOnly" : "", + "xContentTypeOptions" : "nosniff", + "referrerPolicy" : "no-referrer", + "xRobotsTag" : "none", + "xFrameOptions" : "SAMEORIGIN", + "contentSecurityPolicy" : "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection" : "1; mode=block", + "strictTransportSecurity" : "max-age=31536000; includeSubDomains" + }, + "smtpServer" : { }, + "eventsEnabled" : false, + "eventsListeners" : [ "jboss-logging" ], + "enabledEventTypes" : [ ], + "adminEventsEnabled" : false, + "adminEventsDetailsEnabled" : false, + "identityProviders" : [ ], + "identityProviderMappers" : [ ], + "components" : { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy" : [ { + "id" : "e28ab391-e2d1-4d84-be47-52ee1fe69208", + "name" : "Allowed Client Scopes", + "providerId" : "allowed-client-templates", + "subType" : "authenticated", + "subComponents" : { }, + "config" : { + "allow-default-scopes" : [ "true" ] + } + }, { + "id" : "39166c82-c894-42bc-8641-498007a490d6", + "name" : "Allowed Client Scopes", + "providerId" : "allowed-client-templates", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "allow-default-scopes" : [ "true" ] + } + }, { + "id" : "40b0495e-f0b9-4acf-845a-ef181ae26045", + "name" : "Full Scope Disabled", + "providerId" : "scope", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { } + }, { + "id" : "b94fd083-05e1-4a3d-9de4-bdc3a41a5216", + "name" : "Consent Required", + "providerId" : "consent-required", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { } + }, { + "id" : "9c06eb51-7793-477b-bb96-a894bd652684", + "name" : "Max Clients Limit", + "providerId" : "max-clients", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "max-clients" : [ "200" ] + } + }, { + "id" : "4bdfc774-84ff-4b1a-b4d4-f2ecc5a9dab0", + "name" : "Trusted Hosts", + "providerId" : "trusted-hosts", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "host-sending-registration-request-must-match" : [ "true" ], + "client-uris-must-match" : [ "true" ] + } + }, { + "id" : "ed93eae5-8258-4c22-95db-a70f12292a62", + "name" : "Allowed Protocol Mapper Types", + "providerId" : "allowed-protocol-mappers", + "subType" : "authenticated", + "subComponents" : { }, + "config" : { + "allowed-protocol-mapper-types" : [ "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-attribute-mapper", "oidc-full-name-mapper", "oidc-usermodel-property-mapper", "saml-user-property-mapper", "saml-role-list-mapper", "saml-user-attribute-mapper" ] + } + }, { + "id" : "741b8acc-ab84-49fe-8dce-f587b2a22502", + "name" : "Allowed Protocol Mapper Types", + "providerId" : "allowed-protocol-mappers", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "allowed-protocol-mapper-types" : [ "saml-user-property-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper", "oidc-full-name-mapper", "saml-role-list-mapper", "saml-user-attribute-mapper", "oidc-address-mapper" ] + } + } ], + "org.keycloak.keys.KeyProvider" : [ { + "id" : "c00865e8-37e1-4169-bb84-928a68204f9a", + "name" : "rsa-enc-generated", + "providerId" : "rsa-enc-generated", + "subComponents" : { }, + "config" : { + "privateKey" : [ "MIIEowIBAAKCAQEAq1pAjX1HsnXc4qtZ/tuGfyVJyk+ZFQDmr1g4/eOa+0XL69x/pW1wBK6leMCvvlYFX8jr/Vldj7BzqpxTXT+z8b6JHyZwEdloufBTowD3m5dmRlnKXVdAm60s3JaWPJcB0LeOZ+jpIkGDbApNqJ2CseKr/iCEGfpJ4VstdNbx6aMVWd68kLbZ80Swne4yC2UFPucd1033Smnq11Bi4JnObaCk4+3PMfvZ4x2YUjobw/COrEofUxL+7l3UepA319yi+ee/yqCyDnzyE6tjmG3FkQR6Ph4DLLh51K47MmuFTxVUlFbIWpAInEs6NqmSoEHZBGauEQgvf/73palfpJqC4wIDAQABAoIBABFgEfqm8P2+KNtNP89xiDZdXBmpL58rG7k9C25nRYBkR/TPQ5xNOBYpdXsEou18gIgBcjia9rtKy7fJqURj4MXLKdFgTu3oa0+reHencfQzinnowfXsEo+WpF1r4akcMOFus4CC5B0GoqJyewR1kFqkXwdiWbLep94zpizaXOZxadcodsRIu6oxPznCnv0tloqMPGI/U0o0YsZUNUDIX0e3fMJONGSfNjkY3Tpe6qiUC0R0eGzUyH9jjcGXgig8vK1I8qmOQ16sExihRHDMoGz3oPDvuedU65m0E6ppIpNpK7uS1ZQglJLwTmOsyZQ9zVsiKneQZPcQ7Do2DDGcNRkCgYEA5+A8UAw/ljLmBuutP6BNw5/XMHmYLSHdA+dhHtdHoLYHX+2RUOIjTt33XJAR5kVVHiZIc8EFE0xd17oebgRsM7053aC5i3G3eIVV1Y3vpc9983H575eIEzp6Yw0g1Zr426S/dryREZz2Psydop++4YqLRZRj6xUlZwsTsT+ujikCgYEAvS4KvsW0Evc/ij4YeDu5heqJN6NBC+BVZGpXBb8GokhCSXVLMHIbgDPkj0QHinmXa38QN4yJpDSw4EwirFjOuJ8FmcykrvbvW4UdBUjC6o2TeTwtNmKsgMYDyvPHbA5+i2+7o7WuWWH0JMTHdE5GAFudv9PNwTFSNJaRWeOz0isCgYEAkF5Irv4xSO7/0SdDgu3n/Pi0HnZWjSMcXKXfDjizXBh5lvxvEZD6ssv4iyUYP+rDCDaFvaxb4JwXgY1cDNusErqqIJdFbtCqjttVidUJdI8vuDcqikYqbu5l7O3rl5MZhoeYvfDB0dmMZ6U2MF6NlheQhw3Q+Dj+RJ3c8OrZX6ECgYBbxKzjs+XRWWzSM6MNF/O7+XSMr73K9AyYRFloaSzfpeu51JMfsgqTGxkhQh/iVW9VbvK/74WJSSIP+/7J2d6VglZmL/YnBZRAsgbM3Gno+7pxEfbgreb+JJAMcErpqPJL02yTUnt70l9rQqV6Tsn4PHj+Z9EkiTdWWT+y+hjttwKBgBfLxRkQpQFOfS4xnce+dsOxwwfxVToPIGD7BYGYKmWSgAQIWOSnJYGurgHOY/Vmcgaq0czdCJgca6klAO6/Im46+01GlzqV5udkjWfIPZJGxqwhgiguLk1ry1x4i9b4KftDpLQD8ywBxyMBWWPEEhh9bEPhHHY/Ni3d31BC8hqR" ], + "certificate" : [ "MIICnzCCAYcCBgGKgJGyNzANBgkqhkiG9w0BAQsFADATMREwDwYDVQQDDAhneHlyZWFsbTAeFw0yMzA5MTAxOTI3MjZaFw0zMzA5MTAxOTI5MDZaMBMxETAPBgNVBAMMCGd4eXJlYWxtMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq1pAjX1HsnXc4qtZ/tuGfyVJyk+ZFQDmr1g4/eOa+0XL69x/pW1wBK6leMCvvlYFX8jr/Vldj7BzqpxTXT+z8b6JHyZwEdloufBTowD3m5dmRlnKXVdAm60s3JaWPJcB0LeOZ+jpIkGDbApNqJ2CseKr/iCEGfpJ4VstdNbx6aMVWd68kLbZ80Swne4yC2UFPucd1033Smnq11Bi4JnObaCk4+3PMfvZ4x2YUjobw/COrEofUxL+7l3UepA319yi+ee/yqCyDnzyE6tjmG3FkQR6Ph4DLLh51K47MmuFTxVUlFbIWpAInEs6NqmSoEHZBGauEQgvf/73palfpJqC4wIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCCntBPzkcsRwIyjUoXsfvXDmP4o7OouTqhlpqy5y0oebi508qszKITBb8mUiR7PKSIQvfyeRygy7+a+vrB4zmBmYEg3ZRnOKhSJwMEf350c7xkW3dL89wCzPpC4IK+et6Vm8oQBQ4V8iX09cEes5lTxjnNZPmxfTJl23CnZRUvhopNZVTrAaVa6YicvaNSv5VLfgT6ibQjtoRJZvOdFVdXvmvIB+svDSUqehwdyB9TX5fBPVritf2WYaM/LOB84uKA/0NGwTisyPpYheRE7bqNB4PZphB2ETguYtVqEzz2+3+/l9UjG9VYVb6HDfHM+9QEsOPpmOpSvZTjoUfF0wwW" ], + "priority" : [ "100" ], + "algorithm" : [ "RSA-OAEP" ] + } + }, { + "id" : "b6640f7c-d31a-47ec-aefb-4d64237325a6", + "name" : "hmac-generated", + "providerId" : "hmac-generated", + "subComponents" : { }, + "config" : { + "kid" : [ "6f8e230b-bb32-4b2c-aca5-297dfcf0414d" ], + "secret" : [ "Fpzvi9U3AcY0rSfsBBuwdTTN4qApRxU5B_nvX5EwjiovT-xkAF7MdXdB7R1E3-_BNS9EK0WZgwX_LxtawfajeQ" ], + "priority" : [ "100" ], + "algorithm" : [ "HS256" ] + } + }, { + "id" : "46558ad9-a9e3-477e-a078-fbd8f46244a9", + "name" : "rsa-generated", + "providerId" : "rsa-generated", + "subComponents" : { }, + "config" : { + "privateKey" : [ "MIIEowIBAAKCAQEAwEDf8PD76icMMjnWAcc1krAoBvfYQznfzAtDobn0BLbrTjgtT8XGLgx7GbTxtx6N15H8tNaj9Pz1p844dpUx+RLhwLbU5mLmPzSv5N6sG9AlQruG3seOHYXa+BEgjS+P3DFnldvSfIZzrC7gEZJqpGCYlu5WKFwLMGTNyNQyQaFKPK14KS//cLlDWsqsMTI2NbsEFjfAWlE+66VziWCP+pRpvVMpe4CdQQSv8jBEWeCBN8OiiLVF7dK+Oe79dRNXeciXyNWsFKQ/Pw/gu1+eTVopqUHuEMzqvwFjAq9rJJS3wY2BzFeEWYqrZBxPpz3s5lnurCXsgWfcoUVWxUs1nQIDAQABAoIBABi37qMZtOYS/a4ygfnKDXEODaNxt+BS31uqghhX4pA9nYz5y5ejX9r5V1WDjoWDG+0g08tDgqKj6IjaCqfygFGcNCL1TvPNOPnc8hWc7KeJ0FSfbFSV90eD95aoCxWkDRzcTEt4DzzcA6DaiQwxVV1Lnyreu3ymcvjWqR++arTFyUmI7fRmygzw/MhWvF/6USSvPh8H13tD5A25MlUL6EMvDeNBz9gL83Yfv3jQpdi9jaB4BWPCugT145kHPQ5TzvYy9jUIBVeBx4hTUMGY+yUeG6EnXSQlZ6KuaXbsrLuH9+IvSteStEMWL1xmOpiCgH2vkrYGdjNZi8GoOzcWAGkCgYEA4amqjKvTWPUCJWGX1gM4s7HSO/6IQhvex98hfeVYOfLqHClgDxZmZlZnyDvG5t2IN36sIDp67fsfQUVOhcm/GZOgvvhEL1+oe8d/Wi2CVZTUrk/fMdGfL2g9VDL5o8AEIv40lV9Al8i1U090+Ja30vEqd9bNzY1TP4L07dK9iDUCgYEA2hlnZBfbvI5K0uv9YcZiSoPWOX1lPbIa1fi8vYq/gqox/6vDMFWhH0kAR36/3vGQ/bNr1iJSzqBwZ4/lKhyMhZk7j0MNmTBEUgM/OQ/UAqqNtSgfaLxsh2RYRqRSSHn8K9oa7BSxo1Scm9IZzyeE/CARDFBQjoTkkfZbBFKetMkCgYBiBSO61MwJ4Orct+aPJHkVvNDYFHi1VovPf8F5gQxwp24/a015YD58h12vISAFmgaYLGKx4RUSmDj8ThGMlZR3lKOHMCnV9hQmKALOdeQeLnavfKwzZJ1jp0C0eSvsj/R8CrVmiKhzBdCVbncdn8IbU3wh7+EXPyMA/G5Ne3OuXQKBgQCsDgBviRbTQFms3Xjtymg+KpU0k0TsD/Z1uJ2E1dFRDl0VOnZoShUqrhdnT4mkImPkkMfQjRFQamdie7UorI760jCXSymOIPK01FTq+h6h39SdBkMJCCSCBtpRGE7FWF5kZdJ5TbX5iMVnc67iqmWR1OY+FZoHZLVApN2WomsLaQKBgBpq5hsK6lOY7vcWirkSiw8XwqRkIvNx0pSWT+QMkBwwvZLk8Oilbfvu0aJ+JoxI8eekRSOlzRFm0txBc6cUm7PSSVf0kRU2RVdDeZIeFpQ2nB9N3nxQGcFNFUY27XfMuMwxUpitekYtEuH9Dv35xeGOGXpppwtsT2/lz2PYI5wW" ], + "certificate" : [ "MIICnzCCAYcCBgGKgJGy+DANBgkqhkiG9w0BAQsFADATMREwDwYDVQQDDAhneHlyZWFsbTAeFw0yMzA5MTAxOTI3MjZaFw0zMzA5MTAxOTI5MDZaMBMxETAPBgNVBAMMCGd4eXJlYWxtMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwEDf8PD76icMMjnWAcc1krAoBvfYQznfzAtDobn0BLbrTjgtT8XGLgx7GbTxtx6N15H8tNaj9Pz1p844dpUx+RLhwLbU5mLmPzSv5N6sG9AlQruG3seOHYXa+BEgjS+P3DFnldvSfIZzrC7gEZJqpGCYlu5WKFwLMGTNyNQyQaFKPK14KS//cLlDWsqsMTI2NbsEFjfAWlE+66VziWCP+pRpvVMpe4CdQQSv8jBEWeCBN8OiiLVF7dK+Oe79dRNXeciXyNWsFKQ/Pw/gu1+eTVopqUHuEMzqvwFjAq9rJJS3wY2BzFeEWYqrZBxPpz3s5lnurCXsgWfcoUVWxUs1nQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQB47+AFyLVRtF4WHl4HcqGNSnALxuanPnujFMoeEomL7J98n49vdn2gNK9aWk/yx2H8QjreQiz7CGU2/+FY2JvQ43bvyUlCLOl0+hqhyy+DIQ6lwcUP4xDwJtTt0uNKzbu5Ep+V+1SBeQbLlvdSj15Ht1n5gTwOfa5+L7dgmZoR7iDu7zkcQLv1JYWUFIE6REQCf9+lKMDfkAm48rRYkd+b6rvOeRmq0ftS2paGdbNyvPocnX/anEQYnSw/eA0XSiuT7FouRB+0HEh//OLwoVENjRjr2S7IlJM8K3Ihh7gchG7gnrtBMnFq9ydyKHxLptg2C0tJMaio5rIWau+apEFm" ], + "priority" : [ "100" ] + } + }, { + "id" : "fa70ee46-065d-4966-b4da-ba24027c80ed", + "name" : "aes-generated", + "providerId" : "aes-generated", + "subComponents" : { }, + "config" : { + "kid" : [ "96fda61c-6a7a-439b-ab25-e01fb972bdab" ], + "secret" : [ "A0Myp2apX3_fAu49xohrMA" ], + "priority" : [ "100" ] + } + } ] + }, + "internationalizationEnabled" : false, + "supportedLocales" : [ ], + "authenticationFlows" : [ { + "id" : "3735c2f8-003a-4c3e-a0fd-b8aa85743d87", + "alias" : "Account verification options", + "description" : "Method with which to verity the existing account", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-email-verification", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "ALTERNATIVE", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "Verify Existing Account by Re-authentication", + "userSetupAllowed" : false + } ] + }, { + "id" : "bdfb771b-b92d-49ff-9309-5b75000e9a06", + "alias" : "Browser - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "auth-otp-form", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "cd190cea-52f2-4921-8edf-4dccc3cfdcc0", + "alias" : "Direct Grant - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "direct-grant-validate-otp", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "5c87ced6-9213-44bd-bd27-5734674bc686", + "alias" : "First broker login - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "auth-otp-form", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "80adfdbe-adc0-4329-97f7-0ef16c2ed880", + "alias" : "Handle Existing Account", + "description" : "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-confirm-link", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "Account verification options", + "userSetupAllowed" : false + } ] + }, { + "id" : "711e0b6c-5195-4f23-9442-6997ae180e85", + "alias" : "Reset - Conditional OTP", + "description" : "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "reset-otp", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "bb9ef3fa-13a0-4580-932a-1688e2cfb7a2", + "alias" : "User creation or linking", + "description" : "Flow for the existing/non-existing user alternatives", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticatorConfig" : "create unique user config", + "authenticator" : "idp-create-user-if-unique", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "ALTERNATIVE", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "Handle Existing Account", + "userSetupAllowed" : false + } ] + }, { + "id" : "255d2624-07d4-4b1c-a209-a3b568d5e8f8", + "alias" : "Verify Existing Account by Re-authentication", + "description" : "Reauthentication of existing account", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-username-password-form", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "CONDITIONAL", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "First broker login - Conditional OTP", + "userSetupAllowed" : false + } ] + }, { + "id" : "5bff5926-d897-4563-962d-b3b0a415e52f", + "alias" : "browser", + "description" : "browser based authentication", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "auth-cookie", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "auth-spnego", + "authenticatorFlow" : false, + "requirement" : "DISABLED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "identity-provider-redirector", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 25, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "ALTERNATIVE", + "priority" : 30, + "autheticatorFlow" : true, + "flowAlias" : "forms", + "userSetupAllowed" : false + } ] + }, { + "id" : "cf0e01ba-6591-40dc-a872-94eec5c73207", + "alias" : "clients", + "description" : "Base authentication for clients", + "providerId" : "client-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "client-secret", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "client-jwt", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "client-secret-jwt", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 30, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "client-x509", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 40, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "78e4313c-5b61-44cc-960a-b5fc598e3c78", + "alias" : "direct grant", + "description" : "OpenID Connect Resource Owner Grant", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "direct-grant-validate-username", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "direct-grant-validate-password", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "CONDITIONAL", + "priority" : 30, + "autheticatorFlow" : true, + "flowAlias" : "Direct Grant - Conditional OTP", + "userSetupAllowed" : false + } ] + }, { + "id" : "14789471-4ce5-488b-9621-8b17e0c44e9e", + "alias" : "docker auth", + "description" : "Used by Docker clients to authenticate against the IDP", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "docker-http-basic-authenticator", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "73977004-5759-4e82-a4c6-448a28ad2fc0", + "alias" : "first broker login", + "description" : "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticatorConfig" : "review profile config", + "authenticator" : "idp-review-profile", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "User creation or linking", + "userSetupAllowed" : false + } ] + }, { + "id" : "cfb491ee-ff43-46aa-a841-15b01c0af74b", + "alias" : "forms", + "description" : "Username, password, otp and other auth forms.", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "auth-username-password-form", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "CONDITIONAL", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "Browser - Conditional OTP", + "userSetupAllowed" : false + } ] + }, { + "id" : "a7c6cf99-04c3-433e-92ab-22e51693d43b", + "alias" : "registration", + "description" : "registration flow", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "registration-page-form", + "authenticatorFlow" : true, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : true, + "flowAlias" : "registration form", + "userSetupAllowed" : false + } ] + }, { + "id" : "96ffc4d3-e913-4c93-8cba-d57dfe629b7e", + "alias" : "registration form", + "description" : "registration form", + "providerId" : "form-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "registration-user-creation", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "registration-profile-action", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 40, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "registration-password-action", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 50, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "registration-recaptcha-action", + "authenticatorFlow" : false, + "requirement" : "DISABLED", + "priority" : 60, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "8cc09d28-e511-447c-9c0d-4ea4f8a98093", + "alias" : "reset credentials", + "description" : "Reset credentials for a user if they forgot their password or something", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "reset-credentials-choose-user", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "reset-credential-email", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "reset-password", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 30, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "CONDITIONAL", + "priority" : 40, + "autheticatorFlow" : true, + "flowAlias" : "Reset - Conditional OTP", + "userSetupAllowed" : false + } ] + }, { + "id" : "4ffc9dee-24bc-4127-bf32-f3955ad06eb7", + "alias" : "saml ecp", + "description" : "SAML ECP Profile Authentication Flow", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "http-basic-authenticator", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + } ], + "authenticatorConfig" : [ { + "id" : "cf395aeb-ca09-4d9b-a593-ee69efa76295", + "alias" : "create unique user config", + "config" : { + "require.password.update.after.registration" : "false" + } + }, { + "id" : "6c8598e5-47dc-451a-b22d-ea615251853d", + "alias" : "review profile config", + "config" : { + "update.profile.on.first.login" : "missing" + } + } ], + "requiredActions" : [ { + "alias" : "CONFIGURE_TOTP", + "name" : "Configure OTP", + "providerId" : "CONFIGURE_TOTP", + "enabled" : true, + "defaultAction" : false, + "priority" : 10, + "config" : { } + }, { + "alias" : "TERMS_AND_CONDITIONS", + "name" : "Terms and Conditions", + "providerId" : "TERMS_AND_CONDITIONS", + "enabled" : false, + "defaultAction" : false, + "priority" : 20, + "config" : { } + }, { + "alias" : "UPDATE_PASSWORD", + "name" : "Update Password", + "providerId" : "UPDATE_PASSWORD", + "enabled" : true, + "defaultAction" : false, + "priority" : 30, + "config" : { } + }, { + "alias" : "UPDATE_PROFILE", + "name" : "Update Profile", + "providerId" : "UPDATE_PROFILE", + "enabled" : true, + "defaultAction" : false, + "priority" : 40, + "config" : { } + }, { + "alias" : "VERIFY_EMAIL", + "name" : "Verify Email", + "providerId" : "VERIFY_EMAIL", + "enabled" : true, + "defaultAction" : false, + "priority" : 50, + "config" : { } + }, { + "alias" : "delete_account", + "name" : "Delete Account", + "providerId" : "delete_account", + "enabled" : false, + "defaultAction" : false, + "priority" : 60, + "config" : { } + }, { + "alias" : "webauthn-register", + "name" : "Webauthn Register", + "providerId" : "webauthn-register", + "enabled" : true, + "defaultAction" : false, + "priority" : 70, + "config" : { } + }, { + "alias" : "webauthn-register-passwordless", + "name" : "Webauthn Register Passwordless", + "providerId" : "webauthn-register-passwordless", + "enabled" : true, + "defaultAction" : false, + "priority" : 80, + "config" : { } + }, { + "alias" : "update_user_locale", + "name" : "Update User Locale", + "providerId" : "update_user_locale", + "enabled" : true, + "defaultAction" : false, + "priority" : 1000, + "config" : { } + } ], + "browserFlow" : "browser", + "registrationFlow" : "registration", + "directGrantFlow" : "direct grant", + "resetCredentialsFlow" : "reset credentials", + "clientAuthenticationFlow" : "clients", + "dockerAuthenticationFlow" : "docker auth", + "attributes" : { + "cibaBackchannelTokenDeliveryMode" : "poll", + "cibaExpiresIn" : "120", + "cibaAuthRequestedUserHint" : "login_hint", + "oauth2DeviceCodeLifespan" : "600", + "clientOfflineSessionMaxLifespan" : "0", + "oauth2DevicePollingInterval" : "5", + "clientSessionIdleTimeout" : "0", + "parRequestUriLifespan" : "60", + "clientSessionMaxLifespan" : "0", + "clientOfflineSessionIdleTimeout" : "0", + "cibaInterval" : "5", + "realmReusableOtpCode" : "false" + }, + "keycloakVersion" : "22.0.1", + "userManagedAccessAllowed" : false, + "clientProfiles" : { + "profiles" : [ ] + }, + "clientPolicies" : { + "policies" : [ ] + } +} \ No newline at end of file diff --git a/test/integration/oidc/keycloak-server.crt.pem b/test/integration/oidc/keycloak-server.crt.pem new file mode 100644 index 000000000000..fd4b489661a9 --- /dev/null +++ b/test/integration/oidc/keycloak-server.crt.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDVzCCAj+gAwIBAgIUCYRtnqxm/HMxGPpE6VpTmPacZMQwDQYJKoZIhvcNAQEL +BQAwOzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxFzAVBgNVBAoM +DkdhbGF4eSBQcm9qZWN0MB4XDTIzMDkxMDE5MjAwOFoXDTMzMDkwNzE5MjAwOFow +OzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxFzAVBgNVBAoMDkdh +bGF4eSBQcm9qZWN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwF+F +iXMW2emcYCvSkiZCh00RDVCcKUfB/i7Q+Rxl0y0HG6sTJWN8ELf4SVR7Zaq3b30w +5Yx6tmXY0lfzHkfjRXG/WdAjPDeN8j54RK74UAOM72KFySm/CxX2w5peNyBHA4BX +mHKGagIgWH4VUCeJ0z7GkI0ESrdlwOix8Qi8n/l0hjmmS2NOvM1dq1ehNVfOtB4K +DF1AANffiCEz2OkAIWwczwVZrgHg7ILvga+g4GnX6SEioEHMCfyk7E6ASbLb2mDj +iaIVT1PpwtRLgL+yhC7QRWK66IuurcRAJsLL/isIeZGtF0xfvP5nfiWC07lzYior +J3M1I14I7LTpEwmEIwIDAQABo1MwUTAdBgNVHQ4EFgQUbAYXkXuWm8xrOWmsbN5w +rQoaLG4wHwYDVR0jBBgwFoAUbAYXkXuWm8xrOWmsbN5wrQoaLG4wDwYDVR0TAQH/ +BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAhvHjMe20W7TO299iI1fEhob4Ihyr +tm/4hPPBLC9HSiKokKYvgOU0dK2dh/X7ax9N+ouuDmWYCLCP7AcaWS+HHDVyCv+0 +j8YNVTaqoD/u9qnvx9/2Zv1TqGaLhw9UPmro0MnUUOMoQRYQKaH3fatidCMQJt2j +rzvyrkOuicOr1lRh8Hj1+pdKzar4rmskz9SLkoKRiXjsAUrC1JvQxVxORzf4CoD/ +eR+ZXeglmAZu8AbwFd/y5OPaHuqsXXHpzg8oJffymOo8L3jp3fZjHFhzUBIHZX2u +ZVmpT0COKu69yQ1EoHmKpVFCinYVqbfOsu7s4qWOIt8qpoHPdaElzMD/2w== +-----END CERTIFICATE----- diff --git a/test/integration/oidc/keycloak-server.key.pem b/test/integration/oidc/keycloak-server.key.pem new file mode 100644 index 000000000000..a58818417e8e --- /dev/null +++ b/test/integration/oidc/keycloak-server.key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDAX4WJcxbZ6Zxg +K9KSJkKHTRENUJwpR8H+LtD5HGXTLQcbqxMlY3wQt/hJVHtlqrdvfTDljHq2ZdjS +V/MeR+NFcb9Z0CM8N43yPnhErvhQA4zvYoXJKb8LFfbDml43IEcDgFeYcoZqAiBY +fhVQJ4nTPsaQjQRKt2XA6LHxCLyf+XSGOaZLY068zV2rV6E1V860HgoMXUAA19+I +ITPY6QAhbBzPBVmuAeDsgu+Br6DgadfpISKgQcwJ/KTsToBJstvaYOOJohVPU+nC +1EuAv7KELtBFYrroi66txEAmwsv+Kwh5ka0XTF+8/md+JYLTuXNiKisnczUjXgjs +tOkTCYQjAgMBAAECggEAR4Jah8z11hwuSluP408AlP3zVEE+Q2QOuzdY6/j/6t+G +kGTn8ZKJGEfirhOO6SxsEq/1QKcXKH00e4BA+eUcGQDe8fpaVrSJ+d355AIsgMXJ +GmVDuRLhFrRb7bsQwaooa6dGKH+N8pMGxg5cStbft1xTAIHC8ik5VFvLZX9raoVw +DyfLPg3CtWQID9Jbb4lODqHpjSP5J5v+IskCbbk1sihelEoyFQxka8y1UYglSB7E +ntz5j83OqH7qFes/J/g3K8eNvhd3RQZCozzCwjaUUNHpAeTOEmQqdu9joVvQu1pp +/iQcwqCb0TSzezUssCRGJNHCVzb0UwBgqCH4stt+8QKBgQD/NZSj0ofY8GA1XMcK +s1R9dc/iz6FTeFOz4/36xoTSWoLUFVUpP+1XVVzjV98j65D+CtC6RDBso3tf5Jeo +JB0u4Jy86iyVVN8VMgxDLmNsNSPWOdQCfa6ASQRjnlSwa99L+fZ3ITjz71fDR+0E +RKMEzRAu9CYS7X6dy2/6ccRP0QKBgQDA+Bo7VE3HMeSQkT8hJWZpEmFseJ8AdM+/ +2+dwQRFvyBO25hlaaedF3fv52D5S3hiM40tHxSyRmd0qJBQAF5gXVMAtsDD/SZru +maAQXGR0gorGuWkBkyKHDpZmYnK4TWAE5Tikp2638Zrtik6gSZtJVaBqz9oj1Z56 +8elxUxSlswKBgA6lU4dnQJaJjOvjDCUoD3Q3u5tnkPmKUTtknSG0HNE5koJ7SuzY +F1XIt1DwrQSRsztxAXN/EExbwWvKYY2rrOUQzM3mnnJfcYH04lFEqz39erYDkZAn +CEmiZxcUcgXB2wmwKZ3CkVMmbCHv0cFJ8xUGc6ROLJUvffGuSBnWR47RAoGBAJr0 +4IOyD/8cVpGswUgJs3I+m1MyjWQdaVKE8RXYensL7KDbp5JOJrSZLKWV+34p8Qb5 +4tLQSBjnuI0FibYFPFHH8cb+jGoEHlDptnpM5zIUPgKUkvj7wSc5FJdFCSBsqeVk +eJboCOXXMvT7MmZOlU9PXlFHdcF+4hOSPUaOU683AoGAdj+MsVxRgnCEmUCl05EJ +kd5H8bBFB/vq+Rn3erJFXKLM3vpjBV3Yi2RGC06YGe4L1+krrMIrnEFno3G5yi4Z +UWuakpvEDV+esu9s/EcZxv4WtHkQQ+2g3CGqy5BqDefe5YOJrQaCVuwIga1WIV0q +JNY3E9bDmpvvDBw+SEzcJug= +-----END PRIVATE KEY----- diff --git a/test/integration/oidc/oidc_config.xml b/test/integration/oidc/oidc_config.xml new file mode 100644 index 000000000000..2146657621cc --- /dev/null +++ b/test/integration/oidc/oidc_config.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py new file mode 100644 index 000000000000..13739036084f --- /dev/null +++ b/test/integration/oidc/test_auth_oidc.py @@ -0,0 +1,179 @@ +"""Integration tests for the CLI shell plugins and runners.""" +import html +import os +import re +import subprocess +from string import Template +from typing import ClassVar +import tempfile +from urllib import parse + +import requests + +from galaxy_test.driver import integration_util +from galaxy_test.base.api import ApiTestInteractor + + +KEYCLOAK_ADMIN_USERNAME = "admin" +KEYCLOAK_ADMIN_PASSWORD = "admin" +KEYCLOAK_TEST_USERNAME = "gxyuser" +KEYCLOAK_TEST_PASSWORD = "gxypass" + + +OIDC_BACKEND_CONFIG_TEMPLATE = """ + + + https://localhost:8443/realms/gxyrealm + gxyclient + gxytestclientsecret + $galaxy_url/authnz/keycloak/callback + true + + +""" + +def wait_till_keycloak_ready(port): + return subprocess.call(["timeout", "300", "bash", "-c", f"'until curl --silent --output /dev/null http://localhost:{port}; do sleep 0.5; done'"]) == 0 + + +def start_keycloak_docker(container_name, port=8443, image="keycloak/keycloak:22.0.1"): + keycloak_realm_data = os.path.dirname(__file__) + START_SLURM_DOCKER = [ + "docker", + "run", + "-h", + "localhost", + "-p", + f"{port}:8443", + "-d", + "--name", + container_name, + "--rm", + "-v", + f"{keycloak_realm_data}:/opt/keycloak/data/import", + "-e", + f"KEYCLOAK_ADMIN={KEYCLOAK_ADMIN_USERNAME}", + "-e", + f"KEYCLOAK_ADMIN_PASSWORD={KEYCLOAK_ADMIN_PASSWORD}", + "-e", + "KC_HOSTNAME_STRICT=false", + image, + "start", + "--optimized", + "--import-realm", + "--https-certificate-file=/opt/keycloak/data/import/keycloak-server.crt.pem", + "--https-certificate-key-file=/opt/keycloak/data/import/keycloak-server.key.pem" + ] + print(" ".join(START_SLURM_DOCKER)) + subprocess.check_call(START_SLURM_DOCKER) + wait_till_keycloak_ready(port) + + +def stop_keycloak_docker(container_name): + subprocess.check_call(["docker", "rm", "-f", container_name]) + + +class AbstractTestCases: + @integration_util.skip_unless_docker() + class BaseKeycloakIntegrationTestCase(integration_util.IntegrationTestCase): + container_name: ClassVar[str] + + @classmethod + def setUpClass(cls): + # By default, the oidc callback must be done over a secure transport, so + # we forcibly disable it for now + cls.disableOauthlibHttps() + cls.container_name = f"{cls.__name__}_container" + # start_keycloak_docker(container_name=cls.container_name) + super().setUpClass() + # For the oidc callback to work, we need to know Galaxy's hostname and port. + # However, we won't know what the host and port are until the Galaxy test driver is started. + # So let it start, then generate the oidc_backend_config.xml with the correct host and port, + # and finally restart Galaxy so the OIDC config takes effect. + cls.configure_oidc_and_restart() + + @classmethod + def generate_oidc_config_file(cls, server_wrapper): + with tempfile.NamedTemporaryFile('w+t', delete=False) as tmp_file: + host = server_wrapper.host + port = server_wrapper.port + prefix = server_wrapper.prefix or "" + galaxy_url = f"http://{host}:{port}{prefix.rstrip('/')}" + data = Template(OIDC_BACKEND_CONFIG_TEMPLATE).safe_substitute(galaxy_url=galaxy_url) + tmp_file.write(data) + return tmp_file.name + + @classmethod + def configure_oidc_and_restart(cls): + with tempfile.NamedTemporaryFile('w+t', delete=False) as tmp_file: + server_wrapper = cls._test_driver.server_wrappers[0] + cls.backend_config_file = cls.generate_oidc_config_file(server_wrapper) + # Explicitly assign the previously used port, as it's random otherwise + del os.environ["GALAXY_TEST_PORT_RANDOM"] + os.environ["GALAXY_TEST_PORT"] = os.environ["GALAXY_WEB_PORT"] + cls._test_driver.restart(config_object=cls, handle_config=cls.handle_galaxy_oidc_config_kwds) + + @classmethod + def tearDownClass(cls): + #stop_keycloak_docker(cls.container_name) + cls.restoreOauthlibHttps() + os.remove(cls.backend_config_file) + super().tearDownClass() + + @classmethod + def disableOauthlibHttps(cls): + if "OAUTHLIB_INSECURE_TRANSPORT" in os.environ: + cls.saved_oauthlib_insecure_transport = os.environ["OAUTHLIB_INSECURE_TRANSPORT"] + os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "true" + + @classmethod + def restoreOauthlibHttps(cls): + if getattr(cls, "saved_oauthlib_insecure_transport", None): + os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = cls.saved_oauthlib_insecure_transport + else: + del os.environ["OAUTHLIB_INSECURE_TRANSPORT"] + + @classmethod + def handle_galaxy_oidc_config_kwds(cls, config): + config["enable_oidc"] = True + config["oidc_config_file"] = os.path.join(os.path.dirname(__file__), "oidc_config.xml") + config["oidc_backends_config_file"] = cls.backend_config_file + + def _get_interactor(self, api_key=None, allow_anonymous=False) -> "ApiTestInteractor": + return super()._get_interactor(api_key=None, allow_anonymous=True) + + +class TestGalaxyOIDCLoginIntegration(AbstractTestCases.BaseKeycloakIntegrationTestCase): + + REGEX_KEYCLOAK_LOGIN_ACTION = re.compile(r"action=\"(.*)\"\s+") + + def _login_via_keycloak( + self, + username, + password, + expected_codes=[200, 404], + ): + session = requests.Session() + response = session.get(f"{self.url}authnz/keycloak/login") + provider_url = response.json()["redirect_uri"] + response = session.get(provider_url, verify=False) + matches = self.REGEX_KEYCLOAK_LOGIN_ACTION.search(response.text) + auth_url = html.unescape(matches.groups(1)[0]) + response = session.post( + auth_url, data={"username": username, "password": password}, verify=False + ) + if expected_codes: + assert response.status_code in expected_codes, response + self.galaxy_interactor.cookies = session.cookies + return session, response + + def test_oidc_login(self): + _, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD) + # Should have redirected back if auth succeeded + parsed_url = parse.urlparse(response.url) + notification = parse.parse_qs(parsed_url.query)['notification'][0] + assert "Your Keycloak identity has been linked to your Galaxy account." in notification + response = self._get("users/current") + self._assert_status_code_is(response, 200) + assert response.json()["email"] == "gxyuser@galaxy.org" + From d1aa6d189339ca113055429dcea2657ac7659a44 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Mon, 9 Oct 2023 01:03:40 +0530 Subject: [PATCH 024/954] Add oidc logout tests and fix bug in server side logout --- lib/galaxy/webapps/galaxy/controllers/authnz.py | 1 + test/integration/oidc/test_auth_oidc.py | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/lib/galaxy/webapps/galaxy/controllers/authnz.py b/lib/galaxy/webapps/galaxy/controllers/authnz.py index 88af61d8fa11..137cd23c160e 100644 --- a/lib/galaxy/webapps/galaxy/controllers/authnz.py +++ b/lib/galaxy/webapps/galaxy/controllers/authnz.py @@ -188,6 +188,7 @@ def logout(self, trans, provider, **kwargs): success, message, redirect_uri = trans.app.authnz_manager.logout( provider, trans, post_user_logout_href=post_user_logout_href ) + trans.handle_user_logout() if success: return {"redirect_uri": redirect_uri} else: diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index 13739036084f..8b3e3bfc2132 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -177,3 +177,15 @@ def test_oidc_login(self): self._assert_status_code_is(response, 200) assert response.json()["email"] == "gxyuser@galaxy.org" + def test_oidc_logout(self): + # login + session, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD) + # get the user + response = session.get(self._api_url("users/current")) + self._assert_status_code_is(response, 200) + # now logout + response = session.get(self._api_url("../authnz/logout")) + response = session.get(response.json()["redirect_uri"], verify=False) + # make sure we can no longer request the user + response = session.get(self._api_url("users/current")) + self._assert_status_code_is(response, 400) From 9d3d45a0e6bd8ed8434e1699f50ec3d141f528fb Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Mon, 16 Oct 2023 02:21:33 +0530 Subject: [PATCH 025/954] Accept access token for api auth --- lib/galaxy/authnz/custos_authnz.py | 34 ++++++++++++ lib/galaxy/authnz/managers.py | 27 ++++++++++ lib/galaxy/managers/users.py | 7 +++ lib/galaxy/webapps/base/webapp.py | 10 ++++ lib/galaxy/webapps/galaxy/api/__init__.py | 10 +++- test/integration/oidc/keycloak-server.crt.pem | 38 +++++++------- test/integration/oidc/keycloak-server.key.pem | 52 +++++++++---------- test/integration/oidc/test_auth_oidc.py | 26 +++++++++- 8 files changed, 156 insertions(+), 48 deletions(-) diff --git a/lib/galaxy/authnz/custos_authnz.py b/lib/galaxy/authnz/custos_authnz.py index 8f7b65d5de8c..3ff36fe51235 100644 --- a/lib/galaxy/authnz/custos_authnz.py +++ b/lib/galaxy/authnz/custos_authnz.py @@ -60,6 +60,7 @@ class CustosAuthnzConfiguration: redirect_uri: str ca_bundle: Optional[str] pkce_support: bool + accepted_audiences: List[str] extra_params: Optional[dict] authorization_endpoint: Optional[str] token_endpoint: Optional[str] @@ -68,6 +69,8 @@ class CustosAuthnzConfiguration: iam_client_secret: Optional[str] userinfo_endpoint: Optional[str] credential_url: Optional[str] + issuer: Optional[str] + jwks_uri: Optional[str] class OIDCAuthnzBase(IdentityProvider): @@ -84,6 +87,7 @@ def __init__(self, provider, oidc_config, oidc_backend_config, idphint=None): redirect_uri=oidc_backend_config["redirect_uri"], ca_bundle=oidc_backend_config.get("ca_bundle", None), pkce_support=oidc_backend_config.get("pkce_support", False), + accepted_audiences=oidc_backend_config.get("accepted_audiences", []), extra_params={}, authorization_endpoint=None, token_endpoint=None, @@ -92,6 +96,8 @@ def __init__(self, provider, oidc_config, oidc_backend_config, idphint=None): iam_client_secret=None, userinfo_endpoint=None, credential_url=None, + issuer=None, + jwks_uri=None, ) def _decode_token_no_signature(self, token): @@ -457,6 +463,12 @@ def _load_well_known_oidc_config(self, well_known_oidc_config): self.config.token_endpoint = well_known_oidc_config["token_endpoint"] self.config.userinfo_endpoint = well_known_oidc_config["userinfo_endpoint"] self.config.end_session_endpoint = well_known_oidc_config.get("end_session_endpoint") + self.config.issuer = well_known_oidc_config.get("issuer") + self.config.jwks_uri = well_known_oidc_config.get("jwks_uri") + if self.config.jwks_uri: + self.jwks_client = jwt.PyJWKClient(self.config.jwks_uri, cache_jwk_set=True, lifespan=360) + else: + self.jwks_client = None def _get_verify_param(self): """Return 'ca_bundle' if 'verify_ssl' is true and 'ca_bundle' is configured.""" @@ -587,3 +599,25 @@ def _load_config_for_custos(self): params = {"client_id": self.config.client_id} self._load_config(headers, params) + + def match_access_token_to_user(self, sa_session, access_token): + signing_key = self.jwks_client.get_signing_key_from_jwt(access_token) + decoded_jwt = jwt.decode( + access_token, + signing_key.key, + algorithms=["RS256"], + issuer=self.config["issuer"], + audience=self.config["accepted_audiences"], + options={ + "verify_signature": True, + "verify_exp": True, + "verify_nbf": True, + "verify_iat": True, + "verify_aud": self.config["accepted_audiences"] is not None, + "verify_iss": True, + }, + ) + # jwt verified, we can now fetch the user + user_id = decoded_jwt["sub"] + custos_authnz_token = self._get_custos_authnz_token(sa_session, user_id, self.config["provider"]) + return custos_authnz_token.user diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py index d766efcd111d..655c1a43d2c5 100644 --- a/lib/galaxy/authnz/managers.py +++ b/lib/galaxy/authnz/managers.py @@ -404,6 +404,33 @@ def create_user(self, provider, token, trans, login_redirect_url): log.exception(msg) return False, msg, (None, None) + def find_user_by_access_token_in_provider(self, sa_session, provider, access_token): + try: + success, message, backend = self._get_authnz_backend(provider) + if success is False: + msg = f"An error occurred when obtaining user by token with provider `{provider}`: {message}" + log.error(msg) + return None + user = backend.match_access_token_to_user(sa_session, access_token) + if user: + log.debug(f"Found user: {user} via `{provider}` identity provider") + return user + return None + except Exception as e: + msg = f"An error occurred when finding user by token: {e}" + log.error(msg) + return None + + def find_user_by_access_token(self, sa_session, access_token): + # decoded_token = jwt.decode(access_token, options={"verify_signature": False}) + # issuer = decoded_token["iss"] + # audience = decoded_token["aud"] + for provider in self.oidc_backends_config: + user = self.find_user_by_access_token_in_provider(sa_session, provider, access_token) + if user: + return user + return None + def logout(self, provider, trans, post_user_logout_href=None): """ Log the user out of the identity provider. diff --git a/lib/galaxy/managers/users.py b/lib/galaxy/managers/users.py index 65ef2a7919fa..3aba815df54d 100644 --- a/lib/galaxy/managers/users.py +++ b/lib/galaxy/managers/users.py @@ -295,6 +295,13 @@ def by_api_key(self, api_key: str, sa_session=None): raise exceptions.AuthenticationFailed("Provided API key has expired.") return provided_key.user + def by_oidc_access_token(self, access_token: str): + if hasattr(self.app, "authnz_manager") and self.app.authnz_manager: + user = self.app.authnz_manager.find_user_by_access_token(self.app.model.session, access_token) # type: ignore[attr-defined] + return user + else: + return None + def check_bootstrap_admin_api_key(self, api_key): bootstrap_admin_api_key = getattr(self.app.config, "bootstrap_admin_api_key", None) if not bootstrap_admin_api_key: diff --git a/lib/galaxy/webapps/base/webapp.py b/lib/galaxy/webapps/base/webapp.py index 8fc17986afec..f727f091d154 100644 --- a/lib/galaxy/webapps/base/webapp.py +++ b/lib/galaxy/webapps/base/webapp.py @@ -532,6 +532,8 @@ def _authenticate_api(self, session_cookie: str) -> Optional[str]: """ Authenticate for the API via key or session (if available). """ + oidc_access_token = self.request.headers.get("Authorization", None) + oidc_token_supplied = self.environ.get("is_api_request", False) and oidc_access_token and "Bearer " in oidc_access_token api_key = self.request.params.get("key", None) or self.request.headers.get("x-api-key", None) secure_id = self.get_cookie(name=session_cookie) api_key_supplied = self.environ.get("is_api_request", False) and api_key @@ -554,6 +556,14 @@ def _authenticate_api(self, session_cookie: str) -> Optional[str]: ) self.user = None self.galaxy_session = None + elif oidc_token_supplied: + # Sessionless API transaction with oidc token, we just need to associate a user. + oidc_access_token = oidc_access_token.replace("Bearer ", "") + try: + user = self.user_manager.by_oidc_access_token(oidc_access_token) + except AuthenticationFailed as e: + return str(e) + self.set_user(user) else: # Anonymous API interaction -- anything but @expose_api_anonymous will fail past here. self.user = None diff --git a/lib/galaxy/webapps/galaxy/api/__init__.py b/lib/galaxy/webapps/galaxy/api/__init__.py index b6a44a1f43ac..55735e901f69 100644 --- a/lib/galaxy/webapps/galaxy/api/__init__.py +++ b/lib/galaxy/webapps/galaxy/api/__init__.py @@ -36,6 +36,7 @@ APIKeyCookie, APIKeyHeader, APIKeyQuery, + HTTPBearer ) from fastapi_utils.cbv import cbv from fastapi_utils.inferring_router import InferringRouter @@ -80,6 +81,7 @@ api_key_query = APIKeyQuery(name="key", auto_error=False) api_key_header = APIKeyHeader(name="x-api-key", auto_error=False) api_key_cookie = APIKeyCookie(name="galaxysession", auto_error=False) +api_bearer_token = HTTPBearer() def get_app() -> StructuredApp: @@ -139,6 +141,7 @@ def get_api_user( user_manager: UserManager = depends(UserManager), key: str = Security(api_key_query), x_api_key: str = Security(api_key_header), + bearer_token: str = Security(api_bearer_token), run_as: Optional[DecodedDatabaseIdField] = Header( default=None, title="Run as User", @@ -149,9 +152,12 @@ def get_api_user( ), ) -> Optional[User]: api_key = key or x_api_key - if not api_key: + if api_key: + user = user_manager.by_api_key(api_key=api_key) + elif bearer_token: + user = user_manager.by_oidc_access_token(access_token=bearer_token.credentials) + else: return None - user = user_manager.by_api_key(api_key=api_key) if run_as: if user_manager.user_can_do_run_as(user): return user_manager.by_id(run_as) diff --git a/test/integration/oidc/keycloak-server.crt.pem b/test/integration/oidc/keycloak-server.crt.pem index fd4b489661a9..d9bdfb3a6a7d 100644 --- a/test/integration/oidc/keycloak-server.crt.pem +++ b/test/integration/oidc/keycloak-server.crt.pem @@ -1,20 +1,22 @@ -----BEGIN CERTIFICATE----- -MIIDVzCCAj+gAwIBAgIUCYRtnqxm/HMxGPpE6VpTmPacZMQwDQYJKoZIhvcNAQEL -BQAwOzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxFzAVBgNVBAoM -DkdhbGF4eSBQcm9qZWN0MB4XDTIzMDkxMDE5MjAwOFoXDTMzMDkwNzE5MjAwOFow -OzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxFzAVBgNVBAoMDkdh -bGF4eSBQcm9qZWN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwF+F -iXMW2emcYCvSkiZCh00RDVCcKUfB/i7Q+Rxl0y0HG6sTJWN8ELf4SVR7Zaq3b30w -5Yx6tmXY0lfzHkfjRXG/WdAjPDeN8j54RK74UAOM72KFySm/CxX2w5peNyBHA4BX -mHKGagIgWH4VUCeJ0z7GkI0ESrdlwOix8Qi8n/l0hjmmS2NOvM1dq1ehNVfOtB4K -DF1AANffiCEz2OkAIWwczwVZrgHg7ILvga+g4GnX6SEioEHMCfyk7E6ASbLb2mDj -iaIVT1PpwtRLgL+yhC7QRWK66IuurcRAJsLL/isIeZGtF0xfvP5nfiWC07lzYior -J3M1I14I7LTpEwmEIwIDAQABo1MwUTAdBgNVHQ4EFgQUbAYXkXuWm8xrOWmsbN5w -rQoaLG4wHwYDVR0jBBgwFoAUbAYXkXuWm8xrOWmsbN5wrQoaLG4wDwYDVR0TAQH/ -BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAhvHjMe20W7TO299iI1fEhob4Ihyr -tm/4hPPBLC9HSiKokKYvgOU0dK2dh/X7ax9N+ouuDmWYCLCP7AcaWS+HHDVyCv+0 -j8YNVTaqoD/u9qnvx9/2Zv1TqGaLhw9UPmro0MnUUOMoQRYQKaH3fatidCMQJt2j -rzvyrkOuicOr1lRh8Hj1+pdKzar4rmskz9SLkoKRiXjsAUrC1JvQxVxORzf4CoD/ -eR+ZXeglmAZu8AbwFd/y5OPaHuqsXXHpzg8oJffymOo8L3jp3fZjHFhzUBIHZX2u -ZVmpT0COKu69yQ1EoHmKpVFCinYVqbfOsu7s4qWOIt8qpoHPdaElzMD/2w== +MIIDkzCCAnugAwIBAgIUQFOsGXws9PtauKu5YhjKynETHVowDQYJKoZIhvcNAQEL +BQAwWTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDESMBAGA1UEAwwJbG9jYWxob3N0MB4X +DTIzMTAxNTIwMzYyNloXDTMzMTAxMjIwMzYyNlowWTELMAkGA1UEBhMCQVUxEzAR +BgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5 +IEx0ZDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA1rp3CEgQRHmpgK4NFwqxfnhfwJMJeH9rFlSQ1XjN1JPCoICSwNDO +g1NWoPGFIImRnZ34X9CLsLeZdlcE/RaThdzwLbwbExl0mKP+rDSLP5/90K3utVD8 +DWfNSWnCaeXcnhaosl5z8ZhpI5xWN20ajCj3VnrPEbhVDAChxlZvVh+a6uvsEMP0 +SHOcy0OKe0g2I+x1Yw5J62ApC21pDMuFrjyualkpFEx+b7JY9lNhDjSPW8Iu4wRC +7TK0q3MRyyw+er0P7lPobw/khfVTTFmDrc1V8ui+271Bu8L5EvlcOADGM47jgt5j +Y3Q77gy7fVKDwFSS6Y2jpQRBgocUq9qN1QIDAQABo1MwUTAdBgNVHQ4EFgQUHlYS +w56dunNy35FN3D1JG3ZaZRAwHwYDVR0jBBgwFoAUHlYSw56dunNy35FN3D1JG3Za +ZRAwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAIoEdTs1B1h9Q +WOO+zK5kcd6pbOJnfqvZh44TvQs0DnezENQb4y1lR0USPjgyWSF1VHxP1yrm3H64 +tx5E7ZeVMO/Uq2x2x9qgEjJfk9y88+RzYmZyR3PvmtTPaf7ftokGi9eZSsj3khP3 +8YqKDpty5siA+Wd5HkAE/z1xoBAzxQ+D9JNA8miiCkmSMUWuTHXbH8nYaS7a4rWx +YvJJXxdAv7JsoLvSVgMgaiee+MtKhMPxDBJoCeuk8nGt8HXZh615JqYSkNDrfzwQ +C74INkovM6emUW9ehQ6sOK5ROprM7fXlnrH4Klc9BGRgJu5Lsj0VkrBfN2LQ03D2 +77yHVwzrgQ== -----END CERTIFICATE----- diff --git a/test/integration/oidc/keycloak-server.key.pem b/test/integration/oidc/keycloak-server.key.pem index a58818417e8e..bc06d6c86ee8 100644 --- a/test/integration/oidc/keycloak-server.key.pem +++ b/test/integration/oidc/keycloak-server.key.pem @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDAX4WJcxbZ6Zxg -K9KSJkKHTRENUJwpR8H+LtD5HGXTLQcbqxMlY3wQt/hJVHtlqrdvfTDljHq2ZdjS -V/MeR+NFcb9Z0CM8N43yPnhErvhQA4zvYoXJKb8LFfbDml43IEcDgFeYcoZqAiBY -fhVQJ4nTPsaQjQRKt2XA6LHxCLyf+XSGOaZLY068zV2rV6E1V860HgoMXUAA19+I -ITPY6QAhbBzPBVmuAeDsgu+Br6DgadfpISKgQcwJ/KTsToBJstvaYOOJohVPU+nC -1EuAv7KELtBFYrroi66txEAmwsv+Kwh5ka0XTF+8/md+JYLTuXNiKisnczUjXgjs -tOkTCYQjAgMBAAECggEAR4Jah8z11hwuSluP408AlP3zVEE+Q2QOuzdY6/j/6t+G -kGTn8ZKJGEfirhOO6SxsEq/1QKcXKH00e4BA+eUcGQDe8fpaVrSJ+d355AIsgMXJ -GmVDuRLhFrRb7bsQwaooa6dGKH+N8pMGxg5cStbft1xTAIHC8ik5VFvLZX9raoVw -DyfLPg3CtWQID9Jbb4lODqHpjSP5J5v+IskCbbk1sihelEoyFQxka8y1UYglSB7E -ntz5j83OqH7qFes/J/g3K8eNvhd3RQZCozzCwjaUUNHpAeTOEmQqdu9joVvQu1pp -/iQcwqCb0TSzezUssCRGJNHCVzb0UwBgqCH4stt+8QKBgQD/NZSj0ofY8GA1XMcK -s1R9dc/iz6FTeFOz4/36xoTSWoLUFVUpP+1XVVzjV98j65D+CtC6RDBso3tf5Jeo -JB0u4Jy86iyVVN8VMgxDLmNsNSPWOdQCfa6ASQRjnlSwa99L+fZ3ITjz71fDR+0E -RKMEzRAu9CYS7X6dy2/6ccRP0QKBgQDA+Bo7VE3HMeSQkT8hJWZpEmFseJ8AdM+/ -2+dwQRFvyBO25hlaaedF3fv52D5S3hiM40tHxSyRmd0qJBQAF5gXVMAtsDD/SZru -maAQXGR0gorGuWkBkyKHDpZmYnK4TWAE5Tikp2638Zrtik6gSZtJVaBqz9oj1Z56 -8elxUxSlswKBgA6lU4dnQJaJjOvjDCUoD3Q3u5tnkPmKUTtknSG0HNE5koJ7SuzY -F1XIt1DwrQSRsztxAXN/EExbwWvKYY2rrOUQzM3mnnJfcYH04lFEqz39erYDkZAn -CEmiZxcUcgXB2wmwKZ3CkVMmbCHv0cFJ8xUGc6ROLJUvffGuSBnWR47RAoGBAJr0 -4IOyD/8cVpGswUgJs3I+m1MyjWQdaVKE8RXYensL7KDbp5JOJrSZLKWV+34p8Qb5 -4tLQSBjnuI0FibYFPFHH8cb+jGoEHlDptnpM5zIUPgKUkvj7wSc5FJdFCSBsqeVk -eJboCOXXMvT7MmZOlU9PXlFHdcF+4hOSPUaOU683AoGAdj+MsVxRgnCEmUCl05EJ -kd5H8bBFB/vq+Rn3erJFXKLM3vpjBV3Yi2RGC06YGe4L1+krrMIrnEFno3G5yi4Z -UWuakpvEDV+esu9s/EcZxv4WtHkQQ+2g3CGqy5BqDefe5YOJrQaCVuwIga1WIV0q -JNY3E9bDmpvvDBw+SEzcJug= +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDWuncISBBEeamA +rg0XCrF+eF/Akwl4f2sWVJDVeM3Uk8KggJLA0M6DU1ag8YUgiZGdnfhf0Iuwt5l2 +VwT9FpOF3PAtvBsTGXSYo/6sNIs/n/3Qre61UPwNZ81JacJp5dyeFqiyXnPxmGkj +nFY3bRqMKPdWes8RuFUMAKHGVm9WH5rq6+wQw/RIc5zLQ4p7SDYj7HVjDknrYCkL +bWkMy4WuPK5qWSkUTH5vslj2U2EONI9bwi7jBELtMrSrcxHLLD56vQ/uU+hvD+SF +9VNMWYOtzVXy6L7bvUG7wvkS+Vw4AMYzjuOC3mNjdDvuDLt9UoPAVJLpjaOlBEGC +hxSr2o3VAgMBAAECggEAAVbB4RtEyGmqE9WvtexIygrgP68UKxw/tgwl8QtjASwc +HsenmXV4bC2ix7ey9E573FBhWQS9hIcRsBxtglIzN22JCGyLDmOlUNRWqdQUC6CW +i+yht5+Rb1g/2af6qdsXa7buBshpRjyW1hjvq9SpHO4wv3uCsgapbjzxV5PmQIlt +H59GPNcMJTnBLktPfLQ3fNZRjOiE/aFvGoe1YyFNbl5RADzq5YkmuUSFhkBbyNz4 +wOzhfPr8cnthStFY17GGKmu9nueFoiQhmaiUIIIgeHuSg+0tMZa/PrGsnbfdhoKZ +ifvv5Fyi44COEpzcDjfQqQlHAMRPzWnJNdI4phRzoQKBgQDZDJYAt+Eq8sU6ZLVG +W47k6WouDro9ndf7ZLd0j39KwQ2GAxyHvL8JNmphVa2T8yrvG6QRTrUJ/t2WpXYl +sOfTwPXZh4udMLA+ia0fBHB1X7c2grPlQILLhcvqP5ZUAT3+iOg2j17Aa91B+36g +w/OEwgBWfIt4rW8BBfFfjpXtZQKBgQD9Q0KhqoFs2YZk4Dq5tgl5YMFPBKJUbmED +B4cRGDqPtI7R9kGDTQ06boLZ1W72iX4eroPKhosYVIJ0ILRUvdApbc+ZVi5iRp7Q +lR3fkHdGwJznPH4SLgl6XU3O0JRTSeELDGT5dgJSM2hY7kHLSyZVIsrhsfIxwxVr +sLa0hd6PsQKBgCRmjvWZ4QJh6p/LafddvY44cx08TRGuWh2IG5hJxJBV5h22gd9l +0SgLXzXpt42bd3TYTuC0MXp2qtTr9O+HRqnlr5WGvOxk0Pn7/vO3u+CDZ+eVbfQh +qI3XZ4ZmmaCHAM2iSkd0LfDZZib9tZPiqQF3w7S9eGqJs5cZVwyujghdAoGBAM75 +g9/JD4KcdtfD5FpXKYD3kKN6HwuvNTsbkMdorJs6rSlr/fhHJRNmsytA4TE8BkUe +y/fcYppcnkw3WgiTIyZoZqtiof+QxUOjScmmL8Qzr0bOyh0jYH2O+QKWtVEn5HF6 +DJ9OUPFKr/FVrZFtdgNHrtQp78u5Ka1busTnTo7RAoGAQFdl7sIDPaO0OIXJEuk9 +89OBMPQbuMdyQ63FFl8SADCBLuqZLxxNQArVdelPPCVCgi6sL3w23GrGG0LibfrM +Ts8FQ5cXXFwa+qvYjskazBsiDAJ5LjHWXFQ9ZAg1dpYwToTalaXKer4TBgxMTnte +e05IvlRad2fy9xnaBHoQ0wY= -----END PRIVATE KEY----- diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index 8b3e3bfc2132..1dfd45246660 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -18,12 +18,13 @@ KEYCLOAK_ADMIN_PASSWORD = "admin" KEYCLOAK_TEST_USERNAME = "gxyuser" KEYCLOAK_TEST_PASSWORD = "gxypass" +KEYCLOAK_URL = "https://localhost:8443/realms/gxyrealm" -OIDC_BACKEND_CONFIG_TEMPLATE = """ +OIDC_BACKEND_CONFIG_TEMPLATE = f""" - https://localhost:8443/realms/gxyrealm + {KEYCLOAK_URL} gxyclient gxytestclientsecret $galaxy_url/authnz/keycloak/callback @@ -125,6 +126,8 @@ def disableOauthlibHttps(cls): if "OAUTHLIB_INSECURE_TRANSPORT" in os.environ: cls.saved_oauthlib_insecure_transport = os.environ["OAUTHLIB_INSECURE_TRANSPORT"] os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "true" + os.environ["REQUESTS_CA_BUNDLE"] = os.path.dirname(__file__) + "/keycloak-server.crt.pem" + os.environ["SSL_CERT_FILE"] = os.path.dirname(__file__) + "/keycloak-server.crt.pem" @classmethod def restoreOauthlibHttps(cls): @@ -189,3 +192,22 @@ def test_oidc_logout(self): # make sure we can no longer request the user response = session.get(self._api_url("users/current")) self._assert_status_code_is(response, 400) + + def test_auth_by_access_token(self): + # login at least once + self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD) + access_token = self.get_keycloak_access_token() + response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) + self._assert_status_code_is(response, 200) + assert response.json()["email"] == "gxyuser@galaxy.org" + + def get_keycloak_access_token(self, username=KEYCLOAK_TEST_USERNAME, password=KEYCLOAK_TEST_PASSWORD): + data = { + "client_id": "gxyclient", + "client_secret": "gxytestclientsecret", + "grant_type": "password", + "username": username, + "password": password + } + response = requests.post(f"{KEYCLOAK_URL}/protocol/openid-connect/token", data=data, verify=False) + return response.json()["access_token"] From 32dc5863e1111cf0cc221954fd74aa398ea25174 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Mon, 16 Oct 2023 13:17:44 +0530 Subject: [PATCH 026/954] Make bearer token optional and test for API calls by users who have never logged in --- lib/galaxy/authnz/custos_authnz.py | 2 +- lib/galaxy/webapps/galaxy/api/__init__.py | 2 +- .../integration/oidc/galaxy-realm-export.json | 48 +++++++++++++++++++ test/integration/oidc/test_auth_oidc.py | 14 ++++-- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/lib/galaxy/authnz/custos_authnz.py b/lib/galaxy/authnz/custos_authnz.py index 3ff36fe51235..be4c31db08e4 100644 --- a/lib/galaxy/authnz/custos_authnz.py +++ b/lib/galaxy/authnz/custos_authnz.py @@ -620,4 +620,4 @@ def match_access_token_to_user(self, sa_session, access_token): # jwt verified, we can now fetch the user user_id = decoded_jwt["sub"] custos_authnz_token = self._get_custos_authnz_token(sa_session, user_id, self.config["provider"]) - return custos_authnz_token.user + return custos_authnz_token.user if custos_authnz_token else None diff --git a/lib/galaxy/webapps/galaxy/api/__init__.py b/lib/galaxy/webapps/galaxy/api/__init__.py index 55735e901f69..8620573466d3 100644 --- a/lib/galaxy/webapps/galaxy/api/__init__.py +++ b/lib/galaxy/webapps/galaxy/api/__init__.py @@ -81,7 +81,7 @@ api_key_query = APIKeyQuery(name="key", auto_error=False) api_key_header = APIKeyHeader(name="x-api-key", auto_error=False) api_key_cookie = APIKeyCookie(name="galaxysession", auto_error=False) -api_bearer_token = HTTPBearer() +api_bearer_token = HTTPBearer(auto_error=False) def get_app() -> StructuredApp: diff --git a/test/integration/oidc/galaxy-realm-export.json b/test/integration/oidc/galaxy-realm-export.json index 925dd22c1d35..69153a9f11af 100644 --- a/test/integration/oidc/galaxy-realm-export.json +++ b/test/integration/oidc/galaxy-realm-export.json @@ -332,6 +332,54 @@ "realmRoles" : [ "default-roles-gxyrealm" ], "notBefore" : 0, "groups" : [ ] + }, + { + "id" : "24ffa3ff-d351-4d5e-b10b-8d615082ec9d", + "createdTimestamp" : 1694376671733, + "username" : "gxyuser_logged_in_once", + "enabled" : true, + "totp" : false, + "emailVerified" : true, + "firstName" : "Test", + "lastName" : "GalaxyUser", + "email" : "gxyuser_logged_in_once@galaxy.org", + "credentials" : [ { + "id" : "00d87268-7e21-4d08-9f92-cfc06eca5149", + "type" : "password", + "userLabel" : "My password", + "createdDate" : 1694376754826, + "secretData" : "{\"value\":\"uNBI+UnpCLpXWHhm/tPSnnhuINiNw2MNt1XeDmImJaQ=\",\"salt\":\"fHS/FpnORylnSIco16UHwA==\",\"additionalParameters\":{}}", + "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" + } ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "default-roles-gxyrealm" ], + "notBefore" : 0, + "groups" : [ ] + }, + { + "id" : "24ffa3ff-d351-4d5e-b10b-8d615082ec9e", + "createdTimestamp" : 1694376671733, + "username" : "gxyuser_never_logged_in", + "enabled" : true, + "totp" : false, + "emailVerified" : true, + "firstName" : "Test", + "lastName" : "GalaxyUser", + "email" : "gxyuserneverloggedin@galaxy.org", + "credentials" : [ { + "id" : "00d87268-7e21-4d08-9f92-cfc06eca514a", + "type" : "password", + "userLabel" : "My password", + "createdDate" : 1694376754826, + "secretData" : "{\"value\":\"uNBI+UnpCLpXWHhm/tPSnnhuINiNw2MNt1XeDmImJaQ=\",\"salt\":\"fHS/FpnORylnSIco16UHwA==\",\"additionalParameters\":{}}", + "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" + } ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "default-roles-gxyrealm" ], + "notBefore" : 0, + "groups" : [ ] } ], "scopeMappings" : [ { "clientScope" : "offline_access", diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index 1dfd45246660..dc06af986b9f 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -193,13 +193,19 @@ def test_oidc_logout(self): response = session.get(self._api_url("users/current")) self._assert_status_code_is(response, 400) - def test_auth_by_access_token(self): + def test_auth_by_access_token_logged_in_once(self): # login at least once - self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD) - access_token = self.get_keycloak_access_token() + self._login_via_keycloak("gxyuser_logged_in_once", KEYCLOAK_TEST_PASSWORD) + access_token = self.get_keycloak_access_token(username="gxyuser_logged_in_once") response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) self._assert_status_code_is(response, 200) - assert response.json()["email"] == "gxyuser@galaxy.org" + assert response.json()["email"] == "gxyuser_logged_in_once@galaxy.org" + + def test_auth_by_access_token_never_logged_in(self): + # If the user has not previously logged in via OIDC at least once, OIDC API calls are not allowed + access_token = self.get_keycloak_access_token(username="gxyuser_never_logged_in") + response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) + self._assert_status_code_is(response, 400) def get_keycloak_access_token(self, username=KEYCLOAK_TEST_USERNAME, password=KEYCLOAK_TEST_PASSWORD): data = { From 9367a6947e7b604555ac69b7b1cfa2e168485f49 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Mon, 16 Oct 2023 14:22:16 +0530 Subject: [PATCH 027/954] Only run the cleanup if job state is available --- lib/galaxy_test/base/api.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/galaxy_test/base/api.py b/lib/galaxy_test/base/api.py index 8198ae1b8ef0..a63e276363a8 100644 --- a/lib/galaxy_test/base/api.py +++ b/lib/galaxy_test/base/api.py @@ -98,8 +98,10 @@ class UsesApiTestCaseMixin: def tearDown(self): if os.environ.get("GALAXY_TEST_EXTERNAL") is None: # Only kill running jobs after test for managed test instances - for job in self.galaxy_interactor.get("jobs?state=running").json(): - self._delete(f"jobs/{job['id']}") + response = self.galaxy_interactor.get("jobs?state=running") + if response.ok: + for job in response.json(): + self._delete(f"jobs/{job['id']}") def _api_url(self, path, params=None, use_key=None, use_admin_key=None): if not params: From 257ebbcb0e391b690ba8981f7f529af89026d7c1 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Thu, 19 Oct 2023 18:31:32 +0530 Subject: [PATCH 028/954] Refactor method name --- test/integration/oidc/test_auth_oidc.py | 26 ++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index dc06af986b9f..d78765169ecd 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -170,6 +170,17 @@ def _login_via_keycloak( self.galaxy_interactor.cookies = session.cookies return session, response + def _get_keycloak_access_token(self, username=KEYCLOAK_TEST_USERNAME, password=KEYCLOAK_TEST_PASSWORD): + data = { + "client_id": "gxyclient", + "client_secret": "gxytestclientsecret", + "grant_type": "password", + "username": username, + "password": password + } + response = requests.post(f"{KEYCLOAK_URL}/protocol/openid-connect/token", data=data, verify=False) + return response.json()["access_token"] + def test_oidc_login(self): _, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD) # Should have redirected back if auth succeeded @@ -196,24 +207,13 @@ def test_oidc_logout(self): def test_auth_by_access_token_logged_in_once(self): # login at least once self._login_via_keycloak("gxyuser_logged_in_once", KEYCLOAK_TEST_PASSWORD) - access_token = self.get_keycloak_access_token(username="gxyuser_logged_in_once") + access_token = self._get_keycloak_access_token(username="gxyuser_logged_in_once") response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) self._assert_status_code_is(response, 200) assert response.json()["email"] == "gxyuser_logged_in_once@galaxy.org" def test_auth_by_access_token_never_logged_in(self): # If the user has not previously logged in via OIDC at least once, OIDC API calls are not allowed - access_token = self.get_keycloak_access_token(username="gxyuser_never_logged_in") + access_token = self._get_keycloak_access_token(username="gxyuser_never_logged_in") response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) self._assert_status_code_is(response, 400) - - def get_keycloak_access_token(self, username=KEYCLOAK_TEST_USERNAME, password=KEYCLOAK_TEST_PASSWORD): - data = { - "client_id": "gxyclient", - "client_secret": "gxytestclientsecret", - "grant_type": "password", - "username": username, - "password": password - } - response = requests.post(f"{KEYCLOAK_URL}/protocol/openid-connect/token", data=data, verify=False) - return response.json()["access_token"] From e4fd79b90450d6c3360dd59046e69103f76008c3 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Thu, 2 Nov 2023 11:48:40 +0530 Subject: [PATCH 029/954] Move method to correct class after rebase --- lib/galaxy/authnz/custos_authnz.py | 44 +++++++++++++++--------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/lib/galaxy/authnz/custos_authnz.py b/lib/galaxy/authnz/custos_authnz.py index be4c31db08e4..d8df58210120 100644 --- a/lib/galaxy/authnz/custos_authnz.py +++ b/lib/galaxy/authnz/custos_authnz.py @@ -493,6 +493,28 @@ def _username_from_userinfo(trans, userinfo): else: return username + def match_access_token_to_user(self, sa_session, access_token): + signing_key = self.jwks_client.get_signing_key_from_jwt(access_token) + decoded_jwt = jwt.decode( + access_token, + signing_key.key, + algorithms=["RS256"], + issuer=self.config.issuer, + audience=self.config.accepted_audiences, + options={ + "verify_signature": True, + "verify_exp": True, + "verify_nbf": True, + "verify_iat": True, + "verify_aud": bool(self.config.accepted_audiences), + "verify_iss": True, + }, + ) + # jwt verified, we can now fetch the user + user_id = decoded_jwt["sub"] + custos_authnz_token = self._get_custos_authnz_token(sa_session, user_id, self.config.provider) + return custos_authnz_token.user if custos_authnz_token else None + class OIDCAuthnzBaseKeycloak(OIDCAuthnzBase): def __init__(self, provider, oidc_config, oidc_backend_config, idphint=None): @@ -599,25 +621,3 @@ def _load_config_for_custos(self): params = {"client_id": self.config.client_id} self._load_config(headers, params) - - def match_access_token_to_user(self, sa_session, access_token): - signing_key = self.jwks_client.get_signing_key_from_jwt(access_token) - decoded_jwt = jwt.decode( - access_token, - signing_key.key, - algorithms=["RS256"], - issuer=self.config["issuer"], - audience=self.config["accepted_audiences"], - options={ - "verify_signature": True, - "verify_exp": True, - "verify_nbf": True, - "verify_iat": True, - "verify_aud": self.config["accepted_audiences"] is not None, - "verify_iss": True, - }, - ) - # jwt verified, we can now fetch the user - user_id = decoded_jwt["sub"] - custos_authnz_token = self._get_custos_authnz_token(sa_session, user_id, self.config["provider"]) - return custos_authnz_token.user if custos_authnz_token else None From a7a049297dcd42e0d5ecd7946fe21d9cbc06d9c9 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Thu, 2 Nov 2023 11:48:49 +0530 Subject: [PATCH 030/954] Add test for token expiry --- .../integration/oidc/galaxy-realm-export.json | 2 +- test/integration/oidc/test_auth_oidc.py | 23 ++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/test/integration/oidc/galaxy-realm-export.json b/test/integration/oidc/galaxy-realm-export.json index 69153a9f11af..d7cb3e2701fa 100644 --- a/test/integration/oidc/galaxy-realm-export.json +++ b/test/integration/oidc/galaxy-realm-export.json @@ -5,7 +5,7 @@ "defaultSignatureAlgorithm" : "RS256", "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, - "accessTokenLifespan" : 300, + "accessTokenLifespan" : 10, "accessTokenLifespanForImplicitFlow" : 900, "ssoSessionIdleTimeout" : 1800, "ssoSessionMaxLifespan" : 36000, diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index d78765169ecd..b6c30d480691 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -6,6 +6,7 @@ from string import Template from typing import ClassVar import tempfile +import time from urllib import parse import requests @@ -155,6 +156,7 @@ def _login_via_keycloak( username, password, expected_codes=[200, 404], + save_cookies=False, ): session = requests.Session() response = session.get(f"{self.url}authnz/keycloak/login") @@ -167,7 +169,8 @@ def _login_via_keycloak( ) if expected_codes: assert response.status_code in expected_codes, response - self.galaxy_interactor.cookies = session.cookies + if save_cookies: + self.galaxy_interactor.cookies = session.cookies return session, response def _get_keycloak_access_token(self, username=KEYCLOAK_TEST_USERNAME, password=KEYCLOAK_TEST_PASSWORD): @@ -182,7 +185,7 @@ def _get_keycloak_access_token(self, username=KEYCLOAK_TEST_USERNAME, password=K return response.json()["access_token"] def test_oidc_login(self): - _, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD) + _, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD, save_cookies=True) # Should have redirected back if auth succeeded parsed_url = parse.urlparse(response.url) notification = parse.parse_qs(parsed_url.query)['notification'][0] @@ -193,7 +196,7 @@ def test_oidc_login(self): def test_oidc_logout(self): # login - session, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD) + session, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD, save_cookies=True) # get the user response = session.get(self._api_url("users/current")) self._assert_status_code_is(response, 200) @@ -217,3 +220,17 @@ def test_auth_by_access_token_never_logged_in(self): access_token = self._get_keycloak_access_token(username="gxyuser_never_logged_in") response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) self._assert_status_code_is(response, 400) + + def test_auth_with_expired_token(self): + _, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD) + access_token = self._get_keycloak_access_token() + response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) + self._assert_status_code_is(response, 200) + # token shouldn't expire in 4 seconds, so the call should succeed + time.sleep(4) + response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) + self._assert_status_code_is(response, 200) + # token should have expired in 10 seconds, so the call should fail + time.sleep(7) + response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) + self._assert_status_code_is(response, 400) From 54d0bd20ea801f50fad3d8ef91343b5915e47471 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Fri, 3 Nov 2023 18:34:41 +0530 Subject: [PATCH 031/954] Add audience configs with additional clients --- .../integration/oidc/galaxy-realm-export.json | 190 ++++++++++++++++-- 1 file changed, 178 insertions(+), 12 deletions(-) diff --git a/test/integration/oidc/galaxy-realm-export.json b/test/integration/oidc/galaxy-realm-export.json index d7cb3e2701fa..3fcc97413c24 100644 --- a/test/integration/oidc/galaxy-realm-export.json +++ b/test/integration/oidc/galaxy-realm-export.json @@ -45,6 +45,22 @@ "failureFactor" : 30, "roles" : { "realm" : [ { + "id" : "37ff3bbd-ece4-4002-aa00-41e672de7592", + "name" : "bpa-access-role", + "description" : "", + "composite" : false, + "clientRole" : false, + "containerId" : "34fcece2-bf4c-4746-a3f0-3e96a2919e7b", + "attributes" : { } + }, { + "id" : "45d9bc06-649e-4a8d-9a86-f4fd397a95f1", + "name" : "galaxy-access-role", + "description" : "", + "composite" : false, + "clientRole" : false, + "containerId" : "34fcece2-bf4c-4746-a3f0-3e96a2919e7b", + "attributes" : { } + }, { "id" : "657c4f1e-74b7-4c1f-911b-da083f4dff45", "name" : "offline_access", "description" : "${role_offline-access}", @@ -245,7 +261,9 @@ "admin-cli" : [ ], "gxyclient" : [ ], "account-console" : [ ], + "bpaclient" : [ ], "broker" : [ ], + "unauthorizedclient" : [ ], "account" : [ { "id" : "16dd6df5-3072-417f-8a1f-6d6a482b9ec8", "name" : "view-groups", @@ -288,7 +306,7 @@ "otpPolicyLookAheadWindow" : 1, "otpPolicyPeriod" : 30, "otpPolicyCodeReusable" : false, - "otpSupportedApplications" : [ "totpAppMicrosoftAuthenticatorName", "totpAppFreeOTPName", "totpAppGoogleName" ], + "otpSupportedApplications" : [ "totpAppFreeOTPName", "totpAppGoogleName", "totpAppMicrosoftAuthenticatorName" ], "webAuthnPolicyRpEntityName" : "keycloak", "webAuthnPolicySignatureAlgorithms" : [ "ES256" ], "webAuthnPolicyRpId" : "", @@ -310,6 +328,29 @@ "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister" : false, "webAuthnPolicyPasswordlessAcceptableAaguids" : [ ], "users" : [ { + "id" : "8986c875-75b3-4015-91a1-8dace78d9c3b", + "createdTimestamp" : 1698952979247, + "username" : "bpaonlyuser", + "enabled" : true, + "totp" : false, + "emailVerified" : true, + "firstName" : "", + "lastName" : "", + "email" : "bpauser@bpa.org", + "credentials" : [ { + "id" : "00d87268-7e21-4d08-9f92-cfc06eca514b", + "type" : "password", + "userLabel" : "My password", + "createdDate" : 1694376754826, + "secretData" : "{\"value\":\"uNBI+UnpCLpXWHhm/tPSnnhuINiNw2MNt1XeDmImJaQ=\",\"salt\":\"fHS/FpnORylnSIco16UHwA==\",\"additionalParameters\":{}}", + "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" + } ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "bpa-access-role", "default-roles-gxyrealm" ], + "notBefore" : 0, + "groups" : [ ] + }, { "id" : "24ffa3ff-d351-4d5e-b10b-8d615082ec9c", "createdTimestamp" : 1694376671733, "username" : "gxyuser", @@ -329,11 +370,10 @@ } ], "disableableCredentialTypes" : [ ], "requiredActions" : [ ], - "realmRoles" : [ "default-roles-gxyrealm" ], + "realmRoles" : [ "bpa-access-role", "galaxy-access-role", "default-roles-gxyrealm" ], "notBefore" : 0, "groups" : [ ] - }, - { + }, { "id" : "24ffa3ff-d351-4d5e-b10b-8d615082ec9d", "createdTimestamp" : 1694376671733, "username" : "gxyuser_logged_in_once", @@ -353,11 +393,10 @@ } ], "disableableCredentialTypes" : [ ], "requiredActions" : [ ], - "realmRoles" : [ "default-roles-gxyrealm" ], + "realmRoles" : [ "galaxy-access-role", "default-roles-gxyrealm" ], "notBefore" : 0, "groups" : [ ] - }, - { + }, { "id" : "24ffa3ff-d351-4d5e-b10b-8d615082ec9e", "createdTimestamp" : 1694376671733, "username" : "gxyuser_never_logged_in", @@ -377,13 +416,16 @@ } ], "disableableCredentialTypes" : [ ], "requiredActions" : [ ], - "realmRoles" : [ "default-roles-gxyrealm" ], + "realmRoles" : [ "galaxy-access-role", "default-roles-gxyrealm" ], "notBefore" : 0, "groups" : [ ] } ], "scopeMappings" : [ { "clientScope" : "offline_access", "roles" : [ "offline_access" ] + }, { + "clientScope" : "gx:*", + "roles" : [ "galaxy-access-role" ] } ], "clientScopeMappings" : { "account" : [ { @@ -488,6 +530,44 @@ "nodeReRegistrationTimeout" : 0, "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "553f931d-fa16-4946-a655-fdbb38c2b4b1", + "clientId" : "bpaclient", + "name" : "", + "description" : "", + "rootUrl" : "http://localhost", + "adminUrl" : "http://localhost", + "baseUrl" : "", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "dummyclientsecret", + "redirectUris" : [ "http://*" ], + "webOrigins" : [ "http://*" ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : true, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : true, + "protocol" : "openid-connect", + "attributes" : { + "oidc.ciba.grant.enabled" : "false", + "client.secret.creation.time" : "1698952215", + "backchannel.logout.session.required" : "true", + "post.logout.redirect.uris" : "+", + "oauth2.device.authorization.grant.enabled" : "false", + "backchannel.logout.revoke.offline.tokens" : "false" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : true, + "nodeReRegistrationTimeout" : -1, + "defaultClientScopes" : [ "web-origins", "acr", "bpa.*", "profile", "roles", "email" ], + "optionalClientScopes" : [ "gx:*", "address", "phone", "offline_access", "microprofile-jwt" ] }, { "id" : "4a0e0a29-e407-4154-94f3-a82d85ceff04", "clientId" : "broker", @@ -528,7 +608,7 @@ "enabled" : true, "alwaysDisplayInConsole" : false, "clientAuthenticatorType" : "client-secret", - "secret" : "gxytestclientsecret", + "secret" : "dummyclientsecret", "redirectUris" : [ "http://*" ], "webOrigins" : [ ], "notBefore" : 0, @@ -628,6 +708,44 @@ } ], "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "8f00dac4-c117-4703-8fb3-6a1c29f5ce9a", + "clientId" : "unauthorizedclient", + "name" : "", + "description" : "", + "rootUrl" : "http://localhost", + "adminUrl" : "http://localhost", + "baseUrl" : "", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "dummyclientsecret", + "redirectUris" : [ "http://*" ], + "webOrigins" : [ "http://*" ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : true, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : true, + "protocol" : "openid-connect", + "attributes" : { + "oidc.ciba.grant.enabled" : "false", + "client.secret.creation.time" : "1698952892", + "backchannel.logout.session.required" : "true", + "post.logout.redirect.uris" : "+", + "oauth2.device.authorization.grant.enabled" : "false", + "backchannel.logout.revoke.offline.tokens" : "false" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : true, + "nodeReRegistrationTimeout" : -1, + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] } ], "clientScopes" : [ { "id" : "beae5e0b-8350-4d91-b4df-60cbb2410509", @@ -927,6 +1045,30 @@ "user.attribute.locality" : "locality" } } ] + }, { + "id" : "aabfb2e4-8718-4f21-a290-873729b9a64a", + "name" : "gx:*", + "description" : "", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "gui.order" : "", + "consent.screen.text" : "" + }, + "protocolMappers" : [ { + "id" : "f85b7dae-6727-4952-94fc-393a2eb8f1a3", + "name" : "map-audience", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-audience-mapper", + "consentRequired" : false, + "config" : { + "included.client.audience" : "gxyclient", + "id.token.claim" : "false", + "access.token.claim" : "true", + "userinfo.token.claim" : "false" + } + } ] }, { "id" : "371b8bc3-9994-4139-b190-6ac66346d071", "name" : "phone", @@ -1008,6 +1150,30 @@ "userinfo.token.claim" : "true" } } ] + }, { + "id" : "dadedfec-7757-4e1e-8c8c-9c08b59fffdd", + "name" : "bpa.*", + "description" : "", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "true", + "gui.order" : "", + "consent.screen.text" : "" + }, + "protocolMappers" : [ { + "id" : "6e1fa544-7209-443d-b5ab-8e8a08b6e65a", + "name" : "bpa-audience-mapper", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-audience-mapper", + "consentRequired" : false, + "config" : { + "included.client.audience" : "bpaclient", + "id.token.claim" : "false", + "access.token.claim" : "true", + "userinfo.token.claim" : "false" + } + } ] }, { "id" : "0a9c909d-f76e-4640-84f5-736cfbb43837", "name" : "roles", @@ -1093,7 +1259,7 @@ } ] } ], "defaultDefaultClientScopes" : [ "role_list", "profile", "email", "roles", "web-origins", "acr" ], - "defaultOptionalClientScopes" : [ "offline_access", "address", "phone", "microprofile-jwt" ], + "defaultOptionalClientScopes" : [ "offline_access", "address", "phone", "microprofile-jwt", "gx:*" ], "browserSecurityHeaders" : { "contentSecurityPolicyReportOnly" : "", "xContentTypeOptions" : "nosniff", @@ -1171,7 +1337,7 @@ "subType" : "authenticated", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-attribute-mapper", "oidc-full-name-mapper", "oidc-usermodel-property-mapper", "saml-user-property-mapper", "saml-role-list-mapper", "saml-user-attribute-mapper" ] + "allowed-protocol-mapper-types" : [ "oidc-usermodel-property-mapper", "saml-user-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-full-name-mapper", "oidc-address-mapper", "saml-user-property-mapper", "saml-role-list-mapper", "oidc-usermodel-attribute-mapper" ] } }, { "id" : "741b8acc-ab84-49fe-8dce-f587b2a22502", @@ -1180,7 +1346,7 @@ "subType" : "anonymous", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "saml-user-property-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper", "oidc-full-name-mapper", "saml-role-list-mapper", "saml-user-attribute-mapper", "oidc-address-mapper" ] + "allowed-protocol-mapper-types" : [ "oidc-sha256-pairwise-sub-mapper", "oidc-address-mapper", "oidc-full-name-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper", "saml-user-attribute-mapper", "saml-role-list-mapper", "saml-user-property-mapper" ] } } ], "org.keycloak.keys.KeyProvider" : [ { From 9992c38111ac286a34893aa123bef3b9c38d9c20 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Fri, 3 Nov 2023 18:50:20 +0530 Subject: [PATCH 032/954] Make sure that audiences are respected --- lib/galaxy/authnz/custos_authnz.py | 2 +- lib/galaxy/authnz/managers.py | 4 ++++ test/integration/oidc/test_auth_oidc.py | 18 +++++++++++++----- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/galaxy/authnz/custos_authnz.py b/lib/galaxy/authnz/custos_authnz.py index d8df58210120..0c22a29895f1 100644 --- a/lib/galaxy/authnz/custos_authnz.py +++ b/lib/galaxy/authnz/custos_authnz.py @@ -87,7 +87,7 @@ def __init__(self, provider, oidc_config, oidc_backend_config, idphint=None): redirect_uri=oidc_backend_config["redirect_uri"], ca_bundle=oidc_backend_config.get("ca_bundle", None), pkce_support=oidc_backend_config.get("pkce_support", False), - accepted_audiences=oidc_backend_config.get("accepted_audiences", []), + accepted_audiences=list(filter(None, map(str.strip, oidc_backend_config.get("accepted_audiences", oidc_backend_config["client_id"]).split(",")))), extra_params={}, authorization_endpoint=None, token_endpoint=None, diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py index 655c1a43d2c5..41e82359d9cf 100644 --- a/lib/galaxy/authnz/managers.py +++ b/lib/galaxy/authnz/managers.py @@ -165,6 +165,8 @@ def _parse_idp_config(self, config_xml): rtv["tenant_id"] = config_xml.find("tenant_id").text if config_xml.find("pkce_support") is not None: rtv["pkce_support"] = asbool(config_xml.find("pkce_support").text) + if config_xml.find("accepted_audiences") is not None: + rtv["accepted_audiences"] = config_xml.find("accepted_audiences").text return rtv @@ -192,6 +194,8 @@ def _parse_custos_config(self, config_xml): rtv["icon"] = config_xml.find("icon").text if config_xml.find("pkce_support") is not None: rtv["pkce_support"] = asbool(config_xml.find("pkce_support").text) + if config_xml.find("accepted_audiences") is not None: + rtv["accepted_audiences"] = config_xml.find("accepted_audiences").text return rtv def get_allowed_idps(self): diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index b6c30d480691..fcaffbf09bc0 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -27,9 +27,10 @@ {KEYCLOAK_URL} gxyclient - gxytestclientsecret + dummyclientsecret $galaxy_url/authnz/keycloak/callback true + gxyclient """ @@ -173,13 +174,14 @@ def _login_via_keycloak( self.galaxy_interactor.cookies = session.cookies return session, response - def _get_keycloak_access_token(self, username=KEYCLOAK_TEST_USERNAME, password=KEYCLOAK_TEST_PASSWORD): + def _get_keycloak_access_token(self, client_id="gxyclient", username=KEYCLOAK_TEST_USERNAME, password=KEYCLOAK_TEST_PASSWORD, scopes=[]): data = { - "client_id": "gxyclient", - "client_secret": "gxytestclientsecret", + "client_id": client_id, + "client_secret": "dummyclientsecret", "grant_type": "password", "username": username, - "password": password + "password": password, + "scope": scopes, } response = requests.post(f"{KEYCLOAK_URL}/protocol/openid-connect/token", data=data, verify=False) return response.json()["access_token"] @@ -234,3 +236,9 @@ def test_auth_with_expired_token(self): time.sleep(7) response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) self._assert_status_code_is(response, 400) + + def test_auth_with_another_authorized_client(self): + _, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD) + access_token = self._get_keycloak_access_token(client_id="bpaclient", scopes=["gx:*"]) + response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) + self._assert_status_code_is(response, 200) From 41e70f2022bc7d745469af64e7e995b10e7b6c2b Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Fri, 3 Nov 2023 19:07:12 +0530 Subject: [PATCH 033/954] Add test for unauthorized client --- test/integration/oidc/test_auth_oidc.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index fcaffbf09bc0..a1c6ffeec93a 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -242,3 +242,9 @@ def test_auth_with_another_authorized_client(self): access_token = self._get_keycloak_access_token(client_id="bpaclient", scopes=["gx:*"]) response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) self._assert_status_code_is(response, 200) + + def test_auth_with_unauthorized_client(self): + _, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD) + access_token = self._get_keycloak_access_token(client_id="unauthorizedclient") + response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) + self._assert_status_code_is(response, 400) From 4e2f56a08daf3ce540a0a687a1d46873d91817b4 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Fri, 3 Nov 2023 20:01:10 +0530 Subject: [PATCH 034/954] Add default scope --- test/integration/oidc/galaxy-realm-export.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/oidc/galaxy-realm-export.json b/test/integration/oidc/galaxy-realm-export.json index 3fcc97413c24..2153b66ed504 100644 --- a/test/integration/oidc/galaxy-realm-export.json +++ b/test/integration/oidc/galaxy-realm-export.json @@ -632,7 +632,7 @@ "authenticationFlowBindingOverrides" : { }, "fullScopeAllowed" : true, "nodeReRegistrationTimeout" : -1, - "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email", "gx:*" ], "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] }, { "id" : "d27406eb-c929-4658-904f-f42f8bd2812c", From c78788e0e76f12dfb1090b07fd80c5ff508d5524 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Fri, 3 Nov 2023 22:26:15 +0530 Subject: [PATCH 035/954] Add test for unauthorized audience --- lib/galaxy/authnz/managers.py | 5 +---- test/integration/oidc/test_auth_oidc.py | 7 +++++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py index 41e82359d9cf..5abf55e47e6c 100644 --- a/lib/galaxy/authnz/managers.py +++ b/lib/galaxy/authnz/managers.py @@ -421,14 +421,11 @@ def find_user_by_access_token_in_provider(self, sa_session, provider, access_tok return user return None except Exception as e: - msg = f"An error occurred when finding user by token: {e}" + msg = f"An error occurred with provider: {provider} when finding user by token: {e}" log.error(msg) return None def find_user_by_access_token(self, sa_session, access_token): - # decoded_token = jwt.decode(access_token, options={"verify_signature": False}) - # issuer = decoded_token["iss"] - # audience = decoded_token["aud"] for provider in self.oidc_backends_config: user = self.find_user_by_access_token_in_provider(sa_session, provider, access_token) if user: diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index a1c6ffeec93a..e4fc398dde23 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -242,6 +242,13 @@ def test_auth_with_another_authorized_client(self): access_token = self._get_keycloak_access_token(client_id="bpaclient", scopes=["gx:*"]) response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) self._assert_status_code_is(response, 200) + assert response.json()["email"] == "gxyuser@galaxy.org" + + def test_auth_with_authorized_client_but_unauthorized_audience(self): + _, response = self._login_via_keycloak("bpaonlyuser", KEYCLOAK_TEST_PASSWORD) + access_token = self._get_keycloak_access_token(client_id="bpaclient", username="bpaonlyuser") + response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) + self._assert_status_code_is(response, 400) def test_auth_with_unauthorized_client(self): _, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD) From 9cc1a6af5288955fdb42608d995f68041cf526ab Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Fri, 3 Nov 2023 22:39:44 +0530 Subject: [PATCH 036/954] Reformat code --- lib/galaxy/authnz/custos_authnz.py | 10 ++++- lib/galaxy/webapps/base/webapp.py | 4 +- lib/galaxy/webapps/galaxy/api/__init__.py | 2 +- test/integration/oidc/test_auth_oidc.py | 46 ++++++++++++++--------- 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/lib/galaxy/authnz/custos_authnz.py b/lib/galaxy/authnz/custos_authnz.py index 0c22a29895f1..6343c8b4e7fb 100644 --- a/lib/galaxy/authnz/custos_authnz.py +++ b/lib/galaxy/authnz/custos_authnz.py @@ -87,7 +87,15 @@ def __init__(self, provider, oidc_config, oidc_backend_config, idphint=None): redirect_uri=oidc_backend_config["redirect_uri"], ca_bundle=oidc_backend_config.get("ca_bundle", None), pkce_support=oidc_backend_config.get("pkce_support", False), - accepted_audiences=list(filter(None, map(str.strip, oidc_backend_config.get("accepted_audiences", oidc_backend_config["client_id"]).split(",")))), + accepted_audiences=list( + filter( + None, + map( + str.strip, + oidc_backend_config.get("accepted_audiences", oidc_backend_config["client_id"]).split(","), + ), + ) + ), extra_params={}, authorization_endpoint=None, token_endpoint=None, diff --git a/lib/galaxy/webapps/base/webapp.py b/lib/galaxy/webapps/base/webapp.py index f727f091d154..f3e1b493f53f 100644 --- a/lib/galaxy/webapps/base/webapp.py +++ b/lib/galaxy/webapps/base/webapp.py @@ -533,7 +533,9 @@ def _authenticate_api(self, session_cookie: str) -> Optional[str]: Authenticate for the API via key or session (if available). """ oidc_access_token = self.request.headers.get("Authorization", None) - oidc_token_supplied = self.environ.get("is_api_request", False) and oidc_access_token and "Bearer " in oidc_access_token + oidc_token_supplied = ( + self.environ.get("is_api_request", False) and oidc_access_token and "Bearer " in oidc_access_token + ) api_key = self.request.params.get("key", None) or self.request.headers.get("x-api-key", None) secure_id = self.get_cookie(name=session_cookie) api_key_supplied = self.environ.get("is_api_request", False) and api_key diff --git a/lib/galaxy/webapps/galaxy/api/__init__.py b/lib/galaxy/webapps/galaxy/api/__init__.py index 8620573466d3..4cf559f134b2 100644 --- a/lib/galaxy/webapps/galaxy/api/__init__.py +++ b/lib/galaxy/webapps/galaxy/api/__init__.py @@ -36,7 +36,7 @@ APIKeyCookie, APIKeyHeader, APIKeyQuery, - HTTPBearer + HTTPBearer, ) from fastapi_utils.cbv import cbv from fastapi_utils.inferring_router import InferringRouter diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index e4fc398dde23..55799add53db 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -3,17 +3,16 @@ import os import re import subprocess -from string import Template -from typing import ClassVar import tempfile import time +from string import Template +from typing import ClassVar from urllib import parse import requests -from galaxy_test.driver import integration_util from galaxy_test.base.api import ApiTestInteractor - +from galaxy_test.driver import integration_util KEYCLOAK_ADMIN_USERNAME = "admin" KEYCLOAK_ADMIN_PASSWORD = "admin" @@ -35,8 +34,20 @@ """ + def wait_till_keycloak_ready(port): - return subprocess.call(["timeout", "300", "bash", "-c", f"'until curl --silent --output /dev/null http://localhost:{port}; do sleep 0.5; done'"]) == 0 + return ( + subprocess.call( + [ + "timeout", + "300", + "bash", + "-c", + f"'until curl --silent --output /dev/null http://localhost:{port}; do sleep 0.5; done'", + ] + ) + == 0 + ) def start_keycloak_docker(container_name, port=8443, image="keycloak/keycloak:22.0.1"): @@ -65,7 +76,7 @@ def start_keycloak_docker(container_name, port=8443, image="keycloak/keycloak:22 "--optimized", "--import-realm", "--https-certificate-file=/opt/keycloak/data/import/keycloak-server.crt.pem", - "--https-certificate-key-file=/opt/keycloak/data/import/keycloak-server.key.pem" + "--https-certificate-key-file=/opt/keycloak/data/import/keycloak-server.key.pem", ] print(" ".join(START_SLURM_DOCKER)) subprocess.check_call(START_SLURM_DOCKER) @@ -97,7 +108,7 @@ def setUpClass(cls): @classmethod def generate_oidc_config_file(cls, server_wrapper): - with tempfile.NamedTemporaryFile('w+t', delete=False) as tmp_file: + with tempfile.NamedTemporaryFile("w+t", delete=False) as tmp_file: host = server_wrapper.host port = server_wrapper.port prefix = server_wrapper.prefix or "" @@ -108,7 +119,7 @@ def generate_oidc_config_file(cls, server_wrapper): @classmethod def configure_oidc_and_restart(cls): - with tempfile.NamedTemporaryFile('w+t', delete=False) as tmp_file: + with tempfile.NamedTemporaryFile("w+t", delete=False) as tmp_file: server_wrapper = cls._test_driver.server_wrappers[0] cls.backend_config_file = cls.generate_oidc_config_file(server_wrapper) # Explicitly assign the previously used port, as it's random otherwise @@ -118,7 +129,7 @@ def configure_oidc_and_restart(cls): @classmethod def tearDownClass(cls): - #stop_keycloak_docker(cls.container_name) + # stop_keycloak_docker(cls.container_name) cls.restoreOauthlibHttps() os.remove(cls.backend_config_file) super().tearDownClass() @@ -128,8 +139,8 @@ def disableOauthlibHttps(cls): if "OAUTHLIB_INSECURE_TRANSPORT" in os.environ: cls.saved_oauthlib_insecure_transport = os.environ["OAUTHLIB_INSECURE_TRANSPORT"] os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "true" - os.environ["REQUESTS_CA_BUNDLE"] = os.path.dirname(__file__) + "/keycloak-server.crt.pem" - os.environ["SSL_CERT_FILE"] = os.path.dirname(__file__) + "/keycloak-server.crt.pem" + os.environ["REQUESTS_CA_BUNDLE"] = os.path.dirname(__file__) + "/keycloak-server.crt.pem" + os.environ["SSL_CERT_FILE"] = os.path.dirname(__file__) + "/keycloak-server.crt.pem" @classmethod def restoreOauthlibHttps(cls): @@ -149,7 +160,6 @@ def _get_interactor(self, api_key=None, allow_anonymous=False) -> "ApiTestIntera class TestGalaxyOIDCLoginIntegration(AbstractTestCases.BaseKeycloakIntegrationTestCase): - REGEX_KEYCLOAK_LOGIN_ACTION = re.compile(r"action=\"(.*)\"\s+") def _login_via_keycloak( @@ -161,20 +171,20 @@ def _login_via_keycloak( ): session = requests.Session() response = session.get(f"{self.url}authnz/keycloak/login") - provider_url = response.json()["redirect_uri"] + provider_url = response.json()["redirect_uri"] response = session.get(provider_url, verify=False) matches = self.REGEX_KEYCLOAK_LOGIN_ACTION.search(response.text) auth_url = html.unescape(matches.groups(1)[0]) - response = session.post( - auth_url, data={"username": username, "password": password}, verify=False - ) + response = session.post(auth_url, data={"username": username, "password": password}, verify=False) if expected_codes: assert response.status_code in expected_codes, response if save_cookies: self.galaxy_interactor.cookies = session.cookies return session, response - def _get_keycloak_access_token(self, client_id="gxyclient", username=KEYCLOAK_TEST_USERNAME, password=KEYCLOAK_TEST_PASSWORD, scopes=[]): + def _get_keycloak_access_token( + self, client_id="gxyclient", username=KEYCLOAK_TEST_USERNAME, password=KEYCLOAK_TEST_PASSWORD, scopes=[] + ): data = { "client_id": client_id, "client_secret": "dummyclientsecret", @@ -190,7 +200,7 @@ def test_oidc_login(self): _, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD, save_cookies=True) # Should have redirected back if auth succeeded parsed_url = parse.urlparse(response.url) - notification = parse.parse_qs(parsed_url.query)['notification'][0] + notification = parse.parse_qs(parsed_url.query)["notification"][0] assert "Your Keycloak identity has been linked to your Galaxy account." in notification response = self._get("users/current") self._assert_status_code_is(response, 200) From cc2d8c12149d21343da9df6f00f73f37e4e5b108 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Sat, 4 Nov 2023 00:46:34 +0530 Subject: [PATCH 037/954] Reduce access token lifespan --- test/integration/oidc/galaxy-realm-export.json | 2 +- test/integration/oidc/test_auth_oidc.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/integration/oidc/galaxy-realm-export.json b/test/integration/oidc/galaxy-realm-export.json index 2153b66ed504..598544905320 100644 --- a/test/integration/oidc/galaxy-realm-export.json +++ b/test/integration/oidc/galaxy-realm-export.json @@ -5,7 +5,7 @@ "defaultSignatureAlgorithm" : "RS256", "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, - "accessTokenLifespan" : 10, + "accessTokenLifespan" : 6, "accessTokenLifespanForImplicitFlow" : 900, "ssoSessionIdleTimeout" : 1800, "ssoSessionMaxLifespan" : 36000, diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index 55799add53db..199afb11c017 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -238,11 +238,11 @@ def test_auth_with_expired_token(self): access_token = self._get_keycloak_access_token() response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) self._assert_status_code_is(response, 200) - # token shouldn't expire in 4 seconds, so the call should succeed - time.sleep(4) + # token shouldn't expire in 3 seconds, so the call should succeed + time.sleep(3) response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) self._assert_status_code_is(response, 200) - # token should have expired in 10 seconds, so the call should fail + # token should have expired in 7 seconds, so the call should fail time.sleep(7) response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) self._assert_status_code_is(response, 400) From 92851c0b540a8d6bfcd61d402e2b4c26e770c742 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Sat, 4 Nov 2023 10:43:27 +0530 Subject: [PATCH 038/954] Refactor method names and lint fixes --- lib/galaxy/authnz/__init__.py | 13 +++++++++++++ lib/galaxy/authnz/custos_authnz.py | 2 +- lib/galaxy/authnz/managers.py | 10 ++++++---- lib/galaxy/managers/users.py | 2 +- test/integration/oidc/test_auth_oidc.py | 22 +++++++++++----------- 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/lib/galaxy/authnz/__init__.py b/lib/galaxy/authnz/__init__.py index 9225662ff351..39c2270c722d 100644 --- a/lib/galaxy/authnz/__init__.py +++ b/lib/galaxy/authnz/__init__.py @@ -87,3 +87,16 @@ def logout(self, trans, post_user_logout_href=None): :param post_user_logout_href: Optional URL to redirect to after logging out of IDP. """ raise NotImplementedError() + + def find_user_by_access_token(self, sa_session, access_token): + """ + Locates a user by access_token. The access token must be verified prior + to returning the relevant user. + + :type sa_session: sqlalchemy.orm.scoping.scoped_session + :param sa_session: SQLAlchemy database handle. + + :type access_token: string + :param access_token: An OIDC access token + """ + raise NotImplementedError() diff --git a/lib/galaxy/authnz/custos_authnz.py b/lib/galaxy/authnz/custos_authnz.py index 6343c8b4e7fb..8d54aed20177 100644 --- a/lib/galaxy/authnz/custos_authnz.py +++ b/lib/galaxy/authnz/custos_authnz.py @@ -501,7 +501,7 @@ def _username_from_userinfo(trans, userinfo): else: return username - def match_access_token_to_user(self, sa_session, access_token): + def find_user_by_access_token(self, sa_session, access_token): signing_key = self.jwks_client.get_signing_key_from_jwt(access_token) decoded_jwt = jwt.decode( access_token, diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py index 5abf55e47e6c..a1e1570bfb1e 100644 --- a/lib/galaxy/authnz/managers.py +++ b/lib/galaxy/authnz/managers.py @@ -408,26 +408,28 @@ def create_user(self, provider, token, trans, login_redirect_url): log.exception(msg) return False, msg, (None, None) - def find_user_by_access_token_in_provider(self, sa_session, provider, access_token): + def _find_user_by_access_token_in_provider(self, sa_session, provider, access_token): try: success, message, backend = self._get_authnz_backend(provider) if success is False: msg = f"An error occurred when obtaining user by token with provider `{provider}`: {message}" log.error(msg) return None - user = backend.match_access_token_to_user(sa_session, access_token) + user = backend.find_user_by_access_token(sa_session, access_token) if user: log.debug(f"Found user: {user} via `{provider}` identity provider") return user return None + except NotImplementedError: + return None except Exception as e: msg = f"An error occurred with provider: {provider} when finding user by token: {e}" log.error(msg) return None - def find_user_by_access_token(self, sa_session, access_token): + def match_access_token_to_user(self, sa_session, access_token): for provider in self.oidc_backends_config: - user = self.find_user_by_access_token_in_provider(sa_session, provider, access_token) + user = self._find_user_by_access_token_in_provider(sa_session, provider, access_token) if user: return user return None diff --git a/lib/galaxy/managers/users.py b/lib/galaxy/managers/users.py index 3aba815df54d..6df9c45ee623 100644 --- a/lib/galaxy/managers/users.py +++ b/lib/galaxy/managers/users.py @@ -297,7 +297,7 @@ def by_api_key(self, api_key: str, sa_session=None): def by_oidc_access_token(self, access_token: str): if hasattr(self.app, "authnz_manager") and self.app.authnz_manager: - user = self.app.authnz_manager.find_user_by_access_token(self.app.model.session, access_token) # type: ignore[attr-defined] + user = self.app.authnz_manager.match_access_token_to_user(self.app.model.session, access_token) # type: ignore[attr-defined] return user else: return None diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index 199afb11c017..2d590251a7d6 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -119,12 +119,11 @@ def generate_oidc_config_file(cls, server_wrapper): @classmethod def configure_oidc_and_restart(cls): - with tempfile.NamedTemporaryFile("w+t", delete=False) as tmp_file: - server_wrapper = cls._test_driver.server_wrappers[0] - cls.backend_config_file = cls.generate_oidc_config_file(server_wrapper) - # Explicitly assign the previously used port, as it's random otherwise - del os.environ["GALAXY_TEST_PORT_RANDOM"] - os.environ["GALAXY_TEST_PORT"] = os.environ["GALAXY_WEB_PORT"] + server_wrapper = cls._test_driver.server_wrappers[0] + cls.backend_config_file = cls.generate_oidc_config_file(server_wrapper) + # Explicitly assign the previously used port, as it's random otherwise + del os.environ["GALAXY_TEST_PORT_RANDOM"] + os.environ["GALAXY_TEST_PORT"] = os.environ["GALAXY_WEB_PORT"] cls._test_driver.restart(config_object=cls, handle_config=cls.handle_galaxy_oidc_config_kwds) @classmethod @@ -166,9 +165,11 @@ def _login_via_keycloak( self, username, password, - expected_codes=[200, 404], + expected_codes=None, save_cookies=False, ): + if expected_codes is None: + expected_codes = [200, 404] session = requests.Session() response = session.get(f"{self.url}authnz/keycloak/login") provider_url = response.json()["redirect_uri"] @@ -176,14 +177,13 @@ def _login_via_keycloak( matches = self.REGEX_KEYCLOAK_LOGIN_ACTION.search(response.text) auth_url = html.unescape(matches.groups(1)[0]) response = session.post(auth_url, data={"username": username, "password": password}, verify=False) - if expected_codes: - assert response.status_code in expected_codes, response + assert response.status_code in expected_codes, response if save_cookies: self.galaxy_interactor.cookies = session.cookies return session, response def _get_keycloak_access_token( - self, client_id="gxyclient", username=KEYCLOAK_TEST_USERNAME, password=KEYCLOAK_TEST_PASSWORD, scopes=[] + self, client_id="gxyclient", username=KEYCLOAK_TEST_USERNAME, password=KEYCLOAK_TEST_PASSWORD, scopes=None ): data = { "client_id": client_id, @@ -191,7 +191,7 @@ def _get_keycloak_access_token( "grant_type": "password", "username": username, "password": password, - "scope": scopes, + "scope": scopes or [], } response = requests.post(f"{KEYCLOAK_URL}/protocol/openid-connect/token", data=data, verify=False) return response.json()["access_token"] From 7f05d3ff8316c3ff9003cdc212dcd762c54d365b Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Sat, 4 Nov 2023 11:57:40 +0530 Subject: [PATCH 039/954] Fix issues flagged by mypy --- lib/galaxy/authnz/custos_authnz.py | 3 +++ lib/galaxy/webapps/galaxy/api/__init__.py | 3 ++- test/integration/oidc/test_auth_oidc.py | 12 +++++++----- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/galaxy/authnz/custos_authnz.py b/lib/galaxy/authnz/custos_authnz.py index 8d54aed20177..4f9030ab48d2 100644 --- a/lib/galaxy/authnz/custos_authnz.py +++ b/lib/galaxy/authnz/custos_authnz.py @@ -76,6 +76,7 @@ class CustosAuthnzConfiguration: class OIDCAuthnzBase(IdentityProvider): def __init__(self, provider, oidc_config, oidc_backend_config, idphint=None): provider = provider.lower() + self.jwks_client: Optional[jwt.PyJWKClient] self.config = CustosAuthnzConfiguration( provider=provider, verify_ssl=oidc_config["VERIFY_SSL"], @@ -502,6 +503,8 @@ def _username_from_userinfo(trans, userinfo): return username def find_user_by_access_token(self, sa_session, access_token): + if not self.jwks_client: + return None signing_key = self.jwks_client.get_signing_key_from_jwt(access_token) decoded_jwt = jwt.decode( access_token, diff --git a/lib/galaxy/webapps/galaxy/api/__init__.py b/lib/galaxy/webapps/galaxy/api/__init__.py index 4cf559f134b2..f8ebecedb20e 100644 --- a/lib/galaxy/webapps/galaxy/api/__init__.py +++ b/lib/galaxy/webapps/galaxy/api/__init__.py @@ -36,6 +36,7 @@ APIKeyCookie, APIKeyHeader, APIKeyQuery, + HTTPAuthorizationCredentials, HTTPBearer, ) from fastapi_utils.cbv import cbv @@ -141,7 +142,7 @@ def get_api_user( user_manager: UserManager = depends(UserManager), key: str = Security(api_key_query), x_api_key: str = Security(api_key_header), - bearer_token: str = Security(api_bearer_token), + bearer_token: HTTPAuthorizationCredentials = Security(api_bearer_token), run_as: Optional[DecodedDatabaseIdField] = Header( default=None, title="Run as User", diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index 2d590251a7d6..8b8385a6ca07 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -78,7 +78,6 @@ def start_keycloak_docker(container_name, port=8443, image="keycloak/keycloak:22 "--https-certificate-file=/opt/keycloak/data/import/keycloak-server.crt.pem", "--https-certificate-key-file=/opt/keycloak/data/import/keycloak-server.key.pem", ] - print(" ".join(START_SLURM_DOCKER)) subprocess.check_call(START_SLURM_DOCKER) wait_till_keycloak_ready(port) @@ -91,6 +90,8 @@ class AbstractTestCases: @integration_util.skip_unless_docker() class BaseKeycloakIntegrationTestCase(integration_util.IntegrationTestCase): container_name: ClassVar[str] + backend_config_file: ClassVar[str] + saved_oauthlib_insecure_transport: ClassVar[bool] @classmethod def setUpClass(cls): @@ -136,7 +137,7 @@ def tearDownClass(cls): @classmethod def disableOauthlibHttps(cls): if "OAUTHLIB_INSECURE_TRANSPORT" in os.environ: - cls.saved_oauthlib_insecure_transport = os.environ["OAUTHLIB_INSECURE_TRANSPORT"] + cls.saved_oauthlib_insecure_transport = bool(os.environ["OAUTHLIB_INSECURE_TRANSPORT"]) os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "true" os.environ["REQUESTS_CA_BUNDLE"] = os.path.dirname(__file__) + "/keycloak-server.crt.pem" os.environ["SSL_CERT_FILE"] = os.path.dirname(__file__) + "/keycloak-server.crt.pem" @@ -144,7 +145,7 @@ def disableOauthlibHttps(cls): @classmethod def restoreOauthlibHttps(cls): if getattr(cls, "saved_oauthlib_insecure_transport", None): - os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = cls.saved_oauthlib_insecure_transport + os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = str(cls.saved_oauthlib_insecure_transport) else: del os.environ["OAUTHLIB_INSECURE_TRANSPORT"] @@ -175,7 +176,8 @@ def _login_via_keycloak( provider_url = response.json()["redirect_uri"] response = session.get(provider_url, verify=False) matches = self.REGEX_KEYCLOAK_LOGIN_ACTION.search(response.text) - auth_url = html.unescape(matches.groups(1)[0]) + assert matches + auth_url = html.unescape(str(matches.groups(1)[0])) response = session.post(auth_url, data={"username": username, "password": password}, verify=False) assert response.status_code in expected_codes, response if save_cookies: @@ -208,7 +210,7 @@ def test_oidc_login(self): def test_oidc_logout(self): # login - session, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD, save_cookies=True) + session, _ = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD, save_cookies=True) # get the user response = session.get(self._api_url("users/current")) self._assert_status_code_is(response, 200) From e1323b73c0e30a41ec0042cc2b384861197af94d Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Sat, 4 Nov 2023 15:03:11 +0530 Subject: [PATCH 040/954] Start the keycloak container --- test/integration/oidc/test_auth_oidc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index 8b8385a6ca07..6f29e36bb0a8 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -99,7 +99,7 @@ def setUpClass(cls): # we forcibly disable it for now cls.disableOauthlibHttps() cls.container_name = f"{cls.__name__}_container" - # start_keycloak_docker(container_name=cls.container_name) + start_keycloak_docker(container_name=cls.container_name) super().setUpClass() # For the oidc callback to work, we need to know Galaxy's hostname and port. # However, we won't know what the host and port are until the Galaxy test driver is started. From 65ee1e6d4a2a1b6d48c1d069b3ac49cc9b10a830 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Sun, 5 Nov 2023 16:51:02 +0530 Subject: [PATCH 041/954] Change keycloak docker hostname --- test/integration/oidc/test_auth_oidc.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index 6f29e36bb0a8..ae0fe6f51c03 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -55,8 +55,6 @@ def start_keycloak_docker(container_name, port=8443, image="keycloak/keycloak:22 START_SLURM_DOCKER = [ "docker", "run", - "-h", - "localhost", "-p", f"{port}:8443", "-d", From 3d1244d2b69af660fdea40e033f9a8f85e874646 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Mon, 6 Nov 2023 11:00:59 +0530 Subject: [PATCH 042/954] Make keycloak port configurable --- test/integration/oidc/test_auth_oidc.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index ae0fe6f51c03..d12df9c93298 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -18,7 +18,8 @@ KEYCLOAK_ADMIN_PASSWORD = "admin" KEYCLOAK_TEST_USERNAME = "gxyuser" KEYCLOAK_TEST_PASSWORD = "gxypass" -KEYCLOAK_URL = "https://localhost:8443/realms/gxyrealm" +KEYCLOAK_HOST_PORT = 9443 +KEYCLOAK_URL = f"https://localhost:{KEYCLOAK_HOST_PORT}/realms/gxyrealm" OIDC_BACKEND_CONFIG_TEMPLATE = f""" @@ -35,7 +36,7 @@ """ -def wait_till_keycloak_ready(port): +def wait_till_app_ready(url): return ( subprocess.call( [ @@ -43,20 +44,20 @@ def wait_till_keycloak_ready(port): "300", "bash", "-c", - f"'until curl --silent --output /dev/null http://localhost:{port}; do sleep 0.5; done'", + f"'until curl --silent --output /dev/null {url}; do sleep 0.5; done'", ] ) == 0 ) -def start_keycloak_docker(container_name, port=8443, image="keycloak/keycloak:22.0.1"): +def start_keycloak_docker(container_name, image="keycloak/keycloak:22.0.1"): keycloak_realm_data = os.path.dirname(__file__) START_SLURM_DOCKER = [ "docker", "run", "-p", - f"{port}:8443", + f"{KEYCLOAK_HOST_PORT}:8443", "-d", "--name", container_name, @@ -77,7 +78,7 @@ def start_keycloak_docker(container_name, port=8443, image="keycloak/keycloak:22 "--https-certificate-key-file=/opt/keycloak/data/import/keycloak-server.key.pem", ] subprocess.check_call(START_SLURM_DOCKER) - wait_till_keycloak_ready(port) + wait_till_app_ready(KEYCLOAK_URL) def stop_keycloak_docker(container_name): From 517f0e71cb49e2ad361a13e35bc27e805dc3f370 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Mon, 6 Nov 2023 13:41:43 +0530 Subject: [PATCH 043/954] Verify client scopes before accepting token --- lib/galaxy/authnz/__init__.py | 9 +++++--- lib/galaxy/authnz/custos_authnz.py | 17 ++++++++++++-- lib/galaxy/authnz/managers.py | 22 ++++++++++++++++--- lib/galaxy/config/sample/galaxy.yml.sample | 5 +++++ lib/galaxy/config/schemas/config_schema.yml | 9 ++++++++ .../integration/oidc/galaxy-realm-export.json | 10 ++++----- test/integration/oidc/test_auth_oidc.py | 13 ++++++++--- 7 files changed, 69 insertions(+), 16 deletions(-) diff --git a/lib/galaxy/authnz/__init__.py b/lib/galaxy/authnz/__init__.py index 39c2270c722d..c2c5685e3180 100644 --- a/lib/galaxy/authnz/__init__.py +++ b/lib/galaxy/authnz/__init__.py @@ -88,15 +88,18 @@ def logout(self, trans, post_user_logout_href=None): """ raise NotImplementedError() - def find_user_by_access_token(self, sa_session, access_token): + def decode_user_access_token(self, sa_session, access_token): """ - Locates a user by access_token. The access token must be verified prior - to returning the relevant user. + Verifies and decodes an access token against this provider, returning the user and + a dict containing the decoded token data. :type sa_session: sqlalchemy.orm.scoping.scoped_session :param sa_session: SQLAlchemy database handle. :type access_token: string :param access_token: An OIDC access token + + :return: A tuple containing the user and decoded jwt data + :rtype: Tuple[User, dict] """ raise NotImplementedError() diff --git a/lib/galaxy/authnz/custos_authnz.py b/lib/galaxy/authnz/custos_authnz.py index 4f9030ab48d2..3eb603b58446 100644 --- a/lib/galaxy/authnz/custos_authnz.py +++ b/lib/galaxy/authnz/custos_authnz.py @@ -502,7 +502,19 @@ def _username_from_userinfo(trans, userinfo): else: return username - def find_user_by_access_token(self, sa_session, access_token): + def decode_user_access_token(self, sa_session, access_token): + """Verifies and decodes an access token against this provider, returning the user and + a dict containing the decoded token data. + + :type sa_session: sqlalchemy.orm.scoping.scoped_session + :param sa_session: SQLAlchemy database handle. + + :type access_token: string + :param access_token: An OIDC access token + + :return: A tuple containing the user and decoded jwt data + :rtype: Tuple[User, dict] + """ if not self.jwks_client: return None signing_key = self.jwks_client.get_signing_key_from_jwt(access_token) @@ -524,7 +536,8 @@ def find_user_by_access_token(self, sa_session, access_token): # jwt verified, we can now fetch the user user_id = decoded_jwt["sub"] custos_authnz_token = self._get_custos_authnz_token(sa_session, user_id, self.config.provider) - return custos_authnz_token.user if custos_authnz_token else None + user = custos_authnz_token.user if custos_authnz_token else None + return user, decoded_jwt class OIDCAuthnzBaseKeycloak(OIDCAuthnzBase): diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py index a1e1570bfb1e..0a581a343f92 100644 --- a/lib/galaxy/authnz/managers.py +++ b/lib/galaxy/authnz/managers.py @@ -408,16 +408,32 @@ def create_user(self, provider, token, trans, login_redirect_url): log.exception(msg) return False, msg, (None, None) - def _find_user_by_access_token_in_provider(self, sa_session, provider, access_token): + def _assert_jwt_contains_scopes(self, user, jwt, required_scopes): + if not jwt: + raise exceptions.AuthenticationFailed( + err_msg=f"User: {user.username} does not have the required scopes: [{required_scopes}]" + ) + scopes = jwt.get("scope") or "" + if not set(required_scopes).issubset(scopes.split(" ")): + raise exceptions.AuthenticationFailed( + err_msg=f"User: {user.username} has JWT with scopes: [{scopes}] but not required scopes: [{required_scopes}]" + ) + + def _validate_permissions(self, user, jwt): + required_scopes = [f"{self.app.config.oidc_scope_prefix}:*"] + self._assert_jwt_contains_scopes(user, jwt, required_scopes) + + def _match_access_token_to_user_in_provider(self, sa_session, provider, access_token): try: success, message, backend = self._get_authnz_backend(provider) if success is False: msg = f"An error occurred when obtaining user by token with provider `{provider}`: {message}" log.error(msg) return None - user = backend.find_user_by_access_token(sa_session, access_token) + user, jwt = backend.decode_user_access_token(sa_session, access_token) if user: log.debug(f"Found user: {user} via `{provider}` identity provider") + self._validate_permissions(user, jwt) return user return None except NotImplementedError: @@ -429,7 +445,7 @@ def _find_user_by_access_token_in_provider(self, sa_session, provider, access_to def match_access_token_to_user(self, sa_session, access_token): for provider in self.oidc_backends_config: - user = self._find_user_by_access_token_in_provider(sa_session, provider, access_token) + user = self._match_access_token_to_user_in_provider(sa_session, provider, access_token) if user: return user return None diff --git a/lib/galaxy/config/sample/galaxy.yml.sample b/lib/galaxy/config/sample/galaxy.yml.sample index 3f4d369e9981..9d6eec56c6fc 100644 --- a/lib/galaxy/config/sample/galaxy.yml.sample +++ b/lib/galaxy/config/sample/galaxy.yml.sample @@ -2169,6 +2169,11 @@ galaxy: # . #oidc_backends_config_file: oidc_backends_config.xml + # Sets the prefix for OIDC scopes specific to this Galaxy instance. + # If an API call is made against this Galaxy instance using an OIDC bearer token, + # it must include a scope with ":*". e.g "https://galaxyproject.org/api:*" + #oidc_scope_prefix: https://galaxyproject.org/api + # XML config file that allows the use of different authentication # providers (e.g. LDAP) instead or in addition to local authentication # (.sample is used if default does not exist). diff --git a/lib/galaxy/config/schemas/config_schema.yml b/lib/galaxy/config/schemas/config_schema.yml index e2f2c7320acf..24e37afa67ec 100644 --- a/lib/galaxy/config/schemas/config_schema.yml +++ b/lib/galaxy/config/schemas/config_schema.yml @@ -2903,6 +2903,15 @@ mapping: desc: | Sets the path to OIDC backends configuration file. + oidc_scope_prefix: + type: str + default: https://galaxyproject.org/api + required: false + desc: | + Sets the prefix for OIDC scopes specific to this Galaxy instance. + If an API call is made against this Galaxy instance using an OIDC bearer token, + it must include a scope with ":*". e.g "https://galaxyproject.org/api:*" + auth_config_file: type: str default: auth_conf.xml diff --git a/test/integration/oidc/galaxy-realm-export.json b/test/integration/oidc/galaxy-realm-export.json index 598544905320..064b15fce58c 100644 --- a/test/integration/oidc/galaxy-realm-export.json +++ b/test/integration/oidc/galaxy-realm-export.json @@ -424,7 +424,7 @@ "clientScope" : "offline_access", "roles" : [ "offline_access" ] }, { - "clientScope" : "gx:*", + "clientScope" : "https://galaxyproject.org/api:*", "roles" : [ "galaxy-access-role" ] } ], "clientScopeMappings" : { @@ -567,7 +567,7 @@ "fullScopeAllowed" : true, "nodeReRegistrationTimeout" : -1, "defaultClientScopes" : [ "web-origins", "acr", "bpa.*", "profile", "roles", "email" ], - "optionalClientScopes" : [ "gx:*", "address", "phone", "offline_access", "microprofile-jwt" ] + "optionalClientScopes" : [ "https://galaxyproject.org/api:*", "address", "phone", "offline_access", "microprofile-jwt" ] }, { "id" : "4a0e0a29-e407-4154-94f3-a82d85ceff04", "clientId" : "broker", @@ -632,7 +632,7 @@ "authenticationFlowBindingOverrides" : { }, "fullScopeAllowed" : true, "nodeReRegistrationTimeout" : -1, - "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email", "gx:*" ], + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email", "https://galaxyproject.org/api:*" ], "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] }, { "id" : "d27406eb-c929-4658-904f-f42f8bd2812c", @@ -1047,7 +1047,7 @@ } ] }, { "id" : "aabfb2e4-8718-4f21-a290-873729b9a64a", - "name" : "gx:*", + "name" : "https://galaxyproject.org/api:*", "description" : "", "protocol" : "openid-connect", "attributes" : { @@ -1259,7 +1259,7 @@ } ] } ], "defaultDefaultClientScopes" : [ "role_list", "profile", "email", "roles", "web-origins", "acr" ], - "defaultOptionalClientScopes" : [ "offline_access", "address", "phone", "microprofile-jwt", "gx:*" ], + "defaultOptionalClientScopes" : [ "offline_access", "address", "phone", "microprofile-jwt", "https://galaxyproject.org/api:*" ], "browserSecurityHeaders" : { "contentSecurityPolicyReportOnly" : "", "xContentTypeOptions" : "nosniff", diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index d12df9c93298..5e83c2f1d83f 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -18,7 +18,7 @@ KEYCLOAK_ADMIN_PASSWORD = "admin" KEYCLOAK_TEST_USERNAME = "gxyuser" KEYCLOAK_TEST_PASSWORD = "gxypass" -KEYCLOAK_HOST_PORT = 9443 +KEYCLOAK_HOST_PORT = 8443 KEYCLOAK_URL = f"https://localhost:{KEYCLOAK_HOST_PORT}/realms/gxyrealm" @@ -128,7 +128,7 @@ def configure_oidc_and_restart(cls): @classmethod def tearDownClass(cls): - # stop_keycloak_docker(cls.container_name) + stop_keycloak_docker(cls.container_name) cls.restoreOauthlibHttps() os.remove(cls.backend_config_file) super().tearDownClass() @@ -250,7 +250,9 @@ def test_auth_with_expired_token(self): def test_auth_with_another_authorized_client(self): _, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD) - access_token = self._get_keycloak_access_token(client_id="bpaclient", scopes=["gx:*"]) + access_token = self._get_keycloak_access_token( + client_id="bpaclient", scopes=["https://galaxyproject.org/api:*"] + ) response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) self._assert_status_code_is(response, 200) assert response.json()["email"] == "gxyuser@galaxy.org" @@ -266,3 +268,8 @@ def test_auth_with_unauthorized_client(self): access_token = self._get_keycloak_access_token(client_id="unauthorizedclient") response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) self._assert_status_code_is(response, 400) + + def test_auth_without_required_scopes(self): + access_token = self._get_keycloak_access_token(client_id="bpaclient") + response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) + self._assert_status_code_is(response, 400) From ca8cb474efa28d98d8b8c800de4cfccb404705d1 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Mon, 6 Nov 2023 18:08:50 +0530 Subject: [PATCH 044/954] Add integration tests for oidc account linkup --- lib/galaxy/authnz/custos_authnz.py | 2 +- .../integration/oidc/galaxy-realm-export.json | 23 +++++ test/integration/oidc/test_auth_oidc.py | 97 ++++++++++++++++--- 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/lib/galaxy/authnz/custos_authnz.py b/lib/galaxy/authnz/custos_authnz.py index 3eb603b58446..1da7acdba5a4 100644 --- a/lib/galaxy/authnz/custos_authnz.py +++ b/lib/galaxy/authnz/custos_authnz.py @@ -246,7 +246,7 @@ def callback(self, state_token, authz_code, trans, login_redirect_url): ): user = existing_user else: - message = f"There already exists a user with email {email}. To associate this external login, you must first be logged in as that existing account." + message = f"There already exists a user with email {email}. To associate this external login, you must first be logged in as that existing account." log.info(message) login_redirect_url = ( f"{login_redirect_url}login/start" diff --git a/test/integration/oidc/galaxy-realm-export.json b/test/integration/oidc/galaxy-realm-export.json index 064b15fce58c..e71af30a6276 100644 --- a/test/integration/oidc/galaxy-realm-export.json +++ b/test/integration/oidc/galaxy-realm-export.json @@ -373,6 +373,29 @@ "realmRoles" : [ "bpa-access-role", "galaxy-access-role", "default-roles-gxyrealm" ], "notBefore" : 0, "groups" : [ ] + }, { + "id" : "24ffa3ff-d351-4d5e-b10b-8d615082ec9b", + "createdTimestamp" : 1694376671733, + "username" : "gxyuser_existing", + "enabled" : true, + "totp" : false, + "emailVerified" : true, + "firstName" : "Test", + "lastName" : "GalaxyUser", + "email" : "gxyuser_existing@galaxy.org", + "credentials" : [ { + "id" : "00d87268-7e21-4d08-9f92-cfc06eca5147", + "type" : "password", + "userLabel" : "My password", + "createdDate" : 1694376754826, + "secretData" : "{\"value\":\"uNBI+UnpCLpXWHhm/tPSnnhuINiNw2MNt1XeDmImJaQ=\",\"salt\":\"fHS/FpnORylnSIco16UHwA==\",\"additionalParameters\":{}}", + "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" + } ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "galaxy-access-role", "default-roles-gxyrealm" ], + "notBefore" : 0, + "groups" : [ ] }, { "id" : "24ffa3ff-d351-4d5e-b10b-8d615082ec9d", "createdTimestamp" : 1694376671733, diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index 5e83c2f1d83f..fd7c3e4afc52 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -11,6 +11,7 @@ import requests +from galaxy.model.base import transaction from galaxy_test.base.api import ApiTestInteractor from galaxy_test.driver import integration_util @@ -18,7 +19,7 @@ KEYCLOAK_ADMIN_PASSWORD = "admin" KEYCLOAK_TEST_USERNAME = "gxyuser" KEYCLOAK_TEST_PASSWORD = "gxypass" -KEYCLOAK_HOST_PORT = 8443 +KEYCLOAK_HOST_PORT = 9443 KEYCLOAK_URL = f"https://localhost:{KEYCLOAK_HOST_PORT}/realms/gxyrealm" @@ -78,7 +79,7 @@ def start_keycloak_docker(container_name, image="keycloak/keycloak:22.0.1"): "--https-certificate-key-file=/opt/keycloak/data/import/keycloak-server.key.pem", ] subprocess.check_call(START_SLURM_DOCKER) - wait_till_app_ready(KEYCLOAK_URL) + wait_till_app_ready(f"http://localhost:{KEYCLOAK_HOST_PORT}") def stop_keycloak_docker(container_name): @@ -160,17 +161,12 @@ def _get_interactor(self, api_key=None, allow_anonymous=False) -> "ApiTestIntera class TestGalaxyOIDCLoginIntegration(AbstractTestCases.BaseKeycloakIntegrationTestCase): REGEX_KEYCLOAK_LOGIN_ACTION = re.compile(r"action=\"(.*)\"\s+") + REGEX_GALAXY_CSRF_TOKEN = re.compile(r"session_csrf_token\": \"(.*)\"") - def _login_via_keycloak( - self, - username, - password, - expected_codes=None, - save_cookies=False, - ): + def _login_via_keycloak(self, username, password, expected_codes=None, save_cookies=False, session=None): if expected_codes is None: expected_codes = [200, 404] - session = requests.Session() + session = session or requests.Session() response = session.get(f"{self.url}authnz/keycloak/login") provider_url = response.json()["redirect_uri"] response = session.get(provider_url, verify=False) @@ -197,7 +193,7 @@ def _get_keycloak_access_token( response = requests.post(f"{KEYCLOAK_URL}/protocol/openid-connect/token", data=data, verify=False) return response.json()["access_token"] - def test_oidc_login(self): + def test_oidc_login_new_user(self): _, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD, save_cookies=True) # Should have redirected back if auth succeeded parsed_url = parse.urlparse(response.url) @@ -207,6 +203,85 @@ def test_oidc_login(self): self._assert_status_code_is(response, 200) assert response.json()["email"] == "gxyuser@galaxy.org" + def test_oidc_login_existing_user(self): + # pre-create a user account manually + sa_session = self._app.model.session + User = self._app.model.User + user = User(email="gxyuser_existing@galaxy.org", username="precreated_user") + user.set_password_cleartext("test123") + sa_session.add(user) + try: + with transaction(sa_session): + sa_session.commit() + except Exception: + # User already exists + pass + + # login with the corresponding OIDC user + _, response = self._login_via_keycloak("gxyuser_existing", KEYCLOAK_TEST_PASSWORD, save_cookies=True) + + # Should prompt user to associate accounts + parsed_url = parse.urlparse(response.url) + provider = parse.parse_qs(parsed_url.query)["connect_external_provider"][0] + assert "keycloak" == provider + response = self._get("users/current") + self._assert_status_code_is(response, 400) + + def test_oidc_login_account_linkup(self): + # pre-create a user account manually + sa_session = self._app.model.session + User = self._app.model.User + user = User(email="gxyuser_existing@galaxy.org", username="precreated_user") + user.set_password_cleartext("test123") + sa_session.add(user) + try: + with transaction(sa_session): + sa_session.commit() + except Exception: + # User already exists + pass + + # establish a web session + session = requests.Session() + response = session.get(self._api_url("../login/start")) + matches = self.REGEX_GALAXY_CSRF_TOKEN.search(response.text) + assert matches + session_csrf_token = str(matches.groups(1)[0]) + response = session.post( + self._api_url("../user/login"), + data={ + "login": "gxyuser_existing@galaxy.org", + "password": "test123", + "session_csrf_token": session_csrf_token, + }, + ) + + response = session.get(self._api_url("users/current")) + self._assert_status_code_is(response, 200) + assert response.json()["email"] == "gxyuser_existing@galaxy.org" + assert response.json()["username"] == "precreated_user" + + # login with the corresponding OIDC user, while preserving the current session + _, response = self._login_via_keycloak( + "gxyuser_existing", KEYCLOAK_TEST_PASSWORD, save_cookies=True, session=session + ) + + # Should now automatically associate account + parsed_url = parse.urlparse(response.url) + notification = parse.parse_qs(parsed_url.query)["notification"][0] + assert "Your Keycloak identity has been linked to your Galaxy account." in notification + response = session.get(self._api_url("users/current")) + self._assert_status_code_is(response, 200) + assert response.json()["email"] == "gxyuser_existing@galaxy.org" + assert response.json()["username"] == "precreated_user" + + # Now that the accounts are associated, future logins through OIDC should just work + session, response = self._login_via_keycloak("gxyuser_existing", KEYCLOAK_TEST_PASSWORD, save_cookies=True) + response = session.get(self._api_url("users/current")) + self._assert_status_code_is(response, 200) + assert response.json()["email"] == "gxyuser_existing@galaxy.org" + assert response.json()["username"] == "precreated_user" + def test_oidc_logout(self): # login session, _ = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD, save_cookies=True) From 5d316fb6b4286a148a8a6f1c424e16cc13eb8a65 Mon Sep 17 00:00:00 2001 From: nuwang <2070605+nuwang@users.noreply.github.com> Date: Tue, 7 Nov 2023 12:49:14 +0530 Subject: [PATCH 045/954] Improved error messages and exception handling --- lib/galaxy/authnz/custos_authnz.py | 43 +++++++++++++++---------- lib/galaxy/authnz/managers.py | 20 ++++++++---- test/integration/oidc/test_auth_oidc.py | 16 +++++---- 3 files changed, 49 insertions(+), 30 deletions(-) diff --git a/lib/galaxy/authnz/custos_authnz.py b/lib/galaxy/authnz/custos_authnz.py index 1da7acdba5a4..816d1d05700e 100644 --- a/lib/galaxy/authnz/custos_authnz.py +++ b/lib/galaxy/authnz/custos_authnz.py @@ -512,27 +512,36 @@ def decode_user_access_token(self, sa_session, access_token): :type access_token: string :param access_token: An OIDC access token - :return: A tuple containing the user and decoded jwt data + :return: A tuple containing the user and decoded jwt data or [None, None] + if the access token does not belong to this provider. :rtype: Tuple[User, dict] """ if not self.jwks_client: return None - signing_key = self.jwks_client.get_signing_key_from_jwt(access_token) - decoded_jwt = jwt.decode( - access_token, - signing_key.key, - algorithms=["RS256"], - issuer=self.config.issuer, - audience=self.config.accepted_audiences, - options={ - "verify_signature": True, - "verify_exp": True, - "verify_nbf": True, - "verify_iat": True, - "verify_aud": bool(self.config.accepted_audiences), - "verify_iss": True, - }, - ) + try: + signing_key = self.jwks_client.get_signing_key_from_jwt(access_token) + decoded_jwt = jwt.decode( + access_token, + signing_key.key, + algorithms=["RS256"], + issuer=self.config.issuer, + audience=self.config.accepted_audiences, + options={ + "verify_signature": True, + "verify_exp": True, + "verify_nbf": True, + "verify_iat": True, + "verify_aud": bool(self.config.accepted_audiences), + "verify_iss": True, + }, + ) + except jwt.exceptions.PyJWKClientError: + log.debug(f"Could not get signing keys for access token with provider: {self.config.provider}. Ignoring...") + return None, None + except jwt.exceptions.InvalidIssuerError: + # An Invalid issuer means that the access token is not relevant to this provider. + # All other exceptions are bubbled up + return None, None # jwt verified, we can now fetch the user user_id = decoded_jwt["sub"] custos_authnz_token = self._get_custos_authnz_token(sa_session, user_id, self.config.provider) diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py index 0a581a343f92..c9cf34772f49 100644 --- a/lib/galaxy/authnz/managers.py +++ b/lib/galaxy/authnz/managers.py @@ -430,18 +430,24 @@ def _match_access_token_to_user_in_provider(self, sa_session, provider, access_t msg = f"An error occurred when obtaining user by token with provider `{provider}`: {message}" log.error(msg) return None - user, jwt = backend.decode_user_access_token(sa_session, access_token) - if user: - log.debug(f"Found user: {user} via `{provider}` identity provider") + user, jwt = None, None + try: + user, jwt = backend.decode_user_access_token(sa_session, access_token) + except Exception: + log.exception("Could not decode access token") + raise exceptions.AuthenticationFailed(err_msg="Invalid access token or an unexpected error occurred.") + if user and jwt: self._validate_permissions(user, jwt) return user + elif not user and jwt: + # jwt was decoded, but no user could be matched + raise exceptions.AuthenticationFailed( + err_msg="Cannot locate user by access token. The user should log into Galaxy at least once with this OIDC provider." + ) + # Both jwt and user are empty, which means that this provider can't process this access token return None except NotImplementedError: return None - except Exception as e: - msg = f"An error occurred with provider: {provider} when finding user by token: {e}" - log.error(msg) - return None def match_access_token_to_user(self, sa_session, access_token): for provider in self.oidc_backends_config: diff --git a/test/integration/oidc/test_auth_oidc.py b/test/integration/oidc/test_auth_oidc.py index fd7c3e4afc52..09f809218b6b 100644 --- a/test/integration/oidc/test_auth_oidc.py +++ b/test/integration/oidc/test_auth_oidc.py @@ -299,7 +299,7 @@ def test_auth_by_access_token_logged_in_once(self): # login at least once self._login_via_keycloak("gxyuser_logged_in_once", KEYCLOAK_TEST_PASSWORD) access_token = self._get_keycloak_access_token(username="gxyuser_logged_in_once") - response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) + response = self._get("whoami", headers={"Authorization": f"Bearer {access_token}"}) self._assert_status_code_is(response, 200) assert response.json()["email"] == "gxyuser_logged_in_once@galaxy.org" @@ -307,7 +307,8 @@ def test_auth_by_access_token_never_logged_in(self): # If the user has not previously logged in via OIDC at least once, OIDC API calls are not allowed access_token = self._get_keycloak_access_token(username="gxyuser_never_logged_in") response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) - self._assert_status_code_is(response, 400) + self._assert_status_code_is(response, 401) + assert "The user should log into Galaxy at least once" in response.json()["err_msg"] def test_auth_with_expired_token(self): _, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD) @@ -321,7 +322,7 @@ def test_auth_with_expired_token(self): # token should have expired in 7 seconds, so the call should fail time.sleep(7) response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) - self._assert_status_code_is(response, 400) + self._assert_status_code_is(response, 401) def test_auth_with_another_authorized_client(self): _, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD) @@ -336,15 +337,18 @@ def test_auth_with_authorized_client_but_unauthorized_audience(self): _, response = self._login_via_keycloak("bpaonlyuser", KEYCLOAK_TEST_PASSWORD) access_token = self._get_keycloak_access_token(client_id="bpaclient", username="bpaonlyuser") response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) - self._assert_status_code_is(response, 400) + self._assert_status_code_is(response, 401) + assert "Invalid access token" in response.json()["err_msg"] def test_auth_with_unauthorized_client(self): _, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD) access_token = self._get_keycloak_access_token(client_id="unauthorizedclient") response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) - self._assert_status_code_is(response, 400) + self._assert_status_code_is(response, 401) + assert "Invalid access token" in response.json()["err_msg"] def test_auth_without_required_scopes(self): access_token = self._get_keycloak_access_token(client_id="bpaclient") response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"}) - self._assert_status_code_is(response, 400) + self._assert_status_code_is(response, 401) + assert "Invalid access token" in response.json()["err_msg"] From efa2f58d3feb0ac272493df85d876750e7fd87d9 Mon Sep 17 00:00:00 2001 From: Matthias Bernt Date: Fri, 1 Dec 2023 11:53:08 +0100 Subject: [PATCH 046/954] also remove duplicates in sections --- lib/galaxy/tool_util/toolbox/views/static.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/tool_util/toolbox/views/static.py b/lib/galaxy/tool_util/toolbox/views/static.py index 2eba8eef3f4b..7972cc84e835 100644 --- a/lib/galaxy/tool_util/toolbox/views/static.py +++ b/lib/galaxy/tool_util/toolbox/views/static.py @@ -105,7 +105,7 @@ def definition_with_items_to_panel(definition, allow_sections: bool = True, item f"Failed to find matching section for (id, name) = ({section_def.id}, {section_def.name})" ) continue - section = closest_section.copy() + section = closest_section.copy(merge_tools=True) if section_def.id is not None: section.id = section_def.id if section_def.name is not None: From 5b74f377464eefbba22e9704a33e465cd850f93d Mon Sep 17 00:00:00 2001 From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com> Date: Thu, 7 Dec 2023 15:10:55 +0100 Subject: [PATCH 047/954] hide select preference bar --- .../src/components/Form/Elements/FormSelection.vue | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/client/src/components/Form/Elements/FormSelection.vue b/client/src/components/Form/Elements/FormSelection.vue index ca328723e6b0..2b533461c338 100644 --- a/client/src/components/Form/Elements/FormSelection.vue +++ b/client/src/components/Form/Elements/FormSelection.vue @@ -96,14 +96,12 @@ watch( { immediate: true } ); -const displayMany = computed( - () => props.multiple && props.display !== "checkboxes" && props.display !== "radio" && useMany.value -); - -const showManyButton = computed( - () => props.multiple && props.display !== "checkboxes" && props.display !== "radio" && !useMany.value +const showSelectPreference = computed( + () => props.multiple && props.display !== "checkboxes" && props.display !== "radio" ); +const displayMany = computed(() => showSelectPreference.value && useMany.value); +const showManyButton = computed(() => showSelectPreference.value && !useMany.value); const showMultiButton = computed(() => displayMany.value); @@ -114,7 +112,7 @@ const showMultiButton = computed(() => displayMany.value); -
+