lti-client-mock 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lti_client_mock-0.0.1/.env +7 -0
- lti_client_mock-0.0.1/.github/dependabot.yml +13 -0
- lti_client_mock-0.0.1/.github/workflows/codestyle.yml +41 -0
- lti_client_mock-0.0.1/.github/workflows/release.yml +39 -0
- lti_client_mock-0.0.1/.github/workflows/tests.yml +37 -0
- lti_client_mock-0.0.1/.gitignore +2 -0
- lti_client_mock-0.0.1/.pre-commit-config.yaml +7 -0
- lti_client_mock-0.0.1/.vscode/settings.json +9 -0
- lti_client_mock-0.0.1/LICENSE.txt +9 -0
- lti_client_mock-0.0.1/PKG-INFO +48 -0
- lti_client_mock-0.0.1/README.md +21 -0
- lti_client_mock-0.0.1/lti_client_mock/__about__.py +6 -0
- lti_client_mock-0.0.1/lti_client_mock/__init__.py +4 -0
- lti_client_mock-0.0.1/lti_client_mock/server.py +314 -0
- lti_client_mock-0.0.1/lti_client_mock/settings.py +31 -0
- lti_client_mock-0.0.1/pyproject.toml +163 -0
- lti_client_mock-0.0.1/tests/__init__.py +4 -0
- lti_client_mock-0.0.1/tests/conftest.py +45 -0
- lti_client_mock-0.0.1/tests/test_login.py +92 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
LTI_CLIENT_MOCK__LTI = '{"http://localhost:8484": [{"client_id": "lti-provider-mock", "auth_login_url": "http://localhost:8484/authorize", "auth_token_url": "http://localhost:8484/token", "key_set_url": "http://localhost:8484/jwks", "deployment_ids": ["1"]}]}'
|
|
2
|
+
LTI_PROVIDER_MOCK__AUTH_USERS = []
|
|
3
|
+
LTI_PROVIDER_MOCK__USERS = [{"id": "1", "given_name": "The", "family_name": "Admin", "email": "admin@example.com"}, {"id": "2", "given_name": "A", "family_name": "User", "email": "user1@example.com"}]
|
|
4
|
+
LTI_PROVIDER_MOCK__COURSES = [{"id": "1", "name": "Course 1"}, {"id": "2", "name": "Course 2", "users": ["2"]}]
|
|
5
|
+
LTI_PROVIDER_MOCK__LTI__ISS = http://localhost:8484
|
|
6
|
+
LTI_PROVIDER_MOCK__LTI__LOGIN_URL = http://localhost:8485/login
|
|
7
|
+
LTI_PROVIDER_MOCK__LTI__LAUNCH_URL = http://localhost:8485/launch
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
# Check for updates of all GitHub Actions
|
|
4
|
+
- package-ecosystem: "github-actions"
|
|
5
|
+
directory: "/"
|
|
6
|
+
schedule:
|
|
7
|
+
interval: "weekly"
|
|
8
|
+
|
|
9
|
+
# Check for updates of all Python packages
|
|
10
|
+
- package-ecosystem: "pip"
|
|
11
|
+
directory: "/"
|
|
12
|
+
schedule:
|
|
13
|
+
interval: "weekly"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: Code Style
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
ruff:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
python_version: ["3.11", "3.12", "3.13", "3.14"]
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v7
|
|
19
|
+
|
|
20
|
+
- uses: actions/setup-python@v7
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python_version }}
|
|
23
|
+
|
|
24
|
+
- name: Add .local path
|
|
25
|
+
run: |
|
|
26
|
+
echo "PATH=$HOME/.local/bin:$PATH" >> $GITHUB_ENV
|
|
27
|
+
echo "PIPX_HOME=$HOME/.local/pipx" >> $GITHUB_ENV
|
|
28
|
+
echo "PIPX_BIN_DIR=$HOME/.local/bin" >> $GITHUB_ENV
|
|
29
|
+
|
|
30
|
+
- name: Install Base Dependencies
|
|
31
|
+
run: |
|
|
32
|
+
sudo apt-get install python3-venv pipx
|
|
33
|
+
pipx install hatch
|
|
34
|
+
|
|
35
|
+
- name: Run Ruff check
|
|
36
|
+
run: |
|
|
37
|
+
hatch run ruff-check
|
|
38
|
+
|
|
39
|
+
- name: Run Ruff format check
|
|
40
|
+
run: |
|
|
41
|
+
hatch run ruff-format
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
id-token: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
pypi:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
|
|
15
|
+
environment:
|
|
16
|
+
name: pypi
|
|
17
|
+
url: https://pypi.org/project/lti-client-mock
|
|
18
|
+
|
|
19
|
+
permissions:
|
|
20
|
+
id-token: write
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v7
|
|
24
|
+
|
|
25
|
+
- uses: actions/setup-python@v7
|
|
26
|
+
with:
|
|
27
|
+
python-version: |
|
|
28
|
+
3.11
|
|
29
|
+
|
|
30
|
+
- name: Install Base Dependencies
|
|
31
|
+
run: |
|
|
32
|
+
pip install --user build
|
|
33
|
+
|
|
34
|
+
- name: Build the package
|
|
35
|
+
run: |
|
|
36
|
+
python -m build
|
|
37
|
+
|
|
38
|
+
- name: Publish package distributions to PyPI
|
|
39
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
ruff:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
python_version: ["3.11", "3.12", "3.13", "3.14"]
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v7
|
|
19
|
+
|
|
20
|
+
- uses: actions/setup-python@v7
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python_version }}
|
|
23
|
+
|
|
24
|
+
- name: Add .local path
|
|
25
|
+
run: |
|
|
26
|
+
echo "PATH=$HOME/.local/bin:$PATH" >> $GITHUB_ENV
|
|
27
|
+
echo "PIPX_HOME=$HOME/.local/pipx" >> $GITHUB_ENV
|
|
28
|
+
echo "PIPX_BIN_DIR=$HOME/.local/bin" >> $GITHUB_ENV
|
|
29
|
+
|
|
30
|
+
- name: Install Base Dependencies
|
|
31
|
+
run: |
|
|
32
|
+
sudo apt-get install python3-venv pipx
|
|
33
|
+
pipx install hatch
|
|
34
|
+
|
|
35
|
+
- name: Run tests
|
|
36
|
+
run: |
|
|
37
|
+
hatch run test
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026-present Mark Hall <mark.hall@work.room3b.eu>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lti-client-mock
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Project-URL: Documentation, https://github.com/Mark Hall/lti-client-mock#readme
|
|
5
|
+
Project-URL: Issues, https://github.com/Mark Hall/lti-client-mock/issues
|
|
6
|
+
Project-URL: Source, https://github.com/Mark Hall/lti-client-mock
|
|
7
|
+
Author-email: Mark Hall <mark.hall@work.room3b.eu>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE.txt
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
18
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Requires-Dist: fastapi
|
|
21
|
+
Requires-Dist: itsdangerous
|
|
22
|
+
Requires-Dist: pydantic
|
|
23
|
+
Requires-Dist: pydantic-settings
|
|
24
|
+
Requires-Dist: pylti1p3next
|
|
25
|
+
Requires-Dist: python-multipart
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# lti-client-mock
|
|
29
|
+
|
|
30
|
+
[](https://pypi.org/project/lti-client-mock)
|
|
31
|
+
[](https://pypi.org/project/lti-client-mock)
|
|
32
|
+
|
|
33
|
+
-----
|
|
34
|
+
|
|
35
|
+
## Table of Contents
|
|
36
|
+
|
|
37
|
+
- [Installation](#installation)
|
|
38
|
+
- [License](#license)
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```console
|
|
43
|
+
pip install lti-client-mock
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
`lti-client-mock` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# lti-client-mock
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/lti-client-mock)
|
|
4
|
+
[](https://pypi.org/project/lti-client-mock)
|
|
5
|
+
|
|
6
|
+
-----
|
|
7
|
+
|
|
8
|
+
## Table of Contents
|
|
9
|
+
|
|
10
|
+
- [Installation](#installation)
|
|
11
|
+
- [License](#license)
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```console
|
|
16
|
+
pip install lti-client-mock
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## License
|
|
20
|
+
|
|
21
|
+
`lti-client-mock` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""LTI Client (Tool) mock server."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from secrets import token_hex
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
from fastapi import Cookie, FastAPI
|
|
9
|
+
from fastapi.exceptions import HTTPException
|
|
10
|
+
from fastapi.requests import Request
|
|
11
|
+
from fastapi.responses import RedirectResponse, Response
|
|
12
|
+
from itsdangerous.exc import BadData
|
|
13
|
+
from itsdangerous.serializer import Serializer
|
|
14
|
+
from pylti1p3.cookie import CookieService as LTICookieService
|
|
15
|
+
from pylti1p3.exception import LtiException, OIDCException
|
|
16
|
+
from pylti1p3.message_launch import MessageLaunch as LTIMessageLaunch
|
|
17
|
+
from pylti1p3.oidc_login import OIDCLogin as LTILogin
|
|
18
|
+
from pylti1p3.request import Request as LTIRequest
|
|
19
|
+
from pylti1p3.session import SessionService as LTISessionService
|
|
20
|
+
from pylti1p3.tool_config.dict import ToolConfDict
|
|
21
|
+
|
|
22
|
+
from lti_client_mock.settings import settings
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
app = FastAPI()
|
|
26
|
+
lti_session_serializer = Serializer(token_hex(32), salt="lti-login")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FastAPILTIRequest(LTIRequest):
|
|
30
|
+
"""Request wrapper for the LTI authentication processes."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self: "FastAPILTIRequest",
|
|
34
|
+
request: Request,
|
|
35
|
+
parameters: dict,
|
|
36
|
+
clear_session: bool = False, # noqa: FBT001, FBT002
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Create a new :class:`~container_launcher.server.handlers.lti.FastAPILTIRequest`.
|
|
39
|
+
|
|
40
|
+
:param request: The request handler for parameter and session access.
|
|
41
|
+
:type request: :class:`~fastapi.requests.Request`
|
|
42
|
+
:param parameters: The parameters sent with the request
|
|
43
|
+
:type params: dict
|
|
44
|
+
:param clear_session: Whether to clear the session first. Default: `False`
|
|
45
|
+
:type clear_session: bool
|
|
46
|
+
"""
|
|
47
|
+
self._parameters = parameters
|
|
48
|
+
if "lti-session" in request.cookies and not clear_session:
|
|
49
|
+
try:
|
|
50
|
+
self._session = lti_session_serializer.loads(request.cookies["lti-session"])
|
|
51
|
+
except BadData as e:
|
|
52
|
+
logger.error(f"Invalid session data received: {e}")
|
|
53
|
+
self._session = {}
|
|
54
|
+
else:
|
|
55
|
+
self._session = {}
|
|
56
|
+
|
|
57
|
+
def get_param(self: "FastAPILTIRequest", key: str) -> str:
|
|
58
|
+
"""Get the request parameter for the ``key``.
|
|
59
|
+
|
|
60
|
+
:param key: The key of the request parameter
|
|
61
|
+
:type key: str
|
|
62
|
+
:return: The value for the request parameter
|
|
63
|
+
:rtype: str | None
|
|
64
|
+
"""
|
|
65
|
+
return self._parameters[key]
|
|
66
|
+
|
|
67
|
+
def is_secure(self: "FastAPILTIRequest") -> bool:
|
|
68
|
+
"""Determine whether the request is secure.
|
|
69
|
+
|
|
70
|
+
Currently defaults to ``True``.
|
|
71
|
+
"""
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def session(self: "FastAPILTIRequest") -> dict:
|
|
76
|
+
"""Get the session for the request.
|
|
77
|
+
|
|
78
|
+
:return: The current session.
|
|
79
|
+
:rtype: dict
|
|
80
|
+
"""
|
|
81
|
+
return self._session
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class FastAPILTICookieService(LTICookieService):
|
|
85
|
+
"""Cookie wrapper for the LTI authentication process."""
|
|
86
|
+
|
|
87
|
+
def __init__(self: "FastAPILTICookieService", request: Request) -> None:
|
|
88
|
+
"""Create a new :class:`~container_launcher.server.handlers.lti.FastAPILTICookieService`.
|
|
89
|
+
|
|
90
|
+
:param request: The request to use for getting cookies.
|
|
91
|
+
:type request: :class:`~fastapi.requests.Request`
|
|
92
|
+
"""
|
|
93
|
+
super().__init__()
|
|
94
|
+
self._request = request
|
|
95
|
+
self.cookies = {}
|
|
96
|
+
|
|
97
|
+
def set_cookie(self: "FastAPILTICookieService", name: str, value: str | int, exp: int | None = 3600) -> None:
|
|
98
|
+
"""Set a cookie value.
|
|
99
|
+
|
|
100
|
+
:param name: The name of the cookie to set.
|
|
101
|
+
:type name: str
|
|
102
|
+
:param value: The value of the cookie to set.
|
|
103
|
+
:type value: str
|
|
104
|
+
:param expires: The number of seconds until the cookie expires.
|
|
105
|
+
:type expires: int
|
|
106
|
+
"""
|
|
107
|
+
logger.debug(f"Set cookie {name} to {value}")
|
|
108
|
+
if exp is not None:
|
|
109
|
+
self.cookies[name] = {"value": str(value), "expires": exp}
|
|
110
|
+
else:
|
|
111
|
+
self.cookies[name] = {"value": str(value)}
|
|
112
|
+
|
|
113
|
+
def get_cookie(self: "FastAPILTICookieService", name: str) -> str | None:
|
|
114
|
+
"""Get a cookie value.
|
|
115
|
+
|
|
116
|
+
:param name: The name of the cookie to get.
|
|
117
|
+
:type name: str
|
|
118
|
+
:return: The value of the cookie
|
|
119
|
+
:rtype: str
|
|
120
|
+
"""
|
|
121
|
+
logger.debug(f"Fetch cookie {name}")
|
|
122
|
+
return self._request.cookies.get(name)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class FastAPILTIRedirect:
|
|
126
|
+
"""Redirection object required for the LTI authentication process."""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self: "FastAPILTIRedirect", request: "FastAPILTIRequest", cookie_service: "FastAPILTICookieService", url: str
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Create a new :class:`~container_launcher.server.api.auth.lti.FastAPILTIRedirect`.
|
|
132
|
+
|
|
133
|
+
:param request: The request for accessing the session
|
|
134
|
+
:type request: :class:`~container_launcher.server.api.auth.lti.FastAPILTIRequest`
|
|
135
|
+
:param cookie_service: The service used to set cookies
|
|
136
|
+
:type cookie_service: :class:`~container_launcher.server.api.auth.lti.FastAPILTICookieService`
|
|
137
|
+
:param url: The URL to redirect to.
|
|
138
|
+
:type url: str
|
|
139
|
+
"""
|
|
140
|
+
self._request = request
|
|
141
|
+
self._cookie_service = cookie_service
|
|
142
|
+
self._url = url
|
|
143
|
+
|
|
144
|
+
def do_redirect(self: "FastAPILTIRedirect") -> RedirectResponse:
|
|
145
|
+
"""Perform the actual redirect."""
|
|
146
|
+
response = RedirectResponse(self._url)
|
|
147
|
+
for name, content in self._cookie_service.cookies.items():
|
|
148
|
+
response.set_cookie(
|
|
149
|
+
name,
|
|
150
|
+
content["value"],
|
|
151
|
+
expires=content["expires"],
|
|
152
|
+
samesite="lax",
|
|
153
|
+
secure=False,
|
|
154
|
+
)
|
|
155
|
+
session_data = lti_session_serializer.dumps(self._request.session)
|
|
156
|
+
response.set_cookie("lti-session", session_data, expires=300, samesite="lax", secure=False)
|
|
157
|
+
return response
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class FastAPILTILogin(LTILogin):
|
|
161
|
+
"""LTI authentication process step 1: authentication."""
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self: "FastAPILTILogin",
|
|
165
|
+
request: FastAPILTIRequest,
|
|
166
|
+
tool_config: ToolConfDict,
|
|
167
|
+
cookie_service: FastAPILTICookieService,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Create a new :class:`~container_launcher.server.api.auth.lti.FastAPILTILogin`.
|
|
170
|
+
|
|
171
|
+
:param request: The request containing the login initiation data.
|
|
172
|
+
:type request: :class:`~container_launcher.server.api.auth.lti.FastAPILTIRequest`
|
|
173
|
+
:param tool_config: The LTI tool configuration.
|
|
174
|
+
:type tool_config: :class:`~pylti1p3.tool_config.ToolConfigDict`
|
|
175
|
+
:param cookie_service: The service used to set cookies
|
|
176
|
+
:type cookie_service: :class:`~container_launcher.server.api.auth.lti.FastAPILTICookieService`
|
|
177
|
+
"""
|
|
178
|
+
super().__init__(
|
|
179
|
+
request=request,
|
|
180
|
+
tool_config=tool_config,
|
|
181
|
+
session_service=LTISessionService(request),
|
|
182
|
+
cookie_service=cookie_service,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def get_redirect(self: "FastAPILTILogin", url: str) -> FastAPILTIRedirect:
|
|
186
|
+
"""Get the authentication redirect.
|
|
187
|
+
|
|
188
|
+
:param url: The URL to redirect to.
|
|
189
|
+
:type url: str
|
|
190
|
+
:return: The redirection proxy.
|
|
191
|
+
:rtype: :class:`~container_launcher.server.api.auth.lti.FastAPILTIRedirect`
|
|
192
|
+
"""
|
|
193
|
+
return FastAPILTIRedirect(self._request, self._cookie_service, url)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class FastAPILTIMessageLaunch(LTIMessageLaunch):
|
|
197
|
+
"""LTI authentication process step 2: launch."""
|
|
198
|
+
|
|
199
|
+
def __init__(
|
|
200
|
+
self: "FastAPILTIMessageLaunch",
|
|
201
|
+
request: FastAPILTIRequest,
|
|
202
|
+
tool_config: ToolConfDict,
|
|
203
|
+
cookie_service: FastAPILTICookieService,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Create a new :class:`~container_launcher.server.handlers.lti.FastAPILTIMessageLaunch`.
|
|
206
|
+
|
|
207
|
+
:param request: The request containing the launch data.
|
|
208
|
+
:type request: :class:`~container_launcher.server.api.auth.lti.FastAPILTIRequest`
|
|
209
|
+
:param tool_config: The LTI tool configuration.
|
|
210
|
+
:type tool_config: :class:`~pylti1p3.tool_config.ToolConfigDict`
|
|
211
|
+
:param cookie_service: The service used to get cookies
|
|
212
|
+
:type cookie_service: :class:`~container_launcher.server.api.auth.lti.FastAPILTICookieService`
|
|
213
|
+
"""
|
|
214
|
+
super().__init__(
|
|
215
|
+
request=request,
|
|
216
|
+
tool_config=tool_config,
|
|
217
|
+
session_service=LTISessionService(request),
|
|
218
|
+
cookie_service=cookie_service,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def _get_request_param(self: "FastAPILTIMessageLaunch", key: str) -> str:
|
|
222
|
+
"""Get a request parameter value.
|
|
223
|
+
|
|
224
|
+
:param key: The key of the request parameter to fetch.
|
|
225
|
+
:type key: str
|
|
226
|
+
:return: The request parameter value.
|
|
227
|
+
:rtype: str
|
|
228
|
+
"""
|
|
229
|
+
return self._request.get_param(key)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def login(request: Request, response: Response, parameters: dict) -> RedirectResponse: # noqa: ARG001
|
|
233
|
+
"""Start the LTI login process."""
|
|
234
|
+
logger.debug("Starting LTI login process")
|
|
235
|
+
oidc_request = FastAPILTIRequest(request, parameters, clear_session=True)
|
|
236
|
+
oidc_login = FastAPILTILogin(
|
|
237
|
+
request=oidc_request,
|
|
238
|
+
tool_config=ToolConfDict(settings.model_dump()["lti"]),
|
|
239
|
+
cookie_service=FastAPILTICookieService(request),
|
|
240
|
+
)
|
|
241
|
+
logger.debug("Redirecting to the LTI platform")
|
|
242
|
+
if "target_link_uri" in parameters:
|
|
243
|
+
return oidc_login.redirect(parameters["target_link_uri"])
|
|
244
|
+
else:
|
|
245
|
+
return oidc_login.redirect("")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@app.post("/login", response_class=RedirectResponse)
|
|
249
|
+
async def login_post(request: Request, response: Response) -> RedirectResponse:
|
|
250
|
+
"""Start the LTI login process from a POST request."""
|
|
251
|
+
try:
|
|
252
|
+
return login(request, response, dict(await request.form()))
|
|
253
|
+
except OIDCException as e:
|
|
254
|
+
logger.error(e)
|
|
255
|
+
raise HTTPException(422, "No valid LTI login data found") from e
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@app.get("/login", response_class=RedirectResponse)
|
|
259
|
+
def login_get(request: Request, response: Response) -> RedirectResponse:
|
|
260
|
+
"""Start the LTI login process from a GET request."""
|
|
261
|
+
try:
|
|
262
|
+
return login(request, response, dict(request.query_params))
|
|
263
|
+
except OIDCException as e:
|
|
264
|
+
logger.error(e)
|
|
265
|
+
raise HTTPException(422, "No valid LTI login data found") from e
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
async def launch(request: Request, response: Response, parameters: dict) -> str:
|
|
269
|
+
"""Launch the LTI tool for the user."""
|
|
270
|
+
logger.debug("Starting the LTI launch process")
|
|
271
|
+
tool_config = ToolConfDict(settings.model_dump()["lti"])
|
|
272
|
+
oidc_request = FastAPILTIRequest(request, parameters)
|
|
273
|
+
message_launch = FastAPILTIMessageLaunch(
|
|
274
|
+
request=oidc_request,
|
|
275
|
+
tool_config=tool_config,
|
|
276
|
+
cookie_service=FastAPILTICookieService(request),
|
|
277
|
+
)
|
|
278
|
+
message_launch.set_jwt_verify_options({"verify_iat": False, "verify_aud": False})
|
|
279
|
+
logger.debug("LTI launch validated - logging in")
|
|
280
|
+
data = message_launch.get_launch_data()
|
|
281
|
+
logger.debug(data)
|
|
282
|
+
response.set_cookie(
|
|
283
|
+
"lti_client_mock_user", lti_session_serializer.dumps(data), max_age=None, secure=False, samesite="lax"
|
|
284
|
+
)
|
|
285
|
+
return "/success"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@app.post("/launch", response_class=RedirectResponse, status_code=303)
|
|
289
|
+
async def launch_post(request: Request, response: Response) -> str:
|
|
290
|
+
"""Launch the LTI tool from a POST request."""
|
|
291
|
+
try:
|
|
292
|
+
return await launch(request, response, dict(await request.form()))
|
|
293
|
+
except LtiException as e:
|
|
294
|
+
logger.error(e)
|
|
295
|
+
logger.error(str(int(datetime.now(tz=timezone.utc).timestamp())))
|
|
296
|
+
raise HTTPException(422, "No valid LTI launch data found") from e
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@app.get("/launch", response_class=RedirectResponse, status_code=303)
|
|
300
|
+
async def launch_get(request: Request, response: Response) -> str:
|
|
301
|
+
"""Launch the LTI tool from a GET request."""
|
|
302
|
+
try:
|
|
303
|
+
return await launch(request, response, dict(request.query_params))
|
|
304
|
+
except LtiException as e:
|
|
305
|
+
logger.error(e)
|
|
306
|
+
logger.error(str(int(datetime.now(tz=timezone.utc).timestamp())))
|
|
307
|
+
raise HTTPException(422, "No valid LTI launch data found") from e
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@app.get("/success")
|
|
311
|
+
def success(lti_client_mock_user: Annotated[str, Cookie()]):
|
|
312
|
+
"""Show the successfull LTI login information."""
|
|
313
|
+
mock_user = lti_session_serializer.loads(lti_client_mock_user)
|
|
314
|
+
return mock_user
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""LTI client mock settings."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LTIModel(BaseModel):
|
|
8
|
+
"""Validation model for a single LTI configuration."""
|
|
9
|
+
|
|
10
|
+
client_id: str
|
|
11
|
+
auth_login_url: str
|
|
12
|
+
auth_token_url: str
|
|
13
|
+
key_set_url: str
|
|
14
|
+
deployment_ids: list[str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Settings(BaseSettings):
|
|
18
|
+
"""LTI provider mock settings."""
|
|
19
|
+
|
|
20
|
+
lti: dict[str, list[LTIModel]] = {}
|
|
21
|
+
|
|
22
|
+
model_config = SettingsConfigDict(
|
|
23
|
+
env_file=".env",
|
|
24
|
+
env_file_encoding="utf-8",
|
|
25
|
+
env_prefix="LTI_CLIENT_MOCK__",
|
|
26
|
+
env_nested_delimiter="__",
|
|
27
|
+
extra="ignore",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
settings = Settings()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lti-client-mock"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = ''
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = []
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Mark Hall", email = "mark.hall@work.room3b.eu" },
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Programming Language :: Python",
|
|
19
|
+
"Programming Language :: Python :: 3.8",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
25
|
+
"Programming Language :: Python :: Implementation :: PyPy",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"fastapi",
|
|
29
|
+
"itsdangerous",
|
|
30
|
+
"pydantic",
|
|
31
|
+
"pydantic-settings",
|
|
32
|
+
"pylti1p3next",
|
|
33
|
+
"python-multipart",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Documentation = "https://github.com/Mark Hall/lti-client-mock#readme"
|
|
38
|
+
Issues = "https://github.com/Mark Hall/lti-client-mock/issues"
|
|
39
|
+
Source = "https://github.com/Mark Hall/lti-client-mock"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.version]
|
|
42
|
+
path = "lti_client_mock/__about__.py"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.envs.default]
|
|
45
|
+
extra-dependencies = [
|
|
46
|
+
"beautifulsoup4",
|
|
47
|
+
"httpx2",
|
|
48
|
+
"lti-provider-mock",
|
|
49
|
+
"pytest",
|
|
50
|
+
"pytest-cov",
|
|
51
|
+
"ruff==0.15.20",
|
|
52
|
+
"uvicorn",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.hatch.envs.default.scripts]
|
|
56
|
+
server = "uvicorn --port 8485 --reload --reload-dir lti_client_mock --log-level debug --host=0.0.0.0 lti_client_mock.server:app"
|
|
57
|
+
lti-provider = "uvicorn --port 8484 lti_provider_mock.server:app"
|
|
58
|
+
|
|
59
|
+
test = "pytest {args:tests}"
|
|
60
|
+
test-cov = "coverage run -m pytest {args:tests}"
|
|
61
|
+
cov-report = ["- coverage combine", "coverage report"]
|
|
62
|
+
cov = ["test-cov", "cov-report"]
|
|
63
|
+
|
|
64
|
+
ruff-check = "ruff check {args:.}"
|
|
65
|
+
ruff-format = "ruff format {args:.}"
|
|
66
|
+
ruff-style = ["ruff-format", "ruff-check"]
|
|
67
|
+
|
|
68
|
+
[tool.hatch.envs.types]
|
|
69
|
+
extra-dependencies = [
|
|
70
|
+
"mypy>=1.0.0",
|
|
71
|
+
]
|
|
72
|
+
[tool.hatch.envs.types.scripts]
|
|
73
|
+
check = "mypy --install-types --non-interactive {args:lti_client_mock tests}"
|
|
74
|
+
|
|
75
|
+
[tool.coverage.run]
|
|
76
|
+
source_pkgs = ["lti_client_mock", "tests"]
|
|
77
|
+
branch = true
|
|
78
|
+
parallel = true
|
|
79
|
+
omit = [
|
|
80
|
+
"lti_client_mock/__about__.py",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
[tool.coverage.paths]
|
|
84
|
+
lti_client_mock = ["lti_client_mock", "*/lti-client-mock/lti_client_mock"]
|
|
85
|
+
tests = ["tests", "*/lti-client-mock/tests"]
|
|
86
|
+
|
|
87
|
+
[tool.coverage.report]
|
|
88
|
+
exclude_lines = [
|
|
89
|
+
"no cov",
|
|
90
|
+
"if __name__ == .__main__.:",
|
|
91
|
+
"if TYPE_CHECKING:",
|
|
92
|
+
]
|
|
93
|
+
skip_covered = true
|
|
94
|
+
show_missing = true
|
|
95
|
+
|
|
96
|
+
[tool.ruff]
|
|
97
|
+
target-version = "py39"
|
|
98
|
+
line-length = 120
|
|
99
|
+
lint.select = [
|
|
100
|
+
"A",
|
|
101
|
+
"ARG",
|
|
102
|
+
"B",
|
|
103
|
+
"C",
|
|
104
|
+
"D",
|
|
105
|
+
"DTZ",
|
|
106
|
+
"E",
|
|
107
|
+
"EM",
|
|
108
|
+
"F",
|
|
109
|
+
"FBT",
|
|
110
|
+
"I",
|
|
111
|
+
"ICN",
|
|
112
|
+
"ISC",
|
|
113
|
+
"N",
|
|
114
|
+
"PLC",
|
|
115
|
+
"PLE",
|
|
116
|
+
"PLR",
|
|
117
|
+
"PLW",
|
|
118
|
+
"Q",
|
|
119
|
+
"RUF",
|
|
120
|
+
"S",
|
|
121
|
+
"T",
|
|
122
|
+
"TID",
|
|
123
|
+
"UP",
|
|
124
|
+
"W",
|
|
125
|
+
"YTT",
|
|
126
|
+
]
|
|
127
|
+
lint.ignore = [
|
|
128
|
+
# Allow non-abstract empty methods in abstract base classes
|
|
129
|
+
"B027",
|
|
130
|
+
# Allow boolean positional values in function calls, like `dict.get(... True)`
|
|
131
|
+
"FBT003",
|
|
132
|
+
# Ignore single-line concatenation
|
|
133
|
+
"ISC001",
|
|
134
|
+
# Ignore checks for possible passwords
|
|
135
|
+
"S105",
|
|
136
|
+
"S106",
|
|
137
|
+
"S107",
|
|
138
|
+
# Ignore complexity
|
|
139
|
+
"C901",
|
|
140
|
+
"PLR0911",
|
|
141
|
+
"PLR0912",
|
|
142
|
+
"PLR0913",
|
|
143
|
+
"PLR0915",
|
|
144
|
+
# Docstring settings
|
|
145
|
+
"D203",
|
|
146
|
+
"D211",
|
|
147
|
+
"D213",
|
|
148
|
+
]
|
|
149
|
+
lint.unfixable = [
|
|
150
|
+
# Don't touch unused imports
|
|
151
|
+
"F401",
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
[tool.ruff.lint.isort]
|
|
155
|
+
known-first-party = ["container_launcher"]
|
|
156
|
+
|
|
157
|
+
[tool.ruff.lint.flake8-tidy-imports]
|
|
158
|
+
ban-relative-imports = "all"
|
|
159
|
+
|
|
160
|
+
[tool.ruff.lint.per-file-ignores]
|
|
161
|
+
# Tests can use magic values, assertions, and relative imports
|
|
162
|
+
"tests/**/*" = ["ARG001", "PLR2004", "S101", "TID252"]
|
|
163
|
+
"dev/jupyterhub_config.py" = ["F821", "S104"]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present Mark Hall <mark.hall@work.room3b.eu>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""Test fixtures."""
|
|
5
|
+
|
|
6
|
+
from threading import Thread
|
|
7
|
+
from time import sleep
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
import uvicorn
|
|
11
|
+
from lti_provider_mock.server import app as lti_server_app
|
|
12
|
+
|
|
13
|
+
from lti_client_mock.server import app as lti_client_app
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture()
|
|
17
|
+
def lti_client_mock():
|
|
18
|
+
"""Run the LTI Client Mock for a single test."""
|
|
19
|
+
config = uvicorn.Config(lti_client_app, host="0.0.0.0", port=8485) # noqa: S104
|
|
20
|
+
server = uvicorn.Server(config)
|
|
21
|
+
|
|
22
|
+
def run_server():
|
|
23
|
+
server.run()
|
|
24
|
+
|
|
25
|
+
thread = Thread(target=run_server)
|
|
26
|
+
thread.start()
|
|
27
|
+
yield server
|
|
28
|
+
server.should_exit = True
|
|
29
|
+
sleep(0.2)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture()
|
|
33
|
+
def lti_provider_mock():
|
|
34
|
+
"""Run the LTI Provider Mock for a single test."""
|
|
35
|
+
config = uvicorn.Config(lti_server_app, host="0.0.0.0", port=8484) # noqa: S104
|
|
36
|
+
server = uvicorn.Server(config)
|
|
37
|
+
|
|
38
|
+
def run_server():
|
|
39
|
+
server.run()
|
|
40
|
+
|
|
41
|
+
thread = Thread(target=run_server)
|
|
42
|
+
thread.start()
|
|
43
|
+
yield server
|
|
44
|
+
server.should_exit = True
|
|
45
|
+
sleep(0.2)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present Mark Hall <mark.hall@work.room3b.eu>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""Test cases."""
|
|
5
|
+
|
|
6
|
+
from bs4 import BeautifulSoup
|
|
7
|
+
from bs4.element import Tag
|
|
8
|
+
from httpx2 import Client
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def serialise_form(form: Tag) -> dict:
|
|
12
|
+
"""Serialise a HTML form parsed by BeautifulSoup into a dict for submission."""
|
|
13
|
+
data = {}
|
|
14
|
+
for element in form.children:
|
|
15
|
+
if isinstance(element, Tag) and element.name == "input":
|
|
16
|
+
data[element["name"]] = element["value"]
|
|
17
|
+
return data
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_login_flow_admin_user(lti_client_mock, lti_provider_mock):
|
|
21
|
+
"""Test the login flow with the admin user."""
|
|
22
|
+
with Client(follow_redirects=True) as client:
|
|
23
|
+
client.post("http://localhost:8484/login", data={"userid": "1"})
|
|
24
|
+
response = client.post("http://localhost:8484/courses/1")
|
|
25
|
+
doc = BeautifulSoup(response.text, "html.parser")
|
|
26
|
+
form = doc.find("form")
|
|
27
|
+
url = str(form["action"])
|
|
28
|
+
data = serialise_form(form)
|
|
29
|
+
response = client.post(url, data=data)
|
|
30
|
+
doc = BeautifulSoup(response.text, "html.parser")
|
|
31
|
+
form = doc.find("form")
|
|
32
|
+
url = str(form["action"])
|
|
33
|
+
data = serialise_form(form)
|
|
34
|
+
response = client.post(url, data=data)
|
|
35
|
+
assert response.status_code == 200
|
|
36
|
+
userinfo = response.json()
|
|
37
|
+
assert userinfo["aud"] == "lti-provider-mock"
|
|
38
|
+
assert userinfo["family_name"] == "Admin"
|
|
39
|
+
assert userinfo["given_name"] == "The"
|
|
40
|
+
assert userinfo["sub"] == "1"
|
|
41
|
+
assert userinfo["https://purl.imsglobal.org/spec/lti/claim/context"]["id"] == "1"
|
|
42
|
+
assert userinfo["https://purl.imsglobal.org/spec/lti/claim/context"]["label"] == "Course 1"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_login_flow_user_course_1(lti_client_mock, lti_provider_mock):
|
|
46
|
+
"""Test the login flow with the a generic user and course 1."""
|
|
47
|
+
with Client(follow_redirects=True) as client:
|
|
48
|
+
client.post("http://localhost:8484/login", data={"userid": "2"})
|
|
49
|
+
response = client.post("http://localhost:8484/courses/1")
|
|
50
|
+
doc = BeautifulSoup(response.text, "html.parser")
|
|
51
|
+
form = doc.find("form")
|
|
52
|
+
url = str(form["action"])
|
|
53
|
+
data = serialise_form(form)
|
|
54
|
+
response = client.post(url, data=data)
|
|
55
|
+
doc = BeautifulSoup(response.text, "html.parser")
|
|
56
|
+
form = doc.find("form")
|
|
57
|
+
url = str(form["action"])
|
|
58
|
+
data = serialise_form(form)
|
|
59
|
+
response = client.post(url, data=data)
|
|
60
|
+
assert response.status_code == 200
|
|
61
|
+
userinfo = response.json()
|
|
62
|
+
assert userinfo["aud"] == "lti-provider-mock"
|
|
63
|
+
assert userinfo["family_name"] == "User"
|
|
64
|
+
assert userinfo["given_name"] == "A"
|
|
65
|
+
assert userinfo["sub"] == "2"
|
|
66
|
+
assert userinfo["https://purl.imsglobal.org/spec/lti/claim/context"]["id"] == "1"
|
|
67
|
+
assert userinfo["https://purl.imsglobal.org/spec/lti/claim/context"]["label"] == "Course 1"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_login_flow_user_course_2(lti_client_mock, lti_provider_mock):
|
|
71
|
+
"""Test the login flow with the a generic user and course 2."""
|
|
72
|
+
with Client(follow_redirects=True) as client:
|
|
73
|
+
client.post("http://localhost:8484/login", data={"userid": "2"})
|
|
74
|
+
response = client.post("http://localhost:8484/courses/2")
|
|
75
|
+
doc = BeautifulSoup(response.text, "html.parser")
|
|
76
|
+
form = doc.find("form")
|
|
77
|
+
url = str(form["action"])
|
|
78
|
+
data = serialise_form(form)
|
|
79
|
+
response = client.post(url, data=data)
|
|
80
|
+
doc = BeautifulSoup(response.text, "html.parser")
|
|
81
|
+
form = doc.find("form")
|
|
82
|
+
url = str(form["action"])
|
|
83
|
+
data = serialise_form(form)
|
|
84
|
+
response = client.post(url, data=data)
|
|
85
|
+
assert response.status_code == 200
|
|
86
|
+
userinfo = response.json()
|
|
87
|
+
assert userinfo["aud"] == "lti-provider-mock"
|
|
88
|
+
assert userinfo["family_name"] == "User"
|
|
89
|
+
assert userinfo["given_name"] == "A"
|
|
90
|
+
assert userinfo["sub"] == "2"
|
|
91
|
+
assert userinfo["https://purl.imsglobal.org/spec/lti/claim/context"]["id"] == "2"
|
|
92
|
+
assert userinfo["https://purl.imsglobal.org/spec/lti/claim/context"]["label"] == "Course 2"
|