Repository: jupyterhub/ltiauthenticator

Issue:
Use usernames instead of user IDs
I'm currently using LTI authentication with Moodle. By default, the user IDs are used to create the JHub users. Is it possible to use the Moodle usernames (or first/last names) instead?

Help is appreciated. Thanks!

Comments:

Patch:
diff --git a/README.md b/README.md
index 585d09c..9bdde7a 100644
--- a/README.md
+++ b/README.md
@@ -43,7 +43,20 @@ pip install jupyterhub-ltiauthenticator
 
     _Note_: Anyone with these two strings will be able to access your hub, so keep them secure!!
 
-4.  Pick a name for edX to call your JupyterHub server. Then, along with the two random strings you generated in step 3, paste them together to create an _LTI Passport String_ in the following format:
+4. By default, the user's name will be the `custom_canvas_user_id` passed in by canvas. If you
+   would like to use some other bit of information from the LTI request, you can pick what should
+   be used as the user id.
+
+   ```python
+   # Set the user's email as their user id
+   c.LTIAuthenticator.username_key = 'lis_person_contact_email_primary'
+   ```
+
+   A [partial list of keys in an LTI request](https://www.edu-apps.org/code.html#params)
+   is available to help. Your LMS provider might also implement custom keys
+   you can use.
+
+5.  Pick a name for edX to call your JupyterHub server. Then, along with the two random strings you generated in step 4, paste them together to create an _LTI Passport String_ in the following format:
 
     ```
     your-hub-name:client-key:client-secret
diff --git a/ltiauthenticator/lti11/auth.py b/ltiauthenticator/lti11/auth.py
index 4e55788..016cfa6 100644
--- a/ltiauthenticator/lti11/auth.py
+++ b/ltiauthenticator/lti11/auth.py
@@ -3,7 +3,12 @@ from jupyterhub.auth import Authenticator
 from jupyterhub.handlers import BaseHandler
 from jupyterhub.utils import url_path_join
 
+from textwrap import dedent
+
+from tornado.web import HTTPError
+
 from traitlets.config import Dict
+from traitlets.config import Unicode
 
 from ltiauthenticator.lti11.handlers import LTI11AuthenticateHandler
 from ltiauthenticator.lti11.validator import LTI11LaunchValidator
@@ -35,6 +40,31 @@ class LTI11Authenticator(Authenticator):
         """,
     )
 
+    username_key = Unicode(
+        "custom_canvas_user_id",
+        allow_none=True,
+        config=True,
+        help="""
+        Key present in LTI 1.1 launch request used to set the user's JupyterHub's username.
+        Some common examples include:
+          - User's email address: lis_person_contact_email_primary
+          - Canvas LMS custom user id: custom_canvas_user_id
+        Your LMS (Canvas / Open EdX / Moodle / others) may provide additional keys in the
+        LTI 1.1 launch request that you can use to set the username. In most cases these
+        are prefixed with `custom_`. You may also have the option of using variable substitutions
+        to fetch values that aren't provided with your vendor's standard LTI 1.1 launch request.
+        Reference the IMS LTI specification on variable substitutions:
+        https://www.imsglobal.org/specs/ltiv1p1p1/implementation-guide#toc-9.
+        
+        Current default behavior:
+        
+        To preserve legacy behavior, if custom_canvas_user_id is present in the LTI
+        request, it is used as the username. If not, user_id is used. In the future,
+        the default will be just user_id - if you want to use custom_canvas_user_id,
+        you must explicitly set username_key to custom_canvas_user_id.
+        """,
+    )
+
     def get_handlers(self, app: JupyterHub) -> BaseHandler:
         return [("/lti/launch", LTI11AuthenticateHandler)]
 
@@ -59,6 +89,15 @@ class LTI11Authenticator(Authenticator):
         Raises:
             HTTPError if the required values are not in the request
         """
+        # log deprecation warning when using the default custom_canvas_user_id setting
+        if self.username_key == "custom_canvas_user_id":
+            self.log.warning(
+                dedent(
+                    """The default username_key 'custom_canvas_user_id' will be replaced by 'user_id' in a future release.
+                Set c.LTIAuthenticator.username_key to `custom_canvas_user_id` to preserve current behavior.
+                """
+                )
+            )
         validator = LTI11LaunchValidator(self.consumers)
 
         self.log.debug(
@@ -78,16 +117,32 @@ class LTI11Authenticator(Authenticator):
         self.log.debug("Launch url is: %s" % launch_url)
 
         if validator.validate_launch_request(launch_url, handler.request.headers, args):
-            # get the lms vendor to implement optional logic for said vendor
-            canvas_id = handler.get_body_argument("custom_canvas_user_id", default=None)
-
-            if canvas_id is not None:
-                user_id = handler.get_body_argument("custom_canvas_user_id")
-            else:
-                user_id = handler.get_body_argument("user_id")
 
+            # raise an http error if the username_key is not in the request's arguments.
+            if self.username_key not in args.keys():
+                raise HTTPError(
+                    400,
+                    "%s did not match any of the launch request arguments."
+                    % self.username_key,
+                )
+
+            # get the username_key. if empty, fetch the username from the request's user_id value.
+            username = args.get(self.username_key)
+            if not username:
+                username = args.get("user_id")
+
+            # if username is still empty or none, raise an http error.
+            if not username:
+                raise HTTPError(
+                    400,
+                    "The %s value in the launch request is empty or None."
+                    % self.username_key,
+                )
+
+            # return standard authentication where all launch request arguments are added to the auth_state key
+            # except for the oauth_* arguments.
             return {
-                "name": user_id,
+                "name": username,
                 "auth_state": {
                     k: v for k, v in args.items() if not k.startswith("oauth_")
                 },


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index cbe8665..5daf4db 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,3 +1,4 @@
+import os
 import secrets
 import time
 
@@ -7,26 +8,75 @@ import pytest
 
 from typing import Dict
 
+from tornado.web import Application
+from tornado.web import RequestHandler
+from tornado.httputil import HTTPServerRequest
+
+from unittest.mock import Mock
+
+
+@pytest.fixture(scope="function")
+def user_model(username: str, **kwargs) -> dict:
+    """Return a user model"""
+    user = {
+        "username": username,
+        "auth_state": {k: v for k, v in kwargs.items() if not k.startswith("oauth_")},
+    }
+    user.update(kwargs)
+    return user
+
 
 @pytest.fixture(scope="function")
 def make_lti11_basic_launch_request_args() -> Dict[str, str]:
     def _make_lti11_basic_launch_args(
+        roles: str = "Instructor",
+        ext_roles: str = "urn:lti:instrole:ims/lis/Instructor",
+        lms_vendor: str = "canvas",
         oauth_consumer_key: str = "my_consumer_key",
         oauth_consumer_secret: str = "my_shared_secret",
     ):
         oauth_timestamp = str(int(time.time()))
         oauth_nonce = secrets.token_urlsafe(32)
         args = {
-            "lti_message_type": "basic-lti-launch-request",
-            "lti_version": "LTI-1p0".encode(),
-            "resource_link_id": "88391-e1919-bb3456",
+            "oauth_callback": "about:blank",
             "oauth_consumer_key": oauth_consumer_key,
             "oauth_timestamp": str(int(oauth_timestamp)),
             "oauth_nonce": str(oauth_nonce),
             "oauth_signature_method": "HMAC-SHA1",
-            "oauth_callback": "about:blank",
             "oauth_version": "1.0",
-            "user_id": "123123123",
+            "context_id": "888efe72d4bbbdf90619353bb8ab5965ccbe9b3f",
+            "context_label": "Introduction to Data Science",
+            "context_title": "Introduction101",
+            "course_lineitems": "https://canvas.instructure.com/api/lti/courses/1/line_items",
+            "custom_canvas_assignment_title": "test-assignment",
+            "custom_canvas_course_id": "616",
+            "custom_canvas_enrollment_state": "active",
+            "custom_canvas_user_id": "1091",
+            "custom_canvas_user_login_id": "student1@example.com",
+            "ext_roles": ext_roles,
+            "launch_presentation_document_target": "iframe",
+            "launch_presentation_height": "1000",
+            "launch_presentation_locale": "en",
+            "launch_presentation_return_url": "https://canvas.instructure.com/courses/161/external_content/success/external_tool_redirect",
+            "launch_presentation_width": "1000",
+            "lis_outcome_service_url": "http://www.imsglobal.org/developers/LTI/test/v1p1/common/tool_consumer_outcome.php?b64=MTIzNDU6OjpzZWNyZXQ=",
+            "lis_person_contact_email_primary": "student1@example.com",
+            "lis_person_name_family": "Bar",
+            "lis_person_name_full": "Foo Bar",
+            "lis_person_name_given": "Foo",
+            "lti_message_type": "basic-lti-launch-request",
+            "lis_result_sourcedid": "feb-123-456-2929::28883",
+            "lti_version": "LTI-1p0",
+            "resource_link_id": "888efe72d4bbbdf90619353bb8ab5965ccbe9b3f",
+            "resource_link_title": "Test-Assignment",
+            "roles": roles,
+            "tool_consumer_info_product_family_code": lms_vendor,
+            "tool_consumer_info_version": "cloud",
+            "tool_consumer_instance_contact_email": "notifications@mylms.com",
+            "tool_consumer_instance_guid": "srnuz6h1U8kOMmETzoqZTJiPWzbPXIYkAUnnAJ4u:test-lms",
+            "tool_consumer_instance_name": "myedutool",
+            "user_id": "185d6c59731a553009ca9b59ca3a885100000",
+            "user_image": "https://lms.example.com/avatar-50.png",
         }
         extra_args = {"my_key": "this_value"}
         headers = {"Content-Type": "application/x-www-form-urlencoded"}
@@ -48,3 +98,108 @@ def make_lti11_basic_launch_request_args() -> Dict[str, str]:
         return args
 
     return _make_lti11_basic_launch_args
+
+
+@pytest.fixture(scope="function")
+def make_lti11_success_authentication_request_args():
+    def _make_lti11_success_authentication_request_args(
+        roles: str = "Instructor",
+        ext_roles: str = "urn:lti:instrole:ims/lis/Instructor",
+        lms_vendor: str = "canvas",
+        oauth_consumer_key: str = "my_consumer_key",
+    ):
+        """
+        Return a valid request arguments make from LMS to our tool (when authentication steps were success)
+        """
+        args = {
+            "oauth_callback": ["about:blank".encode()],
+            "oauth_consumer_key": [oauth_consumer_key.encode()],
+            "oauth_signature_method": ["HMAC-SHA1".encode()],
+            "oauth_timestamp": ["1585947271".encode()],
+            "oauth_nonce": ["01fy8HKIASKuD9gK9vWUcBj9fql1nOCWfOLPzeylsmg".encode()],
+            "oauth_signature": ["abc123".encode()],
+            "oauth_version": ["1.0".encode()],
+            "context_id": ["888efe72d4bbbdf90619353bb8ab5965ccbe9b3f".encode()],
+            "context_label": ["intro101".encode()],
+            "context_title": ["intro101".encode()],
+            "course_lineitems": [
+                "my.platform.com/api/lti/courses/1/line_items".encode()
+            ],
+            "custom_canvas_assignment_title": ["test-assignment".encode()],
+            "custom_canvas_course_id": ["616".encode()],
+            "custom_canvas_enrollment_state": ["active".encode()],
+            "custom_canvas_user_id": ["1091".encode()],
+            "custom_canvas_user_login_id": ["student1@example.com".encode()],
+            "ext_roles": [ext_roles.encode()],
+            "launch_presentation_document_target": ["iframe".encode()],
+            "launch_presentation_height": ["1000".encode()],
+            "launch_presentation_locale": ["en".encode()],
+            "launch_presentation_return_url": [
+                "https: //illumidesk.instructure.com/courses/161/external_content/success/external_tool_redirect".encode()
+            ],
+            "launch_presentation_width": ["1000".encode()],
+            "lis_outcome_service_url": [
+                "http://www.imsglobal.org/developers/LTI/test/v1p1/common/tool_consumer_outcome.php?b64=MTIzNDU6OjpzZWNyZXQ=".encode()
+            ],
+            "lis_person_contact_email_primary": ["student1@example.com".encode()],
+            "lis_person_name_family": ["Bar".encode()],
+            "lis_person_name_full": ["Foo Bar".encode()],
+            "lis_person_name_given": ["Foo".encode()],
+            "lti_message_type": ["basic-lti-launch-request".encode()],
+            "lis_result_sourcedid": ["feb-123-456-2929::28883".encode()],
+            "lti_version": ["LTI-1p0".encode()],
+            "resource_link_id": ["888efe72d4bbbdf90619353bb8ab5965ccbe9b3f".encode()],
+            "resource_link_title": ["Test-Assignment-Another-LMS".encode()],
+            "roles": [roles.encode()],
+            "tool_consumer_info_product_family_code": [lms_vendor.encode()],
+            "tool_consumer_info_version": ["cloud".encode()],
+            "tool_consumer_instance_contact_email": [
+                "notifications@mylms.com".encode()
+            ],
+            "tool_consumer_instance_guid": [
+                "srnuz6h1U8kOMmETzoqZTJiPWzbPXIYkAUnnAJ4u:test-lms".encode()
+            ],
+            "tool_consumer_instance_name": ["myorg".encode()],
+            "user_id": ["185d6c59731a553009ca9b59ca3a885100000".encode()],
+            "user_image": ["https://lms.example.com/avatar-50.png".encode()],
+        }
+        return args
+
+    return _make_lti11_success_authentication_request_args
+
+
+@pytest.fixture(scope="function")
+def make_lti11_mock_request_handler() -> RequestHandler:
+    """
+    Sourced from https://github.com/jupyterhub/oauthenticator/blob/master/oauthenticator/tests/mocks.py
+    """
+
+    def _make_lti11_mock_request_handler(
+        handler: RequestHandler,
+        uri: str = "https://hub.example.com",
+        method: str = "POST",
+        **settings: dict,
+    ) -> RequestHandler:
+        """Instantiate a Handler in a mock application"""
+        application = Application(
+            hub=Mock(
+                base_url="/hub/",
+                server=Mock(base_url="/hub/"),
+            ),
+            cookie_secret=os.urandom(32),
+            db=Mock(rollback=Mock(return_value=None)),
+            **settings,
+        )
+        request = HTTPServerRequest(
+            method=method,
+            uri=uri,
+            connection=Mock(),
+        )
+        handler = RequestHandler(
+            application=application,
+            request=request,
+        )
+        handler._transforms = []
+        return handler
+
+    return _make_lti11_mock_request_handler
diff --git a/tests/test_lti11_authenticator.py b/tests/test_lti11_authenticator.py
index 0b62ca9..267ceac 100644
--- a/tests/test_lti11_authenticator.py
+++ b/tests/test_lti11_authenticator.py
@@ -1,143 +1,131 @@
-import pytest
-
-from tornado import web
-
-from ltiauthenticator.lti11.validator import LTI11LaunchValidator
-
-
-def test_launch(make_lti11_basic_launch_request_args):
-    """Test a basic launch request"""
-    oauth_consumer_key = "my_consumer_key"
-    oauth_consumer_secret = "my_shared_secret"
-    launch_url = "http://jupyterhub/hub/lti/launch"
-    headers = {"Content-Type": "application/x-www-form-urlencoded"}
-
-    args = make_lti11_basic_launch_request_args(
-        oauth_consumer_key,
-        oauth_consumer_secret,
-    )
-
-    validator = LTI11LaunchValidator({oauth_consumer_key: oauth_consumer_secret})
-
-    assert validator.validate_launch_request(launch_url, headers, args)
-
-
-def test_wrong_key(make_lti11_basic_launch_request_args):
-    """Test that the request is rejected when receiving the wrong consumer key."""
-    oauth_consumer_key = "my_consumer_key"
-    oauth_consumer_secret = "my_shared_secret"
-    launch_url = "http://jupyterhub/hub/lti/launch"
-    headers = {"Content-Type": "application/x-www-form-urlencoded"}
-
-    args = make_lti11_basic_launch_request_args(
-        oauth_consumer_key,
-        oauth_consumer_secret,
-    )
-
-    validator = LTI11LaunchValidator({"wrongkey": oauth_consumer_secret})
-
-    with pytest.raises(web.HTTPError):
-        assert validator.validate_launch_request(launch_url, headers, args)
-
-
-def test_wrong_secret(make_lti11_basic_launch_request_args):
-    """Test that a request is rejected when the signature is created with the wrong secret."""
-    oauth_consumer_key = "my_consumer_key"
-    oauth_consumer_secret = "my_shared_secret"
-    launch_url = "http://jupyterhub/hub/lti/launch"
-    headers = {"Content-Type": "application/x-www-form-urlencoded"}
-
-    args = make_lti11_basic_launch_request_args(
-        oauth_consumer_key,
-        oauth_consumer_secret,
-    )
-
-    validator = LTI11LaunchValidator({oauth_consumer_key: "wrongsecret"})
-
-    with pytest.raises(web.HTTPError):
-        validator.validate_launch_request(launch_url, headers, args)
-
-
-def test_full_replay(make_lti11_basic_launch_request_args):
-    """Ensure that an oauth timestamp/nonce replay raises an HTTPError"""
+import json
 
-    oauth_consumer_key = "my_consumer_key"
-    oauth_consumer_secret = "my_shared_secret"
-    launch_url = "http://jupyterhub/hub/lti/launch"
-    headers = {"Content-Type": "application/x-www-form-urlencoded"}
+from unittest.mock import Mock
+from unittest.mock import patch
 
-    args = make_lti11_basic_launch_request_args(
-        oauth_consumer_key,
-        oauth_consumer_secret,
-    )
-
-    validator = LTI11LaunchValidator({oauth_consumer_key: oauth_consumer_secret})
-
-    assert validator.validate_launch_request(launch_url, headers, args)
-
-    with pytest.raises(web.HTTPError):
-        validator.validate_launch_request(launch_url, headers, args)
-
-
-def test_partial_replay_timestamp(make_lti11_basic_launch_request_args):
-    """Test that a partial timestamp replay raises an HTTPError."""
-
-    oauth_consumer_key = "my_consumer_key"
-    oauth_consumer_secret = "my_shared_secret"
-    launch_url = "http://jupyterhub/hub/lti/launch"
-    headers = {"Content-Type": "application/x-www-form-urlencoded"}
-
-    args = make_lti11_basic_launch_request_args(
-        oauth_consumer_key,
-        oauth_consumer_secret,
-    )
-
-    validator = LTI11LaunchValidator({oauth_consumer_key: oauth_consumer_secret})
-
-    assert validator.validate_launch_request(launch_url, headers, args)
-
-    args["oauth_timestamp"] = str(int(float(args["oauth_timestamp"])) - 1)
-    with pytest.raises(web.HTTPError):
-        validator.validate_launch_request(launch_url, headers, args)
-
-
-def test_partial_replay_nonce(make_lti11_basic_launch_request_args):
-    """Test that a partial nonce replay raises an HTTPError"""
-    oauth_consumer_key = "my_consumer_key"
-    oauth_consumer_secret = "my_shared_secret"
-    launch_url = "http://jupyterhub/hub/lti/launch"
-    headers = {"Content-Type": "application/x-www-form-urlencoded"}
-
-    args = make_lti11_basic_launch_request_args(
-        oauth_consumer_key,
-        oauth_consumer_secret,
-    )
-
-    validator = LTI11LaunchValidator({oauth_consumer_key: oauth_consumer_secret})
-
-    assert validator.validate_launch_request(launch_url, headers, args)
-
-    args["oauth_nonce"] = args["oauth_nonce"] + "1"
-    with pytest.raises(web.HTTPError):
-        validator.validate_launch_request(launch_url, headers, args)
-
-
-def test_dubious_extra_args(make_lti11_basic_launch_request_args):
-    """Ensure that dubious extra args are rejected"""
-    oauth_consumer_key = "my_consumer_key"
-    oauth_consumer_secret = "my_shared_secret"
-    launch_url = "http://jupyterhub/hub/lti/launch"
-    headers = {"Content-Type": "application/x-www-form-urlencoded"}
+import pytest
 
-    args = make_lti11_basic_launch_request_args(
-        oauth_consumer_key,
-        oauth_consumer_secret,
-    )
+from tornado.web import HTTPError
+from tornado.httputil import HTTPServerRequest
+from tornado.web import RequestHandler
 
-    validator = LTI11LaunchValidator({oauth_consumer_key: oauth_consumer_secret})
+from ltiauthenticator.lti11.auth import LTI11Authenticator
+from ltiauthenticator.lti11.validator import LTI11LaunchValidator
 
-    assert validator.validate_launch_request(launch_url, headers, args)
 
-    args["extra_credential"] = "i have admin powers"
-    with pytest.raises(web.HTTPError):
-        validator.validate_launch_request(launch_url, headers, args)
+@pytest.mark.asyncio
+async def test_authenticator_uses_lti11validator(
+    make_lti11_success_authentication_request_args,
+):
+    """
+    Ensure that we call the LTI11Validator from the LTI11Authenticator.
+    """
+    with patch.object(
+        LTI11LaunchValidator, "validate_launch_request", return_value=True
+    ) as mock_validator:
+
+        authenticator = LTI11Authenticator()
+        handler = Mock(spec=RequestHandler)
+        request = HTTPServerRequest(
+            method="POST",
+            connection=Mock(),
+        )
+        handler.request = request
+
+        handler.request.arguments = make_lti11_success_authentication_request_args(
+            "lmsvendor"
+        )
+        handler.request.get_argument = (
+            lambda x, strip=True: make_lti11_success_authentication_request_args(
+                "lmsvendor"
+            )[x][0].decode()
+        )
+
+        _ = await authenticator.authenticate(handler, None)
+        assert mock_validator.called
+
+
+@pytest.mark.asyncio
+async def test_authenticator_returns_auth_dict_when_custom_canvas_user_id_is_empty(
+    make_lti11_success_authentication_request_args,
+):
+    """
+    Do we get a valid username when the custom_canvas_user_id is empty?
+    """
+    local_args = make_lti11_success_authentication_request_args()
+    local_args["custom_canvas_user_id"] = ["".encode()]
+    with patch.object(
+        LTI11LaunchValidator, "validate_launch_request", return_value=True
+    ):
+        authenticator = LTI11Authenticator()
+        handler = Mock(
+            spec=RequestHandler,
+            get_secure_cookie=Mock(return_value=json.dumps(["key", "secret"])),
+            request=Mock(
+                arguments=local_args,
+                headers={},
+                items=[],
+            ),
+        )
+        result = await authenticator.authenticate(handler, None)
+        expected = {
+            "name": "185d6c59731a553009ca9b59ca3a885100000",
+        }
+        assert result["name"] == expected["name"]
+
+
+@pytest.mark.asyncio
+async def test_authenticator_returns_correct_username_when_using_lis_person_contact_email_primary(
+    make_lti11_success_authentication_request_args,
+):
+    """
+    Do we get a valid username with lms vendors other than canvas?
+    """
+    local_args = make_lti11_success_authentication_request_args()
+    local_authenticator = LTI11Authenticator()
+    local_authenticator.username_key = "lis_person_contact_email_primary"
+    with patch.object(
+        LTI11LaunchValidator, "validate_launch_request", return_value=True
+    ):
+        authenticator = local_authenticator
+        handler = Mock(
+            spec=RequestHandler,
+            get_secure_cookie=Mock(return_value=json.dumps(["key", "secret"])),
+            request=Mock(
+                arguments=local_args,
+                headers={},
+                items=[],
+            ),
+        )
+        result = await authenticator.authenticate(handler, None)
+        expected = {
+            "name": "student1@example.com",
+        }
+        assert result["name"] == expected["name"]
+
+
+@pytest.mark.asyncio
+async def test_empty_username_raises_http_error(
+    make_lti11_success_authentication_request_args,
+):
+    """Does an empty username value raise the correct 400 HTTPError?"""
+    local_args = make_lti11_success_authentication_request_args()
+    local_authenticator = LTI11Authenticator()
+    local_args["custom_canvas_user_id"] = ["".encode()]
+    local_args["user_id"] = ["".encode()]
+
+    with patch.object(
+        LTI11LaunchValidator, "validate_launch_request", return_value=True
+    ):
+        authenticator = local_authenticator
+        handler = Mock(
+            spec=RequestHandler,
+            get_secure_cookie=Mock(return_value=json.dumps(["key", "secret"])),
+            request=Mock(
+                arguments=local_args,
+                headers={},
+                items=[],
+            ),
+        )
+        with pytest.raises(HTTPError):
+            _ = await authenticator.authenticate(handler, None)
diff --git a/tests/test_lti11_handlers.py b/tests/test_lti11_handlers.py
new file mode 100644
index 0000000..4719200
--- /dev/null
+++ b/tests/test_lti11_handlers.py
@@ -0,0 +1,41 @@
+from unittest.mock import patch
+
+import pytest
+
+from ltiauthenticator.lti11.handlers import LTI11AuthenticateHandler
+
+
+@pytest.mark.asyncio
+async def test_lti_11_authenticate_handler_invokes_redirect_method(
+    make_lti11_mock_request_handler,
+):
+    """
+    Does the LTI11AuthenticateHandler call the redirect function?
+    """
+    local_handler = make_lti11_mock_request_handler(LTI11AuthenticateHandler)
+    with patch.object(
+        LTI11AuthenticateHandler, "redirect", return_value=None
+    ) as mock_redirect:
+        with patch.object(LTI11AuthenticateHandler, "login_user", return_value=None):
+            await LTI11AuthenticateHandler(
+                local_handler.application, local_handler.request
+            ).post()
+            assert mock_redirect.called
+
+
+@pytest.mark.asyncio
+async def test_lti_11_authenticate_handler_invokes_login_user_method(
+    make_lti11_mock_request_handler,
+):
+    """
+    Does the LTI11AuthenticateHandler call the login_user function?
+    """
+    local_handler = make_lti11_mock_request_handler(LTI11AuthenticateHandler)
+    with patch.object(LTI11AuthenticateHandler, "redirect", return_value=None):
+        with patch.object(
+            LTI11AuthenticateHandler, "login_user", return_value=None
+        ) as mock_login_user:
+            await LTI11AuthenticateHandler(
+                local_handler.application, local_handler.request
+            ).post()
+            assert mock_login_user.called