# Azadira in Canvas (LTI 1.3)

Azadira can be installed in Canvas (or any LTI 1.3 platform) as an **LTI Advantage** tool:

* **LTI 1.3 Core**: OIDC third-party login and signed launches.
* **Deep Linking 2.0**: instructors choose the problems when they add the tool to an assignment or module.
* **Assignment and Grade Services 2.0**: every solve updates the student's grade in the gradebook.

The LTI code is `server/lti.py` (a FastAPI router mounted under `/lti` of the Azadira server) with tables
in `server/lti_schema.sql`. The student-facing page is the static `lti.html`.

## How it works

```
Canvas ──login (iss, login_hint, lti_message_hint, client_id, deployment_id)──▶ /lti/login
       ◀──302 to Canvas authorize endpoint (state, nonce, redirect_uri=/lti/launch)──
Canvas ──form_post id_token + state────────────────────────────────────────────▶ /lti/launch
   LtiResourceLinkRequest  → launch recorded → SITE_URL/lti.html#t=<session token>
   LtiDeepLinkingRequest   → selection page → /lti/deeplink → signed response auto-posted to Canvas
Browser (lti.html, problem.html) ──GET /lti/session, POST /lti/progress──▶ API ──AGS score──▶ Canvas
```

1. **Login.** Canvas calls `GET|POST /lti/login`. The tool looks up the platform by `iss` + `client_id`,
   checks the deployment, stores a random `state` and `nonce` (10-minute expiry) and redirects to
   the platform's authorization endpoint with `scope=openid`, `response_type=id_token`,
   `response_mode=form_post`, `prompt=none`, `client_id`, `redirect_uri`, `login_hint`,
   `lti_message_hint`, `state` and `nonce`.
2. **Launch.** Canvas posts `id_token` and `state` to `/lti/launch`. The state is consumed (single
   use), then the id_token is verified: RS256 only, signature against the platform JWKS (cached for
   an hour, refetched when an unknown `kid` appears), `iss`, `aud` = client id (and `azp` when present
   or when there are several audiences), `exp`/`iat`/`nbf` with 60 s leeway, `nonce` equal to the
   stored one and never seen before, `deployment_id` registered, LTI version `1.3.0`, and
   `target_link_uri` on this tool.
3. **Resource link launch.** The tool records the launch (user `sub`, name, roles, course, resource
   link, AGS line item and scopes, custom parameters). The assignment is carried by the custom
   parameters the instructor chose at deep-linking time: `problems` (comma-separated ids) and
   `title`. The browser is sent to `SITE_URL/lti.html#t=<token>&api=<TOOL_URL>`; the token is an
   HS256 JWT (8 h, `LTI_SESSION_SECRET`) that names the launch and nothing else. It travels in the
   URL fragment, so it is never sent to the static host, and lti.html removes it from the address bar.
4. **Assignment page.** `lti.html` stores `{token, title, problems, expires}` in `localStorage`
   (`azadira.lti`), loads `/lti/session` and lists the problems (links to `problem.html?id=…`) with
   the solved state from the local progress store. Problems already solved on this device are
   reported once, so earlier practice counts. Instructors also see a class table: each student's
   solved problems, score and last solve.
5. **Grades.** After a solve, the problem page calls `reportProgress(id, {stars, style})` from
   `js/lti-client.js`, which posts to `/lti/progress`. The server stores the solve and computes
   **score = 100 × solved assigned problems / assigned problems**. It gets an AGS access token with
   the OAuth2 `client_credentials` grant and a JWT client assertion signed with the tool key (scope
   `…/lti-ags/scope/score`, cached until a minute before expiry, refreshed once on a 401), then posts
   `application/vnd.ims.lis.v1.score+json` to `<lineitem>/scores` with `userId`, `scoreGiven`,
   `scoreMaximum: 100`, `activityProgress` (`Completed` when everything is solved, else
   `InProgress`), `gradingProgress: FullyGraded` and `timestamp`. A failed post is recorded and
   retried with the next solve. Instructors' own solves are recorded but never posted.
6. **Deep linking.** When an instructor adds the tool (Assignment → External Tool, or a module item),
   Canvas sends an `LtiDeepLinkingRequest`. The tool shows a selection page in the Canvas dialog:
   a whole path (its required problems), a phase's core set, or individual problems (search over the
   whole bank). On submit it signs an `LtiDeepLinkingResponse` (RS256, `kid` = `LTI_KID`) with one
   `ltiResourceLink` item: `title`, `url`, `custom: {problems, title}`, `lineItem: {scoreMaximum: 100,
   label}` (and `window.targetName = "_blank"` if "open in a new tab" is ticked), echoes `data`, and
   auto-posts it to `deep_link_return_url`. Only instructors, TAs and administrators may deep link.

### Endpoints

| Method | Path | Purpose |
|---|---|---|
| GET, POST | `/lti/login` | OIDC login initiation |
| POST | `/lti/launch` | id_token launch (resource link or deep linking) |
| POST | `/lti/deeplink` | selection page submit → signed deep-linking response |
| GET | `/lti/session` | assignment for lti.html (`Authorization: Bearer <token>` or `?token=`) |
| POST | `/lti/progress` | `{token, problemId, solved: true, stars?, style?}` → score to the gradebook |
| GET | `/lti/jwks` | tool public key set |
| GET | `/lti/canvas-config.json` | Canvas developer key JSON (`?course_navigation=false` to omit that placement) |

## Server setup

1. Deploy the API as in `docs/DEPLOY.md`; `server/app.py` mounts the router with
   `from server import lti; lti.install(app, pool)` (run the app from the repository root:
   `uvicorn server.app:app`). The LTI tables are created at start-up.
2. Generate the tool key: `python3 tools/lti_keys.py`. It prints `LTI_PRIVATE_KEY` (one line with
   `\n` escapes, accepted as is), `LTI_KID` and a random `LTI_SESSION_SECRET`.
3. Set the environment on the API host:

   | Variable | Example |
   |---|---|
   | `LTI_PRIVATE_KEY`, `LTI_KID`, `LTI_SESSION_SECRET` | from `tools/lti_keys.py` (keep secret) |
   | `TOOL_URL` | `https://azadira-api.onrender.com` (public base URL of the API) |
   | `SITE_URL` | `https://<user>.github.io/<repo>` (public base URL of the static site) |
   | `ALLOWED_ORIGIN` | must include the `SITE_URL` origin, for example `https://<user>.github.io` |
   | `LTI_SESSION_TTL` | optional, seconds (default 28800) |

4. Set `DEFAULT_API_URL` in `js/config.js` to `TOOL_URL`. (If it is empty, lti.html falls back to
   the `api` value the launch put in the URL fragment.)
5. Check `https://<TOOL_URL>/lti/jwks` and `https://<TOOL_URL>/lti/canvas-config.json`.

## Installing in Canvas (administrator)

1. **Admin → Developer Keys → + Developer Key → + LTI Key.**
2. Method **Enter URL**: `https://<TOOL_URL>/lti/canvas-config.json`, or **Paste JSON** with the
   contents of that URL. Enter **Redirect URIs**: `https://<TOOL_URL>/lti/launch` (Canvas takes
   these from the form, not from the JSON). Give the key a name and save.
3. In the key list, switch the key **ON**, and copy its **Client ID** (the long number under "Details").
4. **Admin (or Course) → Settings → Apps → View App Configurations → + App → Configuration Type:
   By Client ID**, paste the Client ID, submit and install.
5. Open the app's gear menu → **Deployment Id** and copy it.
6. Register the platform with the API's database:

   ```bash
   DATABASE_URL=... python3 tools/lti_register.py --client-id <Client ID> --deployment-id <Deployment Id> --name "My school"
   ```

   The defaults are for Instructure-hosted Canvas (production). Use `--canvas beta` or
   `--canvas test` for those environments (register them separately; they have their own issuer),
   `--canvas-url https://canvas.school.edu` for a self-hosted Canvas, or `--issuer/--auth-url/
   --token-url/--jwks-url` for another platform. Re-run with more `--deployment-id` values when the
   app is installed in more accounts or courses; `--list` shows the registrations.

Canvas endpoints used by the defaults (from the Canvas developer documentation):

| | Production | Beta | Test |
|---|---|---|---|
| Issuer (`iss`) | `https://canvas.instructure.com` | `https://canvas.beta.instructure.com` | `https://canvas.test.instructure.com` |
| Authorization | `https://sso.canvaslms.com/api/lti/authorize_redirect` | `https://sso.beta.canvaslms.com/api/lti/authorize_redirect` | `https://sso.test.canvaslms.com/api/lti/authorize_redirect` |
| Token | `https://sso.canvaslms.com/login/oauth2/token` | `https://sso.beta.canvaslms.com/login/oauth2/token` | `https://sso.test.canvaslms.com/login/oauth2/token` |
| JWKS | `https://sso.canvaslms.com/api/lti/security/jwks` | `https://sso.beta.canvaslms.com/api/lti/security/jwks` | `https://sso.test.canvaslms.com/api/lti/security/jwks` |

Canvas moved these from `canvas.instructure.com` to the `sso.canvaslms.com` domains; the issuer is
still `https://canvas.instructure.com` (no trailing slash). The client assertion's `aud` is the token
URL; Canvas accepts the domain of the account or of the OIDC auth endpoint (`--token-audience`
overrides it if needed).

