diff --git a/config/galaxy.yml.sample b/config/galaxy.yml.sample
index 3a2a416a4b93..20890bdc6eee 100644
--- a/config/galaxy.yml.sample
+++ b/config/galaxy.yml.sample
@@ -1405,6 +1405,15 @@ galaxy:
# If OpenID is enabled, consumer cache directory to use.
#openid_consumer_cache_path: database/openid_consumer_cache
+ # Enables and disables OpenID Connect (OIDC) support.
+ #enable_oidc: false
+
+ # Sets the path to OIDC configuration file.
+ #oidc_config_file: config/oidc_config.xml
+
+ # Sets the path to OIDC backends configuration file.
+ #oidc_backends_config_file: config/oidc_backends_config.xml
+
# 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/config/oidc_backends_config.xml.sample b/config/oidc_backends_config.xml.sample
new file mode 100644
index 000000000000..98df83934f08
--- /dev/null
+++ b/config/oidc_backends_config.xml.sample
@@ -0,0 +1,19 @@
+
+
+
+ ...
+ ...
+ http://localhost:8080/authnz/google/callback
+
+
+
+
+
diff --git a/config/oidc_config.xml.sample b/config/oidc_config.xml.sample
new file mode 100644
index 000000000000..f8618dd74dd0
--- /dev/null
+++ b/config/oidc_config.xml.sample
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
diff --git a/doc/source/admin/galaxy_options.rst b/doc/source/admin/galaxy_options.rst
index 440d53a71138..eaa7166d73ad 100644
--- a/doc/source/admin/galaxy_options.rst
+++ b/doc/source/admin/galaxy_options.rst
@@ -2920,6 +2920,36 @@
:Type: str
+~~~~~~~~~~~~~~~
+``enable_oidc``
+~~~~~~~~~~~~~~~
+
+:Description:
+ Enables and disables OpenID Connect (OIDC) support.
+:Default: ``false``
+:Type: bool
+
+
+~~~~~~~~~~~~~~~~~~~~
+``oidc_config_file``
+~~~~~~~~~~~~~~~~~~~~
+
+:Description:
+ Sets the path to OIDC configuration file.
+:Default: ``config/oidc_config.xml``
+:Type: str
+
+
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+``oidc_backends_config_file``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+:Description:
+ Sets the path to OIDC backends configuration file.
+:Default: ``config/oidc_backends_config.xml``
+:Type: str
+
+
~~~~~~~~~~~~~~~~~~~~
``auth_config_file``
~~~~~~~~~~~~~~~~~~~~
diff --git a/lib/galaxy/app.py b/lib/galaxy/app.py
index 574324b9aab3..1d110a5859dc 100644
--- a/lib/galaxy/app.py
+++ b/lib/galaxy/app.py
@@ -183,6 +183,11 @@ def __init__(self, **kwargs):
)
self.heartbeat.daemon = True
self.application_stack.register_postfork_function(self.heartbeat.start)
+
+ if self.config.enable_oidc:
+ from galaxy.authnz import managers
+ self.authnz_manager = managers.AuthnzManager(self, self.config.oidc_config, self.config.oidc_backends_config)
+
self.sentry_client = None
if self.config.sentry_dsn:
diff --git a/lib/galaxy/authnz/__init__.py b/lib/galaxy/authnz/__init__.py
new file mode 100644
index 000000000000..99a9136c3c53
--- /dev/null
+++ b/lib/galaxy/authnz/__init__.py
@@ -0,0 +1,68 @@
+"""
+Contains implementations for authentication and authorization against an
+OpenID Connect (OIDC) Identity Provider (IdP).
+
+This package follows "authorization code flow" authentication protocol to authenticate
+Galaxy users against third-party identity providers.
+
+Additionally, this package implements functionalist's to request temporary access
+credentials for cloud-based resource providers (e.g., Amazon AWS, Microsoft Azure).
+"""
+
+
+class IdentityProvider(object):
+ """
+ OpenID Connect Identity Provider abstract interface.
+ """
+
+ def __init__(self, provider, config):
+ """
+ Initialize the identity provider using the provided configuration,
+ and raise a ParseError (or any more related specific exception) in
+ case the configuration is malformed.
+
+ :type provider: string
+ :param provider: is the name of the identity provider (e.g., Google).
+
+ :type config: xml.etree.ElementTree.Element
+ :param config: Is the configuration element of the provider
+ from the configuration file (e.g., oidc_config.xml).
+ This element contains the all the provider-specific
+ configuration elements.
+ """
+ raise NotImplementedError()
+
+ def authenticate(self, provider, trans):
+ """Runs for authentication process. Checks the database if a
+ valid identity exists in the database; if yes, then the user
+ is authenticated, if not, it generates a provider-specific
+ authentication flow and returns redirect URI to the controller.
+
+ :type trans: GalaxyWebTransaction
+ :param trans: Galaxy web transaction.
+
+ :return: a redirect URI to the provider's authentication
+ endpoint.
+ """
+ raise NotImplementedError()
+
+ def callback(self, state_token, authz_code, trans, login_redirect_url):
+ """
+ Handles authentication call-backs from identity providers.
+ This process maps `state-token` to a user
+ :type state_token: string
+ :param state_token: is an anti-forgery token which identifies
+ a Galaxy user to whom the given authorization code belongs to.
+ :type authz_code: string
+ :param authz_code: a very short-lived, single-use token to
+ request a refresh token.
+ :type trans: GalaxyWebTransaction
+ :param trans: Galaxy web transaction.
+ :return boolean:
+ True: if callback is handled successfully.
+ False: if processing callback fails, then Galaxy attempts re-authentication.
+ """
+ raise NotImplementedError()
+
+ def disconnect(self, provider, trans, disconnect_redirect_url=None):
+ raise NotImplementedError()
diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py
new file mode 100644
index 000000000000..910b34c9bad4
--- /dev/null
+++ b/lib/galaxy/authnz/managers.py
@@ -0,0 +1,145 @@
+
+import importlib
+import logging
+import xml.etree.ElementTree as ET
+from xml.etree.ElementTree import ParseError
+
+from .psa_authnz import PSAAuthnz
+
+log = logging.getLogger(__name__)
+
+
+class AuthnzManager(object):
+
+ def __init__(self, app, oidc_config_file, oidc_backends_config_file):
+ """
+ :type app: galaxy.app.UniverseApplication
+ :param app:
+
+ :type config: string
+ :param config: sets the path for OIDC configuration
+ file (e.g., oidc_backends_config.xml).
+ """
+ self._parse_oidc_config(oidc_config_file)
+ self._parse_oidc_backends_config(oidc_backends_config_file)
+
+ def _parse_oidc_config(self, config_file):
+ self.oidc_config = {}
+ try:
+ tree = ET.parse(config_file)
+ root = tree.getroot()
+ if root.tag != 'OIDC':
+ raise ParseError("The root element in OIDC_Config xml file is expected to be `OIDC`, "
+ "found `{}` instead -- unable to continue.".format(root.tag))
+ for child in root:
+ if child.tag != 'Setter':
+ log.error("Expect a node with `Setter` tag, found a node with `{}` tag instead; "
+ "skipping this node.".format(child.tag))
+ continue
+ if 'Property' not in child.attrib or 'Value' not in child.attrib or 'Type' not in child.attrib:
+ log.error("Could not find the node attributes `Property` and/or `Value` and/or `Type`;"
+ " found these attributes: `{}`; skipping this node.".format(child.attrib))
+ continue
+ try:
+ func = getattr(importlib.import_module('__builtin__'), child.get('Type'))
+ except AttributeError:
+ log.error("The value of attribute `Type`, `{}`, is not a valid built-in type;"
+ " skipping this node").format(child.get('Type'))
+ continue
+ self.oidc_config[child.get('Property')] = func(child.get('Value'))
+ except ImportError:
+ raise
+ except ParseError as e:
+ raise ParseError("Invalid configuration at `{}`: {} -- unable to continue.".format(config_file, e.message))
+
+ def _parse_oidc_backends_config(self, config_file):
+ self.oidc_backends_config = {}
+ try:
+ tree = ET.parse(config_file)
+ root = tree.getroot()
+ if root.tag != 'OIDC':
+ raise ParseError("The root element in OIDC config xml file is expected to be `OIDC`, "
+ "found `{}` instead -- unable to continue.".format(root.tag))
+ for child in root:
+ if child.tag != 'provider':
+ log.error("Expect a node with `provider` tag, found a node with `{}` tag instead; "
+ "skipping the node.".format(child.tag))
+ continue
+ if 'name' not in child.attrib:
+ log.error("Could not find a node attribute 'name'; skipping the node '{}'.".format(child.tag))
+ continue
+ idp = child.get('name').lower()
+ if idp == 'google':
+ self.oidc_backends_config[idp] = self._parse_google_config(child)
+ if len(self.oidc_backends_config) == 0:
+ raise ParseError("No valid provider configuration parsed.")
+ except ImportError:
+ raise
+ except ParseError as e:
+ raise ParseError("Invalid configuration at `{}`: {} -- unable to continue.".format(config_file, e.message))
+ # except Exception as e:
+ # raise Exception("Malformed OIDC Configuration XML -- unable to continue. {}".format(e.message))
+
+ def _parse_google_config(self, config_xml):
+ rtv = {
+ 'client_id': config_xml.find('client_id').text,
+ 'client_secret': config_xml.find('client_secret').text,
+ 'redirect_uri': config_xml.find('redirect_uri').text}
+ if config_xml.find('prompt') is not None:
+ rtv['prompt'] = config_xml.find('prompt').text
+ return rtv
+
+ def _get_authnz_backend(self, provider):
+ provider = provider.lower()
+ if provider in self.oidc_backends_config:
+ try:
+ return True, "", PSAAuthnz(provider, self.oidc_config, self.oidc_backends_config[provider])
+ except Exception as e:
+ log.exception('An error occurred when loading PSAAuthnz: ', str(e))
+ return False, str(e), None
+ else:
+ msg = 'The requested identity provider, `{}`, is not a recognized/expected provider'.format(provider)
+ log.debug(msg)
+ return False, msg, None
+
+ def authenticate(self, provider, trans):
+ """
+ :type provider: string
+ :param provider: set the name of the identity provider to be
+ used for authentication flow.
+ :type trans: GalaxyWebTransaction
+ :param trans: Galaxy web transaction.
+ :return: an identity provider specific authentication redirect URI.
+ """
+ try:
+ success, message, backend = self._get_authnz_backend(provider)
+ if success is False:
+ return False, message, None
+ return True, "Redirecting to the `{}` identity provider for authentication".format(provider), backend.authenticate(trans)
+ except Exception as e:
+ msg = 'An error occurred when authenticating a user on `{}` identity provider: {}'.format(provider, str(e))
+ log.exception(msg)
+ return False, msg, None
+
+ def callback(self, provider, state_token, authz_code, trans, login_redirect_url):
+ try:
+ success, message, backend = self._get_authnz_backend(provider)
+ if success is False:
+ return False, message, (None, None)
+ return True, message, backend.callback(state_token, authz_code, trans, login_redirect_url)
+ except Exception as e:
+ msg = 'An error occurred when handling callback from `{}` identity provider; {}'.format(provider, str(e))
+ log.exception(msg)
+ return False, msg, (None, None)
+
+ def disconnect(self, provider, trans, disconnect_redirect_url=None):
+ try:
+ success, message, backend = self._get_authnz_backend(provider)
+ if success is False:
+ return False, message, None
+ return backend.disconnect(provider, trans, disconnect_redirect_url)
+ except Exception as e:
+ msg = 'An error occurred when disconnecting authentication with `{}` identity provider for user `{}`; ' \
+ '{}'.format(provider, trans.user.username, str(e))
+ log.exception(msg)
+ return False, msg, None
diff --git a/lib/galaxy/authnz/psa_authnz.py b/lib/galaxy/authnz/psa_authnz.py
new file mode 100644
index 000000000000..831c349eeeb5
--- /dev/null
+++ b/lib/galaxy/authnz/psa_authnz.py
@@ -0,0 +1,289 @@
+import six
+from social_core.actions import do_auth, do_complete, do_disconnect
+from social_core.backends.utils import get_backend
+from social_core.strategy import BaseStrategy
+from social_core.utils import module_member, setting_name
+from sqlalchemy.exc import IntegrityError
+
+from ..authnz import IdentityProvider
+from ..model import PSAAssociation, PSACode, PSANonce, PSAPartial, UserAuthnzToken
+
+
+# key: a component name which PSA requests.
+# value: is the name of a class associated with that key.
+DEFAULTS = {
+ 'STRATEGY': 'Strategy',
+ 'STORAGE': 'Storage'
+}
+
+BACKENDS = {
+ 'google': 'social_core.backends.google_openidconnect.GoogleOpenIdConnect'
+}
+
+BACKENDS_NAME = {
+ 'google': 'google-openidconnect'
+}
+
+AUTH_PIPELINE = (
+ # Get the information we can about the user and return it in a simple
+ # format to create the user instance later. On some cases the details are
+ # already part of the auth response from the provider, but sometimes this
+ # could hit a provider API.
+ 'social_core.pipeline.social_auth.social_details',
+
+ # Get the social uid from whichever service we're authing thru. The uid is
+ # the unique identifier of the given user in the provider.
+ 'social_core.pipeline.social_auth.social_uid',
+
+ # Verifies that the current auth process is valid within the current
+ # project, this is where emails and domains whitelists are applied (if
+ # defined).
+ 'social_core.pipeline.social_auth.auth_allowed',
+
+ # Checks if the current social-account is already associated in the site.
+ 'social_core.pipeline.social_auth.social_user',
+
+ # Make up a username for this person, appends a random string at the end if
+ # there's any collision.
+ 'social_core.pipeline.user.get_username',
+
+ # Send a validation email to the user to verify its email address.
+ # 'social_core.pipeline.mail.mail_validation',
+
+ # Associates the current social details with another user account with
+ # a similar email address.
+ 'social_core.pipeline.social_auth.associate_by_email',
+
+ # Create a user account if we haven't found one yet.
+ 'social_core.pipeline.user.create_user',
+
+ # Create the record that associated the social account with this user.
+ 'social_core.pipeline.social_auth.associate_user',
+
+ # Populate the extra_data field in the social record with the values
+ # specified by settings (and the default ones like access_token, etc).
+ 'social_core.pipeline.social_auth.load_extra_data',
+
+ # Update the user record with any changed info from the auth service.
+ 'social_core.pipeline.user.user_details'
+)
+
+DISCONNECT_PIPELINE = (
+ 'galaxy.authnz.psa_authnz.allowed_to_disconnect',
+ 'galaxy.authnz.psa_authnz.disconnect'
+)
+
+
+class PSAAuthnz(IdentityProvider):
+ def __init__(self, provider, oidc_config, oidc_backend_config):
+ self.config = {'provider': provider.lower()}
+ for key, value in oidc_config.iteritems():
+ self.config[setting_name(key)] = value
+
+ self.config[setting_name('USER_MODEL')] = 'models.User'
+ self.config['SOCIAL_AUTH_PIPELINE'] = AUTH_PIPELINE
+ self.config['DISCONNECT_PIPELINE'] = DISCONNECT_PIPELINE
+ self.config[setting_name('AUTHENTICATION_BACKENDS')] = (BACKENDS[provider],)
+
+ # The following config sets PSA to call the `_login_user` function for
+ # logging in a user. If this setting is set to false, the `_login_user`
+ # would not be called, and as a result Galaxy would not know who is
+ # the just logged-in user.
+ self.config[setting_name('INACTIVE_USER_LOGIN')] = True
+
+ if provider == 'google':
+ self._setup_google_backend(oidc_backend_config)
+
+ def _setup_google_backend(self, oidc_backend_config):
+ self.config[setting_name('AUTH_EXTRA_ARGUMENTS')] = {'access_type': 'offline'}
+ self.config['SOCIAL_AUTH_GOOGLE_OPENIDCONNECT_KEY'] = oidc_backend_config.get('client_id')
+ self.config['SOCIAL_AUTH_GOOGLE_OPENIDCONNECT_SECRET'] = oidc_backend_config.get('client_secret')
+ self.config['redirect_uri'] = oidc_backend_config.get('redirect_uri')
+ if oidc_backend_config.get('prompt') is not None:
+ self.config[setting_name('AUTH_EXTRA_ARGUMENTS')]['prompt'] = oidc_backend_config.get('prompt')
+
+ def _on_the_fly_config(self, trans):
+ trans.app.model.PSACode.trans = trans
+ trans.app.model.UserAuthnzToken.trans = trans
+ trans.app.model.PSANonce.trans = trans
+ trans.app.model.PSAPartial.trans = trans
+ trans.app.model.PSAAssociation.trans = trans
+
+ def _get_helper(self, name, do_import=False):
+ this_config = self.config.get(setting_name(name), DEFAULTS.get(name, None))
+ return do_import and module_member(this_config) or this_config
+
+ def _get_current_user(self, trans):
+ return trans.user if trans.user is not None else None
+
+ def _load_backend(self, strategy, redirect_uri):
+ backends = self._get_helper('AUTHENTICATION_BACKENDS')
+ backend = get_backend(backends, BACKENDS_NAME[self.config['provider']])
+ return backend(strategy, redirect_uri)
+
+ def _login_user(self, backend, user, social_user):
+ self.config['user'] = user
+
+ def authenticate(self, trans):
+ self._on_the_fly_config(trans)
+ strategy = Strategy(trans, Storage, self.config)
+ backend = self._load_backend(strategy, self.config['redirect_uri'])
+ return do_auth(backend)
+
+ def callback(self, state_token, authz_code, trans, login_redirect_url):
+ self._on_the_fly_config(trans)
+ self.config[setting_name('LOGIN_REDIRECT_URL')] = login_redirect_url
+ strategy = Strategy(trans, Storage, self.config)
+ strategy.session_set(BACKENDS_NAME[self.config['provider']] + '_state', state_token)
+ backend = self._load_backend(strategy, self.config['redirect_uri'])
+ redirect_url = do_complete(
+ backend,
+ login=lambda backend, user, social_user: self._login_user(backend, user, social_user),
+ user=self._get_current_user(trans),
+ state=state_token)
+ return redirect_url, self.config.get('user', None)
+
+ def disconnect(self, provider, trans, disconnect_redirect_url=None, association_id=None):
+ self._on_the_fly_config(trans)
+ self.config[setting_name('DISCONNECT_REDIRECT_URL')] =\
+ disconnect_redirect_url if disconnect_redirect_url is not None else ()
+ strategy = Strategy(trans, Storage, self.config)
+ backend = self._load_backend(strategy, self.config['redirect_uri'])
+ response = do_disconnect(backend, self._get_current_user(trans), association_id)
+ if isinstance(response, six.string_types):
+ return True, "", response
+ return response.get('success', False), response.get('message', ""), ""
+
+
+class Strategy(BaseStrategy):
+
+ def __init__(self, trans, storage, config, tpl=None):
+ self.trans = trans
+ self.request = trans.request
+ self.session = trans.session if trans.session else {}
+ self.config = config
+ self.config['SOCIAL_AUTH_REDIRECT_IS_HTTPS'] = True if self.trans.request.host.startswith('https:') else False
+ self.config['SOCIAL_AUTH_GOOGLE_OPENIDCONNECT_EXTRA_DATA'] = ['id_token']
+ super(Strategy, self).__init__(storage, tpl)
+
+ def get_setting(self, name):
+ return self.config[name]
+
+ def session_get(self, name, default=None):
+ return self.session.get(name, default)
+
+ def session_set(self, name, value):
+ self.session[name] = value
+
+ def session_pop(self, name):
+ raise NotImplementedError('Not implemented.')
+
+ def request_data(self, merge=True):
+ if not self.request:
+ return {}
+ if merge:
+ data = self.request.GET.copy()
+ data.update(self.request.POST)
+ elif self.request.method == 'POST':
+ data = self.request.POST
+ else:
+ data = self.request.GET
+ return data
+
+ def request_host(self):
+ if self.request:
+ return self.request.host
+
+ def build_absolute_uri(self, path=None):
+ path = path or ''
+ if path.startswith('http://') or path.startswith('https://'):
+ return path
+ return \
+ self.trans.request.host +\
+ '/authn' + ('/' + self.config.get('provider')) if self.config.get('provider', None) is not None else ''
+
+ def redirect(self, url):
+ return url
+
+ def html(self, content):
+ raise NotImplementedError('Not implemented.')
+
+ def render_html(self, tpl=None, html=None, context=None):
+ raise NotImplementedError('Not implemented.')
+
+ def start(self):
+ self.clean_partial_pipeline()
+ if self.backend.uses_redirect():
+ return self.redirect(self.backend.auth_url())
+ else:
+ return self.html(self.backend.auth_html())
+
+ def complete(self, *args, **kwargs):
+ return self.backend.auth_complete(*args, **kwargs)
+
+ def continue_pipeline(self, *args, **kwargs):
+ return self.backend.continue_pipeline(*args, **kwargs)
+
+
+class Storage:
+ user = UserAuthnzToken
+ nonce = PSANonce
+ association = PSAAssociation
+ code = PSACode
+ partial = PSAPartial
+
+ @classmethod
+ def is_integrity_error(cls, exception):
+ return exception.__class__ is IntegrityError
+
+
+def allowed_to_disconnect(name=None, user=None, user_storage=None, strategy=None,
+ backend=None, request=None, details=None, **kwargs):
+ """
+ Disconnect is the process of disassociating a Galaxy user and a third-party authnz.
+ In other words, it is the process of removing any access and/or ID tokens of a user.
+ This function should raise an exception if disconnection is NOT permitted. Do NOT
+ return any value (except an empty dictionary) if disconnect is allowed. Because, at
+ least until PSA social_core v.1.5.0, any returned value (e.g., Boolean) will result
+ in ignoring the rest of the disconnect pipeline.
+ See the following condition in `run_pipeline` function:
+ https://github.com/python-social-auth/social-core/blob/master/social_core/backends/base.py#L114
+ :param name: name of the backend (e.g., google-openidconnect)
+ :type user: galaxy.model.User
+ :type user_storage: galaxy.model.UserAuthnzToken
+ :type strategy: galaxy.authnz.psa_authnz.Strategy
+ :type backend: PSA backend object (e.g., social_core.backends.google_openidconnect.GoogleOpenIdConnect)
+ :type request: webob.multidict.MultiDict
+ :type details: dict
+ :return: empty dict
+ """
+ pass
+
+
+def disconnect(name=None, user=None, user_storage=None, strategy=None,
+ backend=None, request=None, details=None, **kwargs):
+ """
+ Disconnect is the process of disassociating a Galaxy user and a third-party authnz.
+ In other words, it is the process of removing any access and/or ID tokens of a user.
+ :param name: name of the backend (e.g., google-openidconnect)
+ :type user: galaxy.model.User
+ :type user_storage: galaxy.model.UserAuthnzToken
+ :type strategy: galaxy.authnz.psa_authnz.Strategy
+ :type backend: PSA backend object (e.g., social_core.backends.google_openidconnect.GoogleOpenIdConnect)
+ :type request: webob.multidict.MultiDict
+ :type details: dict
+ :return: void or empty dict. Any key-value pair inside the dictionary will be available
+ inside PSA only, and will be passed to the next step in the disconnect pipeline. However,
+ the key-value pair will not be returned as a result of calling the `do_disconnect` function.
+ Additionally, returning any value except for a(n) (empty) dictionary, will break the
+ disconnect pipeline, and that value will be returned as a result of calling the `do_disconnect` function.
+ """
+ user_authnz = strategy.trans.sa_session.query(user_storage).filter(user_storage.table.c.user_id == user.id,
+ user_storage.table.c.provider == name).first()
+ if user_authnz is None:
+ return {'success': False, 'message': 'Not authenticated by any identity providers.'}
+ # option A
+ strategy.trans.sa_session.delete(user_authnz)
+ # option B
+ # user_authnz.extra_data = None
+ strategy.trans.sa_session.flush()
diff --git a/lib/galaxy/config.py b/lib/galaxy/config.py
index 9c7a0633da84..79f67c1bc9a9 100644
--- a/lib/galaxy/config.py
+++ b/lib/galaxy/config.py
@@ -208,6 +208,10 @@ def __init__(self, **kwargs):
self.tool_data_path = resolve_path(kwargs.get("tool_data_path", "tool-data"), os.getcwd())
self.builds_file_path = resolve_path(kwargs.get("builds_file_path", os.path.join(self.tool_data_path, 'shared', 'ucsc', 'builds.txt')), self.root)
self.len_file_path = resolve_path(kwargs.get("len_file_path", os.path.join(self.tool_data_path, 'shared', 'ucsc', 'chrom')), self.root)
+ # Galaxy OIDC settings.
+ self.enable_oidc = kwargs.get("enable_oidc", False)
+ self.oidc_config = kwargs.get('oidc_config_file', None)
+ self.oidc_backends_config = kwargs.get("oidc_backends_config_file", None)
# The value of migrated_tools_config is the file reserved for containing only those tools that have been eliminated from the distribution
# and moved to the tool shed.
self.integrated_tool_panel_config = resolve_path(kwargs.get('integrated_tool_panel_config', 'integrated_tool_panel.xml'), self.root)
diff --git a/lib/galaxy/dependencies/pipfiles/default/Pipfile b/lib/galaxy/dependencies/pipfiles/default/Pipfile
index d8f7744e59f9..1d195551e7f6 100644
--- a/lib/galaxy/dependencies/pipfiles/default/Pipfile
+++ b/lib/galaxy/dependencies/pipfiles/default/Pipfile
@@ -50,6 +50,8 @@ pyparsing = "*"
"Fabric3" = "*"
paramiko = "*"
python-genomespaceclient = "*"
+social_auth_core = "==1.5.0"
+pyjwkest = "==1.4.0"
[requires]
python_version = "2.7"
diff --git a/lib/galaxy/dependencies/pipfiles/default/pinned-requirements.txt b/lib/galaxy/dependencies/pipfiles/default/pinned-requirements.txt
index 495aff6fe849..474b4f2ff486 100644
--- a/lib/galaxy/dependencies/pipfiles/default/pinned-requirements.txt
+++ b/lib/galaxy/dependencies/pipfiles/default/pinned-requirements.txt
@@ -5,7 +5,7 @@ babel==2.5.3
bagit==1.6.4
bcrypt==3.1.4
bdbag==1.2.4
-beaker==1.9.0
+beaker==1.9.1
bioblend==0.10.0
bleach==2.1.3
boltons==18.0.0
@@ -19,9 +19,9 @@ chardet==3.0.4
cheetah3==3.1.0
cliff==2.11.0
cloudbridge==0.3.3
-cmd2==0.8.2
+cmd2==0.8.3
contextlib2==0.5.5
-cryptography==2.2.1
+cryptography==2.2.2
debtcollector==1.19.0
decorator==4.2.1
deprecation==2.0
@@ -29,10 +29,11 @@ dictobj==0.4
docopt==0.6.2
docutils==0.14
dogpile.cache==0.6.5
-enum34==1.1.6; python_version < '3'
+enum34==1.1.6
fabric3==1.14.post1
-funcsigs==1.0.2
+funcsigs==1.0.2; python_version == '2.7' or python_version == '2.6'
functools32==3.2.3.post2; python_version == '2.7'
+future==0.16.0
futures==3.2.0; python_version == '2.7' or python_version == '2.6'
galaxy-sequence-utils==1.1.2
globus-sdk==1.3.0
@@ -42,7 +43,7 @@ idna==2.6
ipaddress==1.0.19; python_version < '3.3'
iso8601==0.1.12
jmespath==0.9.3
-jsonpatch==1.21
+jsonpatch==1.23
jsonpointer==2.0
jsonschema==2.6.0
keystoneauth1==3.4.0
@@ -52,16 +53,17 @@ markupsafe==1.0
mercurial==3.7.3; python_version < '3'
monotonic==1.4
msgpack==0.5.6
-munch==2.2.0
+munch==2.3.0
netaddr==0.7.19
netifaces==0.10.6
nose==1.3.7
numpy==1.14.2
+oauthlib==2.0.7
openstacksdk==0.12.0
os-client-config==1.29.0
os-service-types==1.2.0
osc-lib==1.10.0
-oslo.config==5.2.0
+oslo.config==6.0.1
oslo.i18n==3.20.0
oslo.serialization==2.25.0
oslo.utils==3.36.0
@@ -71,7 +73,7 @@ parsley==1.3
paste==2.0.3
pastedeploy==1.5.2
pastescript==2.0.2
-pbr==3.1.1
+pbr==4.0.1
positional==1.2.1
prettytable==0.7.2
psutil==5.4.3
@@ -80,6 +82,9 @@ py2-ipaddress==3.4.1
pyasn1==0.4.2
pycparser==2.18
pycrypto==2.6.1
+pycryptodomex==3.6.0
+pyjwkest==1.4.0
+pyjwt==1.6.1
pykwalify==1.6.1
pynacl==1.2.1
pyparsing==2.2.0
@@ -93,10 +98,12 @@ python-keystoneclient==3.10.0
python-lzo==1.11
python-neutronclient==6.1.0
python-novaclient==7.0.0
+python-openid==2.2.5
python-swiftclient==3.3.0
pytz==2018.3
pyyaml==3.12
repoze.lru==0.7
+requests-oauthlib==0.8.0
requests-toolbelt==0.8.0
requests==2.18.4
requestsexceptions==1.4.0
@@ -105,9 +112,10 @@ rfc3986==1.1.0
routes==2.4.1
simplejson==3.13.2
six==1.11.0
+social-auth-core==1.5.0
sqlalchemy-migrate==0.11.0
-sqlalchemy-utils==0.33.1
-sqlalchemy==1.2.5
+sqlalchemy-utils==0.33.2
+sqlalchemy==1.2.6
sqlparse==0.2.4
stevedore==1.28.0
subprocess32==3.2.7
diff --git a/lib/galaxy/dependencies/pipfiles/develop/Pipfile.lock b/lib/galaxy/dependencies/pipfiles/develop/Pipfile.lock
index 8a6c98af4c45..99688635895c 100644
--- a/lib/galaxy/dependencies/pipfiles/develop/Pipfile.lock
+++ b/lib/galaxy/dependencies/pipfiles/develop/Pipfile.lock
@@ -192,10 +192,10 @@
},
"pbr": {
"hashes": [
- "sha256:05f61c71aaefc02d8e37c0a3eeb9815ff526ea28b3b76324769e6158d7f95be1",
- "sha256:60c25b7dfd054ef9bb0ae327af949dd4676aa09ac3a9471cdc871d8a9213f9ac"
+ "sha256:56b7a8ba7d64bf6135a9dfefb85a80d95924b3fde5ed6343a1a1d464a040dae3",
+ "sha256:de75cf1d510542c746beeff66b52241eb12c8f95f2ef846ee50ed5d72392caa4"
],
- "version": "==3.1.1"
+ "version": "==4.0.1"
},
"pygithub3": {
"hashes": [
@@ -310,11 +310,11 @@
},
"sphinx-rtd-theme": {
"hashes": [
- "sha256:2df74b8ff6fae6965c527e97cca6c6c944886aae474b490e17f92adfbe843417",
- "sha256:62ee4752716e698bad7de8a18906f42d33664128eea06c46b718fc7fbd1a9f5c"
+ "sha256:220dbf14814001c6475f0c6c25ac4129a18fb5e3681251a7c6ffb1646da5cc30",
+ "sha256:665135dfbdf8f1d218442458a18cf266444354b8c98eed93d1543f7e701cfdba"
],
"index": "galaxy",
- "version": "==0.2.4"
+ "version": "==0.3.0"
},
"sphinxcontrib-websupport": {
"hashes": [
@@ -325,11 +325,11 @@
},
"testfixtures": {
"hashes": [
- "sha256:338aed9695c432b7c9b8a271dabb521e3e7e2c96b11f7b4e60552f1c8408a8f0",
- "sha256:53b8e493366a910f5690749dbfccabbb94e0aac0747e95d267b5a37ac2fc4fe9"
+ "sha256:dd3fdc1b252df3ba5a03a8504b942890abe5d9b1755bc280d0bb8aa9fe914371",
+ "sha256:f6c4cf24d043f9d8e9a9337371ec1d2f6638a0032504bd67dbd724224fd64969"
],
"index": "galaxy",
- "version": "==5.4.0"
+ "version": "==6.0.0"
},
"twill": {
"hashes": [
diff --git a/lib/galaxy/dependencies/pipfiles/develop/pinned-requirements.txt b/lib/galaxy/dependencies/pipfiles/develop/pinned-requirements.txt
index 8c075d018a9c..60646900ce07 100644
--- a/lib/galaxy/dependencies/pipfiles/develop/pinned-requirements.txt
+++ b/lib/galaxy/dependencies/pipfiles/develop/pinned-requirements.txt
@@ -17,7 +17,7 @@ nose==1.3.7
nosehtml==0.4.4
packaging==17.1
pathtools==0.1.2
-pbr==3.1.1
+pbr==4.0.1
pygithub3==0.5.1; python_version < '3'
pygments==2.2.0
pyparsing==2.2.0
@@ -28,10 +28,10 @@ requests==2.18.4
selenium==3.11.0
six==1.11.0
snowballstemmer==1.2.1
-sphinx-rtd-theme==0.2.4
+sphinx-rtd-theme==0.3.0
sphinx==1.7.2
sphinxcontrib-websupport==1.0.1
-testfixtures==5.4.0
+testfixtures==6.0.0
twill==0.9.1; python_version < '3'
typing==3.6.4; python_version < '3.5'
urllib3==1.22
diff --git a/lib/galaxy/dependencies/requirements.txt b/lib/galaxy/dependencies/requirements.txt
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py
index 3513af679caf..daae3897dc45 100644
--- a/lib/galaxy/model/__init__.py
+++ b/lib/galaxy/model/__init__.py
@@ -4,6 +4,7 @@
Naming: try to use class names that have a distinct plural form so that
the relationship cardinalities are obvious (e.g. prefer Dataset to Data)
"""
+import base64
import errno
import json
import logging
@@ -11,12 +12,15 @@
import operator
import os
import pwd
+import random
+import string
import time
from datetime import datetime, timedelta
from string import Template
from uuid import UUID, uuid4
from six import string_types
+from social_core.storage import AssociationMixin, CodeMixin, NonceMixin, PartialMixin, UserMixin
from sqlalchemy import (
and_,
func,
@@ -30,7 +34,7 @@
types)
from sqlalchemy.ext import hybrid
from sqlalchemy.orm import aliased, joinedload, object_session
-
+from sqlalchemy.schema import UniqueConstraint
import galaxy.model.metadata
import galaxy.model.orm.now
@@ -203,7 +207,7 @@ class User(Dictifiable):
# attributes that will be accessed and returned when calling to_dict( view='element' )
dict_element_visible_keys = ['id', 'email', 'username', 'total_disk_usage', 'nice_total_disk_usage', 'deleted', 'active', 'last_password_change']
- def __init__(self, email=None, password=None):
+ def __init__(self, email=None, password=None, username=None):
self.email = email
self.password = password
self.external = False
@@ -211,7 +215,7 @@ def __init__(self, email=None, password=None):
self.purged = False
self.active = False
self.activation_token = None
- self.username = None
+ self.username = username
self.last_password_change = None
# Relationships
self.histories = []
@@ -239,6 +243,14 @@ def set_password_cleartext(self, cleartext):
self.password = new_secure_hash(text_type=cleartext)
self.last_password_change = datetime.now()
+ def set_random_password(self, length=16):
+ """
+ Sets user password to a random string of the given length.
+ :return: void
+ """
+ self.set_password_cleartext(
+ ''.join(random.SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(length)))
+
def check_password(self, cleartext):
"""
Check if `cleartext` matches user password when hashed.
@@ -426,6 +438,18 @@ def expand_user_properties(user, in_string):
environment = User.user_template_environment(user)
return Template(in_string).safe_substitute(environment)
+ def is_active(self):
+ return self.active
+
+ def is_authenticated(self):
+ # TODO: is required for python social auth (PSA); however, a user authentication is relative to the backend.
+ # For instance, a user who is authenticated with Google, is not necessarily authenticated
+ # with Amazon. Therefore, this function should also receive the backend and check if this
+ # user is already authenticated on that backend or not. For now, returning always True
+ # seems reasonable. Besides, this is also how a PSA example is implemented:
+ # https://github.com/python-social-auth/social-examples/blob/master/example-cherrypy/example/db/user.py
+ return True
+
class PasswordResetToken(object):
def __init__(self, user, token=None):
@@ -4446,6 +4470,223 @@ def __init__(self, user=None, session=None, openid=None):
self.openid = openid
+class PSAAssociation(AssociationMixin):
+
+ # This static property is of type: galaxy.web.framework.webapp.GalaxyWebTransaction
+ # and it is set in: galaxy.authnz.psa_authnz.PSAAuthnz
+ trans = None
+
+ def __init__(self, server_url=None, handle=None, secret=None, issued=None, lifetime=None, assoc_type=None):
+ self.server_url = server_url
+ self.handle = handle
+ self.secret = secret
+ self.issued = issued
+ self.lifetime = lifetime
+ self.assoc_type = assoc_type
+
+ def save(self):
+ self.trans.sa_session.add(self)
+ self.trans.sa_session.flush()
+
+ @classmethod
+ def store(cls, server_url, association):
+ try:
+ assoc = cls.trans.sa_session.query(cls).filter_by(server_url=server_url, handle=association.handle)[0]
+ except IndexError:
+ assoc = cls(server_url=server_url, handle=association.handle)
+ assoc.secret = base64.encodestring(association.secret).decode()
+ assoc.issued = association.issued
+ assoc.lifetime = association.lifetime
+ assoc.assoc_type = association.assoc_type
+ cls.trans.sa_session.add(assoc)
+ cls.trans.sa_session.flush()
+
+ @classmethod
+ def get(cls, *args, **kwargs):
+ return cls.trans.sa_session.query(cls).filter_by(*args, **kwargs)
+
+ @classmethod
+ def remove(cls, ids_to_delete):
+ cls.trans.sa_session.query(cls).filter(cls.id.in_(ids_to_delete)).delete(synchronize_session='fetch')
+
+
+class PSACode(CodeMixin):
+ __table_args__ = (UniqueConstraint('code', 'email'),)
+
+ # This static property is of type: galaxy.web.framework.webapp.GalaxyWebTransaction
+ # and it is set in: galaxy.authnz.psa_authnz.PSAAuthnz
+ trans = None
+
+ def __init__(self, email, code):
+ self.email = email
+ self.code = code
+
+ def save(self):
+ self.trans.sa_session.add(self)
+ self.trans.sa_session.flush()
+
+ @classmethod
+ def get_code(cls, code):
+ return cls.trans.sa_session.query(cls).filter(cls.code == code).first()
+
+
+class PSANonce(NonceMixin):
+
+ # This static property is of type: galaxy.web.framework.webapp.GalaxyWebTransaction
+ # and it is set in: galaxy.authnz.psa_authnz.PSAAuthnz
+ trans = None
+
+ def __init__(self, server_url, timestamp, salt):
+ self.server_url = server_url
+ self.timestamp = timestamp
+ self.salt = salt
+
+ def save(self):
+ self.trans.sa_session.add(self)
+ self.trans.sa_session.flush()
+
+ @classmethod
+ def use(cls, server_url, timestamp, salt):
+ try:
+ return cls.trans.sa_session.query(cls).filter_by(server_url=server_url, timestamp=timestamp, salt=salt)[0]
+ except IndexError:
+ instance = cls(server_url=server_url, timestamp=timestamp, salt=salt)
+ cls.trans.sa_session.add(instance)
+ cls.trans.sa_session.flush()
+ return instance
+
+
+class PSAPartial(PartialMixin):
+
+ # This static property is of type: galaxy.web.framework.webapp.GalaxyWebTransaction
+ # and it is set in: galaxy.authnz.psa_authnz.PSAAuthnz
+ trans = None
+
+ def __init__(self, token, data, next_step, backend):
+ self.token = token
+ self.data = data
+ self.next_step = next_step
+ self.backend = backend
+
+ def save(self):
+ self.trans.sa_session.add(self)
+ self.trans.sa_session.flush()
+
+ @classmethod
+ def load(cls, token):
+ return cls.trans.sa_session.query(cls).filter(cls.token == token).first()
+
+ @classmethod
+ def destroy(cls, token):
+ partial = cls.load(token)
+ if partial:
+ cls.trans.sa_session.delete(partial)
+
+
+class UserAuthnzToken(UserMixin):
+ __table_args__ = (UniqueConstraint('provider', 'uid'),)
+
+ # This static property is of type: galaxy.web.framework.webapp.GalaxyWebTransaction
+ # and it is set in: galaxy.authnz.psa_authnz.PSAAuthnz
+ trans = None
+
+ def __init__(self, provider, uid, extra_data=None, lifetime=None, assoc_type=None, user=None):
+ self.provider = provider
+ self.uid = uid
+ self.user_id = user.id
+ self.extra_data = extra_data
+ self.lifetime = lifetime
+ self.assoc_type = assoc_type
+
+ def get_id_token(self):
+ return self.extra_data.get('id_token', None) if self.extra_data is not None else None
+
+ def get_access_token(self):
+ return self.extra_data.get('access_token', None) if self.extra_data is not None else None
+
+ def set_extra_data(self, extra_data=None):
+ # Note: the following unicode conversion is a temporary solution for a
+ # database binding error (InterfaceError: (sqlite3.InterfaceError)).
+ if extra_data is not None:
+ extra_data = str(extra_data)
+ if super(UserAuthnzToken, self).set_extra_data(extra_data):
+ self.trans.sa_session.add(self)
+ self.trans.sa_session.flush()
+
+ def save(self):
+ self.trans.sa_session.add(self)
+ self.trans.sa_session.flush()
+
+ @classmethod
+ def username_max_length(cls):
+ # Note: This is the maximum field length set for the username column of the galaxy_user table.
+ # A better alternative is to retrieve this number from the table, instead of this const value.
+ return 255
+
+ @classmethod
+ def user_model(cls):
+ return User
+
+ @classmethod
+ def changed(cls, user):
+ cls.trans.sa_session.add(user)
+ cls.trans.sa_session.flush()
+
+ @classmethod
+ def user_query(cls):
+ return cls.trans.sa_session.query(cls.user_model())
+
+ @classmethod
+ def user_exists(cls, *args, **kwargs):
+ return cls.user_query().filter_by(*args, **kwargs).count() > 0
+
+ @classmethod
+ def get_username(cls, user):
+ return getattr(user, 'username', None)
+
+ @classmethod
+ def create_user(cls, *args, **kwargs):
+ model = cls.user_model()
+ instance = model(*args, **kwargs)
+ instance.set_random_password()
+ cls.trans.sa_session.add(instance)
+ cls.trans.sa_session.flush()
+ return instance
+
+ @classmethod
+ def get_user(cls, pk):
+ return cls.user_query().get(pk)
+
+ @classmethod
+ def get_users_by_email(cls, email):
+ return cls.user_query().filter_by(email=email)
+
+ @classmethod
+ def get_social_auth(cls, provider, uid):
+ uid = str(uid)
+ try:
+ return cls.trans.sa_session.query(cls).filter_by(provider=provider, uid=uid)[0]
+ except IndexError:
+ return None
+
+ @classmethod
+ def get_social_auth_for_user(cls, user, provider=None, id=None):
+ qs = cls.trans.sa_session.query(cls).filter_by(user_id=user.id)
+ if provider:
+ qs = qs.filter_by(provider=provider)
+ if id:
+ qs = qs.filter_by(id=id)
+ return qs
+
+ @classmethod
+ def create_social_auth(cls, user, uid, provider):
+ uid = str(uid)
+ instance = cls(user=user, uid=uid, provider=provider)
+ cls.trans.sa_session.add(instance)
+ cls.trans.sa_session.flush()
+ return instance
+
+
class Page(Dictifiable):
dict_element_visible_keys = ['id', 'title', 'latest_revision_id', 'slug', 'published', 'importable', 'deleted']
diff --git a/lib/galaxy/model/mapping.py b/lib/galaxy/model/mapping.py
index 0e2a5be1183e..a46a0af3d6a6 100644
--- a/lib/galaxy/model/mapping.py
+++ b/lib/galaxy/model/mapping.py
@@ -20,13 +20,13 @@
not_,
Numeric,
select,
- String,
- Table,
+ String, Table,
TEXT,
Text,
true,
Unicode,
- UniqueConstraint
+ UniqueConstraint,
+ VARCHAR
)
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.orderinglist import ordering_list
@@ -92,6 +92,47 @@
Column("openid", TEXT, index=True, unique=True),
Column("provider", TrimmedString(255)))
+model.PSAAssociation.table = Table(
+ "psa_association", metadata,
+ Column('id', Integer, primary_key=True),
+ Column('server_url', VARCHAR(255)),
+ Column('handle', VARCHAR(255)),
+ Column('secret', VARCHAR(255)),
+ Column('issued', Integer),
+ Column('lifetime', Integer),
+ Column('assoc_type', VARCHAR(64)))
+
+model.PSACode.table = Table(
+ "psa_code", metadata,
+ Column('id', Integer, primary_key=True),
+ Column('email', VARCHAR(200)),
+ Column('code', VARCHAR(32)))
+
+model.PSANonce.table = Table(
+ "psa_nonce", metadata,
+ Column('id', Integer, primary_key=True),
+ Column('server_url', VARCHAR(255)),
+ Column('timestamp', Integer),
+ Column('salt', VARCHAR(40)))
+
+model.PSAPartial.table = Table(
+ "psa_partial", metadata,
+ Column('id', Integer, primary_key=True),
+ Column('token', VARCHAR(32)),
+ Column('data', TEXT),
+ Column('next_step', Integer),
+ Column('backend', VARCHAR(32)))
+
+model.UserAuthnzToken.table = Table(
+ "oidc_user_authnz_tokens", metadata,
+ Column('id', Integer, primary_key=True),
+ Column('user_id', Integer, ForeignKey("galaxy_user.id"), index=True),
+ Column('uid', VARCHAR(255)),
+ Column('provider', VARCHAR(32)),
+ Column('extra_data', TEXT),
+ Column('lifetime', Integer),
+ Column('assoc_type', VARCHAR(64)))
+
model.PasswordResetToken.table = Table(
"password_reset_token", metadata,
Column("token", String(32), primary_key=True, unique=True, index=True),
@@ -1422,6 +1463,20 @@ def simple_mapping(model, **kwds):
order_by=desc(model.UserOpenID.table.c.update_time))
))
+mapper(model.PSAAssociation, model.PSAAssociation.table, properties=None)
+
+mapper(model.PSACode, model.PSACode.table, properties=None)
+
+mapper(model.PSANonce, model.PSANonce.table, properties=None)
+
+mapper(model.PSAPartial, model.PSAPartial.table, properties=None)
+
+mapper(model.UserAuthnzToken, model.UserAuthnzToken.table, properties=dict(
+ user=relation(model.User,
+ primaryjoin=(model.UserAuthnzToken.table.c.user_id == model.User.table.c.id),
+ backref='social_auth')
+))
+
mapper(model.ValidationError, model.ValidationError.table)
simple_mapping(model.HistoryDatasetAssociation,
diff --git a/lib/galaxy/model/migrate/versions/0141_add_oidc_tables.py b/lib/galaxy/model/migrate/versions/0141_add_oidc_tables.py
new file mode 100644
index 000000000000..3d91bbca75e2
--- /dev/null
+++ b/lib/galaxy/model/migrate/versions/0141_add_oidc_tables.py
@@ -0,0 +1,85 @@
+"""
+Migration script to add a new tables for an OpenID Connect authentication and authorization.
+"""
+from __future__ import print_function
+
+import logging
+
+from sqlalchemy import Column, ForeignKey, Integer, MetaData, Table, TEXT, VARCHAR
+
+log = logging.getLogger(__name__)
+metadata = MetaData()
+
+psa_association = Table(
+ "psa_association", metadata,
+ Column('id', Integer, primary_key=True),
+ Column('server_url', VARCHAR(255)),
+ Column('handle', VARCHAR(255)),
+ Column('secret', VARCHAR(255)),
+ Column('issued', Integer),
+ Column('lifetime', Integer),
+ Column('assoc_type', VARCHAR(64)))
+
+
+psa_code = Table(
+ "psa_code", metadata,
+ Column('id', Integer, primary_key=True),
+ Column('email', VARCHAR(200)),
+ Column('code', VARCHAR(32)))
+
+
+psa_nonce = Table(
+ "psa_nonce", metadata,
+ Column('id', Integer, primary_key=True),
+ Column('server_url', VARCHAR(255)),
+ Column('timestamp', Integer),
+ Column('salt', VARCHAR(40)))
+
+
+psa_partial = Table(
+ "psa_partial", metadata,
+ Column('id', Integer, primary_key=True),
+ Column('token', VARCHAR(32)),
+ Column('data', TEXT),
+ Column('next_step', Integer),
+ Column('backend', VARCHAR(32)))
+
+
+oidc_user_authnz_tokens = Table(
+ "oidc_user_authnz_tokens", metadata,
+ Column('id', Integer, primary_key=True),
+ Column('user_id', Integer, ForeignKey("galaxy_user.id"), index=True),
+ Column('uid', VARCHAR(255)),
+ Column('provider', VARCHAR(32)),
+ Column('extra_data', TEXT),
+ Column('lifetime', Integer),
+ Column('assoc_type', VARCHAR(64)))
+
+
+def upgrade(migrate_engine):
+ print(__doc__)
+ metadata.bind = migrate_engine
+ metadata.reflect()
+
+ try:
+ psa_association.create()
+ psa_code.create()
+ psa_nonce.create()
+ psa_partial.create()
+ oidc_user_authnz_tokens.create()
+ except Exception as e:
+ log.exception("Creating OIDC table failed: %s" % str(e))
+
+
+def downgrade(migrate_engine):
+ metadata.bind = migrate_engine
+ metadata.reflect()
+
+ try:
+ psa_association.drop()
+ psa_code.drop()
+ psa_nonce.drop()
+ psa_partial.drop()
+ oidc_user_authnz_tokens.drop()
+ except Exception as e:
+ log.exception("Dropping OIDC table failed: %s" % str(e))
diff --git a/lib/galaxy/webapps/galaxy/buildapp.py b/lib/galaxy/webapps/galaxy/buildapp.py
index 0df0054cb8ef..a14865185b7a 100644
--- a/lib/galaxy/webapps/galaxy/buildapp.py
+++ b/lib/galaxy/webapps/galaxy/buildapp.py
@@ -66,6 +66,11 @@ def app_factory(global_conf, load_app_kwds={}, **kwargs):
# Force /activate to go to the controller
webapp.add_route('/activate', controller='user', action='activate')
+ # Authentication endpoints.
+ webapp.add_route('/authnz/{provider}/login', controller='authnz', action='login', provider=None)
+ webapp.add_route('/authnz/{provider}/callback', controller='authnz', action='callback', provider=None)
+ webapp.add_route('/authnz/{provider}/disconnect', controller='authnz', action='disconnect', provider=None)
+
# These two routes handle our simple needs at the moment
webapp.add_route('/async/{tool_id}/{data_id}/{data_secret}', controller='async', action='index', tool_id=None, data_id=None, data_secret=None)
webapp.add_route('/{controller}/{action}', action='index')
diff --git a/lib/galaxy/webapps/galaxy/config_schema.yml b/lib/galaxy/webapps/galaxy/config_schema.yml
index beb194ba5587..5e9b992a07ef 100644
--- a/lib/galaxy/webapps/galaxy/config_schema.yml
+++ b/lib/galaxy/webapps/galaxy/config_schema.yml
@@ -2170,6 +2170,27 @@ mapping:
desc: |
If OpenID is enabled, consumer cache directory to use.
+ enable_oidc:
+ type: bool
+ default: false
+ required: false
+ desc: |
+ Enables and disables OpenID Connect (OIDC) support.
+
+ oidc_config_file:
+ type: str
+ default: config/oidc_config.xml
+ required: false
+ desc: |
+ Sets the path to OIDC configuration file.
+
+ oidc_backends_config_file:
+ type: str
+ default: config/oidc_backends_config.xml
+ required: false
+ desc: |
+ Sets the path to OIDC backends configuration file.
+
auth_config_file:
type: str
default: config/auth_conf.xml
diff --git a/lib/galaxy/webapps/galaxy/controllers/authnz.py b/lib/galaxy/webapps/galaxy/controllers/authnz.py
new file mode 100644
index 000000000000..ee8aabf707cd
--- /dev/null
+++ b/lib/galaxy/webapps/galaxy/controllers/authnz.py
@@ -0,0 +1,82 @@
+"""
+OAuth 2.0 and OpenID Connect Authentication and Authorization Controller.
+"""
+
+from __future__ import absolute_import
+
+import logging
+
+from galaxy import web
+from galaxy.web import url_for
+from galaxy.web.base.controller import BaseUIController
+
+log = logging.getLogger(__name__)
+
+
+class OIDC(BaseUIController):
+
+ @web.expose
+ def login(self, trans, provider):
+ if not trans.app.config.enable_oidc:
+ msg = "Login to Galaxy using third-party identities is not enabled on this Galaxy instance."
+ log.debug(msg)
+ return trans.show_error_message(msg)
+ success, message, redirect_uri = trans.app.authnz_manager.authenticate(provider, trans)
+ return trans.response.send_redirect(web.url_for(redirect_uri))
+
+ @web.expose
+ def callback(self, trans, provider, **kwargs):
+ user = trans.user.username if trans.user is not None else 'anonymous'
+ if not bool(kwargs):
+ log.error("OIDC callback received no data for provider `{}` and user `{}`".format(provider, user))
+ return trans.show_error_message(
+ 'Did not receive any information from the `{}` identity provider to complete user `{}` authentication '
+ 'flow. Please try again, and if the problem persists, contact the Galaxy instance admin. Also note '
+ 'that this endpoint is to receive authentication callbacks only, and should not be called/reached by '
+ 'a user.'.format(provider, user))
+ if 'error' in kwargs:
+ log.error("Error handling authentication callback from `{}` identity provider for user `{}` login request."
+ " Error message: {}".format(provider, user, kwargs.get('error', 'None')))
+ return trans.show_error_message('Failed to handle authentication callback from {}. '
+ 'Please try again, and if the problem persists, contact '
+ 'the Galaxy instance admin'.format(provider))
+ success, message, (redirect_url, user) = trans.app.authnz_manager.callback(provider,
+ kwargs['state'],
+ kwargs['code'],
+ trans,
+ login_redirect_url=url_for('/'))
+ if success is False:
+ return trans.show_error_message(message)
+ user = user if user is not None else trans.user
+ if user is None:
+ return trans.show_error_message("An unknown error occurred when handling the callback from `{}` "
+ "identity provider. Please try again, and if the problem persists, "
+ "contact the Galaxy instance admin.".format(provider))
+ trans.handle_user_login(user)
+ return trans.fill_template('/user/login.mako',
+ login=user.username,
+ header="",
+ use_panels=False,
+ redirect_url=redirect_url,
+ redirect=redirect_url,
+ refresh_frames='refresh_frames',
+ message="You are now logged in as `{}.`".format(user.username),
+ status='done',
+ openid_providers=trans.app.openid_providers,
+ form_input_auto_focus=True,
+ active_view="user")
+
+ @web.expose
+ @web.require_login("authenticate against the selected identity provider")
+ def disconnect(self, trans, provider, **kwargs):
+ if trans.user is None:
+ # Only logged in users are allowed here.
+ return
+ success, message, redirect_url = trans.app.authnz_manager.disconnect(provider,
+ trans,
+ disconnect_redirect_url=url_for('/'))
+ if success is False:
+ return trans.show_error_message(message)
+ if redirect_url is None:
+ redirect_url = url_for('/')
+ return trans.response.send_redirect(redirect_url)
diff --git a/templates/user/login.mako b/templates/user/login.mako
index 027099392c0a..1090acff9607 100644
--- a/templates/user/login.mako
+++ b/templates/user/login.mako
@@ -48,6 +48,12 @@ def inherit(context):
${render_login_form()}
+
+ %if hasattr(trans.app.config, 'enable_oidc') and trans.app.config.enable_oidc:
+
+ ${render_oidc_form()}
+ %endif
+
%if trans.app.config.enable_openid:
${render_openid_form( redirect, False, openid_providers )}
@@ -100,6 +106,27 @@ def inherit(context):
%def>
+<%def name="render_oidc_form( form_action=None )">
+
+ <%
+ if form_action is None:
+ form_action = h.url_for( controller='authnz', action='login', provider='Google' )
+ %>
+
+ %if header:
+ ${header}
+ %endif
+
+
+%def>
+
<%def name="render_openid_form( redirect, auto_associate, openid_providers )">