Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 4 additions & 3 deletions plugin/src/msgraph/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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", ""),
Expand Down
22 changes: 22 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading