Skip to content

Commit

Permalink
maint: Ditch dependency to OAuthenticator (#164)
Browse files Browse the repository at this point in the history
* Remove dependency to OAuthenticator
* Make handler depend on BaseHandler
  • Loading branch information
martinclaus committed Jun 16, 2023
1 parent 2c9d162 commit de52f3e
Show file tree
Hide file tree
Showing 14 changed files with 287 additions and 83 deletions.
8 changes: 4 additions & 4 deletions ltiauthenticator/lti11/auth.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from textwrap import dedent

from jupyterhub.app import JupyterHub
from jupyterhub.auth import Authenticator
from jupyterhub.handlers import BaseHandler
from jupyterhub.utils import url_path_join
from jupyterhub.app import JupyterHub # type: ignore
from jupyterhub.auth import Authenticator # type: ignore
from jupyterhub.handlers import BaseHandler # type: ignore
from jupyterhub.utils import url_path_join # type: ignore
from tornado.web import HTTPError
from traitlets import CaselessStrEnum, Dict, Unicode

Expand Down
2 changes: 1 addition & 1 deletion ltiauthenticator/lti11/handlers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from jupyterhub.handlers import BaseHandler
from jupyterhub.handlers import BaseHandler # type: ignore
from tornado import gen

from .templates import LTI11_CONFIG_TEMPLATE
Expand Down
4 changes: 2 additions & 2 deletions ltiauthenticator/lti11/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
from typing import Any, Dict
from typing import OrderedDict as OrderedDictType

from oauthlib.common import safe_string_equals
from oauthlib.oauth1.rfc5849 import Client, signature
from oauthlib.common import safe_string_equals # type: ignore
from oauthlib.oauth1.rfc5849 import Client, signature # type: ignore
from tornado.web import HTTPError
from traitlets.config import LoggingConfigurable

Expand Down
47 changes: 20 additions & 27 deletions ltiauthenticator/lti13/auth.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import logging
from typing import Any, Dict, List

from jupyterhub.app import JupyterHub
from jupyterhub.auth import LocalAuthenticator
from jupyterhub.handlers import BaseHandler
from jupyterhub.utils import url_path_join
from oauthenticator.oauth2 import OAuthenticator
from jupyterhub.app import JupyterHub # type: ignore
from jupyterhub.auth import Authenticator # type: ignore
from jupyterhub.handlers import BaseHandler # type: ignore
from jupyterhub.utils import url_path_join # type: ignore
from traitlets import CaselessStrEnum
from traitlets import List as TraitletsList
from traitlets import Set as TraitletsSet
Expand All @@ -20,29 +19,30 @@
logger.setLevel(logging.DEBUG)


class LTI13Authenticator(OAuthenticator):
class LTI13Authenticator(Authenticator):
"""
JupyterHub LTI 1.3 Authenticator which extends the `OAuthenticator` class. (LTI 1.3
is basically an extension of OIDC/OAuth2). Messages sent to this authenticator are sent
from a LTI 1.3 Platform, such as an LMS. JupyterHub, as the authenticator, works as the
LTI 1.3 External Tool. The basic login flow is authentication using the implicit flow. As such,
the client id is always required.
This class utilizes the following required configurables defined in the `OAuthenticator` base class:
- authorize_url
JupyterHub LTI 1.3 Authenticator. LTI 1.3 is basically an extension of OIDC/OAuth2.
Messages sent to this authenticator are sent from a LTI 1.3 Platform, such as an LMS.
JupyterHub, as the authenticator, works as the LTI 1.3 External Tool.
The basic login flow is authentication using the implicit flow.
As such, at least one client id is required.
Ref:
- https://github.com/jupyterhub/oauthenticator/blob/master/oauthenticator/oauth2.py
- http://www.imsglobal.org/spec/lti/v1p3/
- https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth
"""

login_service = "LTI 1.3"

# handlers used for login, callback, and jwks endpoints
# handlers used for login, callback, and json config endpoints
login_handler = LTI13LoginInitHandler
callback_handler = LTI13CallbackHandler
config_handler = LTI13ConfigHandler

authorize_url = Unicode(
config=True,
help="""Authorization end-point of the platforms identity provider.""",
)

client_id = TraitletsSet(
trait=Unicode(),
Expand Down Expand Up @@ -162,19 +162,18 @@ def get_handlers(self, app: JupyterHub) -> List[BaseHandler]:
return [
(self.login_url(""), self.login_handler),
(self.callback_url(""), self.callback_handler),
(self.config_json_url(""), LTI13ConfigHandler),
(self.config_json_url(""), self.config_handler),
]

async def authenticate(
self, handler: LTI13LoginInitHandler, data: Dict[str, str] = None
) -> Dict[str, Any]:
"""
Overrides authenticate from the OAuthenticator base class to handle LTI
1.3 launch requests based on a passed JWT.
Handles LTI 1.3 launch requests based on a passed JWT.
Args:
handler: handler object
data: authentication dictionary. The decoded, verified and validated id_token send by tehe platform
data: authentication dictionary. The decoded, verified and validated id_token send by the platform
Returns:
Authentication dictionary
Expand Down Expand Up @@ -225,9 +224,3 @@ def get_uri_scheme(self, request) -> str:
return get_browser_protocol(request)
# manually specified https or http
return self.uri_scheme


class LocalLTI13Authenticator(LocalAuthenticator, OAuthenticator):
"""A version that mixes in local system user creation"""

pass
122 changes: 100 additions & 22 deletions ltiauthenticator/lti13/handlers.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,23 @@
import base64
import hashlib
import json
import re
import uuid
from typing import Any, Dict, Optional, cast
from urllib.parse import quote, unquote, urlparse

from jupyterhub.handlers import BaseHandler
from jupyterhub.utils import url_path_join
from oauthenticator.oauth2 import (
OAuthCallbackHandler,
OAuthLoginHandler,
_serialize_state,
)
from oauthlib.common import generate_token
from jupyterhub.handlers import BaseHandler # type: ignore
from jupyterhub.utils import url_path_join # type: ignore
from oauthlib.common import generate_token # type: ignore
from tornado.httputil import url_concat
from tornado.web import HTTPError, RequestHandler
from tornado.log import app_log
from tornado.web import HTTPError, MissingArgumentError, RequestHandler

from ..utils import convert_request_to_dict
from .error import InvalidAudienceError, LoginError, ValidationError
from .validator import LTI13LaunchValidator

STATE_COOKIE_NAME = "lti13authenticator-state"
NONCE_STATE_COOKIE_NAME = "lti13authenticator-nonce-state"


Expand All @@ -41,6 +39,28 @@ def get_nonce(nonce_state: str) -> str:
return nonce


def _serialize_state(state):
"""Serialize OAuth state to a base64 string after passing through JSON"""
json_state = json.dumps(state)
return base64.urlsafe_b64encode(json_state.encode("utf8")).decode("ascii")


def _deserialize_state(b64_state):
"""Deserialize OAuth state as serialized in _serialize_state"""
if isinstance(b64_state, str):
b64_state = b64_state.encode("ascii")
try:
json_state = base64.urlsafe_b64decode(b64_state).decode("utf8")
except ValueError:
app_log.error(f"Failed to b64-decode state: {b64_state}")
return {}
try:
return json.loads(json_state)
except ValueError:
app_log.error(f"Failed to json-decode state: {json_state}")
return {}


class LTI13ConfigHandler(BaseHandler):
"""
Handles JSON configuration file for LTI 1.3.
Expand Down Expand Up @@ -125,12 +145,14 @@ async def get(self) -> None:
self.write(json.dumps(keys))


class LTI13LoginInitHandler(OAuthLoginHandler):
class LTI13LoginInitHandler(BaseHandler):
"""
Handles JupyterHub authentication requests according to the
LTI 1.3 standard.
"""

_state = None

def check_xsrf_cookie(self):
"""
Do not attempt to check for xsrf parameter in POST requests. LTI requests are
Expand Down Expand Up @@ -257,8 +279,7 @@ def post(self):
self.log.debug(f"redirect_uri is: {redirect_uri}")

# to prevent CSRF
state = self.get_state()
self.set_state_cookie(state)
state = self.generate_state()

# to prevent replay attacks
nonce = self.generate_nonce()
Expand All @@ -273,19 +294,25 @@ def post(self):
state=state,
)

# GET requests are also allowed by the OpenID Conect launch flow:
# GET requests are also allowed by the OpenID Connect launch flow:
# https://www.imsglobal.org/spec/security/v1p0/#fig_oidcflow
#
get = post

def generate_state(self):
"""Produce a state including the url of the original request."""
state = self.get_state()
self.set_state_cookie(state)
return state

def generate_nonce(self):
"""Produce a nonce.
The nonce state will be stored as a session cookie to later validate the nonce
field of the id_token.
"""
nonce_state = make_nonce_state()
self.set_nonce_cookie(nonce_state)
self.set_nonce_state_cookie(nonce_state)
nonce = get_nonce(nonce_state)
return nonce

Expand All @@ -308,17 +335,23 @@ def _get_optional_arg(self, args: Dict[str, str], arg: str) -> Optional[str]:
self.log.debug(f"{arg} not present in login initiation request")
return value

def set_nonce_cookie(self, nonce_state):
def set_nonce_state_cookie(self, nonce_state):
self._set_oauth_cookie(NONCE_STATE_COOKIE_NAME, nonce_state)

def set_state_cookie(self, state):
self._set_oauth_cookie(STATE_COOKIE_NAME, state)

def _set_oauth_cookie(self, key: str, value):
self._set_cookie(
NONCE_STATE_COOKIE_NAME,
nonce_state,
key,
value,
expires_days=1,
httponly=True,
encrypted=True,
)


class LTI13CallbackHandler(OAuthCallbackHandler):
class LTI13CallbackHandler(BaseHandler):
"""
Handles JupyterHub authentication requests responses according to the
LTI 1.3 standard.
Expand All @@ -328,6 +361,7 @@ class LTI13CallbackHandler(OAuthCallbackHandler):
https://www.imsglobal.org/spec/security/v1p0/#step-4-resource-is-displayed
"""

_state_cookie = None
_nonce_state_cookie = None

def check_xsrf_cookie(self):
Expand Down Expand Up @@ -408,6 +442,19 @@ def decode_and_validate_launch_request(self) -> Dict[str, Any]:

return id_token

def check_state(self):
"""Verify OAuth state
compare value in cookie with redirect url param
"""
cookie_state = self._get_state_cookie()
if not cookie_state:
raise HTTPError(400, "OAuth state missing from cookies")
url_state = self._get_state_from_url()
if cookie_state != url_state:
self.log.warning(f"OAuth state mismatch: {cookie_state} != {url_state}")
raise HTTPError(400, "OAuth state mismatch")

def check_nonce(self, id_token: Dict[str, Any]) -> None:
"""Check if received nonce corresponds to hash of nonce state cookie"""
received_nonce = id_token.get("nonce")
Expand All @@ -422,14 +469,45 @@ def check_nonce(self, id_token: Dict[str, Any]) -> None:
self.log.warning("OAuth nonce mismatch: %s != %s", nonce, received_nonce)
raise HTTPError(400, "OAuth nonce mismatch")

def get_next_url(self, user=None):
"""Get the redirect target from the state field"""
state = self._get_state_from_url()
next_url = _deserialize_state(state).get("next_url")
if next_url:
return next_url
# JupyterHub 0.8 adds default .get_next_url for a fallback
return super().get_next_url(user)

def _get_state_from_url(self):
"""Get OAuth state from URL parameters
Raises HTTPError(400) if `state` argument is missing from request.
"""
try:
return self.get_argument("state")
except MissingArgumentError:
raise HTTPError(400, "OAuth state missing from URL")

def _get_nonce_state_cookie(self):
"""Get OAuth nonce state from cookies
To be compared with the value in id_token
"""
if self._nonce_state_cookie is None:
self._nonce_state_cookie = (
self.get_secure_cookie(NONCE_STATE_COOKIE_NAME) or b""
).decode("utf8")
self.clear_cookie(NONCE_STATE_COOKIE_NAME)
self._nonce_state_cookie = self._get_oauth_cookie(NONCE_STATE_COOKIE_NAME)
return self._nonce_state_cookie

def _get_state_cookie(self):
"""Get OAuth state from cookies
To be compared with the value in redirect URL
"""
if self._state_cookie is None:
self._state_cookie = self._get_oauth_cookie(STATE_COOKIE_NAME)
return self._state_cookie

def _get_oauth_cookie(self, name: str):
"""Get OAuth state cookie."""
cookie = (self.get_secure_cookie(name) or b"").decode("utf8", "replace")
self.clear_cookie(name)
return cookie
6 changes: 3 additions & 3 deletions ltiauthenticator/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def convert_request_to_dict(arguments: Dict[str, List[bytes]]) -> Dict[str, Any]
return args


def user_is_a_student(user_role: str) -> str:
def user_is_a_student(user_role: str) -> bool:
"""Determins if the user has a Student/Learner role.
Args:
Expand All @@ -73,7 +73,7 @@ def user_is_a_student(user_role: str) -> str:
return user_role.lower() in DEFAULT_ROLE_NAMES_FOR_STUDENT


def user_is_an_instructor(user_role: str) -> str:
def user_is_an_instructor(user_role: str) -> bool:
"""Determins if the user has a Instructor/Teacher role.
Args:
Expand All @@ -85,7 +85,7 @@ def user_is_an_instructor(user_role: str) -> str:
if not user_role:
raise ValueError("user_role must have a value")
# find the extra role names to recognize an instructor (to be added in the grader group)
extra_roles = os.environ.get("EXTRA_ROLE_NAMES_FOR_INSTRUCTOR") or []
extra_roles: str = os.environ.get("EXTRA_ROLE_NAMES_FOR_INSTRUCTOR") or ""
if extra_roles:
extra_roles = extra_roles.lower().split(",")
DEFAULT_ROLE_NAMES_FOR_INSTRUCTOR.extend(extra_roles)
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ dependencies = [
"traitlets>=5.1.0",
"escapism>=1.0.1",
"jupyterhub>=1.2",
"oauthenticator>=15.1.0",
"oauthlib>=3.2.2",
"PyJWT[crypto]>=2.7.0",
]
Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import pytest
from jupyterhub.app import JupyterHub
from jupyterhub.app import JupyterHub # type: ignore
from traitlets.config import Config


Expand Down
Loading

0 comments on commit de52f3e

Please sign in to comment.