LTI
How a course platform reaches this Server — the platform record, the four endpoints that cannot ask for a session, and the one place an address a stranger supplies is opened.
AlgoJudge implements LTI 1.3 as a tool: a course platform opens an activity for its students, and grades travel back. Moodle is the platform it is verified against.
Not certified by 1EdTech
The specification is implemented; the certification is not held. The manager's screen says so too, and nothing here should be read as a conformance claim.
The whole of it is one module, and it is meant to stay removable: two lines
outside Lti/ register it, and a test fails the build if any file elsewhere so
much as mentions LTI. Nothing in the core knows this exists.
The addresses
Every route below sits under /api/v1, so /lti/login is really
https://your.server/api/v1/lti/login.
Four of them answer without a session, and each has to.
GET/POST /lti/login | The platform starts the sign-in. Both verbs, because Moodle uses POST |
POST /lti/launch | The launch itself |
GET /lti/jwks.json | The tool's public keys, so a platform can check our signatures |
GET /lti/register | The platform's half of an expected registration |
Requiring a session on any of them would mean requiring somebody to have already signed in to the thing they are being launched into.
They also never answer problem+json. A launch that fails is a redirect
carrying a code, and a registration that fails is an HTML page — because
both are read by a person inside a frame on somebody else's site, and there is
nobody there to read a JSON error document.
Everything else — platforms, placements, invitations, keys, deep-link responses —
is an ordinary authorized API. provider:manage gates the platform, invitation
and key endpoints; the roster and grades are scoped to the activity instead, by
result:read:all, activity:enroll and activity:update.
What a launch does
The platform sends the browser to /lti/login with iss, client_id and
lti_deployment_id; the Server matches a platform on that triple, writes a
launch state row and sends the browser on to the platform's own login address.
The platform authenticates the person and posts state and an id_token to
/lti/launch. The Server consumes the state row, fetches the platform's key set
and checks the signature, issuer, audience, lifetime and nonce, resolves the
placement, and only then asks who arrived. The browser ends up at the activity,
at a conflict to report, at an offer to sign in through SSO, or at
/lti/failed?reason=….
The placement is resolved before anybody is signed in: signing somebody in and then telling them the tool is misconfigured is the worse order.
The platform record
Twelve fields, and three of them can never be changed: the issuer, the
client id and the deployment id. Every launch and every identity link hangs off
that triple, so an attempt to move one is refused with
lti.platform.immutable.
The twelfth is the platform's role rules, below.
The four addresses — issuer, key set, token and login — must each be an
absolute https URL, or http on loopback so a development stack can be
registered. Anything else is refused by name.
Registering a platform writes two rows: the platform, and an identity provider paired with it. That provider is an attribution row — it names the source of a grant's roles and nothing else — so it is never listed among the identity providers, and nothing can enable or delete it there. Every provider read, write and delete answers only for the ones people sign in through.
It is created disabled as well, and that is the second guard rather than a default: a disabled provider is never given a sign-in scheme or a callback address, so nobody signs in through a platform. It holds an empty secret rather than a random one, so has a secret answers no, which is true.
What a launch's roles are worth here
A platform carries its own role rules, and they are the whole of how an LTI
role becomes something here. One rule is an LTI role — Learner, Instructor,
a sub-role such as Instructor#TeachingAssistant — and what it grants: a role of
the installation's, named by id, or the set the activity enrolls participants
or managers into. The second kind is a platform's alone: it resolves against
the activity a launch names, and a sign-in happens in no activity.
A platform starts with the rules the Server used to apply in code — Learner to
the participants set, and Instructor, ContentDeveloper and Mentor to the
managers set — so an installation that changes nothing behaves as it did.
Only the membership vocabulary is read. A value outside
…/vocab/lis/v2/membership is an institution or system role, not a course role,
and is ignored: a lecturer enrolled as a student in a colleague's course is a
student there.
A sub-role matches two values — itself and the role it is a sub-role of — and
every rule matching either one applies. There is no precedence: a rule for
Instructor#TeachingAssistant adds to a rule for Instructor rather than
replacing it, because what a launch grants is a union and a union has no
ordering to decide. So an installation that wants its teaching assistants
treated differently has to take the broad rule away; writing the narrow one
beside it gives both.
system:administrator is unreachable here by the three guards the sign-in path
uses, and a platform's own Administrator role is deliberately a participant.
Deleting a platform is refused while any course still has an activity from it, and the refusal names the count.
`isIdentityAuthority` is the dangerous field
With it on, a launch attaches itself to the account whose username the platform sends — so whoever controls that platform can reach those accounts. It is off unless somebody turns it on, and it is inert without a namespace: the namespace is an identity provider's slug, and an account that did not come through that provider cannot be claimed. Local accounts and administrators are therefore out of reach.
What the Server hands back
GET /lti/platforms/{id}/registration answers the four addresses to paste into
the platform's external-tool configuration, plus the custom parameters without
which a launch cannot tell who arrived:
username=$User.username
context_history=$Context.id.historyA fifth is added per placement and is not in that list — activity=<slug> — which
is what tells a launch which activity it is opening.
Those addresses are built from PublicApiUrl, falling back to the address of
the request. Dynamic registration is the one caller that will not accept the
fallback: if the result is not an absolute URL it refuses with
lti.registration.publicApiUrl, because it is about to hand that address to
somebody else's server.
Expected registrations
A platform may register itself, but only if somebody here is expecting it.
An invitation is a random code with a thirty-minute life, single use, and revocable before it is spent. Revoking sets the expiry; the row stays, so the history of who invited whom does not disappear.
What arrives is switched off. A dynamically registered platform is written
with Enabled = false, no identity authority and no namespace — the two decisions
that matter are left to a person on the platforms screen, with the consequences
in front of them.
A refusal does not spend the invitation. The claim and the two outbound calls are one transaction. Separately, a platform that answered badly would burn a single-use code and leave the administrator to ask for another.
Keys
The tool signs two things: the deep-link response it hands back to the platform,
and the assertion it uses to get an access token for grades and rosters. A
platform checks both against /lti/jwks.json.
RSA-2048 and RS256. That is this project's choice, not the specification's —
the security framework makes RS256 a default at SHOULD strength and mandates no
key size at all. It is the right choice because of the platform: Moodle
advertises exactly RS256, in every supported version.
Rotation is two deliberate acts and there is no schedule.
rotatemints a new key and retires every key that was signing — and leaves the retired ones published, so signatures already in flight still verify.withdrawremoves one retired key and deletes its private half. Refused for the key that is currently signing: rotate first.
Automating either would put the failure in somebody else's installation at a moment nobody chose.
A key is generated on the first read of the key set, so a platform that fetches it before anything has been signed finds a key rather than an empty document it would then cache.
The one address a stranger chooses
Every outbound call in this module goes somewhere an administrator wrote down — except one. During an expected registration the platform tells us where its own configuration lives, and we fetch it.
That single HTTP client is guarded, and the guard is worth stating because it is unusual:
httpsonly. Nothttp, and not even on loopback — unlike the fields an administrator types, where loopback is exempted so a development stack works.- No credentials in the address.
https://moodle.example@evil.example/is refused rather than parsed, because everything before the@is credentials and the host is what follows it. - Redirects are refused, not followed. A redirect can change the scheme, and
the
httpsrule cannot ride along. - The address is checked at the moment of connecting, not when the name is read, so a name that resolves differently a minute later cannot slip between the two.
- Connections are never reused, because a pooled connection is one whose address was checked for somebody else's request.
Private networks are allowed on purpose. A university runs its Moodle and its
AlgoJudge on one private network, so refusing 10.0.0.0/8 would refuse the
ordinary deployment while protecting nothing. What stays refused is loopback,
link-local — where every major cloud answers instance credentials — carrier-grade
NAT, multicast, and the documentation and benchmarking ranges.
The same check is applied twice: to the configuration address, and again to the registration address read out of whatever that first fetch returned. Whoever answered the first request chooses where a bearer token is posted.
A refusal reads "This Server will not open a connection to …", as a page.
What it deliberately does not do
- It never writes into a course. The roster is read-only, permanently.
- A launch adds roles and never removes one. Somebody who leaves a course, or is demoted at the platform, keeps what they were given here; withdrawing it is a person's decision. A role a manager removed by hand is remembered as removed and is never added back, so a correction outlives the next launch.
- It never revokes or clears a grade at the platform.
- Nobody signs in through a platform, per the disabled provider row above.
- The private key is never exposed, and no shape in the API has a field for one.
target_link_uriis captured and not validated.- Account creation from a launch is off, and today it can only be changed in the database — there is no endpoint and no screen for it.
When a launch is refused
Nine codes, each arriving as /lti/failed?reason=… and each rendered by the
Client as a sentence rather than a code. They are worth knowing because they are
what an operator reads in a log.
| Code | |
|---|---|
unknownPlatform | no registration matched, or several did and the launch did not say which |
platformDisabled | registered and switched off |
badState | the launch did not follow an initiation this Server issued — also what a replay looks like |
badToken | the signature, issuer, audience, lifetime or nonce did not check out |
unsupportedMessage | not a resource-link or deep-linking request, or not LTI 1.3 |
platformUnreachable | the platform's key set could not be fetched |
noActivity | the placement carries no activity=<slug>, or names one that does not exist |
notPublished | the activity is not published, and the launch is not somebody who runs the course |
sharingNotAcknowledged | the activity is placed in more than one course and nobody has accepted that |
The state of a launch is a row, not a cookie, and deliberately: Moodle implements none of LTI Platform Storage in any supported version. It is consumed by a delete, so a replayed launch and an expired one are one answer — a flag would let two requests both read unused and both proceed.