Renew session tokens eagerly (on-start heal + 24h cadence) so server-minted claims propagate - #463
Conversation
ReviewNicely scoped change with strong test coverage on the pure Potential issue: renewal failures are now all treated as transientIn Err(e) => {
tracing::error!("Failed to refresh JWT: {e}");
}
Worth considering: keep the network/5xx case non-destructive (the actual fix here), but delete the session on a Minor / non-blocking
Nits
No security concerns beyond the above (JWT decoding remains intentionally unverified client-side, consistent with existing code; session file permissions unchanged at |
Tokens minted before the sync-enforcement rollout (and any token loaded
at `cook server` boot) never carried a `sync_until` claim, and since
tokens live 100 days while the renewal threshold was <2h remaining,
existing sessions effectively never picked up the claim once the server
started minting it.
- session.rs: add `sync_until` to the decoded JWT claims (presence-only,
value unused client-side), plus `jwt_expires_in` and
`jwt_lacks_sync_until_claim` helpers built on the existing unverified
decode helper. Drops the now-superseded `should_refresh_jwt`.
- runner.rs: extract the renew decision into a pure `should_renew(expires_in,
since_last_renew, claim_missing_and_not_yet_healed)` used by both an
immediate boot-time check and the existing hourly loop:
- renews when < 2h of token life remains (unchanged)
- renews when >= 24h have passed since the last successful renewal in
this process (falling back to the session file's mtime when this
process hasn't renewed yet, so a restart doesn't wait a full 24h)
- renews once, immediately at boot, if the claim is entirely absent —
guarded by a one-attempt-per-process flag so a free-tier token (which
never carries the claim) isn't hammered every tick
Renewal failures are now split by cause (review fix):
- HTTP 401 (`RenewError::AuthRejected`): the account was logged out,
deleted, or otherwise had its session revoked server-side — retrying
with the same token can never succeed. Restores the pre-PR behavior for
this case only: delete the session file, clear the in-memory session,
and let the existing 'no session' pathway (the same one
`SyncSession::load` uses for a naturally-expired token) tell the local
UI a re-login is needed.
- Anything else (`RenewError::Transient`: network failure, timeout,
non-401 non-2xx, bad body): log and keep the old token for the next
tick, as before — a renewal endpoint blip should not log the user out.
Unit tests: claim-presence detection against hand-built JWTs (present,
absent, explicit null); the full should_renew truth table; 401-vs-other
status classification; and the auth-rejected-clears-session /
transient-keeps-session outcome split.
ce8eb01 to
62050f5
Compare
Review of
|
Why
Part of the server-side sync-enforcement rollout: the sync server now mints a
sync_until(unix ts) claim on every renewed JWT when the account has the sync entitlement. Tokens minted before that rollout — and, unavoidably, any token in a session file loaded atcook serverboot — carry no such claim at all. CookCLI's session tokens live 100 days, and the renewal threshold was "renew only when < 2h of life remains," so existing sessions would have gone ~100 days before ever hitting the server again and picking up the claim.This brings CookCLI's renewal cadence in line with the other Cooklang clients: sync-agent renews when near expiry OR ≥24h since last refresh; the editor renews on start + every 24h.
What changed
src/sync/session.rsJwtClaimsgainssync_until: Option<i64>(#[serde(default)]) — presence, not value, is what matters client-side.jwt_expires_in(jwt) -> Result<i64>andjwt_lacks_sync_until_claim(jwt) -> Result<bool>, both built on the existing unverified-decode helper.should_refresh_jwt(folded into the above + the new decision function).pub(crate) tests_support::make_test_jwt— a small cross-module test helper sorunner's tests can build a well-formed JWT without duplicating the encoding logic.alg: none) with the claim present, absent, and explicitlynull.src/sync/runner.rsshould_renew(expires_in_secs, since_last_renew_secs, claim_missing_and_not_yet_healed) -> boolexpires_in_secs < 7200(2h) — unchangedsince_last_renew_secs >= 86400(24h) — new cadenceclaim_missing_and_not_yet_healed— the on-start healstart_token_refreshnow runs an immediate check at boot (heals a session missingsync_untilwithout waiting for the first hourly tick), then continues on the existing hourly cadence. Both the boot check and the loop go through one sharedcheck_and_maybe_renewso the decision logic isn't duplicated.cook serverrestart doesn't reset the 24h clock to zero.sync_untilpermanently, not just until healed, so re-checking it every tick would otherwise hit/api/sessions/renewevery hour forever for free users.should_renewcovered by unit tests, plus tests for the mtime fallback helper.No changes to login/logout flows or the session file format.
Review fix: 401 vs. transient renewal failures
An earlier version of this PR collapsed every
/api/sessions/renewfailure — a hard 401 as much as a network blip — into the sameanyhow::Error, and kept the old token unconditionally on any of them. That meant a revoked/deleted account (or any other reason the server hands back a 401) would retry forever with sync silently dead and no way for the local UI to tell the user to re-login.Fixed by typing the failure:
refresh_tokennow returnsResult<String, RenewError>, withRenewErrorsplit into:AuthRejected— HTTP 401 specifically. The token itself was rejected; retrying with the same token can never succeed.Transient(anyhow::Error)— everything else: network failure, timeout, non-401 non-2xx, unexpected body.classify_renew_failure(status) -> RenewErroris a pure function doing just the status-code split (401 vs. everything else), independently unit-tested.apply_renew_outcome(...)(split out ofcheck_and_maybe_renewfor testability) now branches on the two cases:AuthRejected→ restores the pre-fix/original behavior for this case only: delete the session file, clear the in-memory session. This reuses the exact same "no session" pathwaySyncSession::loadalready uses for a naturally-expired token, so the local UI's existingsync_logged_in == false→ "Login to CookCloud" rendering picks it up automatically — no new status/reason plumbing needed.Transient→ unchanged from before: log and keep the old token for the next tick.classifies_401_as_auth_rejected,classifies_503_as_transient,classifies_other_4xx_as_transient_not_auth_rejected(a 404/500/etc is not grounds to log the user out — only 401 is),auth_rejected_clears_the_session_and_deletes_the_file,transient_failure_keeps_the_session_and_the_file,successful_renewal_replaces_the_session_and_records_the_time.Verification
cargo build --features sync— cleancargo test --workspace— all passing (105 in the main test binary, including 26 new/changed tests insync::runnerandsync::session)cargo clippy --workspace --all-targets --all-features -- -D warnings— cleancargo fmt --all -- --check— cleanNotes
check_and_maybe_renewreads the session under a lock, awaits the network call, then re-acquires the lock to write the result. If a logout happens in that window, the in-flight renewal can resurrect the just-logged-out session (both the in-memory state and the file) once it completes. This read-lock → await → write-lock shape predates this PR (the originalstart_token_refreshhad the same structure) — it's a candidate for a follow-up, not something this PR expands scope to fix.Do not merge — opening for review.