fix: OAuth bearer resolver fatal before init (#127) - #128
Merged
Conversation
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
4 tasks
This was referenced Aug 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 earlydetermine_current_userfires.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:resolve()is hooked ondetermine_current_user. When another plugin callsis_user_logged_in()atplugins_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 callsresource_id(), andrest_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 targetsauth-probe, not a URI containingsaddle/v1/mcp.How
resource_id()routes through a new privateSaddle_OAuth::rest_url_early(): with the rewrite global present it defers torest_url()untouched; without it, it replicates coreget_rest_url( null, $path, 'rest' )exactly — including therest_urlfilter with core's argument shape.resourcestored on every access token at authorize time, so any divergence would refuse every token on the site asinvalid_token. It is provably identical: the only thingsget_rest_url()needs from$wp_rewriteareusing_index_permalinks()— a pure regex on thepermalink_structureoption — andWP_Rewrite::$index, hard-coded'index.php'. Anything customizing$indexdoes 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 plainrest_url()— a full call-site audit foundclass-saddle-oauth-bearer.php:122is the sole pre-init-reachablerest_url()in the OAuth subsystem.class-saddle-oauth.phpwas already past the ~300-line soft cap (440 lines) like most ofincludes/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_rewritenulled;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.is_user_logged_in()atplugins_loadedprio 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.tools/list; unauthenticated request gets the proper 401 +WWW-Authenticatediscovery challenge.WP_DEBUGon.🤖 Generated with Claude Code
https://claude.ai/code/session_016dGNUUe36hKi5o9hzFb3Cb