fix(levelcode): rotate refresh tokens; say "session expired" instead of leaking the JWT error - #438
Conversation
…ead of leaking the JWT error
A user back from a week away opened the editor, typed a goal, and got
LevelCode Cloud API 401: Signature has expired
in the transcript — while the account popover beside it still said they were signed in. Two
things were wrong, one of them the actual cause.
THE CAUSE: refresh tokens were never rotated. auth#refresh returned a new access token only,
so the editor kept the refresh token it was handed at sign-in for its whole life, and that
token's 30 days ran from the last SIGN-IN, not the last use. This user had signed in 37 days
earlier. Daily use changed nothing; the session was always going to die on day 30. The
refresh endpoint now returns a fresh 30-day refresh token with every access token, so the
window slides with use: an active user never reaches the wall, an idle one does after a
month. The previous token is NOT revoked — rotation here is for the sliding window, not
replay detection, and a client that crashes between receiving the new token and persisting
it must keep working on the old one.
THE SYMPTOM: JWT::ExpiredSignature was collapsed into InvalidToken with the library's own
message, and rescue_from rendered that message verbatim. "Signature has expired" is true and
useless. Expiry is now its own class, ExpiredToken, carrying a sentence a person can act on,
and it reaches the client under its own codes — `token_expired` at any bearer endpoint,
`refresh_expired` from the refresh endpoint — so the editor can tell "sign in again" apart
from "this credential is broken" and show a sign-in prompt rather than an error.
Verified: auth spec 27 examples (7 new); full suite 1038 examples, 0 failures; rubocop
clean. Each change reverted in turn fails only its own examples: expiry collapsed back into
InvalidToken -> the two expired examples; rotation removed -> the rotation example (and the
two login/exchange examples that assert a refresh token is issued); the ExpiredToken
rescue_from removed -> the bearer example. The spec file includes TimeHelpers so expiry is
exercised by moving the clock, the same path a real expiry takes.
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The implementation matches the documented token lifecycle and is covered by focused request tests.
Review effort: Balanced
Findings: None
What changed in this PR
Adds sliding refresh-token rotation and user-friendly handling for expired JWTs.
Changes:
- Returns a fresh refresh token during renewal without revoking the previous token.
- Distinguishes expired tokens with actionable error codes and messages.
- Adds request coverage for rotation, expiry, and invalid-token behavior.
| File | Description |
|---|---|
app/services/levelcode/editor_token.rb |
Introduces ExpiredToken and expiry messaging. |
app/controllers/api/levelcode/v1/base_controller.rb |
Maps expired bearer tokens to token_expired. |
app/controllers/api/levelcode/v1/auth_controller.rb |
Rotates refresh tokens and returns refresh_expired. |
spec/requests/api/levelcode/v1/auth_spec.rb |
Tests rotation and expiry responses. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
The red brakeman check is not a finding against this change. |
The incident
A user back from a week away opened the editor, typed a goal, and got
LevelCode Cloud API 401: Signature has expiredin the transcript — while the account popover beside it still said signed in · Max. Nothing on screen said what to do.The cause — refresh tokens were never rotated
auth#refreshreturned a new access token only. The editor therefore kept the refresh token it was handed at sign-in for its whole life, and that token's 30 days ran from the last sign-in, not the last use. This user had signed in 37 days earlier; daily use changed nothing. Every active user was on a 30-day countdown they could not see.Fix: the refresh endpoint now returns
{ access, refresh }— a fresh 30-day refresh token every time — so the window slides with use. An active user never reaches the wall; an idle one does after a month, which is the behaviour people expect.The previous token is deliberately not revoked. Rotation here is for the sliding window, not replay detection; a client that crashes between receiving the new token and persisting it must keep working on the old one. The old token still dies on its own clock, so exposure is unchanged from today.
The symptom — the JWT library's message reached the user
JWT::ExpiredSignaturewas collapsed intoInvalidTokenwith the library's own text, andrescue_fromrendered it verbatim. "Signature has expired" is true and useless.Fix: expiry is its own class,
ExpiredToken, carrying a sentence a person can act on, under its own codes so the editor can key on them:token_expiredPOST /auth/refresh, expired refresh tokenrefresh_expiredunauthorized/invalid_refreshVerification
travel_to) rather than minting pre-expired tokens, so they take the same path a real expiry takes.TimeHelpersis included in the one spec that needs it.InvalidToken→ the two expired examples; rotation removed → the rotation example; theExpiredTokenrescue_fromremoved → the bearer example.Companion
The editor side (levelcodeai/levelcode#92 — levelcodeai/levelcode#92): stores the rotated token, treats a 401 from
/auth/refreshas the session ending — clears the dead credentials, flips the popover, shows a sign-in card with a button instead of red text — and checks the session on launch and on window focus so the expiry is found before the first message rather than by it.