├── 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 | --------------------------------------------------------------------------------