fix(auth): rotate refresh tokens with single-use enforcement + enable rotation#3083
fix(auth): rotate refresh tokens with single-use enforcement + enable rotation#3083xilosada wants to merge 6 commits into
Conversation
…tion - each refresh rotates the refresh token; a consumed refresh token is recorded in a storage-backed denylist and may not be reused (reuse revokes the token family), surfaced via an x-auth-error: token_reuse response signal - a throttled, process-wide sweep bounds the denylist size - spawn the signing-secret rotation task at startup, safe now that verification accepts an unexpired backup secret
There was a problem hiding this comment.
🤖 MeroReviewer
Reviewed by 1 agents | Quality score: 24% | Review time: 69.9s
🟡 3 warnings, 💡 1 suggestions, 📝 1 nitpicks. See inline comments.
🤖 Generated by MeroReviewer | Review ID: review-60d2d926
Documentation ReviewThe following documentation may need updates based on the changes in this PR:
|
|
This pull request has been automatically marked as stale. If this pull request is still relevant, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize reviewing it yet. Your contribution is very much appreciated. |
Triage of the
|
# Conflicts: # crates/auth/src/lib.rs
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
- claim the refresh jti atomically (consume lock) BEFORE minting: closes
the TOCTOU between the consumed-check and the denylist write, and fixes
the record-after-return failure mode where a storage error let the
caller retry a not-yet-denylisted token after a pair was already minted
- family revocation now chases the client key-rotation chain
(system:refresh:rotated:{old} -> new) so a replayed rotated refresh
token revokes the LIVE key instead of silently failing on the deleted id
- key lookup no longer short-circuits reuse detection when the key id was
rotated away
- denylist/rotation-map sweep runs in a spawned background task off the
refresh hot path
- X-Auth-Error uses HeaderValue::from_static
New tests: concurrent_refresh_of_same_token_yields_exactly_one_success,
replayed_client_refresh_after_rotation_revokes_live_key
There was a problem hiding this comment.
🤖 AI Code Reviewer
Reviewed by 2 agents | Quality score: 90% | Review time: 644.2s
⚠️ Review Incomplete
1 of 2 agent(s) did not finish (patterns-reviewer) and no findings were produced. Treat this PR as not yet reviewed, not as approved.
🤖 Generated by AI Code Reviewer | Review ID: review-be013ee1
…n grace Two ways this PR could brick a node: 1. Reuse-revocation hit ROOT keys. A user_password login mints its pair against the root key, so a replayed root refresh token revoked the node's root key. verify_credentials then rejects it while list_keys still reports it, so the bootstrap path cannot re-fire either: the node is permanently unauthenticatable. Two tabs sharing one token bundle (the desktop SSO model) are enough to trigger it, on a FRESH node. Reuse on a root key is now refused WITHOUT revoking the key — the single-use denylist already defeats the replay, and revoking a root key stays an explicit admin action (mirroring delete_key_handler, which refuses to revoke the last active root key). 2. Rotation grace was shorter than the refresh-token lifetime. Storage keeps ONE previous secret, so a token must verify while its signing secret is at most one generation old. With 24h rotation / 48h grace and 30-day refresh tokens, enabling rotation for the first time would have invalidated every refresh token within 48h, forcing a re-login every day or two. Defaults are now 30d rotation / 60d grace, which satisfy rotation_interval >= token lifetime and grace >= interval + lifetime. New test: root_key_survives_refresh_reuse. Updated refresh_rotates_and_consumed_token_is_reuse_rejected, whose old assertion encoded the bricking behavior.
|
Refresh tokens are single-use (calimero-network/core#3083): every POST /auth/refresh consumes the presented refresh token and mints a new one. Re-presenting a consumed refresh token is treated as theft — 401 `x-auth-error: token_reuse` — and the node revokes the entire token family, hard-logging-out every holder. `persistTauriHashAuth()` unconditionally overwrote the stored `mero-tokens` bundle from the SSO hash on every load. The desktop re-opens MeroPixArt with the bundle it minted at ITS login, so the hash routinely carries a bundle older than the stored one — mero-js may have rotated several times since. Clobbering it re-presents a refresh token the node has already consumed, and the next refresh kills the session. Seed only when nothing is stored, the node changed, or the hash bundle is genuinely newer (utils/authTokens.ts, with tests) — mirroring the same helper in mero-chat. Depends on calimero-network/core#3083 and calimero-network/mero-js#67. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Refresh tokens are single-use (calimero-network/core#3083): every POST /auth/refresh consumes the presented token and mints a new one, and re-presenting a consumed token is treated as theft — the node revokes the whole token family and replies 401 x-auth-error: token_reuse, logging out every holder. The desktop broke that rule twice: 1. Across webviews. openAppFrontend put the real refresh token in every app window's SSO URL hash. Each webview is a separate origin with its own localStorage and its own MeroJs, so N holders each rotated the same single-use token: the first consumed it, the next tripped reuse detection, and the family was revoked. Re-opening an app also re-injected the desktop's (possibly stale) token over the app's live one. 2. Within the desktop. Several MeroJs instances exist (the lazy apiClient default, createClientAsync, plus StrictMode double-mounting), each deduping refreshes only among its own requests. A burst of 401s fired six concurrent refresh POSTs carrying the same token — one succeeded, the rest were reuse. The desktop is now the single holder and single rotator. App windows get the access token plus an opaque BROKERED_REFRESH_TOKEN sentinel, never the real refresh token; the injected proxy script intercepts their POST /auth/refresh and routes it to the new broker_token_refresh Tauri command, which the desktop answers from its own token store. All rotations — desktop and app alike — funnel through one single-flight that re-reads the store before POSTing, so a consumed token can never be replayed. Apps need no changes: an unmodified mero-js/mero-react app still just calls /auth/refresh. Also fixes the e2e fixture: the refresh route was mocked at **/auth/refresh-token, which matches nothing (the real path is /auth/refresh), so the refresh path was never exercised. The mock now models single-use rotation — a new pair per call, token_reuse + family revocation on replay — and it caught bug 2 above. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A browser hides every response header from JS unless the server names it in Access-Control-Expose-Headers. The standalone mero-auth router set allow_origin/methods/headers for an explicit origin allow-list but never called expose_headers, so a cross-origin client saw a bare 401: it could not auto-refresh (SDKs only refresh on x-auth-error: token_expired) and, with this PR's single-use enforcement, could not recognise a revoked family (token_reuse) either — it would retry forever. CorsLayer::permissive() (the allow-all branch) already exposes everything, which is why this went unnoticed; mero-server has carried the same list, and tests pinning it, since an earlier production incident. Test: explicit_origin_cors_exposes_x_auth_error.
What
Makes refresh tokens single-use and turns on signing-secret rotation. Closes security-review findings 11 (root refresh tokens never rotated, no reuse detection) and 13 (secret-rotation task defined but never started).
Before → after
jtiinto a storage-backed denylist atomically, before minting (process-wide consume lock → two concurrent requests with the same token yield exactly one success). A replay is treated as theft: the token family's live key is revoked and the client gets 401 +x-auth-error: token_reuse.old id → new id; reuse handling chases the chain (bounded at 32 hops) and revokes the live key.start_rotation_taskexists but has zero callers → the JWT signing secret is permanent (finding 13).mero-authand merod-embedded paths). Safe only because #3079 makes verification accept the unexpired backup secret — hence the stack order.Review-feedback fixes (meroreviewer inline, all 5 addressed)
system:refresh:rotated:{old_id}mapping +revoke_token_familychain resolution.consume_refresh_lock(storage has no CAS; the persistent backend is single-process RocksDB, so a process lock is sufficient).tokio::spawned, throttled internally."token_reuse".parse().unwrap()→HeaderValue::from_static.How to test
Targeted tests:
refresh_rotates_and_consumed_token_is_reuse_rejected— exchange, replay →TokenReuse, family revoked.concurrent_refresh_of_same_token_yields_exactly_one_success— the TOCTOU regression test.replayed_client_refresh_after_rotation_revokes_live_key— the rotated-client-key revocation regression test.fresh_refresh_token_is_not_flagged_as_reuse— no false positives.Manual:
POST /auth/refreshtwice with the same refresh token → second responds 401 withx-auth-error: token_reuse, and the first exchange's new tokens stop working (family revoked).Compatibility