PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 All 32 releases
← All changes | includes/modules/Mcp/Mcp_OAuth.php +61 -13 1.3.1 → 1.3.6 View file →
@@ -8,12 +8,17 @@
8 8 * authorization-code + PKCE flow themselves. See the security contract and
9 9 * the end-to-end flow notes below.
10 10 *
11 11 * Flow: unauthenticated MCP call -> 401 + WWW-Authenticate (Mcp_Server) ->
12 - * client fetches /.well-known/oauth-protected-resource + oauth-authorization-
13 - * server -> dynamic registration (RFC 7591) -> /authorize (admin consent +
14 - * PKCE) -> /token (code + verifier -> access + refresh) -> MCP calls with
15 - * `Authorization: Bearer <access>` validated by validate_token().
12 + * client fetches /.well-known/oauth-protected-resource/xspeed/mcp +
13 + * /.well-known/oauth-authorization-server/xspeed/mcp -> dynamic registration
14 + * (RFC 7591) -> /authorize (admin consent + PKCE) -> /token (code + verifier
15 + * -> access + refresh) -> MCP calls with `Authorization: Bearer <access>`
16 + * validated by validate_token(). Both canonical identifiers -- issuer and
17 + * resource -- are the MCP endpoint URL, which is what puts the documents
18 + * under that path rather than at the contested site root (#266). The root
19 + * URLs are still answered, with the legacy host-only issuer, on sites where
20 + * no other plugin has claimed them -- see legacy_issuer().
16 21 *
17 22 * Security contract:
18 23 * - PKCE S256 REQUIRED (OAuth 2.1 public clients); codes are single-use,
19 24 * 60 s TTL, bound to client_id + redirect_uri + challenge.
@@ -123,13 +128,45 @@
123 128 private static $writing = false;
124 129
125 130 // -- URLs ------------------------------------------------------------
126 131
127 - /** Base site URL used as the OAuth issuer (no trailing slash). */
132 + /**
133 + * The OAuth issuer identifier -- the MCP endpoint URL, identical to
134 + * resource().
135 + *
136 + * RFC 8414 §2 allows an issuer to carry a path, and §3.1 then moves its
137 + * metadata to /.well-known/oauth-authorization-server/xspeed/mcp, a URL
138 + * only this plugin answers. A bare-host issuer put the document at the
139 + * site root, which every other MCP-serving plugin on the same site also
140 + * wants, and WordPress hands that URL to whichever rewrite rule happens
141 + * to sit first in the table.
142 + *
143 + * Nothing stored carries the issuer -- access and refresh tokens are
144 + * opaque random strings, client records hold redirect_uris/name/created
145 + * -- so changing it invalidates no grant. A client that re-discovers
146 + * simply registers again and asks the admin for consent once more.
147 + * (#266)
148 + */
128 149 public static function issuer(): string {
129 - return untrailingslashit( home_url() );
150 + return self::resource();
130 151 }
131 152
153 + /**
154 + * The host-only issuer earlier builds used, still served at the bare
155 + * /.well-known/oauth-* URLs when no other plugin has claimed them.
156 + *
157 + * RFC 8414 §3.3 makes a client reject a document whose `issuer` is not
158 + * the value it inserted into the URL it fetched, and a client that
159 + * fetched the ROOT document inserted nothing -- it derived that URL from
160 + * `https://site`. Stamping the path issuer there would be the same RFC
161 + * violation this change set out to remove, pointed the other way. So the
162 + * two locations carry two identities, each self-consistent, and a client
163 + * ends up on whichever one it asked for. (#266)
164 + */
165 + public static function legacy_issuer(): string {
166 + return untrailingslashit( home_url( '/' ) );
167 + }
168 +
132 169 /** The protected resource identifier -- the MCP endpoint URL. */
133 170 public static function resource(): string {
134 171 return Mcp_Pairing::site_endpoint();
135 172 }
@@ -140,17 +177,17 @@
140 177 * round-trip — a REST route would see the cookie without a nonce and
141 178 * treat the admin as logged-out, looping back to login.
142 179 */
143 180 public static function authorize_url(): string {
144 - return home_url( '/xspeed/authorize' );
181 + return Mcp_Pairing::absolute( home_url( '/xspeed/authorize' ) );
145 182 }
146 183
147 184 public static function token_url(): string {
148 - return rest_url( 'xspeed/v1/mcp/oauth/token' );
185 + return Mcp_Pairing::absolute( rest_url( 'xspeed/v1/mcp/oauth/token' ) );
149 186 }
150 187
151 188 public static function register_url(): string {
152 - return rest_url( 'xspeed/v1/mcp/oauth/register' );
189 + return Mcp_Pairing::absolute( rest_url( 'xspeed/v1/mcp/oauth/register' ) );
153 190 }
154 191
155 192 // -- Discovery documents (RFC 8414 / RFC 9728) -----------------------
156 193
@@ -157,14 +194,20 @@
157 194 /**
158 195 * RFC 9728 protected-resource metadata -- tells the client which
159 196 * authorization server(s) protect the MCP endpoint (this site).
160 197 *
198 + * @param string|null $issuer The authorization server to name. Defaults
199 + * to the canonical path issuer; the root
200 + * /.well-known/ location passes the legacy
201 + * host-only one, so that the AS document a
202 + * client goes on to fetch is the one served
203 + * at the URL that issuer derives.
161 204 * @return array<string,mixed>
162 205 */
163 - public static function protected_resource_metadata(): array {
206 + public static function protected_resource_metadata( ?string $issuer = null ): array {
164 207 return array(
165 208 'resource' => self::resource(),
166 - 'authorization_servers' => array( self::issuer() ),
209 + 'authorization_servers' => array( $issuer ?? self::issuer() ),
167 210 'scopes_supported' => self::SUPPORTED_SCOPES,
168 211 'bearer_methods_supported' => array( 'header' ),
169 212 );
170 213 }
@@ -173,13 +216,18 @@
173 216 * RFC 8414 authorization-server metadata -- the endpoint map + the
174 217 * capabilities we actually implement (auth-code grant, PKCE S256,
175 218 * dynamic registration, refresh tokens).
176 219 *
220 + * @param string|null $issuer Which identity this copy of the document
221 + * speaks for; see protected_resource_metadata().
222 + * Every endpoint URL below is identical either
223 + * way, which is why a client that switches
224 + * identities keeps its cached endpoints.
177 225 * @return array<string,mixed>
178 226 */
179 - public static function authorization_server_metadata(): array {
227 + public static function authorization_server_metadata( ?string $issuer = null ): array {
180 228 return array(
181 - 'issuer' => self::issuer(),
229 + 'issuer' => $issuer ?? self::issuer(),
182 230 'authorization_endpoint' => self::authorize_url(),
183 231 'token_endpoint' => self::token_url(),
184 232 'registration_endpoint' => self::register_url(),
185 233 'scopes_supported' => self::SUPPORTED_SCOPES,