From 6abc96fb07a204649ed2692c9f6a1ae77e29d870 Mon Sep 17 00:00:00 2001 From: Dannon Date: Tue, 28 Jan 2020 19:56:02 -0500 Subject: [PATCH] Merge pull request #9162 from machristie/idp-logout Add support for logging out of OIDC Identity Provider --- client/galaxy/scripts/layout/menu.js | 26 ++++++++++--- lib/galaxy/authnz/__init__.py | 13 +++++++ lib/galaxy/authnz/custos_authnz.py | 13 +++++++ lib/galaxy/authnz/managers.py | 37 +++++++++++++++++-- .../sample/oidc_backends_config.xml.sample | 2 + lib/galaxy/webapps/galaxy/buildapp.py | 3 ++ .../webapps/galaxy/controllers/authnz.py | 21 +++++++++++ test/unit/authnz/test_custos_authnz.py | 12 +++++- 8 files changed, 116 insertions(+), 11 deletions(-) diff --git a/client/galaxy/scripts/layout/menu.js b/client/galaxy/scripts/layout/menu.js index ff5c15ec4488..9c0ede4fab78 100644 --- a/client/galaxy/scripts/layout/menu.js +++ b/client/galaxy/scripts/layout/menu.js @@ -14,12 +14,26 @@ function logoutClick() { const galaxy = getGalaxyInstance(); const session_csrf_token = galaxy.session_csrf_token; const url = `${galaxy.root}user/logout?session_csrf_token=${session_csrf_token}`; - axios.get(url).then(() => { - if (galaxy.user) { - galaxy.user.clearSessionStorage(); - } - window.top.location.href = `${galaxy.root}root/login?is_logout_redirect=true`; - }); + axios + .get(url) + .then(() => { + if (galaxy.user) { + galaxy.user.clearSessionStorage(); + } + // Check if we need to logout of OIDC IDP + if (galaxy.config.enable_oidc) { + return axios.get(`${galaxy.root}authnz/logout`); + } else { + return {}; + } + }) + .then(response => { + if (response.data && response.data.redirect_uri) { + window.top.location.href = response.data.redirect_uri; + } else { + window.top.location.href = `${galaxy.root}root/login?is_logout_redirect=true`; + } + }); } const Collection = Backbone.Collection.extend({ diff --git a/lib/galaxy/authnz/__init__.py b/lib/galaxy/authnz/__init__.py index 6db25196df65..4b70484bc037 100644 --- a/lib/galaxy/authnz/__init__.py +++ b/lib/galaxy/authnz/__init__.py @@ -70,3 +70,16 @@ def callback(self, state_token, authz_code, trans, login_redirect_url): def disconnect(self, provider, trans, disconnect_redirect_url=None): raise NotImplementedError() + + def logout(self, trans, post_logout_redirect_url=None): + """ + Return a URL that will log the user out of the IDP. In OIDC this is + called the 'end_session_endpoint'. + + :type trans: GalaxyWebTransaction + :param trans: Galaxy web transaction. + + :type trans: string + :param trans: Optional URL to redirect to after logging out of IDP. + """ + raise NotImplementedError() diff --git a/lib/galaxy/authnz/custos_authnz.py b/lib/galaxy/authnz/custos_authnz.py index 1d5b5b17bc0e..165f6a5615a1 100644 --- a/lib/galaxy/authnz/custos_authnz.py +++ b/lib/galaxy/authnz/custos_authnz.py @@ -9,6 +9,7 @@ import requests from oauthlib.common import generate_nonce from requests_oauthlib import OAuth2Session +from six.moves.urllib.parse import quote from galaxy import util from galaxy.model import CustosAuthnzToken, User @@ -39,6 +40,7 @@ def __init__(self, provider, oidc_config, oidc_backend_config): self.config['authorization_endpoint'] = well_known_oidc_config['authorization_endpoint'] 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['end_session_endpoint'] else: realm = oidc_backend_config['realm'] self._load_config_for_provider_and_realm(self.config['provider'], realm) @@ -142,6 +144,16 @@ def disconnect(self, provider, trans, disconnect_redirect_url=None): except Exception as e: return False, "Failed to disconnect provider {}: {}".format(provider, util.unicodify(e)), None + def logout(self, trans, post_logout_redirect_url=None): + try: + redirect_url = self.config['end_session_endpoint'] + if post_logout_redirect_url is not None: + redirect_url += "?redirect_uri={}".format(quote(post_logout_redirect_url)) + return redirect_url + except Exception as e: + log.error("Failed to generate logout redirect_url", exc_info=e) + return None + def _create_oauth2_session(self, state=None, scope=None): client_id = self.config['client_id'] redirect_uri = self.config['redirect_uri'] @@ -191,6 +203,7 @@ def _load_config_for_provider_and_realm(self, provider, realm): self.config['authorization_endpoint'] = well_known_oidc_config['authorization_endpoint'] 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['end_session_endpoint'] def _get_well_known_uri_for_provider_and_realm(self, provider, realm): # TODO: Look up this URL from a Python library diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py index b2bcbd94953b..1876bdb82bd0 100644 --- a/lib/galaxy/authnz/managers.py +++ b/lib/galaxy/authnz/managers.py @@ -16,7 +16,7 @@ from galaxy import exceptions from galaxy import model -from galaxy.util import string_as_bool +from galaxy.util import asbool, string_as_bool from galaxy.util import unicodify from .custos_authnz import CustosAuthnz from .psa_authnz import ( @@ -117,7 +117,8 @@ def _parse_idp_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} + 'redirect_uri': config_xml.find('redirect_uri').text, + 'enable_idp_logout': asbool(config_xml.findtext('enable_idp_logout', 'false'))} if config_xml.find('prompt') is not None: rtv['prompt'] = config_xml.find('prompt').text return rtv @@ -128,7 +129,8 @@ def _parse_custos_config(self, config_xml): 'client_id': config_xml.find('client_id').text, 'client_secret': config_xml.find('client_secret').text, 'redirect_uri': config_xml.find('redirect_uri').text, - 'realm': config_xml.find('realm').text} + 'realm': config_xml.find('realm').text, + 'enable_idp_logout': asbool(config_xml.findtext('enable_idp_logout', 'false'))} if config_xml.find('well_known_oidc_config_uri') is not None: rtv['well_known_oidc_config_uri'] = config_xml.find('well_known_oidc_config_uri').text if config_xml.find('idphint') is not None: @@ -262,6 +264,35 @@ def callback(self, provider, state_token, authz_code, trans, login_redirect_url) log.exception(msg) return False, msg, (None, None) + def logout(self, provider, trans, post_logout_redirect_url=None): + """ + Log the user out of the identity provider. + + :type provider: string + :param provider: set the name of the identity provider. + :type trans: GalaxyWebTransaction + :param trans: Galaxy web transaction. + :type post_logout_redirect_url: string + :param post_logout_redirect_url: (Optional) URL for identity provider + to redirect to after logging user out. + :return: a tuple (success boolean, message, redirect URI) + """ + try: + # check if logout is enabled for this idp and return false if not + unified_provider_name = self._unify_provider_name(provider) + if self.oidc_backends_config[unified_provider_name]['enable_idp_logout'] is False: + return False, "IDP logout is not enabled for {}".format(provider), None + + success, message, backend = self._get_authnz_backend(provider) + if success is False: + return False, message, None + return True, message, backend.logout(trans, post_logout_redirect_url) + except Exception as e: + msg = 'The following error occurred when logging out from `{}` identity provider: ' \ + '{}'.format(provider, e.message) + log.exception(msg) + return False, msg, None + def disconnect(self, provider, trans, disconnect_redirect_url=None): try: success, message, backend = self._get_authnz_backend(provider) diff --git a/lib/galaxy/config/sample/oidc_backends_config.xml.sample b/lib/galaxy/config/sample/oidc_backends_config.xml.sample index 90b157b16320..6cb60e611936 100644 --- a/lib/galaxy/config/sample/oidc_backends_config.xml.sample +++ b/lib/galaxy/config/sample/oidc_backends_config.xml.sample @@ -99,6 +99,8 @@ Please mind `http` and `https`. + + diff --git a/lib/galaxy/webapps/galaxy/buildapp.py b/lib/galaxy/webapps/galaxy/buildapp.py index 76705a46079d..7db22fe0fe1d 100644 --- a/lib/galaxy/webapps/galaxy/buildapp.py +++ b/lib/galaxy/webapps/galaxy/buildapp.py @@ -72,6 +72,9 @@ def app_factory(global_conf, load_app_kwds={}, **kwargs): 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) + webapp.add_route('/authnz/{provider}/logout', controller='authnz', action='logout', provider=None) + # Returns the provider specific logout url for currently logged in provider + webapp.add_route('/authnz/logout', controller='authnz', action='get_logout_url') # 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) diff --git a/lib/galaxy/webapps/galaxy/controllers/authnz.py b/lib/galaxy/webapps/galaxy/controllers/authnz.py index 70e68d4098a4..ad4f34435f52 100644 --- a/lib/galaxy/webapps/galaxy/controllers/authnz.py +++ b/lib/galaxy/webapps/galaxy/controllers/authnz.py @@ -13,6 +13,8 @@ log = logging.getLogger(__name__) +PROVIDER_COOKIE_NAME = 'oidc-provider' + class OIDC(JSAppLauncher): @@ -79,6 +81,8 @@ def callback(self, trans, provider, **kwargs): "identity provider. Please try again, and if the problem persists, " "contact the Galaxy instance admin.".format(provider)) trans.handle_user_login(user) + # Record which idp provider was logged into, so we can logout of it later + trans.set_cookie(value=provider, name=PROVIDER_COOKIE_NAME) return trans.response.send_redirect(url_for('/')) @web.expose @@ -95,3 +99,20 @@ def disconnect(self, trans, provider, **kwargs): if redirect_url is None: redirect_url = url_for('/') return trans.response.send_redirect(redirect_url) + + @web.json + def logout(self, trans, provider, **kwargs): + post_logout_redirect_url = trans.request.base + url_for('/') + 'root/login?is_logout_redirect=true' + success, message, redirect_uri = trans.app.authnz_manager.logout(provider, + trans, + post_logout_redirect_url=post_logout_redirect_url) + if success: + return {'redirect_uri': redirect_uri} + else: + return {'message': message} + + @web.expose + def get_logout_url(self, trans, **kwargs): + idp_provider = trans.get_cookie(name=PROVIDER_COOKIE_NAME) + if idp_provider: + return trans.response.send_redirect(url_for(controller='authnz', action='logout', provider=idp_provider)) diff --git a/test/unit/authnz/test_custos_authnz.py b/test/unit/authnz/test_custos_authnz.py index 2166a86ed224..6d3d8cfc26d5 100644 --- a/test/unit/authnz/test_custos_authnz.py +++ b/test/unit/authnz/test_custos_authnz.py @@ -6,7 +6,7 @@ import jwt import requests -from six.moves.urllib.parse import parse_qs, urlparse +from six.moves.urllib.parse import parse_qs, quote, urlparse from galaxy.authnz import custos_authnz from galaxy.model import CustosAuthnzToken, User @@ -33,7 +33,8 @@ def setUp(self): requests.get = self.mockRequest(self._get_idp_url(), { "authorization_endpoint": "https://test-auth-endpoint", "token_endpoint": "https://test-token-endpoint", - "userinfo_endpoint": "https://test-userinfo-endpoint" + "userinfo_endpoint": "https://test-userinfo-endpoint", + "end_session_endpoint": "https://test-end-session-endpoint" }) self.custos_authnz = custos_authnz.CustosAuthnz('Custos', { 'VERIFY_SSL': True @@ -476,3 +477,10 @@ def test_disconnect_when_more_than_one_associated_token_for_provider(self): self.assertFalse(success) self.assertNotEqual("", message) self.assertIsNone(redirect_uri) + + def test_logout_with_redirect(self): + + logout_redirect_url = "http://localhost:8080/post-logout" + redirect_url = self.custos_authnz.logout(self.trans, logout_redirect_url) + + self.assertEqual(redirect_url, "https://test-end-session-endpoint?redirect_uri=" + quote(logout_redirect_url))