From 035b0575aa87835ef9a0fb9751fb967124065128 Mon Sep 17 00:00:00 2001 From: Samuel Gaist Date: Thu, 23 Sep 2021 15:04:01 +0200 Subject: [PATCH 1/3] Implement MultiOAuthenticator This authenticator allows to use several different services to authenticate to one instance of JupyterHub. --- oauthenticator/multioauthenticator.py | 95 +++++++++++++++++++ .../tests/test_multioauthenticator.py | 92 ++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 oauthenticator/multioauthenticator.py create mode 100644 oauthenticator/tests/test_multioauthenticator.py diff --git a/oauthenticator/multioauthenticator.py b/oauthenticator/multioauthenticator.py new file mode 100644 index 00000000..2d788163 --- /dev/null +++ b/oauthenticator/multioauthenticator.py @@ -0,0 +1,95 @@ +""" +Custom Authenticator to use multiple OAuth providers with JupyterHub + +Example of configuration: + + from oauthenticator.github import GitHubOAuthenticator + from oauthenticator.google import GoogleOAuthenticator + + c.MultiOAuthenticator.authenticators = [ + (GitHubOAuthenticator, '/github', { + 'client_id': 'xxxx', + 'client_secret': 'xxxx', + 'oauth_callback_url': 'http://example.com/hub/github/oauth_callback' + }), + (GoogleOAuthenticator, '/google', { + 'client_id': 'xxxx', + 'client_secret': 'xxxx', + 'oauth_callback_url': 'http://example.com/hub/google/oauth_callback' + }) + ] + + c.JupyterHub.authenticator_class = 'oauthenticator.multioauthenticator.MultiOAuthenticator' + +The same Authenticator class can be used several to support different providers. + +""" +from jupyterhub.auth import Authenticator +from jupyterhub.utils import url_path_join +from traitlets import List + + +class MultiOAuthenticator(Authenticator): + """Wrapper class that allows to use more than one authentication provider + for JupyterHub""" + + authenticators = List(help="The subauthenticators to use", config=True) + + def __init__(self, *arg, **kwargs): + super().__init__(*arg, **kwargs) + self._authenticators = [] + for ( + authenticator_klass, + url_scope, + authenticator_configuration, + ) in self.authenticators: + configuration = self.trait_values() + # Remove this one as it will overwrite the value if the authenticator_klass + # makes it configurable and the default value is used (take a look at + # GoogleOAuthenticator for example). + configuration.pop("login_service") + configuration.update(authenticator_configuration) + self._authenticators.append( + { + "instance": authenticator_klass(**configuration), + "url_scope": url_scope, + } + ) + + def get_custom_html(self, base_url): + """Re-implementation generating one login button per configured authenticator""" + + html = [] + for authenticator in self._authenticators: + login_service = authenticator["instance"].login_service + url = url_path_join(base_url, authenticator["url_scope"], "oauth_login") + + html.append( + f""" +
+ + Sign in with {login_service} + +
+ """ + ) + return "\n".join(html) + + def get_handlers(self, app): + """Re-implementation that will return the handlers for all configured + authenticators""" + + routes = [] + for _authenticator in self._authenticators: + for path, handler in _authenticator["instance"].get_handlers(app): + + class WrapperHandler(handler): + """'Real' handler configured for each authenticator. This allows + to reuse the same authenticator class configured for different + services (for example GitLab.com, gitlab.example.com) + """ + + authenticator = _authenticator["instance"] + + routes.append((f'{_authenticator["url_scope"]}{path}', WrapperHandler)) + return routes diff --git a/oauthenticator/tests/test_multioauthenticator.py b/oauthenticator/tests/test_multioauthenticator.py new file mode 100644 index 00000000..e06ab087 --- /dev/null +++ b/oauthenticator/tests/test_multioauthenticator.py @@ -0,0 +1,92 @@ +"""Test module for the MultiOAuthenticator class""" +from pytest import fixture + +from ..github import GitHubOAuthenticator +from ..gitlab import GitLabOAuthenticator +from ..google import GoogleOAuthenticator +from ..multioauthenticator import MultiOAuthenticator + + +@fixture +def different_authenticators(): + return [ + ( + GitLabOAuthenticator, + "/gitlab", + { + "client_id": "xxxx", + "client_secret": "xxxx", + "oauth_callback_url": "http://example.com/hub/gitlab/oauth_callback", + }, + ), + ( + GitHubOAuthenticator, + "/github", + { + "client_id": "xxxx", + "client_secret": "xxxx", + "oauth_callback_url": "http://example.com/hub/github/oauth_callback", + }, + ), + ] + + +@fixture +def same_authenticators(): + return [ + ( + GoogleOAuthenticator, + "/mygoogle", + { + "login_service": "My Google", + "client_id": "yyyyy", + "client_secret": "yyyyy", + "oauth_callback_url": "http://example.com/hub/mygoogle/oauth_callback", + }, + ), + ( + GoogleOAuthenticator, + "/othergoogle", + { + "login_service": "Other Google", + "client_id": "xxxx", + "client_secret": "xxxx", + "oauth_callback_url": "http://example.com/hub/othergoogle/oauth_callback", + }, + ), + ] + + +def test_different_authenticators(different_authenticators): + MultiOAuthenticator.authenticators = different_authenticators + + authenticator = MultiOAuthenticator() + assert len(authenticator._authenticators) == 2 + + handlers = authenticator.get_handlers("") + assert len(handlers) == 6 + for path, handler in handlers: + if "gitlab" in path: + assert isinstance(handler.authenticator, GitLabOAuthenticator) + elif "github" in path: + assert isinstance(handler.authenticator, GitHubOAuthenticator) + else: + raise ValueError(f"Unknown path: {path}") + + +def test_same_authenticators(same_authenticators): + MultiOAuthenticator.authenticators = same_authenticators + + authenticator = MultiOAuthenticator() + assert len(authenticator._authenticators) == 2 + + handlers = authenticator.get_handlers("") + assert len(handlers) == 6 + for path, handler in handlers: + assert isinstance(handler.authenticator, GoogleOAuthenticator) + if "mygoogle" in path: + assert handler.authenticator.login_service == "My Google" + elif "othergoogle" in path: + assert handler.authenticator.login_service == "Other Google" + else: + raise ValueError(f"Unknown path: {path}") From d1f05e561c2a9cfe8c5105741a8e02f9d942c64a Mon Sep 17 00:00:00 2001 From: Samuel Gaist Date: Fri, 24 Sep 2021 14:59:46 +0200 Subject: [PATCH 2/3] Refactor MultiOAuthenticator to properly support standard Authenticator as well --- oauthenticator/multioauthenticator.py | 53 +++++++++++++++++++++------ 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/oauthenticator/multioauthenticator.py b/oauthenticator/multioauthenticator.py index 2d788163..5c37fd11 100644 --- a/oauthenticator/multioauthenticator.py +++ b/oauthenticator/multioauthenticator.py @@ -16,7 +16,8 @@ 'client_id': 'xxxx', 'client_secret': 'xxxx', 'oauth_callback_url': 'http://example.com/hub/google/oauth_callback' - }) + }), + (PAMAuthenticator, "/pam", {"service_name": "PAM"}), ] c.JupyterHub.authenticator_class = 'oauthenticator.multioauthenticator.MultiOAuthenticator' @@ -29,6 +30,24 @@ from traitlets import List +class URLScopeMixin(object): + """Mixin class that adds the""" + + scope = "" + + def login_url(self, base_url): + return super().login_url(url_path_join(base_url, self.scope)) + + def logout_url(self, base_url): + return super().logout_url(url_path_join(base_url, self.scope)) + + def get_handlers(self, app): + handlers = super().get_handlers(app) + return [ + (url_path_join(self.scope, path), handler) for path, handler in handlers + ] + + class MultiOAuthenticator(Authenticator): """Wrapper class that allows to use more than one authentication provider for JupyterHub""" @@ -48,21 +67,31 @@ def __init__(self, *arg, **kwargs): # makes it configurable and the default value is used (take a look at # GoogleOAuthenticator for example). configuration.pop("login_service") + + class WrapperAuthenticator(URLScopeMixin, authenticator_klass): + scope = url_scope + + service_name = authenticator_configuration.pop("service_name", None) configuration.update(authenticator_configuration) - self._authenticators.append( - { - "instance": authenticator_klass(**configuration), - "url_scope": url_scope, - } - ) + + authenticator = WrapperAuthenticator(**configuration) + + if service_name: + authenticator.service_name = service_name + + self._authenticators.append(authenticator) def get_custom_html(self, base_url): """Re-implementation generating one login button per configured authenticator""" html = [] for authenticator in self._authenticators: - login_service = authenticator["instance"].login_service - url = url_path_join(base_url, authenticator["url_scope"], "oauth_login") + if hasattr(authenticator, "service_name"): + login_service = getattr(authenticator, "service_name") + else: + login_service = authenticator.login_service + + url = authenticator.login_url(base_url) html.append( f""" @@ -81,7 +110,7 @@ def get_handlers(self, app): routes = [] for _authenticator in self._authenticators: - for path, handler in _authenticator["instance"].get_handlers(app): + for path, handler in _authenticator.get_handlers(app): class WrapperHandler(handler): """'Real' handler configured for each authenticator. This allows @@ -89,7 +118,7 @@ class WrapperHandler(handler): services (for example GitLab.com, gitlab.example.com) """ - authenticator = _authenticator["instance"] + authenticator = _authenticator - routes.append((f'{_authenticator["url_scope"]}{path}', WrapperHandler)) + routes.append((path, WrapperHandler)) return routes From 9ddd1b24eb856771af519eab88b5934e289a523a Mon Sep 17 00:00:00 2001 From: Samuel Gaist Date: Fri, 24 Sep 2021 16:52:00 +0200 Subject: [PATCH 3/3] Renamed MultiOAuthenticator to MultiAuthenticator --- ...{multioauthenticator.py => multiauthenticator.py} | 6 +++--- ...ioauthenticator.py => test_multiauthenticator.py} | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) rename oauthenticator/{multioauthenticator.py => multiauthenticator.py} (95%) rename oauthenticator/tests/{test_multioauthenticator.py => test_multiauthenticator.py} (88%) diff --git a/oauthenticator/multioauthenticator.py b/oauthenticator/multiauthenticator.py similarity index 95% rename from oauthenticator/multioauthenticator.py rename to oauthenticator/multiauthenticator.py index 5c37fd11..847568d8 100644 --- a/oauthenticator/multioauthenticator.py +++ b/oauthenticator/multiauthenticator.py @@ -6,7 +6,7 @@ from oauthenticator.github import GitHubOAuthenticator from oauthenticator.google import GoogleOAuthenticator - c.MultiOAuthenticator.authenticators = [ + c.MultiAuthenticator.authenticators = [ (GitHubOAuthenticator, '/github', { 'client_id': 'xxxx', 'client_secret': 'xxxx', @@ -20,7 +20,7 @@ (PAMAuthenticator, "/pam", {"service_name": "PAM"}), ] - c.JupyterHub.authenticator_class = 'oauthenticator.multioauthenticator.MultiOAuthenticator' + c.JupyterHub.authenticator_class = 'oauthenticator.MultiAuthenticator.MultiAuthenticator' The same Authenticator class can be used several to support different providers. @@ -48,7 +48,7 @@ def get_handlers(self, app): ] -class MultiOAuthenticator(Authenticator): +class MultiAuthenticator(Authenticator): """Wrapper class that allows to use more than one authentication provider for JupyterHub""" diff --git a/oauthenticator/tests/test_multioauthenticator.py b/oauthenticator/tests/test_multiauthenticator.py similarity index 88% rename from oauthenticator/tests/test_multioauthenticator.py rename to oauthenticator/tests/test_multiauthenticator.py index e06ab087..f7e69865 100644 --- a/oauthenticator/tests/test_multioauthenticator.py +++ b/oauthenticator/tests/test_multiauthenticator.py @@ -1,10 +1,10 @@ -"""Test module for the MultiOAuthenticator class""" +"""Test module for the MultiAuthenticator class""" from pytest import fixture from ..github import GitHubOAuthenticator from ..gitlab import GitLabOAuthenticator from ..google import GoogleOAuthenticator -from ..multioauthenticator import MultiOAuthenticator +from ..multiauthenticator import MultiAuthenticator @fixture @@ -58,9 +58,9 @@ def same_authenticators(): def test_different_authenticators(different_authenticators): - MultiOAuthenticator.authenticators = different_authenticators + MultiAuthenticator.authenticators = different_authenticators - authenticator = MultiOAuthenticator() + authenticator = MultiAuthenticator() assert len(authenticator._authenticators) == 2 handlers = authenticator.get_handlers("") @@ -75,9 +75,9 @@ def test_different_authenticators(different_authenticators): def test_same_authenticators(same_authenticators): - MultiOAuthenticator.authenticators = same_authenticators + MultiAuthenticator.authenticators = same_authenticators - authenticator = MultiOAuthenticator() + authenticator = MultiAuthenticator() assert len(authenticator._authenticators) == 2 handlers = authenticator.get_handlers("")