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

feat(nbviewer): add OAuth2 support for jupyterhub > 3.4 #1065

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ MANIFEST
package-lock.json
.vscode/
*.tgz
.idea
85 changes: 85 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,88 @@ c.JupyterHub.services = [
```

The nbviewer instance will automatically read the [various `JUPYTERHUB_*` environment variables](http://jupyterhub.readthedocs.io/en/latest/reference/services.html#launching-a-hub-managed-service) and configure itself accordingly. You can also run the nbviewer instance as an [externally managed JupyterHub service](http://jupyterhub.readthedocs.io/en/latest/reference/services.html#externally-managed-services), but must set the requisite environment variables yourself.

---

# nbviewer Integration with JupyterHub via OAuth2

This guide explains how to configure **nbviewer** as a JupyterHub service using OAuth2 token-based authentication.

## Requirements
- **JupyterHub** (version 2.x or higher)
- **nbviewer** service
- Access to the environment variables for both **JupyterHub** and **nbviewer**.


## JupyterHub Configuration

In JupyterHub’s `jupyterhub_config.py`, add the following configuration to integrate nbviewer as a service:

```python
c.JupyterHub.services.append(
{
'name': 'nbviewer',
'url': 'http://nbviewer:8080',
'api_token': os.environ['JUPYTERHUB_API_TOKEN'],
'oauth_no_confirm': True,
'oauth_client_id': 'service-nbviewer',
'oauth_redirect_uri': 'https://jupyterhub.yourcompany.com/services/nbviewer/oauth_callback',
}
)

c.JupyterHub.load_roles = [
{
'name': 'nbviewer',
'services': ['nbviewer', 'jupyterhub-idle-culler'],
'scopes': [
"read:users:activity",
"list:users",
"users:activity",
"servers", # For starting and stopping servers
'admin:users' # Needed if idle users are culled
]
},
{
"name": "user",
"scopes": ["self", "access:services"],
}
]

c.JupyterHub.service_tokens = {
os.environ['JUPYTERHUB_API_TOKEN'] : 'nbviewer'
}
```

### Explanation of Key Settings:
- oauth_client_id: The unique ID for the nbviewer service.
- oauth_redirect_uri: The URL that nbviewer uses to handle OAuth2 callbacks from JupyterHub.
- service_tokens: Set the service token used by nbviewer to authenticate with JupyterHub.

## nbviewer Configuration
In the deployment of nbviewer, configure the following environment variables:
```yaml
extraEnv:
JUPYTERHUB_SERVICE_NAME: 'nbviewer'
JUPYTERHUB_API_URL: 'http://hub:8081/hub/api'
JUPYTERHUB_BASE_URL: '/'
JUPYTERHUB_SERVICE_PREFIX: '/services/nbviewer/'
JUPYTERHUB_URL: 'https://jupyterhub.yourcompany.com'
JUPYTERHUB_CLIENT_ID: 'service-nbviewer'
```
### Explanation of Environment Variables:
- JUPYTERHUB_API_URL: The internal URL where nbviewer can access JupyterHub’s API.
- JUPYTERHUB_URL: The base URL of your JupyterHub installation (public-facing).
- JUPYTERHUB_CLIENT_ID: Should match the oauth_client_id in the JupyterHub configuration.
- JUPYTERHUB_SERVICE_PREFIX: Specifies the service's routing prefix.

### OAuth2 Flow
When a user accesses nbviewer, they will be authenticated via the OAuth2 token from JupyterHub. The oauth_callback URL specified in the configuration will be used to handle the token exchange.

Ensure nbviewer correctly handles OAuth2 requests by ensuring the callback URL is properly set and that nbviewer is able to request the necessary scopes from JupyterHub.

### Troubleshooting
If you encounter issues with token authentication or authorization, ensure that:
The correct API token is set both in JupyterHub and nbviewer.
The service roles and scopes are correctly configured to allow nbviewer access to JupyterHub's user data.

---
26 changes: 25 additions & 1 deletion nbviewer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ class NBViewer(Application):
default_value="nbviewer.providers.gist.handlers.UserGistsHandler",
help="The Tornado handler to use for viewing directory containing all of a user's Gists",
).tag(config=True)
jupyterhub_login_handler = Unicode(
default_value="nbviewer.handlers.JupyterHubLoginHandler",
help="The Tornado handler to use for OAuth login with JupyterHub.",
).tag(config=True)

answer_yes = Bool(
default_value=False,
Expand Down Expand Up @@ -634,6 +638,7 @@ def init_tornado_application(self):
local_handler=self.local_handler,
url_handler=self.url_handler,
user_gists_handler=self.user_gists_handler,
jupyterhub_login_handler=self.jupyterhub_login_handler,
)
handler_kwargs = {
"handler_names": handler_names,
Expand All @@ -656,6 +661,17 @@ def init_tornado_application(self):
if os.environ.get("DEBUG"):
self.log.setLevel(logging.DEBUG)

hub_api = "/hub/api"
if os.getenv("JUPYTERHUB_URL"):
hub_api = os.getenv("JUPYTERHUB_URL").rstrip("/") + hub_api
redirect_url = (
os.environ["JUPYTERHUB_URL"].rstrip("/")
+ os.getenv("JUPYTERHUB_SERVICE_PREFIX", "/").rstrip("/")
+ "/oauth_callback"
)
else:
redirect_url = None

# input traitlets to settings
settings = dict(
# Allow FileFindHandler to load static directories from e.g. a Docker container
Expand All @@ -676,7 +692,8 @@ def init_tornado_application(self):
gzip=True,
hub_api_token=os.getenv("JUPYTERHUB_API_TOKEN"),
hub_api_url=os.getenv("JUPYTERHUB_API_URL"),
hub_base_url=os.getenv("JUPYTERHUB_BASE_URL"),
hub_base_url=os.getenv("JUPYTERHUB_BASE_URL", "/"),
hub_cookie_name="jupyterhub-services",
index=self.index,
ipywidgets_base_url=self.ipywidgets_base_url,
jinja2_env=self.env,
Expand All @@ -701,6 +718,13 @@ def init_tornado_application(self):
statsd_host=self.statsd_host,
statsd_port=self.statsd_port,
statsd_prefix=self.statsd_prefix,
login_url="/oauth_callback",
cookie_secret=os.urandom(32), # generate a random cookie secret
client_id=os.getenv("JUPYTERHUB_CLIENT_ID"),
redirect_uri=redirect_url,
authorize_url=hub_api + "/oauth2/authorize",
token_url=hub_api + "/oauth2/token",
user_url=hub_api + "/user",
)

if self.localfiles:
Expand Down
102 changes: 99 additions & 3 deletions nbviewer/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@
# Distributed under the terms of the BSD License. The full license is in
# the file COPYING, distributed as part of this software.
# -----------------------------------------------------------------------------
import json
from urllib.parse import urlencode

from tornado import web
from tornado.httpclient import AsyncHTTPClient
from tornado.httpclient import HTTPClientError
from tornado.httpclient import HTTPRequest
from tornado.httputil import url_concat

from .providers import _load_handler_from_location
from .providers import provider_handlers
Expand Down Expand Up @@ -38,11 +45,96 @@ def render_index_template(self, **namespace):
text=self.frontpage_setup.get("text", None),
show_input=self.frontpage_setup.get("show_input", True),
sections=self.frontpage_setup.get("sections", []),
**namespace
**namespace,
)

def get(self):
self.finish(self.render_index_template())
def get_current_user(self):
"""The login handler stored a JupyterHub API token in a cookie

@web.authenticated calls this method.
If a Falsy value is returned, the request is redirected to `login_url`.
If a Truthy value is returned, the request is allowed to proceed.
"""
token = self.get_secure_cookie(self.settings["hub_cookie_name"])
if token:
# secure cookies are bytes, decode to str
return token.decode("ascii", "replace")

async def user_for_token(self, token):
"""Retrieve the user for a given token, via /hub/api/user"""
req = HTTPRequest(
self.settings["user_url"], headers={"Authorization": f"token {token}"}
)
response = await AsyncHTTPClient().fetch(req)
return json.loads(response.body.decode("utf8", "replace"))

@web.authenticated
async def get(self):
try:
user_token = self.get_current_user()
await self.user_for_token(user_token)
except HTTPClientError as e:
# If the token is invalid, clear the cookie and redirect to the login page.
# This occurs when we log out from JupyterHub and then log back in.
if e.code == 403:
self.log.info("clearing the cookie and redirecting to the login page")
self.clear_cookie(self.settings["hub_cookie_name"])
self.redirect_to_login()
return

await self.finish(self.render_index_template())


class JupyterHubLoginHandler(web.RequestHandler):
"""Login Handler

this handler both begins and ends the OAuth process
"""

async def token_for_code(self, code):
"""Complete OAuth by requesting an access token for an oauth code"""
params = dict(
client_id=self.settings["client_id"],
client_secret=self.settings["hub_api_token"],
grant_type="authorization_code",
code=code,
redirect_uri=self.settings["redirect_uri"],
)

req = HTTPRequest(
self.settings["token_url"],
method="POST",
body=urlencode(params).encode("utf8"),
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response = await AsyncHTTPClient().fetch(req)
data = json.loads(response.body.decode("utf8", "replace"))
return data["access_token"]

async def get(self):
code = self.get_argument("code", None)

if code:
# code is set, we are the oauth callback
# complete oauth
token = await self.token_for_code(code)
# login successful, set cookie and redirect back to home
self.set_secure_cookie(self.settings["hub_cookie_name"], token)
self.redirect("/")
else:
# we are the login handler,
# begin oauth process which will come back later with an
# authorization_code
self.redirect(
url_concat(
self.settings["authorize_url"],
dict(
redirect_uri=self.settings["redirect_uri"],
client_id=self.settings["client_id"],
response_type="code",
),
)
)


class FAQHandler(BaseHandler):
Expand Down Expand Up @@ -120,11 +212,15 @@ def init_handlers(formats, providers, base_url, localfiles, **handler_kwargs):
custom404_handler = _load_handler_from_location(handler_names["custom404_handler"])
faq_handler = _load_handler_from_location(handler_names["faq_handler"])
index_handler = _load_handler_from_location(handler_names["index_handler"])
jupyterhub_login_handler = _load_handler_from_location(
handler_names["jupyterhub_login_handler"]
)

# If requested endpoint matches multiple routes, it only gets handled by handler
# corresponding to the first matching route. So order of URLSpecs in this list matters.
pre_providers = [
("/?", index_handler, {}),
("/oauth_callback/?", jupyterhub_login_handler, {}),
("/index.html", index_handler, {}),
(r"/faq/?", faq_handler, {}),
(r"/create/?", create_handler, {}),
Expand Down
Loading