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

Would it be possible to have multiple authentication methods simultaneously? #136

Closed
stefanonicotri opened this issue Oct 13, 2017 · 31 comments

Comments

@stefanonicotri
Copy link

Hi,
is it possible to setup multiple authentication methods simultaneously?
For example, I would like my JupyterHub instance to have Google, GitHub and Local authentication method on the same login page: is it possible?
Many thanks

@minrk
Copy link
Member

minrk commented Oct 13, 2017

This should be possible, but it is not ready to go at the moment. A MultiAuthenticator could have various sub-authenticators and dispatch to them. The trick would be hooking up the various URLs needed.

@stefanonicotri
Copy link
Author

Thank you very much for your suggestion :)

@zonca
Copy link
Contributor

zonca commented Oct 15, 2017

I would like to have this feature as well, even just for the authentication classes in oauthenticator...I don't have time right now to work on this myself, but I could be a beta-tester! ;-)

@jm2004
Copy link

jm2004 commented Oct 24, 2017

Same here. Our project also needs more than one ways to do the authentication in different situations.
I am now trying to implement my own. But it definitely great if we have this MultiAuthenticator.

Thanks!

@cmseal
Copy link

cmseal commented Jul 13, 2018

I'd like to second this... We use FreeIPA for our team, and LDAP works fine there, but our parent company uses Google Apps. Being able to auth with either of these would be awesome. I'm looking at how it might work, but has anyone else made any progress with this? Cheers!

@betatim
Copy link
Member

betatim commented Jul 13, 2018

Might be worth checking out https://www.keycloak.org/ as well as investigating a MultiAuthenticator.

@ablekh
Copy link

ablekh commented Feb 21, 2019

