Skip to content

feat: LTI 1.3 integration (optional module 'lti')#267

Merged
italovalcy merged 20 commits into
mainfrom
feat/lti-integration
Jul 9, 2026
Merged

feat: LTI 1.3 integration (optional module 'lti')#267
italovalcy merged 20 commits into
mainfrom
feat/lti-integration

Conversation

@italovalcy

Copy link
Copy Markdown
Contributor

What

Integrates the Dashboard as an LTI 1.3 tool (Moodle-first, platform-agnostic), shipped as the optional module lti (OPTIONAL_MODULES=clabs,lti), built on PyLTI1p3 2.x.

New blueprint under /lti:

Endpoint Purpose
/lti/login/ OIDC third-party-initiated login — logs incoming iss/client_id/target_link_uri and the outgoing redirect (these lines solve most integration failures)
/lti/launch/ Message launch: validates the id_token, provisions/logs in the local user
/lti/jwks/ Public keyset — active keys plus retired keys still in their grace period
/lti/register/ LTI Dynamic Registration (credential-gated)

How it works

  • Platform config in the DB: lti_config table keyed by issuer (LMS base URL, exact string, no trailing slash — normalized on write), holding a JSON-encoded per-issuer list of registrations {client_id, auth_login_url, auth_token_url, key_set_url, deployment_ids, private/public key files} (key paths relative to DATA_DIR). A ToolConfDict subclass rebuilds the pylti1p3 config from the DB per request, so every worker sees new registrations without restart.
  • Dynamic registration is the onboarding path: gated by one-time tokens (flask lti mint-registration-token, stored hashed, consumed only on successful registration) or static LTI_REGISTRATION_KEY/SECRET; disabled when neither is configured. Each registration gets its own fresh RSA-2048 keypair; orphan keys are removed if the platform rejects the registration.
  • Key rollover (publish-then-switch): flask lti rotate-key publishes the new key, switches the registration to it and retires the old pair into DATA_DIR/lti/keys/retired/, whose public halves stay merged into /lti/jwks/ until flask lti purge-retired-keys (cron-friendly) removes them after the grace period.
  • User provisioning: launches create/reuse a Users row keyed by (issuer, subject) — later logins inherit the same account; name/e-mail sync from fresh claims; accounts are passwordless (local sign-in already rejects those). Role mapping is promotion-only: Learner→student, Instructor→teacher, never demoting teacher/labcreator/admin. Logins recorded in login_logging with auth_provider=lti.
  • Multi-worker ready: OIDC state/nonce/launch data live in Flask-Caching; CACHE_TYPE=RedisCache + CACHE_REDIS_URL are now configurable and required with gunicorn -w N (a startup warning fires on SimpleCache). COOKIES_SECURE=True enables the Secure; SameSite=None cookies needed inside LMS iframes.

Reviewer notes

  • pylti1p3 raises plain Exception (not LtiException) for an unknown issuer — the most common misconfiguration — so /login/ and /launch/ catch broadly and return logged 4xx instead of 500s.
  • With enable_check_cookies() every launch hits /login/ twice (cookie-check page, then a re-post with lti1p3_new_window=1) — expected, covered by tests.
  • Migration 2.0.11 (upgrade + downgrade verified on a fresh SQLite DB).
  • Deep linking, AGS grade passback, NRPS roster and account merging are explicitly out of scope (see doc/lti/IMPLEMENTATION_PLAN.md).
  • Docs: doc/lti/DASHBOARD.md (setup guide), plus the reference playbook/README used to design this.

Testing

  • tests/test_lti.py: 23 tests — key generation/rotation/purge and JWKS merging, DB tool conf, registration gating (disabled/invalid/expired token, static creds, mismatched openid_configuration URL), happy path against a faked platform, token consumption/reuse, launch provisioning + role mapping (incl. admin-never-demoted), OIDC login logging and both login passes.
  • Full suite: 465 passed (442 pre-existing + 23 new).
  • Not yet done (needs infra): gunicorn -w 2 + Redis smoke test and an end-to-end launch against a real Moodle.

🤖 Generated with Claude Code

italovalcy and others added 20 commits July 7, 2026 06:29
Adds the Dashboard as an LTI 1.3 tool with PyLTI1p3 2.x:

- new lti blueprint (/lti): /login/ (OIDC initiation with diagnostic
  logging of iss/client_id/target_link_uri and the outgoing redirect),
  /launch/ (message launch), /jwks/ (public keyset), /register/
  (dynamic registration)
- platform config in the DB: lti_config table keyed by issuer (LMS base
  URL, exact string, no trailing slash) with a JSON-encoded per-issuer
  list of registrations (client_id, auth/token/keyset URLs,
  deployment_ids, key file paths relative to DATA_DIR)
- dynamic registration gated by one-time tokens (flask lti
  mint-registration-token, consumed only on success) or static
  LTI_REGISTRATION_KEY/SECRET; per-registration RSA-2048 key generation
