├── src
├── __init__.py
├── templates
│ └── index.html
├── configs
│ └── lti.json
└── app.py
├── tests
├── __init__.py
└── test_canvas.py
├── Procfile
├── .env.sample
├── requirements.txt
├── docker-compose.yml
├── Dockerfile
├── LICENSE
├── .gitignore
└── README.md
/src/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: cd src && python app.py
2 |
--------------------------------------------------------------------------------
/.env.sample:
--------------------------------------------------------------------------------
1 | PUBLIC_KEY=""
2 | PRIVATE_KEY=""
3 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Flask==2.0.3
2 | Flask-Caching==1.10.1
3 | Flask-Session==0.4.0
4 | PyLTI1p3==1.10.0
5 |
6 | # development
7 | pytest==7.0.1
8 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 | services:
3 | app:
4 | restart: always
5 | build: .
6 | stdin_open: true
7 | tty: true
8 | volumes:
9 | - .:/app
10 | working_dir: /app/src
11 | ports:
12 | - "9001:9001"
13 | environment:
14 | - PUBLIC_KEY
15 | - PRIVATE_KEY
16 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.10-alpine
2 |
3 | RUN apk add --update \
4 | build-base libffi-dev openssl-dev \
5 | xmlsec xmlsec-dev \
6 | && rm -rf /var/cache/apk/*
7 |
8 | ADD requirements.txt /tmp
9 | RUN pip install --upgrade pip
10 | RUN pip install -r /tmp/requirements.txt
11 |
12 | EXPOSE 9001
13 | ENV FLASK_ENV development
14 | CMD python app.py
15 |
--------------------------------------------------------------------------------
/tests/test_canvas.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from src.app import app
3 |
4 | # https://flask.palletsprojects.com/en/2.0.x/testing/#fixtures
5 | @pytest.fixture()
6 | def app_fixture():
7 | app.config.update({"TESTING": True})
8 | yield app
9 |
10 |
11 | @pytest.fixture()
12 | def client(app_fixture):
13 | return app_fixture.test_client()
14 |
15 |
16 | def test_uses_https(client):
17 | response = client.get("/config/canvas.json")
18 | assert response.status_code == 200
19 | assert response.json["oidc_initiation_url"].startswith("https://")
20 | assert response.json["target_link_uri"].startswith("https://")
21 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Dmitry Viskov
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/templates/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Register to Vote - VoteAmerica
6 |
13 |
27 |
28 |
29 |
30 |
31 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | *.py,cover
51 | .hypothesis/
52 | .pytest_cache/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | target/
76 |
77 | # Jupyter Notebook
78 | .ipynb_checkpoints
79 |
80 | # IPython
81 | profile_default/
82 | ipython_config.py
83 |
84 | # pyenv
85 | .python-version
86 |
87 | # pipenv
88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
91 | # install all needed dependencies.
92 | #Pipfile.lock
93 |
94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
95 | __pypackages__/
96 |
97 | # Celery stuff
98 | celerybeat-schedule
99 | celerybeat.pid
100 |
101 | # SageMath parsed files
102 | *.sage.py
103 |
104 | # Environments
105 | .env
106 | .venv
107 | env/
108 | venv/
109 | venv3/
110 | ENV/
111 | env.bak/
112 | venv.bak/
113 |
114 | # Spyder project settings
115 | .spyderproject
116 | .spyproject
117 |
118 | # Rope project settings
119 | .ropeproject
120 |
121 | # mkdocs documentation
122 | /site
123 |
124 | # mypy
125 | .mypy_cache/
126 | .dmypy.json
127 | dmypy.json
128 |
129 | # Pyre type checker
130 | .pyre/
131 |
132 | # Pycharm
133 | .idea
134 |
135 | # keys
136 | *.key
137 | !configs/cert_suite_private.key
138 | *.key.pub
139 |
--------------------------------------------------------------------------------
/src/configs/lti.json:
--------------------------------------------------------------------------------
1 | {
2 | "http://imsglobal.org": [
3 | {
4 | "default": true,
5 | "client_id": "voteamerica-test",
6 | "auth_login_url": "https://lti-ri.imsglobal.org/platforms/2992/authorizations/new",
7 | "auth_token_url": "https://lti-ri.imsglobal.org/platforms/2992/access_tokens",
8 | "key_set_url": "https://lti-ri.imsglobal.org/platforms/2992/platform_keys/2765.json",
9 | "key_set": {
10 | "keys": [
11 | {
12 | "kty": "RSA",
13 | "e": "AQAB",
14 | "n": "r3WB5ECKptJliYft6F_XJysCy1KevoGJgKNHgdVR20lplUv1SzRH1mifzOmEzxWM0kj6blS7SRxK9GFGs6optHAmzcb6_joegKzLHSj14RRVSoI0MgyltJcAl8z6d4yZ9KobV8OvpICnMgsGO20Wih-Cq-oSUjtJT7WET3GZmzmM9MzamiGsCtC0dUWdDOW1FOMzTt8et9YA5jOfkLdJdPyZ5mdUZjBkYMlDGoD8fPRPdS9M-uczxvUeuKvyy1BVGlu5AG0xy-wN1tKjSE1iuC5Kkm39CZwQXBRpStDExWw_ApzP40SK3CKez4ls3jjkE3i4CpJSgLn1D8rT6wOpJw",
15 | "kid": "uhMfBQzVLmaJNU9c1am2X9pTzcEYhgYL2hO6hbYAvdw",
16 | "alg": "RS256",
17 | "use": "sig"
18 | }
19 | ]
20 | },
21 | "private_key_file": null,
22 | "public_key_file": null,
23 | "deployment_ids": ["IGNORED"]
24 | }
25 | ],
26 | "https://canvas.instructure.com": [
27 | {
28 | "default": true,
29 | "client_id": "10000000000003",
30 | "auth_login_url": "http://canvas.test/api/lti/authorize_redirect",
31 | "auth_token_url": "http://canvas.test/login/oauth2/token",
32 | "key_set_url": "http://canvas.test/api/lti/security/jwks",
33 | "key_set": null,
34 | "private_key_file": null,
35 | "public_key_file": null,
36 | "deployment_ids": ["IGNORED"]
37 | },
38 | {
39 | "client_id": "10000000000004",
40 | "auth_login_url": "http://canvas.test/api/lti/authorize_redirect",
41 | "auth_token_url": "http://canvas.test/login/oauth2/token",
42 | "key_set_url": "http://canvas.test/api/lti/security/jwks",
43 | "key_set": null,
44 | "private_key_file": null,
45 | "public_key_file": null,
46 | "deployment_ids": ["IGNORED"]
47 | }
48 | ],
49 | "ltiadvantagevalidator.imsglobal.org": [
50 | {
51 | "default": true,
52 | "client_id": "imstestuser",
53 | "auth_login_url": "https://ltiadvantagevalidator.imsglobal.org/ltitool/oidcauthurl.html",
54 | "auth_token_url": "https://oauth2server.imsglobal.org/oauth2server/authcodejwt",
55 | "key_set_url": "https://oauth2server.imsglobal.org/jwks",
56 | "key_set": null,
57 | "private_key_file": null,
58 | "public_key_file": null,
59 | "deployment_ids": ["IGNORED"]
60 | }
61 | ],
62 | "https://blackboard.com": [
63 | {
64 | "default": true,
65 | "client_id": "82b10b0f-ad48-45bf-b9ae-fbc1e4143530",
66 | "auth_login_url": "https://developer.blackboard.com/api/v1/gateway/oidcauth",
67 | "auth_token_url": "https://developer.blackboard.com/api/v1/gateway/oauth2/jwttoken",
68 | "key_set_url": "https://developer.blackboard.com/api/v1/management/applications/82b10b0f-ad48-45bf-b9ae-fbc1e4143530/jwks.json",
69 | "key_set": null,
70 | "private_key_file": null,
71 | "public_key_file": null,
72 | "deployment_ids": ["IGNORED"]
73 | }
74 | ],
75 | "https://partners.brightspace.com": [
76 | {
77 | "default": true,
78 | "client_id": "client-id",
79 | "auth_login_url": "https://partners.brightspace.com/d2l/lti/authenticate",
80 | "auth_token_url": "https://auth.brightspace.com/core/connect/token",
81 | "auth_audience": "https://api.brightspace.com/auth/token",
82 | "key_set_url": "https://partners.brightspace.com/d2l/.well-known/jwks",
83 | "key_set": null,
84 | "private_key_file": null,
85 | "public_key_file": null,
86 | "deployment_ids": ["IGNORED"]
87 | }
88 | ]
89 | }
90 |
--------------------------------------------------------------------------------
/src/app.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 |
4 | from tempfile import mkdtemp
5 | from urllib.parse import urlencode
6 | from flask import Flask, jsonify, render_template, request, url_for
7 | from flask_caching import Cache
8 | from pylti1p3.contrib.flask import (
9 | FlaskOIDCLogin,
10 | FlaskMessageLaunch,
11 | FlaskRequest,
12 | FlaskCacheDataStorage,
13 | )
14 | from pylti1p3.tool_config import ToolConfDict
15 | from pylti1p3.registration import Registration
16 | from werkzeug.middleware.proxy_fix import ProxyFix
17 |
18 |
19 | def get_app():
20 | app = Flask(__name__)
21 | app.wsgi_app = ProxyFix(app.wsgi_app)
22 |
23 | config = {
24 | "CACHE_TYPE": "SimpleCache",
25 | "CACHE_DEFAULT_TIMEOUT": 600,
26 | "SESSION_TYPE": "filesystem",
27 | "SESSION_FILE_DIR": mkdtemp(),
28 | }
29 | app.config.from_mapping(config)
30 |
31 | return app
32 |
33 |
34 | def get_tool_conf():
35 | lti_config_path = os.path.join(app.root_path, "configs", "lti.json")
36 | config = json.load(open(lti_config_path))
37 | tool_conf = ToolConfDict(config)
38 |
39 | public_key = os.environ["PUBLIC_KEY"]
40 | private_key = os.environ["PRIVATE_KEY"]
41 |
42 | for iss, clients in tool_conf._config.items():
43 | for client in clients:
44 | tool_conf.set_public_key(iss, public_key, client_id=client["client_id"])
45 | tool_conf.set_private_key(iss, private_key, client_id=client["client_id"])
46 |
47 | return tool_conf
48 |
49 |
50 | app = get_app()
51 | cache = Cache(app)
52 | tool_conf = get_tool_conf()
53 |
54 |
55 | class ExtendedFlaskMessageLaunch(FlaskMessageLaunch):
56 | # ignore the deployment ID
57 | # https://github.com/dmitry-viskov/pylti1.3/issues/2#issuecomment-524109023
58 | def validate_deployment(self):
59 | return self
60 |
61 |
62 | def get_launch_data_storage():
63 | return FlaskCacheDataStorage(cache)
64 |
65 |
66 | def get_jwk_from_public_key(key_name):
67 | key_path = os.path.join(app.root_path, "configs", key_name)
68 | f = open(key_path, "r")
69 | key_content = f.read()
70 | jwk = Registration.get_jwk(key_content)
71 | f.close()
72 | return jwk
73 |
74 |
75 | def external_url(endpoint):
76 | return url_for(endpoint, _external=True, _scheme="https")
77 |
78 |
79 | @app.route("/login/", methods=["GET", "POST"])
80 | def login():
81 | launch_data_storage = get_launch_data_storage()
82 |
83 | flask_request = FlaskRequest()
84 | target_link_uri = flask_request.get_param("target_link_uri")
85 | if not target_link_uri:
86 | raise Exception('Missing "target_link_uri" param')
87 |
88 | oidc_login = FlaskOIDCLogin(
89 | flask_request, tool_conf, launch_data_storage=launch_data_storage
90 | )
91 | return oidc_login.enable_check_cookies().redirect(target_link_uri)
92 |
93 |
94 | @app.route("/launch/", methods=["POST"])
95 | def launch():
96 | flask_request = FlaskRequest()
97 | launch_data_storage = get_launch_data_storage()
98 | message_launch = ExtendedFlaskMessageLaunch(
99 | flask_request, tool_conf, launch_data_storage=launch_data_storage
100 | )
101 | message_launch_data = message_launch.get_launch_data()
102 |
103 | query = urlencode(
104 | {
105 | "first_name": message_launch_data.get("given_name", ""),
106 | "last_name": message_launch_data.get("family_name", ""),
107 | "email": message_launch_data.get("email", ""),
108 | }
109 | )
110 | return render_template("index.html", query=query)
111 |
112 |
113 | @app.route("/jwks/", methods=["GET"])
114 | def get_jwks():
115 | return jsonify({"keys": tool_conf.get_jwks()})
116 |
117 |
118 | # https://canvas.instructure.com/doc/api/file.lti_dev_key_config.html#configuring-the-tool-in-canvas
119 | @app.route("/config/canvas.json", methods=["GET"])
120 | def canvas_config():
121 | target_link_uri = external_url("launch")
122 | icon_url = "https://www.voteamerica.com/img/apple-touch-icon.png"
123 |
124 | return jsonify(
125 | {
126 | "title": "VoteAmerica",
127 | "description": "Register to vote",
128 | "oidc_initiation_url": external_url("login"),
129 | "target_link_uri": target_link_uri,
130 | "scopes": [
131 | "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
132 | "https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly",
133 | ],
134 | "extensions": [
135 | {
136 | "domain": request.host,
137 | "tool_id": "voteamerica",
138 | "platform": "canvas.instructure.com",
139 | "privacy_level": "public",
140 | "settings": {
141 | "text": "VoteAmerica",
142 | "icon_url": icon_url,
143 | "placements": [
144 | {
145 | "text": "VoteAmerica",
146 | "placement": "user_navigation",
147 | "message_type": "LtiResourceLinkRequest",
148 | "target_link_uri": target_link_uri,
149 | "canvas_icon_class": "icon-lti",
150 | }
151 | ],
152 | },
153 | }
154 | ],
155 | "privacy_level": "public",
156 | "icon_url": icon_url,
157 | "public_jwk_url": external_url("get_jwks"),
158 | }
159 | )
160 |
161 |
162 | if __name__ == "__main__":
163 | port = int(os.environ.get("PORT", 9001))
164 | app.run(host="0.0.0.0", port=port)
165 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | _**This repository has been deprecated.** VoteAmerica staff, see the [internal documentation](https://docs.google.com/document/d/1rmiNR6ifgRjta_xnfAj6bpForJkR1lAUuK0KiAsHXRA/edit) that covers the new setup._
2 |
3 | ## [Architectural Decision Record](https://18f.gsa.gov/2021/07/06/architecture_decision_records_helpful_now_invaluable_later/)
4 |
5 | Aidan Feldman, 3/10/22
6 |
7 | ### Context
8 |
9 | VoteAmerica is looking to get more young folks registered to vote, first with [FutureVoter](https://futurevoter.com/), then integrating with schools to "meet students where they're at." We established partnerships with [Blackboard](https://www.blackboard.com/) and [Instructure](https://www.instructure.com/) (Canvas), and will start conversations with school administrators at the [NASPA 2022 conference](https://conference.naspa.org/).
10 |
11 | The minimum viable product (MVP) was to get the [VoteAmerica registration form](https://www.voteamerica.com/voter-registration/) to show up in the Blackboard and Canvas learning management systems (LMSes). It turns out that most major LMSes implement a standard called [LTI 1.3](https://www.imsglobal.org/activity/learning-tools-interoperability), which gives a consistent(ish) way to do integrations of third-party tools. This meant that the VoteAmerica tool could be built once, then theoretically be compatible with all LMS platforms. The MVP was implemented in this repository as a Flask app, and it worked.
12 |
13 | While looking to get it working with Canvas, I came across a [reference to LTI As A Service (LTIAAS)](https://community.canvaslms.com/t5/Partner-Listings/Partner-Listing-LTI-As-a-Service-LTIAAS/ta-p/490121). After a bit of testing, it was clear that LTIAAS could be used in place of the custom application. We made the switch and are deprecating this app.
14 |
15 | ### Decision
16 |
17 | We switched to using [LTIAAS](https://ltiaas.com/) for our LTI tool offering.
18 |
19 | ### Consequences
20 |
21 | - LTIAAS supports displaying of a page in an iframe with zero custom code.
22 | - The [pricing of LTIAAS](https://ltiaas.com/#pricing) seems very reasonable, especially compared to maintenance burden and direct cost of running a custom app.
23 | - LTI platforms pass the user's first name, last name, and email to the tool. [The custom server used that to pre-populate the form.](https://github.com/vote/lms-plugin/blob/68e6a2803b5358f16b64891afff46359522517be/src/app.py#L101-L110) Getting rid of the LTI-specific backend/page means that the form is no longer being pre-populated. This seemed like a worthwhile tradeoff.
24 | - If we feel strongly about doing the pre-populating, we can always implement that behind LTIAAS and [retrieve the user information from there](https://ltiaas.com/docs/api_documentation/#idtoken-endpoint).
25 | - Good LTI support
26 | - LTIAAS specializes in LTI tools, meaning:
27 | - It should work well
28 | - The product, documentation, etc. should improve over time without us having to do anything
29 | - They are a great resource for troubleshooting
30 | - They (presumably) have relationships with the LMS vendors that could be leveraged
31 | - The LTIAAS team has been very responsive and helpful from the beginning, with a couple examples of making changes to their documentation and product immediately after issues were raised.
32 | - The LTIAAS team is small (two people?). One the one hand, this means they are very invested in keeping clients. On the other, it's unclear how stable the business will be long term.
33 | - We were able to set up a custom domain for our LTIAAS endpoint, meaning the service is effectively [white-labeled](https://en.wikipedia.org/wiki/White-label_product).
34 | - This should avoid vendor lock-in, in that we could move off of LTIAAS without schools needing to change their configuration of URLs. (This is hypothetical; there may stored tokens or something else behind the scenes that would require migration.)
35 |
36 | ---
37 |
38 | # VoteAmerica LMS Plugin
39 |
40 | This is a plugin to show the [VoteAmerica voter registration form](https://www.voteamerica.com/voter-registration/) in learning management systems (LMSes). It uses the [LTI 1.3](https://www.imsglobal.org/activity/learning-tools-interoperability) standard and is derived from this [Flask example](https://github.com/dmitry-viskov/pylti1.3-flask-example).
41 |
42 | ## Local setup
43 |
44 | While the app isn't super useful to run locally (an LMS provider needs to be able to access it), you can do so with the following steps:
45 |
46 | 1. [Install Docker.](https://docs.docker.com/get-docker/)
47 | 1. Make a file for environment variables.
48 |
49 | ```sh
50 | cp .env.sample .env
51 | ```
52 |
53 | 1. [Generate a key](https://github.com/dmitry-viskov/pylti1.3/wiki/How-to-generate-JWT-RS256-key-and-JWKS).
54 | 1. In the `.env` file, put the contents of the `jwtRS256.key.pub` into `PUBLIC_KEY` and `jwtRS256.key` into `PRIVATE_KEY`.
55 | 1. Start the server.
56 |
57 | ```sh
58 | docker compose up --build
59 | ```
60 |
61 | 1. To get a publicly-accessible URL for hosted LMSes (not running on your machine) to interact with, try using [Localtunnel](https://localtunnel.github.io/www/).
62 | - You may want to pick a stable subdomain (with `--subdomain`) so that you don't have to modify your registrations each time.
63 | - Use this hostname instead of the Heroku ones below.
64 |
65 | ## LMS setup
66 |
67 | ### Blackboard
68 |
69 | The Tool is [centrally registered](https://docs.blackboard.com/lti/lti-registration-and-deployment) as a [System placement](https://docs.blackboard.com/lti/getting-started-with-lti#lti-placements). To [register with a Blackboard Learn instance](https://help.blackboard.com/Learn/Administrator/SaaS/Integrations/Learning_Tools_Interoperability#addlti13), use the [`Application ID`](https://developer.blackboard.com/portal/applications) as the `Client ID`.
70 |
71 | ### Canvas
72 |
73 | To add to [Canvas](https://www.instructure.com/canvas):
74 |
75 | 1. [Configure an LTI key](https://community.canvaslms.com/t5/Admin-Guide/How-do-I-configure-an-LTI-key-for-an-account/ta-p/140)
76 | 1. For `Key Name`, enter `VoteAmerica`
77 | 1. Under `Method`, select `Enter URL`
78 | 1. Fill `JSON URL` with `https://va-pylti.herokuapp.com/config/canvas.json`
79 | 1. Click `Save`
80 | 1. Turn `State` to `ON`
81 | 1. Under `Details`, copy the Client ID
82 | 1. [Add the External App](https://community.canvaslms.com/t5/Admin-Guide/How-do-I-configure-an-external-app-for-an-account-using-a-client/ta-p/202)
83 |
84 | ## Running tests
85 |
86 | ```sh
87 | docker compose run -w /app app pytest
88 | ```
89 |
--------------------------------------------------------------------------------