Three things happened this week: I undid the cookie change from week 5 with something
better, I put the entire LTI 1.3 feature behind a flag that defaults to off, and I
closed two of my own pull requests.
Undoing the cookie
Last week I set SameSite=None on the session cookie so it would survive the
cross-site POST that carries the id_token back from the platform. It worked, and it
weakened a CSRF defence across the whole application to serve one endpoint.
This week's commit is
fix(lti): sign the OIDC state instead of weakening the session cookie.
The reframe that unlocked it: I had been treating "the state must survive the round
trip" as "the state must be stored somewhere that survives the round trip." Those are
not the same requirement. The state does not need to be stored at all. It needs to be
recognisable when it comes back.
So instead of keeping a value in the session and comparing, the state becomes a signed,
self-contained token carrying its own payload:
LTI_STATE_PURPOSE = "lti.launch.state"
LTI_STATE_TTL = 5.minutes
state = lti_state_verifier.generate(
{ "nonce" => nonce, "deployment_id" => deployment.id },
purpose: LTI_STATE_PURPOSE,
expires_in: LTI_STATE_TTL
)
Rails.application.message_verifier signs it with the application's secret. When the
launch returns, we verify the signature, and if it checks out we know the contents came
from us and have not been altered. The nonce and the deployment ID ride inside the
token itself.
What this buys:
- No session, so no cookie problem. Nothing needs to survive the cross-site POST except the parameter the platform is already sending back to us.
-
No cookie policy change.
SameSite=Laxstays as it is for every other page on the site. - No server-side state. No table, no cleanup job, no expiry sweeper.
- The signature is the CSRF defence. Only CircuitVerse can mint a valid state, so a launch carrying one is answering an initiation we started.
- It expires. Five minutes, enforced by the verifier.
The purpose argument is a detail worth calling out. It scopes the signature to this
one use, so a token signed elsewhere in the application with the same secret will not
verify here. Same secret, different purposes, no cross-use.
The general lesson: when a constraint blocks you, check whether you are solving the
constraint or the requirement behind it. "Survive a cross-site POST" sounded like a
cookie problem. It was a state-management problem, and moving the state out of the
session dissolved the cookie question entirely.
The flag
The commit feat(lti): gate LTI 1.3 behind a default-off lti_advantage flag does what
it says: every 1.3 endpoint returns 404 unless an operator turns the flag on.
The reason is specific. The 1.3 launch auto-provisions user accounts. A launch from
a registered platform creates a CircuitVerse user, confirmed, ready to sign in. That is
correct behaviour for the feature and it is also a significant thing to have live in
production the moment a branch merges.
A default-off flag means the code can land, be reviewed, be tested, and sit there inert
until somebody deliberately switches it on. It decouples "this code is merged" from
"this code is running", which for an auth path is exactly the separation you want.
There was also a small commit removing some stale comments about CSRF token handling —
notes I had written to myself that had stopped being true after the state change.
Comments that describe an older version of the code are worse than no comments.
Closing two pull requests
On 12 July I closed #7510,
the lti-advantage gem addition, and
#7632, the subgroups feature.
The gem one was straightforward: I had already reversed that decision in week 4 and
gone direct to ruby-jwt. Leaving the PR open was just noise in the queue. Closing your
own obsolete pull requests is a small courtesy — every open PR is something a maintainer
has to look at at least once to decide it is not for them.
Subgroups was harder, because that one was finished. Data layer, UI, controller, API
endpoints, scoping, tests. Earlier in the week I had added the REST API v1 endpoints and
fixed a bug where a synced user who was also a subgroup mentor got a duplicate
membership row inserted.
But it had the same problem as my original POC: it was a large change to a core model,
arriving as one piece, at a moment when the maintainers' attention on my work was
pointed at LTI. It was not going to get the review it needed, and holding it open would
not change that.
The work is not lost — the branch is intact and the design still holds up. It just is
not the thing I should be spending review capital on right now.
That framing — review capacity as a budget you spend — was new to me this week.
Maintainer attention is the scarcest resource on the project. Every PR I open spends
some, and if I spend it on something that is not on the critical path, the thing that
is on the critical path waits longer.
Opening the first small one
Right at the end of the week I opened
#7647, and it is deliberately
tiny.
It fixes a bug in the existing LTI 1.1 code: when a teacher grades a project in an LTI
session, the score was pushed to the LMS before the grade was saved locally. If the
local save then failed validation, the LMS would show a grade that CircuitVerse had
never stored. The gradebook and the source of truth silently disagree.
It has nothing to do with 1.3. I picked it as the first thing to send precisely because
it is small, self-contained, and improves the integration that already has real users —
and because it demonstrates I can send something a maintainer can review in ten minutes.
Which, after week 3, felt like the point.
Next week: a reviewer finds a bug in it that I had looked straight past, and I cut the
remaining work into twenty-four pull requests.
Top comments (0)