Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement MultiOAuthenticator #459

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions oauthenticator/multiauthenticator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""
Custom Authenticator to use multiple OAuth providers with JupyterHub

Example of configuration:

from oauthenticator.github import GitHubOAuthenticator
from oauthenticator.google import GoogleOAuthenticator

c.MultiAuthenticator.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'
}),
(PAMAuthenticator, "/pam", {"service_name": "PAM"}),
]

c.JupyterHub.authenticator_class = 'oauthenticator.MultiAuthenticator.MultiAuthenticator'

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 URLScopeMixin(object):
"""Mixin class that adds the"""

scope = ""
Copy link

@Ph0tonic Ph0tonic Jul 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be renamed to avoid collision with scope variable defined in oauth2, like this, it breaks with gitlab.

Suggested change
scope = ""
url_scope = ""


def login_url(self, base_url):
return super().login_url(url_path_join(base_url, self.scope))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return super().login_url(url_path_join(base_url, self.scope))
return super().login_url(url_path_join(base_url, self.url_scope))


def logout_url(self, base_url):
return super().logout_url(url_path_join(base_url, self.scope))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return super().logout_url(url_path_join(base_url, self.scope))
return super().logout_url(url_path_join(base_url, self.url_scope))


def get_handlers(self, app):
handlers = super().get_handlers(app)
return [
(url_path_join(self.scope, path), handler) for path, handler in handlers

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
(url_path_join(self.scope, path), handler) for path, handler in handlers
(url_path_join(self.url_scope, path), handler) for path, handler in handlers

]


class MultiAuthenticator(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,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
url_scope,
url_scope_authenticator,

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")

class WrapperAuthenticator(URLScopeMixin, authenticator_klass):
scope = url_scope

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
scope = url_scope
url_scope = url_scope_authenticator


Copy link

@Ph0tonic Ph0tonic Jul 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The username conflict might also be managed in the wrapper by adding a prefix to the username.
I managed to make it work with the following code:

class WrapperAuthenticator(URLScopeMixin, authenticator_klass):
    scope = url_scope
    username_prefix = service_name+'_'
    
    async def authenticate(self, handler, data=None, **kwargs):
        response = await super().authenticate(handler, data, **kwargs)
        if response is None:
            return None
        elif type(response) == str:
            return self.username_prefix+response
        else:
            response['name'] = self.username_prefix+response['name']
            return response
    
    # def normalize_username(self, username):
    #     print("normalize username :",username, super().normalize_username(username))
    #     return self.username_prefix+super().normalize_username(username)
    
    def check_allowed(self, username, authentication=None):
        print("check allowed provided :",username)
        return super().check_allowed(username.removeprefix(self.username_prefix), authentication)
    
    def check_blocked_users(self, username, authentication=None):
        print("check blocked provided :",username)
        return super().check_allowed(username.removeprefix(self.username_prefix), authentication)

As you can see, I initially tried to add the prefix by overriding normalize_username but it appears to be called inside oauth2. This call is breaking the admin check done in oauth2:authorize. One drawback is the display of this username's prefix in the upper corner of jupyterhub.

service_name = authenticator_configuration.pop("service_name", None)
configuration.update(authenticator_configuration)

authenticator = WrapperAuthenticator(**configuration)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When providing additional config values for example (allowed_users) :

(GitHubOAuthenticator, '/github', {
  'service_name': 'github',
  'client_id': '...',
  'client_secret': '...',
  'allowed_users': {'rene.dumont'},
}),

Those values are not set in the created Authenticator. I am not sure for which reason. I found a way to fix this issue, see below but I am sure there is a better way linked with the way configuration is built.

Suggested change
traits = authenticator.traits()
for key, value in authenticator_configuration.items():
trait = traits.get(key, None)
if hasattr(authenticator, key) and trait and trait.this_class == Authenticator and trait.metadata.get('config', False):
setattr(authenticator, key, value)

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:
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"""
<div class="service-login">
<a role="button" class='btn btn-jupyter btn-lg' href='{url}'>
Sign in with {login_service}
</a>
</div>
"""
)
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.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

routes.append((path, WrapperHandler))
return routes
92 changes: 92 additions & 0 deletions oauthenticator/tests/test_multiauthenticator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Test module for the MultiAuthenticator class"""
from pytest import fixture

from ..github import GitHubOAuthenticator
from ..gitlab import GitLabOAuthenticator
from ..google import GoogleOAuthenticator
from ..multiauthenticator import MultiAuthenticator


@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):
MultiAuthenticator.authenticators = different_authenticators

authenticator = MultiAuthenticator()
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):
MultiAuthenticator.authenticators = same_authenticators

authenticator = MultiAuthenticator()
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}")