- key rollover: flask lti rotate-key (publish-then-switch), retired
  public keys stay merged into /jwks/ until purge-retired-keys
- launches auto-provision Users rows keyed by (issuer, subject) so
  later logins inherit the account; promotion-only role mapping
  (Learner->student, Instructor->teacher, never demote)
- multi-worker ready: OIDC state/nonce/launch data in Flask-Caching
  (CACHE_TYPE=RedisCache + CACHE_REDIS_URL required with gunicorn -w N;
  warning logged on SimpleCache), COOKIES_SECURE gate for iframe
  cookies, alembic migration 2.0.11, docs in doc/lti/, 23 tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Launches now read the LTI custom claim
(https://purl.imsglobal.org/spec/lti/claim/custom) and, when the platform
sends a next_url custom parameter (Moodle: the activity's Custom
parameters field), store it in session['next_url'] before the e-mail
check - so the destination survives the /email/required detour and the
existing session pop performs the redirect.

Only safe targets are honored (is_safe_redirect_url): relative paths, or
absolute URLs whose scheme and host:port exactly equal BASE_URL's. No
prefix/substring matching - userinfo tricks, lookalike subdomains, port
swaps, scheme downgrades, protocol-relative //host, javascript: and
backslash/control-char forms are all rejected, logged at WARNING, and the
launch falls back to the default redirect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…I sweep

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements alternative B from doc/pre-approved-membership-on-create-plan.md
(check_pre_approved is left untouched):

- new Users after_insert listener joins brand-new accounts (LTI, OAuth,
  local signup - any creation path) to every non-SYSTEM group whose
  pre-approved list contains their e-mail, using raw connection inserts
  like the existing Everybody listener; category promotion still happens
  via the unchanged check_pre_approved() in the same login/transaction
- shared find_pre_approved_groups(email) helper in apps/utils.py
- flask cli sync-pre-approved-users [--dry-run] [--promote] backfills
  existing users (skips SYSTEM groups, duplicates and soft-deleted
  users); documented in doc/DEV.md with the other cron-able jobs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When an LTI-launched user reaches finished-lab-infos, the Dashboard posts
their lab result to the platform gradebook via Assignment and Grade
Services:

- answers always go out as a friendly plain-text feedback comment, one
  line per question; unanswered questions (union of answers and answer
  sheet) are listed as '(not answered)', so a user who answered nothing
  still posts the full question list
- a 0-100 score is sent only when the lab has an answer sheet; the
  computation is the same as the teachers' listing (scoring loop
  extracted into apps/utils.py:compute_lab_score and reused by both)
- AGS context (endpoint claim, client/deployment/resource-link, custom
  next_url) is persisted at launch in the new lti_launch_context table
  (migration 2.0.13) so grades can be sent long after the launch and
  from any worker; context selection prefers the activity deep-linked
  to the finished lab, else the most recent launch
- dynamic registration now requests the AGS score/lineitem.readonly
  scopes; pre-existing registrations need AGS enabled in the LMS
- best-effort by design: default request timeout on all tool->platform
  calls, every failure logged without breaking the congratulations page,
  Moodle's 400 for non-gradable enrolments logged at INFO

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LTI_TOOL_LOGO config (default /static/assets/img/hackinsdn.png, resolved
against BASE_URL; full URLs pass through; empty disables the field).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moodle with plain 'grade sync' (no column management) and course-level
tools send no default 'lineitem' claim - only the 'lineitems' collection
URL - so grade passback was always skipped with 'no lineitem'.

Now, when the claim lacks a default lineitem, the activity's column is
located in the collection by resource link id (works with the
lineitem.readonly scope Moodle grants for grade sync), and created on the
fly (tag hackinsdn-lab-<id>) when the platform granted the full lineitem
scope. Dynamic registration now also requests the full lineitem scope so
column management is available after (re-)registration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…docs

Moodle fetches the tool keyset server-side on the first platform->tool
call (token request for AGS), and an unreachable/untrusted jwks_uri
surfaces as 'token.php: 404' + 'jwks_helper::fix_jwks_alg(): null given'.
'flask lti show-public-key --issuer X' prints the registration's PEM so
admins can switch the Moodle tool to 'RSA key' mode as a workaround;
DASHBOARD.md documents the diagnosis order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the lineitems collection yields no match and no create scope is
granted, log the collection size and items - distinguishing 'activity has
no grade configured' (empty collection) from a resource-link mismatch.
Documented both cases in DASHBOARD.md troubleshooting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@italovalcy italovalcy marked this pull request as ready for review July 9, 2026 12:21
@italovalcy italovalcy self-assigned this Jul 9, 2026
@italovalcy italovalcy merged commit 20de979 into main Jul 9, 2026
2 checks passed
@italovalcy italovalcy deleted the feat/lti-integration branch July 9, 2026 12:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant