0012. LTI 1.3: one `lti` package, first-party platform side, packbackbooks tool side
Generated from docs/decisions/0012-lti-package-and-libraries.md
- Status: Accepted (2026-10-09)
- Date: 2026-10-09
Context and problem statement
Section titled “Context and problem statement”The spec (1.3, high priority) asks for both directions of LTI 1.3: as a platform we launch
external tools (GeoGebra, coding sandboxes, …) inside lessons with grade passback (AGS) and deep
linking; as a tool other LMSs (Moodle, Canvas) launch our courses. Security requirements: key
rotation, nonce/state validation, per-tenant registrations. Tenancy is database per tenant with a
per-tenant APP_KEY (ADR 0007). Courses are shown to learners in iframes, where third-party cookies
are usually blocked.
Considered options
Section titled “Considered options”| Side | Option | Licence | Notes |
|---|---|---|---|
| Platform | First-party on firebase/php-jwt (already a dependency) |
BSD-3-Clause | A handful of endpoints we must own anyway: OIDC authorize, JWKS, token, AGS, deep-linking return |
| Platform | celtic/lti |
LGPL-3.0 | Licence conflicts with the open-core plan |
| Platform | oat-sa/lib-lti1p3-core |
GPL-2.0 | Licence |
| Tool | packbackbooks/lti-1p3-tool 6.4 |
Apache-2.0 | Mature, IMS-certified tool library; launch validation, OIDC, deep linking, AGS/NRPS clients; pure PHP on php-jwt 7 and Guzzle (both present) |
| Tool | First-party | - | Re-implements security-critical validation |
| Both | ltijs |
Apache-2.0 | Node: one more service to run, against principle 8 |
Decision
Section titled “Decision”One package api/packages/lti (Ulams\Lti) with Platform and Tool namespaces sharing the key
set, the nonce store, claim names and the SSRF-safe HTTP client.
- Keys: per tenant (
lti_keys, private key encrypted with the tenantAPP_KEY), statesnext,active,retired.ulams:lti:rotate-keyspromotes next to active monthly (scheduler) and keeps retired keys in the JWKS for 30 days. Provisioning steplti_keyscreates the set. JWKS at/api/lti/jwksand/.well-known/jwks.json. - Platform:
POST /api/lti/launches/{topic}returns the tool’s OIDC login URL with a signed (HS256,APP_KEY-derived), 2-minute, single-uselogin_hint; no cookies, so it works in iframes./api/lti/platform/authorizechecks client, registered redirect URI and hint and posts an RS256id_token. AGS: client-credentials tokens from a JWT assertion (jti single use), line items scoped to tool and course, append-only scores, aCompleted/FullyGradedscore completes the topic throughCourseProgressRepository. Deep linking createsLtiLinktopics throughTopicRepository. The topic typeLtiLinkis a normal topic-type registration. - Tool:
packbackbooks/lti-1p3-toolvalidates launches. Its cookie and cache interfaces are implemented on the tenant database: the OIDCstateis stored server-side (single use, 10 min) instead of in a cookie. Users are mapped by(platform, sub), never by e-mail; Instructor becomes tutor, nobody becomes admin. Course access throughCourseAccessService; the front gets a 60-second one-time code it exchanges for a Passport token. Grade passback: aTopicFinishedlistener queues an AGS score (course progress percentage) with retries. - Outgoing HTTP (JWKS, tokens, AGS) only to
httpsURLs on public addresses, no redirects, with the resolved address pinned (LTI_ALLOW_INSECURE_URLSfor development only). - Passport interplay: global middleware resolves the Passport user, and Passport blanks any
bearer header that is not one of its tokens. AGS paths move the header aside before that
(
IsolateLtiBearer).
Consequences
Section titled “Consequences”- One new runtime dependency (
packbackbooks/lti-1p3-tool, Apache-2.0, ~200 KB, no services). - Platform-side conformance is ours to keep: covered by feature tests against an in-test fake tool;
the 1EdTech/saLTIre round trip and a Moodle profile (
lti-e2e) are still to be added (M1.9). - Server-side state trades the browser binding of a state cookie for working inside LMS iframes;
the nonce bound to the state still ties an
id_tokento its login. - The LTI Client-Side OIDC (platform storage via
postMessage) is not implemented yet. - NRPS (names and roles) is not offered on the platform side yet.