-
Notifications
You must be signed in to change notification settings - Fork 367
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 = "" | ||||||||||||||||||
|
||||||||||||||||||
def login_url(self, base_url): | ||||||||||||||||||
return super().login_url(url_path_join(base_url, self.scope)) | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
|
||||||||||||||||||
def logout_url(self, base_url): | ||||||||||||||||||
return super().logout_url(url_path_join(base_url, self.scope)) | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
|
||||||||||||||||||
def get_handlers(self, app): | ||||||||||||||||||
handlers = super().get_handlers(app) | ||||||||||||||||||
return [ | ||||||||||||||||||
(url_path_join(self.scope, path), handler) for path, handler in handlers | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
] | ||||||||||||||||||
|
||||||||||||||||||
|
||||||||||||||||||
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, | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
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 | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
|
||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. 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 |
||||||||||||||||||
service_name = authenticator_configuration.pop("service_name", None) | ||||||||||||||||||
configuration.update(authenticator_configuration) | ||||||||||||||||||
|
||||||||||||||||||
authenticator = WrapperAuthenticator(**configuration) | ||||||||||||||||||
|
||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When providing additional config values for example (
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
Suggested change
|
||||||||||||||||||
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 |
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}") |
There was a problem hiding this comment.
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 inoauth2
, like this, it breaks with gitlab.