In addition to Keycloak, there is also Gluu (http://gluu.org). Also, "a quick and ready-to-use solution off the top of my head seems to be using JupyterHub Globus authenticator, which offers Globus, OpenID, Google, SAML/CILogon and E-mail authentication in a single package. Unfortunately, it is far from ideal due to missing GitHub/GitLab integration and adding Globus infrastructure dependency" (from my post on Gitter last night).

@betatim For MultiAuthenticator, are you talking about this code: https://gist.github.com/danizen/78111676530738fcbca8d8ad87c56690?

@cjreyn
Copy link

cjreyn commented Jul 12, 2019

+1 for this

@zhiyuli
Copy link

zhiyuli commented Sep 16, 2019

@stefanonicotri

we extended the snippet @ablekh shared above
hope this helps
https://github.com/zhiyuli/oauthenticator/tree/hydroshare/examples/multi_oauth

@ablekh
Copy link

ablekh commented Sep 16, 2019

@zhiyuli Nice work, though a more detailed README would be appreciated. However, it seems that your approach only covers OAuth-based authenticators, whereas the original code (that I have linked above) is more generic, allowing for any authenticators (assuming it's relevantly updated). Am I correct on this?

@zhiyuli
Copy link

zhiyuli commented Sep 16, 2019

@zhiyuli Nice work, though a more detailed README would be appreciated. However, it seems that your approach only covers OAuth-based authenticators, whereas the original code (that I have linked above) is more generic, allowing for any authenticators (assuming it's relevantly updated). Am I correct on this?

@ablekh sorry for the rushed readme. For the project I am working on, we only need to use OAuthenticators for now... and Yes, the snippet you shared seems to support any type of authenticator as the basic idea is to relay the call to the actual authenticators underneath the multiauthenticator.

@ablekh
Copy link

ablekh commented Sep 16, 2019

@zhiyuli No need to apologize - your work is much appreciated (but please ping me, if you update the description). Thank you for prompt clarifications. I just wanted to make sure that my understanding of your code is correct and I'm not missing anything relevant. I still plan to further review both the original and your code for better understanding. Thank you!

@zhiyuli
Copy link

zhiyuli commented Oct 4, 2019

@ablekh enriched the readme a little bit
https://github.com/zhiyuli/oauthenticator/blob/hydroshare/examples/multi_oauth/README.md

@ablekh
Copy link

ablekh commented Oct 4, 2019

@zhiyuli Thank you very much for letting me know. Nice work. I still think that using Keycloak would be the optimal approach, since (based on my limited research) it is both comprehensive and flexible. I still plan to explore this path further. Have you had a chance to look at / play with / explore Keycloak?

@louis-she
Copy link

louis-she commented Dec 26, 2019

I got this work with the latest jupyterhub, not sure if it is the right way to do it but it just worked for me.

from traitlets import List
from jupyterhub.auth import Authenticator

class PackedAuthenticator(Authenticator):
    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, configs in self.authenticators:
            self._authenticators.append({
                'instance': authenticator_klass(**configs),
                'url_scope':  url_scope
            })

    async def authenticate(self, handler, data):
        """Using the url of the request to decide which authenticator
        is responsible for this task.
        """
        return self._get_responsible_authenticator(handler).authenticate(handler, data)

    def get_callback_url(self, handler):
        return self._get_responsible_authenticator(handler).get_callback_url()

    def _get_responsible_authenticator(self, handler):
        responsible_authenticator = None
        for authenticator in self._authenticators:
            if handler.request.path.find(authenticator['url_scope']) != -1:
                responsible_authenticator = authenticator
                break
        return responsible_authenticator['instance']

    def get_handlers(self, app):
        routes = []
        for authenticator in self._authenticators:
            handlers = authenticator['instance'].get_handlers(app)
            handlers = list(map(lambda route: (f'{authenticator["url_scope"]}{route[0]}', route[1]), handlers))
            for path, handler in handlers:
                setattr(handler, 'authenticator', authenticator['instance'])
            routes.extend(handlers)
        return routes

And used it like:

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


c.PackedAuthenticator.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 = PackedAuthenticator

Now, using the following path will redirect you to the right login page:

/hub/github/oauth_login    # for github login
/hub/google/oauth_login  # for google login

@ablekh
Copy link

ablekh commented Dec 26, 2019

@louis-she That's awesome! Thank you very much for sharing. I will give it a try when I get a chance. By the way, unless I'm missing something, I'm not seeing any code responsible for displaying relevant UI options (as buttons) for a user. How did you handle that? Please clarify.

@louis-she
Copy link

louis-she commented Dec 27, 2019

Currently I'm only using the OAuth authenticator(in the above case, Github and Google), so there is no new pages(the login page is provided by Google and Github).

But the login button (which may be just a <a> tag) I haven't put it there. If use my configuration, the path of /hub/github/oauth_login will redirect you to the Github login page, and /hub/google/oauth_login will redirect to the Google login page. ( Already added to my previous comment)

I just tested the code with Github and Google yet, not sure if the others Authenticator will work. But the PackedAuthenticator is just a wrapper of other authenticator, I think it should be easy to play up with the other authenticators, even for the normal one like username - password.

@ablekh
Copy link

ablekh commented Dec 27, 2019

@louis-she I appreciate your clarifications and updates. I understand that OAuth flow redirects to relevant IdP providers. My confusion is about whether your approach is compatible with JupyterHub's default login page (/hub/login), where, for the multiple authentication case, I would expect a presentation of multiple buttons Login with <<IdP>> on that same page. So, it is not clear to me whether the above-mentioned default login page is used in your approach.

@louis-she
Copy link

louis-she commented Dec 28, 2019

The code above just added the routes and handlers. I didn't change any frontend code of jupyterhub. I didn't use the default login page of jupyterhub in my project( I write my own ), so the buttons are in my own login page.

@ablekh
Copy link

ablekh commented Dec 28, 2019

@louis-she Understood. Thank you for clarifying. And Happy New Year! :-)

@manics
Copy link
Member

manics commented Oct 13, 2020

I'm closing this as a multi-authenticator wouldn't be specific to oauthenticator. Please feel free to continue discussion on the Jupyter Community Forum. Thanks!

@manics manics closed this as completed Oct 13, 2020
@meeseeksmachine
Copy link

This issue has been mentioned on Jupyter Community Forum. There might be relevant details there:

https://discourse.jupyter.org/t/multiple-authentication-sources-for-zero-to-jupyterhub/6461/2

@nijisakai
Copy link

I got this work with the latest jupyterhub, not sure if it is the right way to do it but it just worked for me.

from traitlets import List
from jupyterhub.auth import Authenticator

class PackedAuthenticator(Authenticator):
    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, configs in self.authenticators:
            self._authenticators.append({
                'instance': authenticator_klass(**configs),
                'url_scope':  url_scope
            })

    async def authenticate(self, handler, data):
        """Using the url of the request to decide which authenticator
        is responsible for this task.
        """
        return self._get_responsible_authenticator(handler).authenticate(handler, data)

    def get_callback_url(self, handler):
        return self._get_responsible_authenticator(handler).get_callback_url()

    def _get_responsible_authenticator(self, handler):
        responsible_authenticator = None
        for authenticator in self._authenticators:
            if handler.request.path.find(authenticator['url_scope']) != -1:
                responsible_authenticator = authenticator
                break
        return responsible_authenticator['instance']

    def get_handlers(self, app):
        routes = []
        for authenticator in self._authenticators:
            handlers = authenticator['instance'].get_handlers(app)
            handlers = list(map(lambda route: (f'{authenticator["url_scope"]}{route[0]}', route[1]), handlers))
            for path, handler in handlers:
                setattr(handler, 'authenticator', authenticator['instance'])
            routes.extend(handlers)
        return routes

And used it like:

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


c.PackedAuthenticator.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 = PackedAuthenticator

Now, using the following path will redirect you to the right login page:

/hub/github/oauth_login    # for github login
/hub/google/oauth_login  # for google login

Could you tell me where to put the first part of the code please?
I put them all to jupyterhub_config.py and both link (/hub/github/oauth_login # for github login
/hub/google/oauth_login # for google login) goes to google.
Then I change part two to

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


c.PackedAuthenticator.authenticators = [
    (GitHubOAuthenticator, '/google', {
        'client_id': 'xxxx',
        'client_secret': 'xxxx',
        'oauth_callback_url': 'http://example.com/hub/google/oauth_callback'
    }),
    (GoogleOAuthenticator, '/github', {
        'client_id': 'xxxx',
        'client_secret': 'xxxx',
        'oauth_callback_url': 'http://example.com/hub/github/oauth_callback'
    })
]
c.JupyterHub.authenticator_class = PackedAuthenticator

and every link goes to github.

@rkevin-arch
Copy link
Contributor

Re: @nijisakai, I had a similar issue. The problem is both Google and Github's authenticator use the exact same handlers (OAuthLoginHandler and OAuthCallbackHandler, both in oauthenticator.oauth2). Both OAuthenticator's get_handlers return the same underlying classes, so your setattr affects the same object, and the last setattr is the one that persists. To fix this issue, I subclassed whatever I got from the authenticator. Something like this:

    def get_handlers(self, app):
        routes = []
        for authenticator in self._authenticators:
            handlers = []
            for path, handler in authenticator['instance'].get_handlers(app):
                class SubHandler(handler):
                    authenticator = authenticator['instance']
                handlers.append((f'{authenticator["url_scope"]}{path}', SubHandler))
            routes.extend(handlers)
        return routes

(untested code, I'm doing something a bit different, but the logic should be the same). Hopefully this helps.

@rkevin-arch
Copy link
Contributor

Just an update on the code written by @ louis-she, it has a couple of weird quirks that others might not expect. Other than the problem above, the PackedAuthenticator.authenticate function actually never gets called. The get_handlers function is called and returns some handlers, but when you visit for example /google/login, it calls the handler for Google, and that handler.authenticator is the authenticator instance for Google, not the PackedAuthenticator. All the code in JupyterHub after that point operate on handler.authenticator, so you're calling authenticate and other functions on the GoogleOAuthenticator instance rather than on the PackedAuthenticator.

This is especially a problem if you want to override functions and just do it in PackedAuthenticator and assume it will work. (I spent a couple of hours getting extremely confused why my functions aren't called before doing a deep dive in JupyterHub's code and finding this quirk.) I wrote a horrible, horrible solution that attempts to fix this problem:

class HorribleObjectAmalgam:
    def __init__(self, base, override):
        self.__base = base
        self.__override = override
   def __getattr__(self, name):
        try:
            return getattr(self.__override, name)
        except AttributeError:
            return getattr(self.__base, name)

And later, in get_handlers, instead of doing authenticator = authenticator['instance'] like the above comment, I did authenticator = HorribleObjectAmalgam(authenticator['instalce'], self). I feel dirty after writing that code, but this seems like the easiest solution, especially if you want to override other functions in the authenticator.

@dtaniwaki
Copy link
Contributor

dtaniwaki commented Dec 23, 2020

Thank you @louis-she @rkevin-arch

Now, I made it work with the following code.

class MultiOAuthenticator(Authenticator):
    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, configs in self.authenticators:
            c = self.trait_values()
            c.update(configs)
            self._authenticators.append({"instance": authenticator_klass(**c), "url_scope": url_scope})

    def get_custom_html(self, base_url):
        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"""
                <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):
        routes = []
        for _authenticator in self._authenticators:
            for path, handler in _authenticator["instance"].get_handlers(app):

                class SubHandler(handler):
                    authenticator = _authenticator["instance"]

                routes.append((f'{_authenticator["url_scope"]}{path}', SubHandler))
        return routes

In jupyterhub_config.py,

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


c.MultiOAuthenticator.authenticators = [
    (GitHubOAuthenticator, '/google', {
        'client_id': 'xxxx',
        'client_secret': 'xxxx',
        'oauth_callback_url': 'http://example.com/hub/google/oauth_callback'
    }),
    (GoogleOAuthenticator, '/github', {
        'client_id': 'xxxx',
        'client_secret': 'xxxx',
        'oauth_callback_url': 'http://example.com/hub/github/oauth_callback'
    })
]
c.JupyterHub.authenticator_class = MultiOAuthenticator

This code works w/ my PR of JupyterHub.

@meeseeksmachine
Copy link

This issue has been mentioned on Jupyter Community Forum. There might be relevant details there:

https://discourse.jupyter.org/t/make-jupyterhub-authentication-pluggable/10122/1

@meeseeksmachine
Copy link

This issue has been mentioned on Jupyter Community Forum. There might be relevant details there:

https://discourse.jupyter.org/t/make-jupyterhub-authentication-pluggable/10122/2

@meeseeksmachine
Copy link

This issue has been mentioned on Jupyter Community Forum. There might be relevant details there:

https://discourse.jupyter.org/t/multiple-authentication-options/12711/2

@M4C4R
Copy link

M4C4R commented Aug 25, 2023

Thanks all, this thread was a big help in getting it to work with the helm chart too.

I did have to fix the login_url function though to account for the url_scope.

hub:
  config:
    Authenticator:
      allowed_users:
        - [email protected]
        - [email protected]
      admin_users:
        - [email protected]
    AzureAdOAuthenticator:
      client_id: ""
      client_secret: ""
      tenant_id: ""
      oauth_callback_url: https://<domain>/hub/azuread/oauth_callback
      username_claim: unique_name
      scope:
        - openid
        - email
    GoogleOAuthenticator:
      client_id: ""
      client_secret: ""
      oauth_callback_url: https://<domain>/hub/google/oauth_callback
      hosted_domain:
        - abc.com
  extraConfig:
    PackedAuthenticator: |-
        from traitlets import List
        from jupyterhub.auth import Authenticator
        from oauthenticator.google import GoogleOAuthenticator
        from oauthenticator.azuread import AzureAdOAuthenticator

        class PackedAuthenticator(Authenticator):
            authenticators = List(help="The sub-authenticators to use", config=True)

            def __init__(self, *arg, **kwargs):
                super().__init__(*arg, **kwargs)
                self._authenticators = []
                for auth_class, url_scope, configs in self.authenticators:
                    instance = auth_class(**configs)
                    # get the login url for this authenticator, e.g. 'login' for PAM, 'oauth_login' for Google
                    login_url = instance.login_url('')

                    # update the login_url function on the instance to fix it as we are adding url_scopes
                    instance._url_scope = url_scope
                    instance._login_url = login_url
                    
                    def custom_login_url(self, base_url):
                        return url_path_join(base_url, self._url_scope, self._login_url)
                    
                    instance.login_url = custom_login_url.__get__(instance, auth_class)
                    self._authenticators.append({
                        'instance': instance,
                        'url_scope': url_scope,
                    })

            def get_handlers(self, app):
                routes = []
                for _auth in self._authenticators:
                    for path, handler in _auth['instance'].get_handlers(app):

                        class SubHandler(handler):
                            authenticator = _auth['instance']

                        routes.append((f'{_auth["url_scope"]}{path}', SubHandler))
                print("routes", routes)
                return routes
            
            def get_custom_html(self, base_url):
                html = [
                  '<div class="service-login">',
                  '<h2>Please sign in below</h2>',
                ]
                for authenticator in self._authenticators:
                    login_service = authenticator['instance'].login_service or "Local User"
                    url = authenticator['instance'].login_url(base_url)

                    html.append(
                        f"""
                        <div style="margin-bottom:10px;">
                          <a style="width:20%;" role="button" class='btn btn-jupyter btn-lg' href='{url}'>
                          Sign in with {login_service}
                          </a>
                        </div>
                        """
                    )
                footer_html = [
                  '</div>',
                ]
                return '\n'.join(html + footer_html)

        c.PackedAuthenticator.authenticators = [
          (GoogleOAuthenticator, '/google', c['GoogleOAuthenticator']),
          (AzureAdOAuthenticator, '/azuread', c['AzureAdOAuthenticator']),
        ]

        c.JupyterHub.authenticator_class = PackedAuthenticator

@lahwaacz
Copy link

For people who get here after searching for similar solutions: the current work seems to be https://github.com/idiap/multiauthenticator which originated from #459

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests