Logging users in via URL#
Sometimes, JupyterHub is integrated into an existing application that has already handled user login, etc.. It is often preferable in these applications to be able to link users to their running JupyterHub server without prompting the user to login again with the Hub when the Hub should really be an implementation detail, and not part of the user experience.
One way to do this has been to use API only mode, issue tokens for users, and redirect users to a URL like /users/name/?token=abc123.
This is disabled by default in JupyterHub 5, because it presents a vulnerability for users to craft links that let other users login as them, which can lead to inter-user attacks.
But that leaves the question: how do I as an application developer embedding JupyterHub link users to their own running server without triggering another login prompt?
The problem with ?token=... in the URL is specifically that users can get and create these tokens, and share URLs.
This wouldn’t be an issue if only authorized applications could issue tokens that behave this way.
The single-user server doesn’t exactly have the hooks to manage this easily, but the Authenticator API does.
Problem statement#
We want our external application to be able to:
authenticate users
(maybe) create JupyterHub users
start JupyterHub servers
redirect users into running servers without any login prompts/loading pages from JupyterHub, and without any prior JupyterHub credentials
Step 1 is up to the application and not JupyterHub’s problem. Step 2 and 3 use the JupyterHub REST API. The service would need the scopes:
admin:users # creating users
servers # start/stop servers
That leaves the last step: sending users to their running server with credentials, without prompting login. This is where things can get tricky!
Ideal case: oauth#
Ideally, the best way to set this up is with the external service as an OAuth provider, though in some cases it works best to use proxy-based authentication like Shibboleth / REMOTE_USER. The main things to know are:
Links to
/hub/user-redirect/some/pathwill ultimately land users at/users/theirserver/some/pathafter completing login, ensuring the server is running, etc.Setting
Authenticator.auto_login = Trueallows beginning the login process without JupyterHub’s “Login with…” prompt
If your OAuth provider allows logging in to external services via your oauth provider without prompting, this is enough. Not all do, though.
If you’ve already ensured the server is running, this will appear to the user as if they are being sent directly to their running server. But what actually happens is quite a series of redirects, state checks, and cookie-setting:
visiting
/hub/user-redirect/some/pathchecks if the user is logged inif not, begin the login process (
/hub/login?next=/hub/user-redirect/...)redirects to your oauth provider to authenticate the user
redirects back to
/hub/oauth_callbackto complete loginredirects back to
/hub/user-redirect/...
once authenticated, checks that the user’s server is running
if not running, begins launch of the server
redirects to
/hub/spawn-pending/?next=...
once the server is running, redirects to the actual user server
/users/username/some/path
Now we’re done, right? Actually, no, because the browser doesn’t have credentials for their user server! This sequence of redirects happens all the time in JupyterHub launch, and is usually totally transparent.
at the user server, check for a token in cookie
if not present or not valid, begin oauth with the Hub (redirect to
/hub/api/oauth2/authorize/...)hub redirects back to
/users/user/oauth_callbackto complete oauthredirect again to the URL that started this internal oauth
finally, arrive at
/users/username/some/path, the ultimate destination, with valid JupyterHub credentials
The steps that will show users something other than the page you want them to are:
Step 1.1 will be a prompt e.g. with “Login with…” unless you set
c.Authenticator.auto_login = TrueStep 1.2 may be a prompt from your oauth provider. This isn’t controlled by JupyterHub, and may not be avoidable.
Step 2.2 will show the spawn pending page only if the server is not already running
Otherwise, this is all transparent redirects to the final destination.
Using an authentication proxy (REMOTE_USER)#
If you use an Authentication proxy like Shibboleth that sets e.g. the REMOTE_USER header, you can use an Authenticator like RemoteUserAuthenticator to automatically login users based on headers in the request. The same process will work, but instead of step 1.1 redirecting to the oauth provider, it logs in immediately. If you do support an auth proxy, you also need to be extremely sure that requests only come from the auth proxy, and don’t accept any requests setting the REMOTE_USER header coming from other sources.
Custom case#
But let’s say you can’t use OAuth or REMOTE_USER, and you still want to hide JupyterHub implementation details. All you really want is a way to write a URL that will take users to their servers without any login prompts.
You can do this if you create an Authenticator with auto_login=True that logs users in based on something in the request, e.g. a query parameter.
We have an example in the JupyterHub repo in examples/forced-login that does this.
It is a sample ‘external service’ where you type in a username and a destination path.
When you ‘login’ with this username:
a token is issued
the token is stored and associated with the username
redirect to
/hub/login?login_token=...&next=/hub/user-redirect/destination/path
Then on the JupyterHub side, there is the ForcedLoginAuthenticator.
This class implements authenticate, which:
has
auto_login = Trueso visiting/hub/logincallsauthenticate()directly instead of serving a pagegets the token from the
login_tokenURL parametermakes a POST request to the external application with the token, requesting a username
the external application returns the username and deletes the token, so it cannot be re-used
Authenticator returns the username
This doesn’t bypass JupyterHub authentication, as some deployments have done, but it does hide it.
If your service launches servers via the API, you could run this in API only mode by adding /hub/login as well:
c.JupyterHub.hub_routespec = "/hub/api/"
c.Proxy.additional_routes = {"/hub/login": "http://hub:8080"}
class ForcedLoginAuthenticator(Authenticator):
"""Authenticator to force login with a token provided by an external service
The external service issues tokens, which are exchanged for a username.
Visiting `/hub/login?login_token=...` logs in a user
Each token can be used only once.
"""
auto_login = True # begin login without prompt (token is in url)
allow_all = True # external login app controls this
token_provider_url = Unicode(
config=True, help="""The URL of the token/username provider"""
)
async def authenticate(self, handler, data):
token = handler.get_argument("login_token", None)
if not token:
raise web.HTTPError(
400, f"Login with external provider at {self.token_provider_url}"
)
client = AsyncHTTPClient()
try:
response = await client.fetch(
url_path_join(self.token_provider_url, "/login"),
method="POST",
headers={"Content-Type": "application/json"},
body=json.dumps({"token": token}),
)
except HTTPClientError as e:
self.log.info(
"Error exchanging token for username: %s",
e.response.body.decode("utf8", "replace"),
)
if e.code == 404:
raise web.HTTPError(
403,
f"Invalid token. Login with external provider at {self.token_provider_url}",
)
else:
raise
# pass through the response
return json.loads(response.body.decode())
Why does this work?
This is still logging in with a token in the URL, right? Yes, but the key difference is that users cannot issue these tokens. The sample application is still technically vulnerable, because the token link should really be non-transferrable, even if it can only be used once. The only defense the sample application has against this is rapidly expiring tokens (they expire after 30 seconds). You can use state cookies, etc. to manage that more rigorously, as done in OAuth (at which point, maybe implement OAuth itself, why not?).