Authentication & Authorization#
To connect the Grader Service with JupyterHub, you need to configure authentication and authorization. This is crucial for managing user access and roles within the Grader Service, especially in educational settings where different users (instructors, tutors, students) have varying levels of access.
The Grader Service supports two distinct modes for handling authentication and authorization, designed to integrate with JupyterHub in educational environments. Each mode offers a different balance of simplicity and flexibility depending on your infrastructure and requirements.
In the Grader Service, users are associated with lectures and can hold one of the following roles:
instructortutorstudent
These roles determine access permissions within the app and are stored in the internal user model of the Grader Service.
Mode 1: JupyterHub Provides API Token#
In this simpler mode, JupyterHub handles the user management, and provides the user’s API token to
the Grader Service at server startup.
The Grader Service then queries the JupyterHub API to obtain information about the current user (e.g., username, groups)
and stores this in its own database.
Architecture Overview:
Advantages:
Easy to set up and understand
No need to manage additional OAuth flows
Limitations:
Works only with one JupyterHub instance
The Grader Service can only access information that JupyterHub exposes (e.g., groups)
Less flexible for multi-hub or cross-institutional setups
Example Configuration#
In this example, we will use the DummyAuthenticator for simplicity, but you can replace it with any other authenticator that suits your needs.
Furthermore, Grader Service will use JupyterHub groups (e.g., lect1:instructor, lect1:student, lect1:tutor) to manage user roles.
First we will start with the JupyterHub configuration. This is typically done in the jupyterhub_config.py file.
Warning
The following configuration is an example and should be adapted to your specific needs.
Make sure to replace the URLs and client credentials with your own values.
Also, ensure that encryption is enabled by the presence of the JUPYTERHUB_CRYPT_KEY environment variable, which should be a hex-encoded 32-byte key.
Otherwise, the enable_auth_state option will not work as expected.
# jupyterhub_config.py
## authenticator
from jupyterhub.auth import DummyAuthenticator
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
from tornado.escape import json_encode, json_decode
c.JupyterHub.authenticator_class = DummyAuthenticator
c.Authenticator.allowed_users = {'admin', 'instructor', 'tutor', 'student'}
c.Authenticator.admin_users = {"admin"}
c.JupyterHub.load_groups = {"lect1:instructor": {'users' : ["admin", "instructor"]}, "lect1:student": {'users' : ["student"]}, "lect1:tutor": {'users' : ["tutor"]}}
c.Authenticator.enable_auth_state = True
# this hook will be called when a user starts a server and will pass the JupyterHub API token to the Grader Service
# in return the Grader Service will return an API token that can be used to authenticate with the Grader Service
async def pre_spawn_hook(spawner):
http_client = AsyncHTTPClient()
data = {"token" : spawner.api_token}
request = HTTPRequest(url="<grader-service-url>/services/grader/login", method='POST', body=json_encode(data))
response = await http_client.fetch(request=request)
grader_api_token = json_decode(response.body)["api_token"]
spawner.environment.update({"GRADER_API_TOKEN": grader_api_token})
c.Spawner.pre_spawn_hook = pre_spawn_hook
Next, we will configure the Grader Service to use the JupyterHub API token for authentication. This is typically done in the grader_service_config.py file.
Grader Service only needs JupyterHub’s user information endpoint to fetch user details and roles.
After the user is authenticated, the Grader Service can be configured to create or update the user and their roles in its own database using the post_auth_hook function.
# grader_service_config.py
from typing import Optional
from grader_service.auth.auth import Authenticator
from grader_service.auth.token import JupyterHubTokenAuthenticator
from grader_service.handlers.base_handler import BaseHandler
from grader_service.orm import User, Lecture
from grader_service.orm.base import DeleteState
from grader_service.orm.lecture import LectureState
from grader_service.orm.takepart import Scope, Role
c.GraderService.authenticator_class = JupyterHubTokenAuthenticator
c.Authenticator.allow_all = True
# c.Authenticator.admin_users = {'admin1', 'admin2'}
c.JupyterHubTokenAuthenticator.user_info_url = "<jupyterhub-url>/hub/api/user"
def post_auth_hook(authenticator: Authenticator, handler: BaseHandler, authentication: dict):
log = handler.log
session = handler.session
groups: list[str] = authentication["groups"]
username = authentication["name"]
user_model: Optional[User] = session.query(User).filter(User.name == username).one_or_none()
if user_model is None:
user_model = User()
user_model.name = username
user_model.display_name = username
session.add(user_model)
session.commit()
for group in groups:
if (":" in group):
split_group = group.split(":")
lecture_code = split_group[0]
scope = split_group[1]
scope = Scope[scope]
lecture = session.query(Lecture).filter(Lecture.code == lecture_code).one_or_none()
if lecture is None:
lecture = Lecture()
lecture.code = lecture_code
lecture.name = lecture_code
lecture.state = LectureState.active
lecture.deleted = DeleteState.active
session.add(lecture)
session.commit()
role = session.query(Role).filter(Role.user_id == user_model.id, Role.lectid == lecture.id).one_or_none()
if role is None:
log.info(f'No role for user {username} in lecture {lecture_code}... creating role')
role = Role(user_id=user_model.id, lectid=lecture.id, role=scope)
session.add(role)
session.commit()
else:
log.info(f'Found role {role.role.name} for user {username} in lecture {lecture_code}... updating role to {scope.name}')
role.role = scope
session.commit()
else:
log.info("Found group that doesn't match schema. Ignoring %s", group)
return authentication
c.Authenticator.post_auth_hook = post_auth_hook
Mode 2: Grader Service Wraps JupyterHub#
In this configuration, the Grader Service acts as the primary authentication gateway, handling authentication via its own authenticator. It acts as a OAuth2 provider for JupyterHub instances and authenticates to them using OAuth2.
Architecture Overview:
Advantages:
Centralized user authentication across multiple JupyterHub deployments
Fine-grained control over user roles and access in the Grader Service
Can enrich user records with external data (e.g., institutional systems)
Considerations:
More complex setup
Requires configuring both the Grader Service and JupyterHub for OAuth2 communication
Example Configuration#
Just like in the previous example, we will use the DummyAuthenticator for simplicity, but you can replace it with any other authenticator that suits your needs.
Here, the Grader Service will act as an OAuth2 provider for JupyterHub, and will use its own user management system to handle user roles.
# grader_service_config.py
from grader_service.auth.dummy import DummyAuthenticator
c.GraderService.authenticator_class = DummyAuthenticator
# default users
c.Authenticator.allowed_users = {'admin', 'instructor', 'student', 'tutor'}
c.Authenticator.admin_users = {'admin'}
# default roles
c.GraderService.load_roles = {"lect1": [{"members": ["admin", "instructor"], "role": "instructor"},
{"members": ["tutor"], "role": "tutor"},
{"members": ["student"], "role": "student"}]}
# JupyterHub client config
c.GraderService.oauth_clients = [{
'client_id': '<my_id>',
'client_secret': '<my_secret>',
'redirect_uri': '<jupyterhub-url>/hub/oauth_callback'
}]
Lastly, we will configure the JupyterHub to use the Grader Service as an OAuth2 provider. After the user is authenticated, the Grader Service will provide an API token that can be used to authenticate with the Grader Service.
Warning
The following configuration is an example and should be adapted to your specific needs.
Make sure to replace the URLs and client credentials with your own values.
Also, ensure that encryption is enabled by the presence of the JUPYTERHUB_CRYPT_KEY environment variable, which should be a hex-encoded 32-byte key.
Otherwise, the enable_auth_state option will not work as expected.
# jupyterhub_config.py
## authenticator
from oauthenticator.generic import GenericOAuthenticator
c.JupyterHub.authenticator_class = GenericOAuthenticator
c.GenericOAuthenticator.oauth_callback_url = "<jupyterhub-url>/hub/oauth_callback"
c.GenericOAuthenticator.client_id = "<my_id>"
c.GenericOAuthenticator.client_secret = "<my_secret>"
c.GenericOAuthenticator.authorize_url = "<grader-service-url>/services/grader/api/oauth2/authorize"
c.GenericOAuthenticator.token_url = "<grader-service-url>/services/grader/api/oauth2/token"
c.GenericOAuthenticator.logout_redirect_url = "<grader-service-url>/services/grader/logout"
c.GenericOAuthenticator.userdata_url = "<grader-service-url>/services/grader/api/user"
c.GenericOAuthenticator.username_claim = "name"
c.Authenticator.enable_auth_state = True
def auth_state_hook(spawner, auth_state):
token = auth_state["access_token"]
# The environment variable GRADER_API_TOKEN is used by the labextension
# to identify the user in API calls to the Grader Service.
spawner.environment.update({"GRADER_API_TOKEN": token})
# We have access to the authentication data, which we can use to set
# `userdata` in the spawner of the user.
c.Spawner.auth_state_hook = auth_state_hook