Hi @danielmcquillen and thanks for your reply!
In my case we are not using TLJH, so not sure if this will be useful. I set everything up with two different methods:
- For production it was using k8s in multiple EC2 instances
- and for local dev I am using docker compose, where one docker image replicates the k8s cluster.
The main difference is that in local dev, to configure the hub, it uses jupyterhub_config.py, while for k8s, everything is inside the helm chart. Another difference is that the spawner in local uses DockerSpawner, whereas for k8s it uses KubeSpawner, but they are very similar configuration-wise
I finally managed to make the authenticator work using FusionAuth (https://fusionauth.io/), but there are a few tweaks I had to make. Not sure if it will be relevant to your authentication tool, but hereâs what I could find:
The LTIAuthenticator class is expecting a few keys in the id_token, such as âversionâ, âdeployment_idâ and other parameters. I couldnât add these properties from the LMS itself, so I used a trick on FusionAuth that allows you to populate your token by making it first go through a Lambda function:
function populate(jwt, user, registration) {
jwt["https://purl.imsglobal.org/spec/lti/claim/version"] = "1.3.0";
jwt["https://purl.imsglobal.org/spec/lti/claim/deployment_id"] = <your deployment id (I randomly created one)
jwt["https://purl.imsglobal.org/spec/lti/claim/message_type"] = "LtiResourceLinkRequest";
jwt["https://purl.imsglobal.org/spec/lti/claim/roles"] = [
"http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student",
"http://purl.imsglobal.org/vocab/lis/v2/membership#Learner",
"http://purl.imsglobal.org/vocab/lis/v2/membership#Mentor"
];
jwt["https://purl.imsglobal.org/spec/lti/claim/target_link_uri"] = <your jupyterhub url>
jwt["https://purl.imsglobal.org/spec/lti/claim/resource_link"] = {
id: user.data.resource_link_id
};
jwt.email = user.email;
}
Then, in the authenticator tool you also need to add the allowed redirect URLs and Authorized Request URLs.
Another thing that caused me to get stuck for a long time was that your authentication tool and your LMS have to be in the same domain - from what I can see in your case, it should be fine
In case it helps, I spent a lot of time debugging, so I added this custom authenticator simply to see the logs and to see if some of the methods were called. In my helm chart, under hub â extraConfig, I added this:
customLTIAuthenticator: |
from ltiauthenticator.lti13.auth import LTI13Authenticator
from ltiauthenticator.lti13.handlers import LTI13LoginInitHandler, LTI13CallbackHandler, LTI13ConfigHandler
from ltiauthenticator.lti13.validator import LTI13LaunchValidator
from ltiauthenticator.lti13.error import ValidationError
from ltiauthenticator.utils import convert_request_to_dict
from traitlets import List, Set, Unicode
from typing import Any, Dict, Optional, cast
from tornado.httputil import url_concat
from tornado.web import HTTPError
class MyLTI13LoginHandler(LTI13LoginInitHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
print("Custom LTI Login Handler init")
print(self.__dict__)
def check_xsrf_cookie(self):
print("=====Attempting to check cookies from Login====")
"""
Do not attempt to check for xsrf parameter in POST requests. LTI requests are
meant to be cross-site, so it must not be verified.
"""
return
def authorize_redirect(
self,
redirect_uri: str,
login_hint: str,
nonce: str,
client_id: str,
state: str,
purl_args: Dict[str, str] = None,
lti_message_hint: Optional[str] = None,
) -> None:
"""
Overrides the OAuth2Mixin.authorize_redirect method to to initiate the LTI 1.3 / OIDC
login flow with the required and optional arguments.
User Agent (browser) is redirected to the platform's authorization url for further
processing.
References:
https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
http://www.imsglobal.org/spec/lti/v1p3/#additional-login-parameters-0
Args:
redirect_uri: redirect url specified during tool installation (callback url) to
which the user will be redirected from the platform after attempting authorization.
login_hint: opaque value used by the platform for user identity
nonce: unique value sent to allow recipients to protect themselves against replay attacks
client_id: used to identify the tool's installation with a platform
state: opaque value for the platform to maintain state between the request and
callback and provide Cross-Site Request Forgery (CSRF) mitigation.
lti_message_hint: similarly to the login_hint parameter, lti_message_hint value is opaque to the tool.
If present in the login initiation request, the tool MUST include it back in
the authentication request unaltered.
"""
handler = cast(RequestHandler, self)
# Required parameter with values specified by LTI 1.3
# https://www.imsglobal.org/spec/security/v1p0/#step-2-authentication-request
args = {
"response_type": "id_token",
"scope": "openid",
"response_mode": "form_post",
"prompt": "none",
}
# Dynamically computed required parameter values
args["client_id"] = client_id
args["redirect_uri"] = redirect_uri
args["login_hint"] = login_hint
args["nonce"] = nonce
args["state"] = state
if lti_message_hint is not None:
args["lti_message_hint"] = lti_message_hint
if purl_args:
args.update(purl_args)
print("Args for authenticate")
url = self.authenticator.authorize_url
handler.redirect(url_concat(url, args))
def get_purl_args(self, args: Dict[str, str]) -> Dict[str, str]:
"""
Return value of optional argument or None if not present.
"""
PURL_ARGS = [
"https://purl.imsglobal.org/spec/lti/claim/launch_presentation",
"https://purl.imsglobal.org/spec/lti/claim/tool_platform",
"https://purl.imsglobal.org/spec/lti/claim/deployment_id",
"https://purl.imsglobal.org/spec/lti/claim/message_type",
"https://purl.imsglobal.org/spec/lti/claim/version",
"https://purl.imsglobal.org/spec/lti/claim/resource_link",
"https://purl.imsglobal.org/spec/lti/claim/context",
]
values = {}
for purl_arg in PURL_ARGS:
value = self._get_optional_arg(args, purl_arg)
if value:
values[purl_arg] = value
return values
def post(self):
"""
Validates required login arguments sent from platform and then uses the authorize_redirect() method
to redirect users to the authorization url.
"""
print("------POST method called-------")
validator = LTI13LaunchValidator()
args = convert_request_to_dict(self.request.arguments)
print("------POST method called-------", args)
self.log.debug(f"Initial login request args are {args}")
# Raises HTTP 400 if login request arguments are not valid
try:
validator.validate_login_request(args)
except ValidationError as e:
raise HTTPError(400, str(e))
login_hint = args["login_hint"]
self.log.debug(f"login_hint is {login_hint}")
lti_message_hint = self._get_optional_arg(args, "lti_message_hint")
client_id = self._get_optional_arg(args, "client_id")
# lti_deployment_id is not used anywhere. It may be used in the future to influence the
# login flow depending on the deployment settings. A configurable hook, similar to `Authenticator`'s `post_auth_hook`
# would be a good way to implement this.
# lti_deployment_id = self._get_optional_arg(args, "lti_deployment_id")
purl_args = self.get_purl_args(args)
redirect_uri = self.get_redirect_uri()
self.log.debug(f"redirect_uri is: {redirect_uri}")
# to prevent CSRF
state = self.generate_state()
# to prevent replay attacks
nonce = self.generate_nonce()
self.log.debug(f"nonce value: {nonce}")
# Set cookies with appropriate attributes
handler = cast(RequestHandler, self)
handler.set_secure_cookie('oauth_state', state, secure=True, httponly=True, samesite='None')
handler.set_secure_cookie('oauth_nonce', nonce, secure=True, httponly=True, samesite='None')
self.authorize_redirect(
client_id=client_id,
login_hint=login_hint,
lti_message_hint=lti_message_hint,
nonce=nonce,
redirect_uri=redirect_uri,
state=state,
purl_args=purl_args,
)
# GET requests are also allowed by the OpenID Connect launch flow:
# https://www.imsglobal.org/spec/security/v1p0/#fig_oidcflow
#
get = post
class MyLTI13CallbackHandler(LTI13CallbackHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
print("Custom LTI CallBack Handler init")
print(self.__dict__)
async def get(self):
print("----------Get method used------------")
await self.post()
def decode_and_validate_launch_request(self) -> Dict[str, Any]:
"""Decrypt, verify and validate launch request parameters.
Raises subclasses of `ValidationError` of `HTTPError` if anything fails.
References:
https://openid.net/specs/openid-connect-core-1_0.html#IDToken
https://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDTValidation
"""
print("Callback self.request", self.request)
print("Callback self.request.arguments", self.request.arguments)
validator = LTI13LaunchValidator()
args = convert_request_to_dict(self.request.arguments)
self.log.debug(f"Initial launch request args are {args}")
validator.validate_auth_response(args)
# Check is state is the same as in the authorization request issued
# constructed in `LTI13LoginInitHandler.post`, prevents CSRF
self.check_state()
print("______Verifying and decoding jwt______")
id_token = validator.verify_and_decode_jwt(
encoded_jwt=args.get("id_token"),
issuer=self.authenticator.issuer,
audience=self.authenticator.client_id,
jwks_endpoint=self.authenticator.jwks_endpoint,
jwks_algorithms=self.authenticator.jwks_algorithms,
)
print("Id Token: ", id_token)
print("______Validating id token______")
validator.validate_id_token(id_token)
validator.validate_azp_claim(id_token, self.authenticator.client_id)
# Check nonce matches the one that has been used in the authorization request.
# A nonce is a hash of random state which is stored in a session cookie before
# redirecting to make authorization request. This mitigates replay attacks.
#
# References:
# https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes
# https://auth0.com/docs/get-started/authentication-and-authorization-flow/mitigate-replay-attacks-when-using-the-implicit-flow
self.check_nonce(id_token)
print("Checks bypassed")
print("=============Returning Id Token=========")
return id_token
def check_xsrf_cookie(self):
print("=====Attempting to check cookies from callback====")
"""
Do not attempt to check for xsrf parameter in POST requests. LTI requests are
meant to be cross-site, so it must not be verified.
"""
return
class MyLTI13ConfigHandler(LTI13ConfigHandler):
def check_xsrf_cookie(self):
print("=====Attempting to check cookies from config====")
"""
Do not attempt to check for xsrf parameter in POST requests. LTI requests are
meant to be cross-site, so it must not be verified.
"""
return
class MyAuthenticator(LTI13Authenticator):
login_handler = MyLTI13LoginHandler
callback_handler = MyLTI13CallbackHandler
config_handler = MyLTI13ConfigHandler
auto_login = True
login_service = "LTI 1.3"
username_key = Unicode("email")
client_id = Set({"b5b6ba99-a446-4fdb-806e-0d642f3eb6c5"})
authorize_url = Unicode("<authorizartion_tool_URL>/oauth2/authorize")
jwks_endpoint = Unicode("<authorizartion_tool_URL>/.well-known/jwks.json")
issuer = Unicode("acme.com")
async def authenticate(self, handler, data=None):
print("Custom LTI Authenticator authenticate")
print(handler)
print(data)
return await super().authenticate(handler, data)
async def pre_spawn_start(self, user, spawner):
print("Custom Pre Spawn Start called")
print(user)
print(spawner)
c.JupyterHub.authenticator_class = MyAuthenticator
Most methods are just copy pasted from the original codebase, I added them just in case I wanted to add prints statements eventually
Then, from the LMS, I tell it to go open localhost:8080 and add some parameters:
const launchParams = {
iss: âacme.comâ, (or the issues you used in your application in the authenticator tool)
sub: <user_id in your LMS>
name:
email:
exp: Math.floor(Date.now() / 1000) + 60,
iat: Math.floor(Date.now() / 1000),
target_link_uri:
client_id:
login_hint: <user email - or what you want the user to use as the authentication login>
};
And these will be used as the searchParams (for example localhost:8080?iss=acme.com&sub=user_idâŚ)
I am afraid I canât help more, I havenât used TLJH, so I donât know what config it requires, but I hope this really helps
Good luck with your project!