Sources:
[LTI launch overview](https://developerdocs.instructure.com/services/canvas/external-tools/lti/file.lti_launch_overview)
(issuers, `sso.canvaslms.com` authorization endpoints, login parameters),
[Configuring LTI Advantage tools](https://canvas.instructure.com/doc/api/file.lti_dev_key_config.html)
(JSON configuration, placements, privacy level, JWKS URLs),
[OAuth2 endpoints](https://canvas.instructure.com/doc/api/file.oauth_endpoints.html)
(`client_credentials` grant and client assertion claims),
[Instructure Community: determining LTI 1.3 URLs](https://community.canvaslms.com/t5/Developers-Group/How-to-determine-LTI-1-3-login-oauth-authorize-jwks-URLs/m-p/444286).

### Configuration JSON

`/lti/canvas-config.json` returns title, description, `oidc_initiation_url` (`/lti/login`),
`target_link_uri` (`/lti/launch`), `public_jwk_url` (`/lti/jwks`), the scopes `lineitem`,
`result.readonly` and `score`, and one extension for `canvas.instructure.com` with
`privacy_level: public` (names are needed for the class table and `sub` for grades) and the
placements `assignment_selection` and `link_selection` (both `LtiDeepLinkingRequest`) plus an
optional `course_navigation` link (`LtiResourceLinkRequest`, disabled by default).

## Instructors

* **Graded assignment:** Assignments → + Assignment → Submission type **External Tool** → Find →
  **Azadira problems**. Pick a path, a phase's core set or individual problems (search by title, id,
  phase or difficulty), set the title, **Add to course**, then save the assignment. Points: Canvas
  scales the 0-100 score to the assignment's points.
* **Module item:** Modules → + → External Tool → Azadira problems. With a line item Canvas creates a
  graded assignment for it.
* Opening the assignment yourself shows the problem list plus **Class progress**: one row per student
  who has opened it, their solved problems, score and last solve. Your own solves are never graded.
* Editing the selection: re-open the assignment's external tool settings and pick again. Students
  get the new list on their next launch; scores are recomputed on their next solve.
* A whole path assigns its **required** problems only (the ones each level's lesson teaches); the
  per-learner picks of `paths.html` (one problem from each topic of a level) are not part of a graded
  assignment.

## Students

Opening the assignment in Canvas shows the Azadira assignment page: the title, the problems (■ solved,
□ not yet), a *Next problem* button and the recorded score. Each problem opens in the normal Azadira
workspace; after a solve the grade in Canvas updates within seconds. Problems solved earlier in the
same browser count. The session lasts 8 hours; after that, open the assignment from Canvas again.

## Data stored

In the API database (`lti_*` tables): registered platforms and deployments; login state and used
nonces (deleted after expiry); one launch row per student per assignment link (LMS user id `sub`,
display name, roles, course id and title, resource link id, line item URL, custom parameters, last
score and sync status); solved problem ids per launch with time, stars and style score; cached AGS
access tokens. No email address, no code and no passwords are stored; the id_token itself is not kept.
Session tokens are never logged by the tool, and lti.html sends them in the `Authorization` header so
they do not appear in access logs.

## Grade integrity (trade-off)

All code runs and is checked **in the student's browser** (Pyodide). For practice problems the
server does not re-run it: `/lti/progress` trusts the reported solve. A determined student can
therefore call the endpoint by hand (with their own session token) and mark problems solved. The
token only covers their own launch, so nobody can change another student's grade, and every solve is
logged with its time for the instructor. This keeps the server light (no grading load for a whole
class) and suits formative, practice-style grading. For high-stakes grading use multi-player boards
(`compete.html`), where the server re-grades every submission, or supervised sessions.

## Security notes

* The id_token is verified exactly as listed in "How it works"; `alg` is pinned to RS256 (no `none`,
  no HMAC confusion). State and nonce are single use.
* The OIDC state is bound to the database, not to a browser cookie, because Canvas runs the tool in an
  iframe where third-party cookies are often blocked. The tool still sets a partitioned
  `SameSite=None; Secure` state cookie; set `LTI_STRICT_STATE_COOKIE=1` to require it.
* Session and deep-linking tokens are HS256 JWTs with distinct audiences, verified with constant-time
  HMAC comparison. Rotate `LTI_SESSION_SECRET` to end all sessions.
* Key rotation: generate a new key with a new `LTI_KID` and redeploy. Canvas refetches the JWKS; the
  JWKS publishes a single key, so deep-linking responses signed moments before a rotation can fail.

## Testing

`tools/test_lti.py` runs the whole flow against a mock platform (JWKS, authorization, token and AGS
endpoints) and the local Postgres:

```bash
DATABASE_URL=postgresql://azadira@127.0.0.1:55432/azadira python3 tools/test_lti.py
```

It drops and recreates the `lti_*` tables of that database. It covers registration, the login
redirect, student and instructor launches, 14 kinds of invalid id_token, session lookup, AGS token
and score posts (including token refresh and a failing endpoint), and deep linking.

## Limitations

* **No roster sync (NRPS).** Students appear in the class table after their first launch.
* One line item per link; per-problem line items and reading results back (`result.readonly`) are
  not used, although the scope is requested for future use.
* Progress in the browser is per device (and, inside the Canvas iframe, per partitioned storage);
  the server-side record used for grades is not.
* No LTI platform-storage (`postMessage`) support yet, so the strict state-cookie mode does not work
  in browsers that block third-party cookies.
* Tested against a mock platform, not a live Canvas instance.
