Skip to content

fix(levelcode): rotate refresh tokens; say "session expired" instead of leaking the JWT error - #438

Merged
ndemianc merged 2 commits into
developfrom
fix/session-expiry-ux
Oct 3, 2026
Merged

ndemianc merged 2 commits into
developfrom
fix/session-expiry-ux

Conversation

@ndemianc

@ndemianc ndemianc commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

The incident

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 signed in · Max. Nothing on screen said what to do.

The cause — refresh tokens were never rotated

auth#refresh returned 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::ExpiredSignature was collapsed into InvalidToken with the library's own text, and rescue_from rendered 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:

Where Code Message
any bearer endpoint, expired access token token_expired Your LevelCode Cloud session has expired. Sign in again to continue.
POST /auth/refresh, expired refresh token refresh_expired same
anything else wrong with a token unauthorized / invalid_refresh unchanged

Verification

  • auth spec 27 examples (7 new); full suite 1038 examples, 0 failures; rubocop clean.
  • The new examples move the clock (travel_to) rather than minting pre-expired tokens, so they take the same path a real expiry takes. TimeHelpers is included in the one spec that needs it.
  • Each change reverted in turn fails only its own examples: expiry collapsed back into InvalidToken → the two expired examples; rotation removed → the rotation example; the ExpiredToken rescue_from removed → the bearer example.

Companion

The editor side (levelcodeai/levelcode#92 — levelcodeai/levelcode#92): stores the rotated token, treats a 401 from /auth/refresh as 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.

…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ndemianc

ndemianc commented Oct 3, 2026

Copy link
Copy Markdown
Member Author

The red brakeman check is not a finding against this change. bin/brakeman runs with --ensure-latest, and brakeman 8.1.0 was published on 2026-10-01 while the lock holds 8.0.6 — so every branch's brakeman job has exited 5 since then with zero warnings and zero scan errors (reproduced locally with CI's exact invocation, on develop as well as here). The one-line bump is systemu-net/thin.ly PR chore/brakeman-8.1; once it merges, re-running this check goes green. This branch also scans clean under 8.1.0 itself.

@ndemianc
ndemianc merged commit fbefce7 into develop Oct 3, 2026
3 checks passed
@ndemianc
ndemianc deleted the fix/session-expiry-ux branch October 3, 2026 03:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants