After a week with nothing to show, this one produced a lot: the LTI 1.3 infrastructure
rebuilt from the specification rather than from other people's example code, a
dependency added and then removed again, and the three security checks that make a 1.3
launch trustworthy written as three separate, named things instead of one blob.
Starting with the gem
I had opened #7510 the
previous week to add lti-advantage 0.1.0, and I had a separate issue
(#7509) tracking it. My
assumption was the ordinary one: this is a well-specified protocol, somebody has
written the gem, I should use the gem rather than hand-rolling security code.
That assumption is usually right. It was not right here, and the version number was the
clue I ignored. 0.1.0 is not a version that has been through much contact with
production.
When I started wiring the rebuilt launch against it, the shape of the problem became
clear. The gem gave me some convenience wrappers around things ruby-jwt already does
well, and did not give me the parts that are actually hard: resolving a platform's
public key by kid from its JWKS endpoint, handling key rotation, binding a token to a
specific deployment. Those I was going to write regardless.
So I was adding a dependency, in the authentication path, that I would still have to
write the security-critical code around. That is the worst of both: a supply-chain
surface without the benefit.
By the end of the week I had reversed it. Two commits tell that story —
build(deps): restore ims-lti for the LTI 1.1 launch path and
build(deps): drop unused lti-advantage gem. CircuitVerse keeps ims-lti for the
existing 1.1 path, which still has real users, and 1.3 gets built directly on
ruby-jwt, which is mature and which I understand.
The lesson I want to keep: "there's a gem for it" is a hypothesis, not a conclusion.
Check what it actually does before you build on it, especially in an auth path. The
cost of finding out late is that you have already designed around it.
The three checks
The main work of the week was the commit
fix(lti): enforce state, nonce, and deployment binding on 1.3 launch. In the POC, all
of this had been one function that either returned a user or didn't. Pulling the three
defences apart and naming them individually changed how I thought about each.
State. The state parameter is a value the tool generates when it starts the login
and checks when the launch comes back. Without it, anyone can POST a token at your
launch endpoint and you have no idea whether you ever asked for that login. It is the
CSRF defence for the handshake.
Nonce. The tool generates a nonce, sends it in the authorization request, and the
platform copies it into the id_token it returns. When the token comes back, the nonce
must match the one issued. This ties the token to this login attempt. Without it a
token captured once could be presented again later.
Deployment binding. This is the one I had missed entirely in the POC, and the one
reading the spec properly surfaced. A platform is identified by an issuer, a client ID,
and a deployment ID. Every one of those platforms signs its own valid tokens. So a
token can be perfectly valid — correct signature, unexpired, well-formed — and still be
from a completely different institution's Canvas than the one that started this login.
The token has to be bound to the deployment the login was initiated against, or a valid
token from platform B can be presented in a session opened for platform A.
It is the kind of hole you do not find by testing, because every test you write by hand
uses one platform. You find it by reading the spec and asking what each field is for.
Identity, and a bug I created for myself
Two smaller commits this week were about actually signing the user in, and both came
from things breaking.
fix(lti): confirm LTI 1.3 provisioned users so sign_in succeeds — CircuitVerse uses
Devise with the :confirmable module, so a newly created user has to have a
confirmation timestamp before they can be signed in. When CircuitVerse provisions an
account from a launch, the platform has already authenticated that person; there is no
email round-trip to wait for. So provisioning sets confirmed_at at creation. Obvious
in hindsight, and it took a while to see, because the failure looked like the sign-in
silently doing nothing.
fix(spec): stub JWKS HTTP request in LTI 1.3 launch tests — the launch fetches the
platform's public keys over HTTP. My tests were really trying to make that request.
Stubbing it made the suite fast and deterministic, and it forced me to be explicit
about what the JWKS response actually looks like, which paid off later when I came to
handle key rotation.
There was also a housekeeping commit,
fix(lti): add missing schema migration and resolve all rubocop offenses. Not
interesting, except as a reminder that "it works" and "it is ready for someone else to
look at" are different states.
Writing tests against forgeries
The other shift this week was in how I write tests for this. My instinct had been to
test the happy path thoroughly and add a couple of failure cases.
For authentication code that is the wrong ratio. The happy path is the case an attacker
does not care about. What matters is every way a token can be wrong: signed with the
wrong key, signed with no key at all, signed with a symmetric algorithm when we expect
an asymmetric one, expired, wrong issuer, wrong audience, right issuer but wrong
deployment, missing the subject claim, replayed nonce.
Each of those is a test that fails loudly if I ever weaken the validator. That framing —
the test suite as a set of attacks the code has to survive, not a set of features it has
to have — is the thing I have found most useful to internalise, and it is what made the
validator work later in the project reviewable in isolation.
Where this leaves the week
The protocol layer exists again, built from the spec, with the three defences separated
and named, no unnecessary dependency in the auth path, and a test suite that mostly
consists of things going wrong.
What it does not have yet is a shape a reviewer can accept. It is still one branch with
everything on it. That problem is still ahead of me, and it is the one that closed my
POC.
Next week: the email address a platform sends you is not proof of anything, and I
weaken a cookie in a way I will have to undo.
Top comments (0)