Skip to content

fix(oauth): advertise offline_access without letting it touch the tier clamp (#159) - #165

Merged
ifahimreza merged 2 commits into
mainfrom
fix/159-advertise-offline-access
Sep 7, 2026
Merged

fix(oauth): advertise offline_access without letting it touch the tier clamp (#159)#165
ifahimreza merged 2 commits into
mainfrom
fix/159-advertise-offline-access

Conversation

@ifahimreza

Copy link
Copy Markdown
Contributor

Closes #159

What

Both OAuth discovery documents now list offline_access in scopes_supported, and a client that sends that scope, at authorize or at refresh, is treated exactly as if it had not: it is never mapped to a tier, and it never changes what is granted.

Why

OpenAI's "Developer mode and MCP apps in ChatGPT" page (re-read 2026-09-07) says ChatGPT inspects a provider's discovery metadata for offline_access before trusting refresh-token renewal, and that without it "ChatGPT may lose access after the original authorization expires". Saddle issues refresh tokens unconditionally and advertises the refresh_token grant, but never listed the scope. With ACCESS_TTL = 3600 the failure mode is a connector that works for one hour and then needs reconnecting, which is indistinguishable from the "connected but broken" reports already being fielded.

How

  • Saddle_OAuth::REFRESH_SCOPE and advertised_scopes(). SCOPES is untouched and a test pins it at exactly three; the tier clamp never sees the new word.
  • Saddle_OAuth::strip_refresh_scope() runs before the authorize endpoint's "did the client name a scope?" decision. Without it, a client sending only offline_access would have been treated as one that asked for openid profile: pinned to read forever, the pre-fix(mcp): answer the handshake the way strict clients require (#97) #98 ChatGPT outcome all over again.
  • The same strip on the refresh path. Without it a refresh naming only offline_access normalized to saddle:read and silently narrowed an admin grant on renewal.
  • Nothing is echoed back: the granted scope never contains offline_access, so the invariant that every granted scope is a subset of SCOPES holds.

Testing

  • composer lint — 0 errors
  • composer test — 657 tests (was 654), 1 pre-existing skip
  • Four new tests: discovery lists it in both documents; offline_access alone reaches consent proposing the site's own level; saddle:read offline_access is not widened; a refresh naming only offline_access keeps the granted scope
  • Empirical: connect a ChatGPT Business workspace and confirm the connection survives past one hour — needs an account we do not have on hand

ChatGPT checks discovery metadata for offline_access before trusting
refresh-token renewal. Advertise it in both documents, and strip it at
authorize and refresh before the scope decision, so a client sending
only that word is neither pinned to read nor narrowed on renewal.

Closes #159

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApYKEKcBnpuz8w6BX2agfH
@ifahimreza
ifahimreza merged commit 9b8f0c6 into main Sep 7, 2026
7 checks passed
@ifahimreza
ifahimreza deleted the fix/159-advertise-offline-access branch September 7, 2026 08:52
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.

OAuth discovery never advertises offline_access, which ChatGPT looks for

1 participant