Skip to content

fix: an OAuth connection can no longer be pinned to read forever (#107) - #108

Merged
ifahimreza merged 4 commits into
mainfrom
fix/107-oauth-scope-pinned-read
Aug 17, 2026
Merged

fix: an OAuth connection can no longer be pinned to read forever (#107)#108
ifahimreza merged 4 commits into
mainfrom
fix/107-oauth-scope-pinned-read

Conversation

@ifahimreza

Copy link
Copy Markdown
Contributor

Closes #107

What

An app that signs in through Saddle's OAuth screen — ChatGPT is the only client that must — was granted saddle:read once, at consent, and could never be raised. On a site set to Managing the site the connector still ran at read, was offered 83 of 160 tools, and could draft an SEO title and description with no way to save it. Reconnecting produced an identical grant.

You now pick the level on the approval screen, and you can change it afterwards for an app that is already connected.

Why

Saddle_Capabilities::get_tier() returns min(site tier, credential ceiling). The ceiling was read because three separate places named saddle:read as a constant:

  1. The live causenormalize_scope() fell back to DEFAULT_SCOPE when the client sent no scope. ChatGPT sends none: it registers via DCR and starts the flow without a scope parameter at all (OpenAI developer community).
  2. Saddle_OAuth_Bearer::challenge() advertised it in WWW-Authenticate regardless of the site's level, so a spec-following client was told to ask for read-only.
  3. The consent screen handled "asked for more than the site allows" and had no branch, and no control, for "asked for less".

filter_adapter_tools_list() then dropped every ability above the ceiling at dispatch, which is why the tools vanished rather than failing loudly. Application-Password clients carry no ceiling and were never affected.

How

  • tier_to_scope() / site_scope() derive a cumulative scope string from the site's own level. The challenge advertises it.
  • normalize_scope( $requested, $fallback ) — the authorize endpoint passes site_scope() only when the client sent no scope at all. An explicit saddle:read is still granted exactly saddle:read: widening a request a client actually made is a spec violation, and some clients compare the granted scope against what they asked for. A fallback is re-run through the same intersection so it cannot introduce a scope Saddle does not grant.
  • The consent screen renders the levels up to get_site_tier(), preselected at what the client asked for (or the site's level when it asked for nothing), and handle() re-clamps the posted value server-side. The granted level is now recorded in the activity log.
  • Saddle_OAuth_Store::set_grant_scope() rewrites the grant and every token bound to it, behind POST /oauth-connections/<id> (manage_options, clamped to the site tier).
  • tier_ceiling() now takes the lower of the token's scope and its grant's current one, so lowering a level lands on the next request rather than up to ACCESS_TTL later. Raising still requires the token rewritten — the safe direction to be strict in.
  • Renamed Saddle_OAuth_Bearer::$grant to $token_record. It never held a grant, and mistaking one for the other is precisely what makes this feature fail silently.
  • Hardened update_settings(), which read get_json_params() only: a form-encoded tier passed the route enum, passed can_manage, returned 200 and saved nothing.

Guardrails — unchanged

  • Fresh installs still default to read.
  • A scope still only ever lowers the site tier, never raises it. Pinned by test_a_scope_still_cannot_exceed_the_site_tier.
  • Every widening still takes an explicit administrator click on the consent screen; there is still no "don't ask again".
  • PKCE S256-only, exact-string redirect matching, refresh rotation and replay revocation are untouched.

Testing

  • composer test594 tests, 1986 assertions, green (577 before; 17 new in tests/oauth-scope-test.php, which pins the reported symptom first, then the fix, then its limits)
  • composer lint — clean; new files scoped-linted explicitly
  • npm run lint:js — clean; npm run build run, committed bundle matches
  • .pot regenerated (it was stale against 1.0.0-rc3, missing far more than this change)
  • Live round-trip on the affected site — the real gate, not yet run

Note for the live test

If the write tools are now listed and ChatGPT still declines to call them, that is client-side, not Saddle. OpenAI's developer-mode docs say write actions work on every eligible plan with confirmation, while several secondary sources and our own STATUS.md:266 say fully write-capable custom connectors are limited to Business/Enterprise/Edu. The Connect tab now says so. Worth settling in STATUS.md once we have a definitive answer from the live test.

Version

No version bump. The plugin header, SADDLE_VERSION, readme.txt stable tag and package.json are untouched; changelog entries went under the existing unreleased = 1.0.0 =.

Ask before merge

Over the ~400-line threshold (~570 lines of source, plus the pot regeneration and the rebuilt bundle), and it touches OAuth. Yours to merge.

An OAuth connection was pinned to saddle:read at consent time and could
never be raised. get_tier() returns min(site tier, credential ceiling),
so a site set to "Managing the site" still ran every ChatGPT call at read
and tools/list withheld every write tool — 83 of 160 offered, with the
agent able to draft an SEO title and description and unable to save it.

Three places named saddle:read as a constant. normalize_scope() fell back
to it when the client sent no scope, and ChatGPT sends none: it registers
dynamically and starts the flow without a scope parameter, so that was
the live cause. The 401 challenge advertised it regardless of the site's
level. The consent screen rendered Allow/Deny with no control that could
grant more than was asked for. Reconnecting produced an identical grant.

Now: tier_to_scope()/site_scope() derive the scope from the site's own
level; the challenge advertises it; the authorize endpoint falls back to
it ONLY when the client sent no scope at all, because widening a request
a client actually made is both a spec violation and a safety regression;
the consent screen offers the levels up to the site tier and clamps the
posted choice server-side; and set_grant_scope() plus POST
/oauth-connections/<id> repair a connection that already exists.

The ceiling now takes the lower of the token's scope and its grant's, so
lowering a connection's level lands on the next request instead of
whenever its hour-long access token happens to expire. Raising still
needs the token rewritten, which is the safe direction to be strict in.
$grant was renamed $token_record because it never held a grant, and
mistaking one for the other is exactly what makes this fail quietly.

Also hardens update_settings(), which read JSON params only: a
form-encoded tier passed the enum, passed can_manage, returned 200 and
saved nothing.

Refs #107
Connect now carries a level picker per signed-in app, capped at the site
tier, so the connection that shipped at read can be raised without a
reconnect — which would not have helped anyway, since an app that
requests no scope comes back at the same level every time.

Permissions names any connection sitting below the level saved there.
That gap was invisible from the one screen where someone sets the level,
which is how "I set this to Managing the site and my app still can't save
anything" happens.

The apply-bar note said already-connected apps keep the old tool list
"until they reconnect". Wrong for apps that sign in themselves:
reconnecting re-runs consent and re-grants the same level. They need a
refresh, not a reconnect.

Refs #107
The pot had gone stale again — it was generated against 1.0.0-rc3 and was
missing most of a release's worth of msgids, not only this change's.

Refs #107
@ifahimreza
ifahimreza merged commit 0c3b266 into main Aug 17, 2026
1 of 7 checks passed
ifahimreza added a commit that referenced this pull request Aug 17, 2026
Carries the OAuth scope fix from #108. Stable tag stays at 1.0.0 — it
never moves for a release candidate, and version_compare sorts
1.0.0-rc5 below 1.0.0 so every rc install still upgrades cleanly onto
the real release.

Refs #107
@ifahimreza
ifahimreza deleted the fix/107-oauth-scope-pinned-read branch August 20, 2026 07:12
ifahimreza added a commit that referenced this pull request Aug 24, 2026
The function ended with verbatim copies of two branches it had already
run. Both conditions are character-identical to the earlier ones, the
earlier ones always return, and nothing between them touches $required,
$gate, or any state either condition reads — so the copies could never
execute.

Worth deleting rather than ignoring, because the duplicate tier block is
a WORSE version of the one that runs: it omits the
saddle_insufficient_scope branch. If it ever became reachable it would
send an OAuth-scope-limited agent to raise the site's access level, which
is the wrong screen — the exact bug #108 fixed. A stale copy of a
corrected code path sitting next to the corrected one is how that comes
back.

No behaviour change and no new test: unreachable code cannot change
behaviour, and the proof nothing moved is capabilities-test.php's
existing gate-ordering coverage staying green. 612 tests, 0 lint errors.

Closes #151
ifahimreza added a commit that referenced this pull request Aug 24, 2026
…() (#152)

The function ended with verbatim copies of two branches it had already
run. Both conditions are character-identical to the earlier ones, the
earlier ones always return, and nothing between them touches $required,
$gate, or any state either condition reads — so the copies could never
execute.

Worth deleting rather than ignoring, because the duplicate tier block is
a WORSE version of the one that runs: it omits the
saddle_insufficient_scope branch. If it ever became reachable it would
send an OAuth-scope-limited agent to raise the site's access level, which
is the wrong screen — the exact bug #108 fixed. A stale copy of a
corrected code path sitting next to the corrected one is how that comes
back.

No behaviour change and no new test: unreachable code cannot change
behaviour, and the proof nothing moved is capabilities-test.php's
existing gate-ordering coverage staying green. 612 tests, 0 lint errors.

Closes #151
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 connections are permanently pinned to the read tier (ChatGPT sees 83/160 tools)

1 participant