feat: LTI 1.3 integration (optional module 'lti')#267
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:/lti/login/iss/client_id/target_link_uriand the outgoing redirect (these lines solve most integration failures)/lti/launch//lti/jwks//lti/register/How it works
lti_configtable 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 toDATA_DIR). AToolConfDictsubclass rebuilds the pylti1p3 config from the DB per request, so every worker sees new registrations without restart.flask lti mint-registration-token, stored hashed, consumed only on successful registration) or staticLTI_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.flask lti rotate-keypublishes the new key, switches the registration to it and retires the old pair intoDATA_DIR/lti/keys/retired/, whose public halves stay merged into/lti/jwks/untilflask lti purge-retired-keys(cron-friendly) removes them after the grace period.Usersrow 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 demotingteacher/labcreator/admin. Logins recorded inlogin_loggingwithauth_provider=lti.CACHE_TYPE=RedisCache+CACHE_REDIS_URLare now configurable and required with gunicorn -w N (a startup warning fires on SimpleCache).COOKIES_SECURE=Trueenables theSecure; SameSite=Nonecookies needed inside LMS iframes.Reviewer notes
Exception(notLtiException) for an unknown issuer — the most common misconfiguration — so/login/and/launch/catch broadly and return logged 4xx instead of 500s.enable_check_cookies()every launch hits/login/twice (cookie-check page, then a re-post withlti1p3_new_window=1) — expected, covered by tests.2.0.11(upgrade + downgrade verified on a fresh SQLite DB).doc/lti/IMPLEMENTATION_PLAN.md).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.-w 2+ Redis smoke test and an end-to-end launch against a real Moodle.🤖 Generated with Claude Code