Skip to content

fix: OAuth bearer resolver fatal before init (#127) - #128

Merged
ifahimreza merged 2 commits into
mainfrom
fix/127-bearer-pre-init-fatal
Aug 20, 2026
Merged

fix: OAuth bearer resolver fatal before init (#127)#128
ifahimreza merged 2 commits into
mainfrom
fix/127-bearer-pre-init-fatal

Conversation

@ifahimreza

Copy link
Copy Markdown
Contributor

Closes #127

What

Fixes a PHP fatal that killed every ChatGPT MCP request on sites where another plugin asks WordPress who is signed in during plugins_loaded — AIOSEO Pro and Internal Links Premium both do. Saddle_OAuth::resource_id() now works at any point in the bootstrap, so the bearer resolver can authenticate a token however early determine_current_user fires.

Why

Customer report (Mark Roach, staging.kesuk.net): ChatGPT completes OAuth consent, then every request is refused 401 and the connector never reaches tools/list — while the identical build works on mrr.org.uk. His WP_DEBUG log pinned it:

Call to a member function using_index_permalinks() on null  (wp-includes/rest-api.php:529)
Saddle_OAuth_Bearer::resolve() → Saddle_OAuth::resource_id() → rest_url() → get_rest_url()

resolve() is hooked on determine_current_user. When another plugin calls is_user_logged_in() at plugins_loaded, that filter fires before wp-settings.php creates $GLOBALS['wp_rewrite'] — and on a genuine Bearer MCP request all six guards pass, the RFC 8707 audience check calls resource_id(), and rest_url() fatals on the missing global. Saddle_OAuth_Store::find() had already hardened the DB half of this exact window (raw $wpdb, documented); the URL half three lines later was missed. Latent since the OAuth server landed — the site's plugin mix, not rc7, decides whether it fires — and the most likely real root cause of the whole two-week kesuk.net thread, including the diagnosis retracted in cd70a48. The rc7 Bearer probe (#123) can't see it because it targets auth-probe, not a URI containing saddle/v1/mcp.

How

  • resource_id() routes through a new private Saddle_OAuth::rest_url_early(): with the rewrite global present it defers to rest_url() untouched; without it, it replicates core get_rest_url( null, $path, 'rest' ) exactly — including the rest_url filter with core's argument shape.
  • The replica must be byte-identical to core's output: the value is compared against the RFC 8707 resource stored on every access token at authorize time, so any divergence would refuse every token on the site as invalid_token. It is provably identical: the only things get_rest_url() needs from $wp_rewrite are using_index_permalinks() — a pure regex on the permalink_structure option — and WP_Rewrite::$index, hard-coded 'index.php'. Anything customizing $index does so on the real instance, which only exists once the branch defers to core anyway. Existing tokens therefore keep working; kesuk.net recovers without re-consent.
  • issuer()/endpoint() stay on plain rest_url() — a full call-site audit found class-saddle-oauth-bearer.php:122 is the sole pre-init-reachable rest_url() in the OAuth subsystem.
  • Non-obvious: class-saddle-oauth.php was already past the ~300-line soft cap (440 lines) like most of includes/oauth/; this adds ~55 more. Splitting the class is out of scope for a bugfix.

Testing

  • composer test — 612 tests, 2411 assertions, green (1 pre-existing skip). Three new regression tests, committed red first with the exact customer stack: resolution works with $wp_rewrite nulled; resource_id() is byte-identical before/after init on pretty, index and plain permalinks; the audience check still refuses a foreign token early. (No CI on this repo — results are from local runs.)
  • composer lint — 0 errors; only pre-existing warnings in untouched files.
  • Verified in a real install (WP Playground, mu-plugin firing is_user_logged_in() at plugins_loaded prio 5 + seeded OAuth token, Bearer POST to /wp-json/saddle/v1/mcp):
    • main: HTTP 500, using_index_permalinks() on null, stack identical to the customer's debug.log.
    • This branch: HTTP 200 with the full 40-tool tools/list; unauthenticated request gets the proper 401 + WWW-Authenticate discovery challenge.
    • This branch without the mu-plugin: HTTP 200 — the normal post-init path is unchanged.
  • No new notices or warnings with WP_DEBUG on.

🤖 Generated with Claude Code

https://claude.ai/code/session_016dGNUUe36hKi5o9hzFb3Cb

Any plugin calling is_user_logged_in() during plugins_loaded fires
determine_current_user before wp-settings.php has created $wp_rewrite.
On a genuine Bearer MCP request the resolver reaches its RFC 8707
audience check, resource_id() calls rest_url(), and get_rest_url()
fatals on the missing global — killing the request and the connector.

Three tests, committed red with the exact customer stack
(using_index_permalinks() on null via class-saddle-oauth.php:183):
resolution must work early, resource_id() must be byte-identical
before and after init on all three permalink shapes, and being early
must not skip the audience check.

Refs #127
resource_id() now routes through rest_url_early(): with the rewrite
global present it defers to rest_url() untouched; without it, it
replicates core get_rest_url(null, $path, 'rest') exactly from
pre-init-available state. The replica must be byte-identical because
the value is compared against the RFC 8707 resource stored on every
access token — divergence would refuse every token as invalid_token.
The only substitution is using_index_permalinks(), a pure function of
the permalink_structure option and the hard-coded index.php default.

Store::find() had already hardened the DB half of this window with raw
wpdb; this closes the URL half. issuer()/endpoint() stay on plain
rest_url() — audit shows no pre-init caller.

Refs #127
@ifahimreza
ifahimreza merged commit acfe215 into main Aug 20, 2026
1 of 7 checks passed
@ifahimreza
ifahimreza deleted the fix/127-bearer-pre-init-fatal branch August 20, 2026 18:45
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.

Fatal in OAuth bearer resolver when determine_current_user fires before init ($wp_rewrite is null)

1 participant