From 784cc0cbc603265060eb70abc8ce90ba0b61cd85 Mon Sep 17 00:00:00 2001 From: neilgfoster <1370457+neilgfoster@users.noreply.github.com> Date: Sat, 27 Jun 2026 08:36:15 +0100 Subject: [PATCH] fix: preserve refresh token when token endpoint omits it on renewal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC 6749 §6, a token-endpoint response MAY omit refresh_token (meaning "keep the old one"). _store_token_response was storing "" in that case, causing the next expiry to raise "no refresh token" and force a full re-auth. The previous refresh token is now preserved when the response does not supply a new one. Adds a regression test covering the omitted-refresh_token path. Bumps to 0.6.1. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 10 ++++++++++ plugin/.claude-plugin/plugin.json | 2 +- plugin/src/msgraph/runtime.py | 7 ++++--- tests/test_client.py | 22 ++++++++++++++++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28df663..cef94ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ Add notes here under Added / Changed / Fixed / Removed. On release, move them un ## [X.Y.Z] - YYYY-MM-DD heading and bump plugin/.claude-plugin/plugin.json to match. --> +## [0.6.1] - 2026-06-27 + +### Fixed + +- **Refresh token lost after silent renewal** — per RFC 6749 §6, a token-endpoint response MAY + omit `refresh_token` (meaning "keep using the previous one"). `_store_token_response` was + storing `""` in that case, so the very next expiry would raise "no refresh token — run + auth-login again" and force a full re-authentication. The old refresh token is now preserved + when the response does not supply a new one. + ## [0.6.0] - 2026-06-23 ### Fixed diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 8703e6a..0319146 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "msgraph-stdlib", - "version": "0.6.0", + "version": "0.6.1", "description": "Stdlib-only, zero-backend Claude Code plugin for Microsoft Graph: read Outlook mail and author/verify native Outlook message rules. Read-only by default, with a least-privilege scope ratchet and read-only catch-set verification before any rule is installed.", "author": { "name": "Neil Foster", diff --git a/plugin/src/msgraph/runtime.py b/plugin/src/msgraph/runtime.py index f9789fe..4861bea 100644 --- a/plugin/src/msgraph/runtime.py +++ b/plugin/src/msgraph/runtime.py @@ -265,7 +265,7 @@ def _refresh_if_needed(tok: dict) -> dict: "scope": tok.get("scope", ""), }, ) - renewed = _store_token_response(resp, fallback_scope=tok.get("scope", "")) + renewed = _store_token_response(resp, fallback_scope=tok.get("scope", ""), prev_tok=tok) # Make silent refresh observable (feature 008, Issue 2): the user perceives "expired every # session" when refresh fails silently; a stderr note shows it actually working. stdout stays # machine-clean. @@ -295,11 +295,12 @@ def _authed_token(needed) -> dict: return _refresh_if_needed(tok) -def _store_token_response(resp: dict, fallback_scope: str) -> dict: +def _store_token_response(resp: dict, fallback_scope: str, *, prev_tok: dict | None = None) -> dict: """Shape a Microsoft token response into our cache record and persist it (data-model TokenCache).""" tok = { "access_token": resp["access_token"], - "refresh_token": resp.get("refresh_token", ""), + # RFC 6749 §6: the server MAY omit refresh_token (meaning "keep the old one"). + "refresh_token": resp.get("refresh_token") or (prev_tok or {}).get("refresh_token", ""), "scope": resp.get("scope") or fallback_scope, "expires_at": int(time.time()) + int(resp.get("expires_in", 3600)), "account": resp.get("account", ""), diff --git a/tests/test_client.py b/tests/test_client.py index 10d37fe..2ee7058 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1148,6 +1148,28 @@ def responder(method, url, **kw): self.assertFalse(any("devicecode" in u for u in urls)) # no re-sign-in self.assertIn("renewed access token silently", err.getvalue()) + def test_refresh_token_preserved_when_response_omits_it(self): + # RFC 6749 §6: server MAY omit refresh_token; old one must survive. + client.save_token( + { + "access_token": "stale", + "refresh_token": "rt-old", + "scope": "Mail.Read MailboxSettings.Read offline_access", + "expires_at": 1, + } + ) + + def responder(method, url, **kw): + if url.endswith("/token"): + return {"access_token": "fresh2", "expires_in": 3600} # no refresh_token + return {} + + runtime._http = _HttpRecorder(responder) + with contextlib.redirect_stderr(io.StringIO()): + runtime._authed_token("Mail.Read") + saved = client.load_token() + self.assertEqual(saved["refresh_token"], "rt-old") + # ================================================================================================ # feature 008 — scope-superset warning helper (Issue 3 / ADR-0001).