Services Auth Issues After Upgrading to JuyterHub 2.1.1

I am in the process of upgrading our JupyterHub deploy from 1.1.0 to 2.1.1. Most things are working fine after the upgrade, however, I am having issues getting JupyterHub services to authenticate.

We have a hub-managed service that is called by the browser after a user logs in. Basically it’s an API endpoint that serves up some special information about the user.

return fetch('/services/projects/user.json')
            .then(response => response.json())
            .then(response => { /* do some stuff */ });

Because it serves user information, it requires a user to be logged in. In JupyterHub 1.x we are able to achieve this by simply attaching the @authenticated decorator and relying on the fact that the user cookie was already set when you logged in.

class UserHandler(HubAuthenticated, RequestHandler):

    @authenticated
    def get(self):
        user = self.hub_auth.get_user(self)

        # Code simplified for demonstration purposes
        self.write({ 'name': user['name'] } if user else {})

In JupyterHub 2.x, however, the endpoint is called and the user cookie is no longer able to authenticate the request. Instead, it redirects to /hub/login?next=/services/projects/user.json, which is authenticated by the cookie, and it redirects back to the endpoint in an infinite loop.

My question is: In JupyterHub 2.x, what is the recommended way to authenticate Services API endpoints like this?

I should note that other forms of authentication are functioning. For example, if I manually generate a token and pass it is as a GET parameter, it reads as authenticated and the request works. Or if I change the mixin to HubOAuthenticated and call the endpoint directly from the browser, it works (after accepting the OAuth permissions). But neither of these really suffice for an API endpoint that’s meant to be called automatically in the background.

I suspect that the reason cookie authentication stopped working for services can be found on lines 450-454 of jupyterhub/services/auth.py.

def user_for_cookie(self, encrypted_cookie, use_cache=True, session_id=''):
    """Deprecated and removed. Use HubOAuth to authenticate browsers."""
    raise RuntimeError(
        "Identifying users by shared cookie is removed in JupyterHub 2.0. Use OAuth tokens."
    )

Is this correct? If so, how do I go about authenticating a service that is called as an API endpoint?

It sounds like you’ll need to switch to using HubOAuth:

What I don’t understand about HubOAuth as a possible solution is how to make it work if the services endpoint is called by Javascript rather than as a full-page browser request.

So imagine this: A user signs into the hub. A custom template loads and some Javascript on the page calls the services endpoint.

Now, normally the endpoint would serve up some JSON data, which the template would use to draw some parts of the page for the user. But the endpoint still needs to authenticate.

With HubOAuth set, the endpoint redirects to the oauth confirmation page. But the Javascript calling the endpoint is expecting JSON data. So at this point, how should it handle authentication? I don’t understand how OAuth addresses this workflow.

I wouldn’t want to send the browser viewport to the services endpoint directly, because that is just going to, after the authorization page, redirect to the oauth callback, which in turn redirects to the endpoint itself. And the services endpoint is just a JSON dump; it’s not intended to be human-readable.

Extending main JupyterHub pages with cross-site requests to services is not something I expected folks to do. JupyterHub treats services as “external” so most cross-site scripting issues apply trying to embed content from one site into another (i.e. you must login to services separately, users may have the opportunity to confirm login, but this can be disabled). One difference is they are technically on the same site, so it’s not quite as restrictive as truly cross-site requests, unless you run external services.

You can transparently login to the service via oauth if you are not already logged in to the service, so your javascript does:

  1. try to fetch the service endpoint
  2. if it fails with a login redirect, redirect to service login with e.g. ?next=location.href
  3. service completes oauth purely via redirects, arrive back at original url
  4. this time, fetch succeeds
  5. proceed as normal

This does technically send the viewport to the service, but it only processes a series of redirects to complete oauth and then arrives back at the same page, so it doesn’t feel like sending the user to another page (the user never sees another page; the most they should see is an immediate reload on the current page on their first visit).

I made this example showing how it can be done.

2 Likes

@minrk I tested the example you put together and that approach should work.

Thank you so much!