PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
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 1.1.6 1.1.7 All 30 releases
xspeed / includes / modules / Mcp / Mcp_OAuth.php

Mcp_OAuth.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.4, at includes/modules/Mcp/Mcp_OAuth.php

922 lines 35.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP OAuth 2.1 authorization server -- the "paste a URL only" connect path.
4 *
5 * The pairing token (Mcp_Pairing) covers clients that accept a pasted Bearer
6 * token; this class covers spec-compliant MCP clients (e.g. the claude.ai
7 * remote-connector flow) that take only the server URL and run the OAuth 2.1
8 * authorization-code + PKCE flow themselves. See the security contract and
9 * the end-to-end flow notes below.
10 *
11 * Flow: unauthenticated MCP call -> 401 + WWW-Authenticate (Mcp_Server) ->
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().
21 *
22 * Security contract:
23 * - PKCE S256 REQUIRED (OAuth 2.1 public clients); codes are single-use,
24 * 60 s TTL, bound to client_id + redirect_uri + challenge.
25 * - /authorize gates on manage_options -- only an admin can grant access,
26 * matching the pairing token's admin-only mint (anon -> wp-login first).
27 * - Access/refresh tokens stored only as SHA-256 hashes; raw value exists
28 * solely in the /token response. Constant-time comparison.
29 * - Tokens carry the read/write scope model; a read-only grant refuses
30 * every write tool, exactly like a read-only pairing token.
31 * - Off until an admin approves consent; a fresh install exposes discovery
32 * metadata but issues nothing.
33 *
34 * State lives in the `xspeed_mcp_oauth` option (clients, codes, tokens,
35 * refresh -- keyed by id or sha256 of the secret); expired codes/tokens are
36 * pruned lazily on every read.
37 *
38 * @package XSpeed
39 */
40
41 declare(strict_types=1);
42
43 namespace XSpeed\Modules\Mcp;
44
45 defined( 'ABSPATH' ) || exit;
46
47 final class Mcp_OAuth {
48
49 /** Option key holding all OAuth server state. */
50 public const OPTION = 'xspeed_mcp_oauth';
51
52 /** Authorization-code lifetime (seconds). Deliberately short. */
53 private const CODE_TTL = 60;
54
55 /** Access-token lifetime (seconds) -- 1 hour, refreshable. */
56 private const ACCESS_TTL = 3600;
57
58 /** Refresh-token lifetime (seconds) -- 30 days. */
59 private const REFRESH_TTL = 2592000;
60
61 /**
62 * Scopes we advertise + honor. `mcp` is the umbrella scope MCP clients
63 * request (read+write). `configure` is an ADDITIONAL, opt-in scope that a
64 * client must request explicitly to write credential/secret fields — it is
65 * NOT implied by `mcp` or `write`, so credential writes stay off by default
66 * on an ordinary connection. (#116)
67 */
68 private const SUPPORTED_SCOPES = array( 'mcp', 'read', 'write', 'configure' );
69
70 /**
71 * Resource bounds for dynamic client registration (RFC 7591).
72 *
73 * The register endpoint is public by design — that is what makes an MCP
74 * client able to connect without an admin minting credentials first. What
75 * was missing is a resource policy: every request appended a client to one
76 * persistent option with no count, size, expiry or rate bound, so repeated
77 * anonymous requests grew `xspeed_mcp_oauth` without limit. Every later
78 * registration and OAuth operation then loaded and reserialized a larger
79 * option, burning storage, memory and CPU until availability degraded.
80 *
81 * Codes, access tokens and refresh tokens were already pruned on expiry;
82 * `clients` was the one collection retained forever.
83 */
84 private const MAX_CLIENTS = 100;
85
86 /** Redirect URIs accepted per registration. */
87 private const MAX_REDIRECT_URIS = 5;
88
89 /** Longest accepted redirect URI, in bytes. */
90 private const MAX_REDIRECT_URI_LEN = 2048;
91
92 /** Longest accepted client_name, in bytes. */
93 private const MAX_CLIENT_NAME_LEN = 200;
94
95 /**
96 * How long an UNUSED client survives. A client that never completes a
97 * flow is almost always an abandoned or hostile registration, so it is
98 * collected after this window. A client with a live code, access token or
99 * refresh token is never collected on age — see prune_clients().
100 */
101 private const UNUSED_CLIENT_TTL = 86400;
102
103 /** Registrations allowed per IP inside RATE_WINDOW. */
104 private const RATE_MAX = 10;
105
106 /** Window for the registration rate limit, in seconds. Fixed, not sliding. */
107 private const RATE_WINDOW = 3600;
108
109 /**
110 * Fixed number of rate-limit counters.
111 *
112 * One transient per IP would let a distributed flood grow the options
113 * TABLE without bound — the same CWE-770 shape this class exists to fix,
114 * moved from the option value to the row count. Hashing the IP into a
115 * fixed bucket space caps that at a constant, whatever the traffic.
116 * (QA F2 on #254)
117 */
118 private const RATE_BUCKETS = 64;
119
120 /**
121 * True while a caller is between state() and its own save().
122 *
123 * state() persists a self-heal prune on read-only paths, but a caller that
124 * is about to save() anyway would then write twice — and the second write
125 * would be against a $state it had already mutated. Callers that mutate
126 * set this so the read-side write stands down. (QA F1 on #254)
127 */
128 private static $writing = false;
129
130 // -- URLs ------------------------------------------------------------
131
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 */
149 public static function issuer(): string {
150 return self::resource();
151 }
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
169 /** The protected resource identifier -- the MCP endpoint URL. */
170 public static function resource(): string {
171 return Mcp_Pairing::site_endpoint();
172 }
173
174 /**
175 * The browser-facing authorize page. Served OUTSIDE the REST API (via a
176 * rewrite rule) so standard cookie auth works after the wp-login
177 * round-trip — a REST route would see the cookie without a nonce and
178 * treat the admin as logged-out, looping back to login.
179 */
180 public static function authorize_url(): string {
181 return Mcp_Pairing::absolute( home_url( '/xspeed/authorize' ) );
182 }
183
184 public static function token_url(): string {
185 return Mcp_Pairing::absolute( rest_url( 'xspeed/v1/mcp/oauth/token' ) );
186 }
187
188 public static function register_url(): string {
189 return Mcp_Pairing::absolute( rest_url( 'xspeed/v1/mcp/oauth/register' ) );
190 }
191
192 // -- Discovery documents (RFC 8414 / RFC 9728) -----------------------
193
194 /**
195 * RFC 9728 protected-resource metadata -- tells the client which
196 * authorization server(s) protect the MCP endpoint (this site).
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.
204 * @return array<string,mixed>
205 */
206 public static function protected_resource_metadata( ?string $issuer = null ): array {
207 return array(
208 'resource' => self::resource(),
209 'authorization_servers' => array( $issuer ?? self::issuer() ),
210 'scopes_supported' => self::SUPPORTED_SCOPES,
211 'bearer_methods_supported' => array( 'header' ),
212 );
213 }
214
215 /**
216 * RFC 8414 authorization-server metadata -- the endpoint map + the
217 * capabilities we actually implement (auth-code grant, PKCE S256,
218 * dynamic registration, refresh tokens).
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.
225 * @return array<string,mixed>
226 */
227 public static function authorization_server_metadata( ?string $issuer = null ): array {
228 return array(
229 'issuer' => $issuer ?? self::issuer(),
230 'authorization_endpoint' => self::authorize_url(),
231 'token_endpoint' => self::token_url(),
232 'registration_endpoint' => self::register_url(),
233 'scopes_supported' => self::SUPPORTED_SCOPES,
234 'response_types_supported' => array( 'code' ),
235 'grant_types_supported' => array( 'authorization_code', 'refresh_token' ),
236 'code_challenge_methods_supported' => array( 'S256' ),
237 'token_endpoint_auth_methods_supported' => array( 'none' ),
238 );
239 }
240
241 // -- Dynamic client registration (RFC 7591) --------------------------
242
243 /**
244 * Register a public client. We accept the client's redirect_uris and
245 * mint a client_id (no secret -- public clients rely on PKCE). Minimal
246 * metadata is echoed back per RFC 7591.
247 *
248 * @param array<string,mixed> $body Parsed JSON registration request.
249 * @return array<string,mixed>|\WP_Error
250 */
251 public static function register_client( array $body ) {
252 // CHECK the rate limit before any work — an over-limit caller must not
253 // be able to make us read, mutate or reserialize the option at all.
254 // The COUNT happens later, only once the payload has proven valid, so
255 // a legitimate but buggy client sending malformed bodies is not locked
256 // out for an hour over registrations that never stored anything.
257 // (QA F3 on #254)
258 if ( ! self::rate_limit_ok( false ) ) {
259 return new \WP_Error(
260 'too_many_requests',
261 __( 'Too many client registrations. Try again later.', 'xspeed' ),
262 array( 'status' => 429 )
263 );
264 }
265
266 $raw = isset( $body['redirect_uris'] ) && is_array( $body['redirect_uris'] )
267 ? array_map( 'strval', $body['redirect_uris'] )
268 : array();
269
270 // Cap the count before validating, so a huge array costs a count()
271 // rather than a full validation pass.
272 if ( count( $raw ) > self::MAX_REDIRECT_URIS ) {
273 return new \WP_Error(
274 'invalid_redirect_uri',
275 sprintf(
276 /* translators: %d: maximum number of redirect URIs. */
277 __( 'At most %d redirect_uris are allowed.', 'xspeed' ),
278 self::MAX_REDIRECT_URIS
279 ),
280 array( 'status' => 400 )
281 );
282 }
283
284 $redirect_uris = array_values( array_filter( $raw, array( self::class, 'is_valid_redirect_uri' ) ) );
285
286 if ( empty( $redirect_uris ) ) {
287 return new \WP_Error(
288 'invalid_redirect_uri',
289 __( 'At least one valid redirect_uri is required.', 'xspeed' ),
290 array( 'status' => 400 )
291 );
292 }
293
294 $name = isset( $body['client_name'] ) ? sanitize_text_field( (string) $body['client_name'] ) : 'MCP Client';
295 if ( strlen( $name ) > self::MAX_CLIENT_NAME_LEN ) {
296 // mb_strcut, NOT substr: the bound is in BYTES (that is what the
297 // storage limit is about), but cutting at byte 200 lands inside a
298 // multi-byte character for any CJK or emoji name — 200 is not a
299 // multiple of 3 — and the result is invalid UTF-8. MySQL then
300 // refuses the whole option write, so the registration was silently
301 // dropped while the endpoint still answered 201 with a client_id
302 // that had never been stored. mb_strcut keeps the byte budget and
303 // never splits a character. (QA on #254)
304 $name = function_exists( 'mb_strcut' )
305 ? mb_strcut( $name, 0, self::MAX_CLIENT_NAME_LEN, 'UTF-8' )
306 : substr( $name, 0, self::MAX_CLIENT_NAME_LEN );
307 }
308
309 // The payload is valid, so this request counts against the window.
310 // Deliberately AFTER validation (F3) but BEFORE the option read below,
311 // so an over-limit caller still cannot make us touch the option.
312 self::rate_limit_ok( true );
313
314 $client_id = 'xsc_' . bin2hex( random_bytes( 16 ) );
315
316 // state() has already pruned unused/expired clients on load.
317 $state = self::state_for_write();
318
319 // Hard cap. Pruning above already dropped unused and expired
320 // registrations, so hitting this means MAX_CLIENTS clients are
321 // genuinely in use — refuse rather than evict a live one, which would
322 // break a working integration to satisfy an anonymous caller.
323 if ( count( $state['clients'] ) >= self::MAX_CLIENTS ) {
324 return new \WP_Error(
325 'too_many_clients',
326 __( 'Client registration limit reached.', 'xspeed' ),
327 array( 'status' => 429 )
328 );
329 }
330
331 $state['clients'][ $client_id ] = array(
332 'redirect_uris' => $redirect_uris,
333 'name' => $name,
334 'created' => time(),
335 );
336
337 // A freshly-minted client_id always changes the option, so a false here
338 // is a genuine write failure and never the "value unchanged" case. Fail
339 // loudly: handing back a 201 and a client_id that was never stored
340 // leaves the caller holding a credential that can never authorize, and
341 // the only symptom is a confusing "Unknown client_id" much later.
342 if ( ! self::save( $state ) ) {
343 return new \WP_Error(
344 'registration_failed',
345 __( 'The client registration could not be stored.', 'xspeed' ),
346 array( 'status' => 500 )
347 );
348 }
349
350 return array(
351 'client_id' => $client_id,
352 'client_id_issued_at' => time(),
353 'redirect_uris' => $redirect_uris,
354 'client_name' => $name,
355 'token_endpoint_auth_method' => 'none',
356 'grant_types' => array( 'authorization_code', 'refresh_token' ),
357 'response_types' => array( 'code' ),
358 );
359 }
360
361 // -- Authorization endpoint ------------------------------------------
362
363 /**
364 * Validate an /authorize request's parameters WITHOUT issuing anything.
365 * Returns a sanitized param bag on success, or WP_Error on a protocol
366 * violation the client must fix. The caller (route handler) decides how
367 * to surface it (redirect vs error page) based on whether redirect_uri
368 * is trustworthy.
369 *
370 * @param array<string,string> $params Query params.
371 * @return array<string,string>|\WP_Error
372 */
373 public static function validate_authorize_request( array $params ) {
374 $client_id = isset( $params['client_id'] ) ? (string) $params['client_id'] : '';
375 $redirect_uri = isset( $params['redirect_uri'] ) ? (string) $params['redirect_uri'] : '';
376 $response_type = isset( $params['response_type'] ) ? (string) $params['response_type'] : '';
377 $challenge = isset( $params['code_challenge'] ) ? (string) $params['code_challenge'] : '';
378 $method = isset( $params['code_challenge_method'] ) ? (string) $params['code_challenge_method'] : '';
379 $scope = isset( $params['scope'] ) ? (string) $params['scope'] : 'mcp';
380 $state = isset( $params['state'] ) ? (string) $params['state'] : '';
381
382 $client = self::client( $client_id );
383 if ( null === $client ) {
384 return new \WP_Error( 'invalid_client', __( 'Unknown client_id.', 'xspeed' ), array( 'status' => 400 ) );
385 }
386 if ( ! in_array( $redirect_uri, $client['redirect_uris'], true ) ) {
387 // redirect_uri mismatch must NOT redirect (open-redirect guard).
388 return new \WP_Error( 'invalid_redirect_uri', __( 'redirect_uri does not match a registered value.', 'xspeed' ), array( 'status' => 400 ) );
389 }
390 if ( 'code' !== $response_type ) {
391 return new \WP_Error( 'unsupported_response_type', __( 'Only response_type=code is supported.', 'xspeed' ), array( 'status' => 400, 'redirectable' => true ) );
392 }
393 // OAuth 2.1: PKCE S256 is mandatory for public clients.
394 if ( 'S256' !== $method || '' === $challenge ) {
395 return new \WP_Error( 'invalid_request', __( 'PKCE with code_challenge_method=S256 is required.', 'xspeed' ), array( 'status' => 400, 'redirectable' => true ) );
396 }
397
398 return array(
399 'client_id' => $client_id,
400 'client_name' => $client['name'],
401 'redirect_uri' => $redirect_uri,
402 'code_challenge' => $challenge,
403 'scope' => self::normalize_scope( $scope ),
404 'state' => $state,
405 );
406 }
407
408 /**
409 * Issue an authorization code after the admin approves consent. Binds
410 * the code to the client, redirect_uri, PKCE challenge, granted scope,
411 * and the approving user. Single-use, 60 s TTL.
412 *
413 * @param array<string,string> $req Output of validate_authorize_request().
414 * @param int $user_id Approving admin user id.
415 * @return string The authorization code.
416 */
417 public static function issue_code( array $req, int $user_id ): string {
418 $code = bin2hex( random_bytes( 32 ) );
419 $state = self::state_for_write();
420 $state['codes'][ $code ] = array(
421 'client_id' => $req['client_id'],
422 'redirect_uri' => $req['redirect_uri'],
423 'challenge' => $req['code_challenge'],
424 'scope' => $req['scope'],
425 'user_id' => $user_id,
426 'expires' => time() + self::CODE_TTL,
427 );
428 self::save( $state );
429 return $code;
430 }
431
432 // -- Token endpoint --------------------------------------------------
433
434 /**
435 * Exchange an authorization code (+ PKCE verifier) for tokens, or a
436 * refresh token for a fresh access token. Returns the RFC 6749 token
437 * response or a WP_Error whose data carries the OAuth error code.
438 *
439 * @param array<string,string> $body POST body params.
440 * @return array<string,mixed>|\WP_Error
441 */
442 public static function exchange_token( array $body ) {
443 $grant = isset( $body['grant_type'] ) ? (string) $body['grant_type'] : '';
444
445 if ( 'authorization_code' === $grant ) {
446 return self::grant_authorization_code( $body );
447 }
448 if ( 'refresh_token' === $grant ) {
449 return self::grant_refresh_token( $body );
450 }
451 return self::oauth_error( 'unsupported_grant_type', 'Unsupported grant_type.' );
452 }
453
454 /**
455 * authorization_code grant: verify the code + PKCE, mint tokens.
456 *
457 * @param array<string,string> $body POST body.
458 * @return array<string,mixed>|\WP_Error
459 */
460 private static function grant_authorization_code( array $body ) {
461 $code = isset( $body['code'] ) ? (string) $body['code'] : '';
462 $client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : '';
463 $redirect_uri = isset( $body['redirect_uri'] ) ? (string) $body['redirect_uri'] : '';
464 $verifier = isset( $body['code_verifier'] ) ? (string) $body['code_verifier'] : '';
465
466 $state = self::state_for_write();
467 if ( '' === $code || ! isset( $state['codes'][ $code ] ) ) {
468 return self::oauth_error( 'invalid_grant', 'Unknown or expired authorization code.' );
469 }
470 $entry = $state['codes'][ $code ];
471
472 // Single-use: remove immediately whether or not verification passes.
473 unset( $state['codes'][ $code ] );
474 self::save( $state );
475
476 if ( $entry['expires'] < time() ) {
477 return self::oauth_error( 'invalid_grant', 'Authorization code expired.' );
478 }
479 if ( ! hash_equals( (string) $entry['client_id'], $client_id ) ) {
480 return self::oauth_error( 'invalid_grant', 'client_id mismatch.' );
481 }
482 if ( ! hash_equals( (string) $entry['redirect_uri'], $redirect_uri ) ) {
483 return self::oauth_error( 'invalid_grant', 'redirect_uri mismatch.' );
484 }
485 // PKCE S256: BASE64URL(SHA256(verifier)) must equal the stored challenge.
486 if ( '' === $verifier || ! hash_equals( (string) $entry['challenge'], self::s256( $verifier ) ) ) {
487 return self::oauth_error( 'invalid_grant', 'PKCE verification failed.' );
488 }
489
490 return self::mint_tokens( $entry['client_id'], $entry['scope'], (int) $entry['user_id'] );
491 }
492
493 /**
494 * refresh_token grant: rotate the refresh token, issue a fresh access
495 * token. The old refresh + its access token are revoked.
496 *
497 * @param array<string,string> $body POST body.
498 * @return array<string,mixed>|\WP_Error
499 */
500 private static function grant_refresh_token( array $body ) {
501 $refresh = isset( $body['refresh_token'] ) ? (string) $body['refresh_token'] : '';
502 $client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : '';
503
504 $state = self::state_for_write();
505 $rhash = self::hash( $refresh );
506 if ( '' === $refresh || ! isset( $state['refresh'][ $rhash ] ) ) {
507 return self::oauth_error( 'invalid_grant', 'Unknown refresh token.' );
508 }
509 $entry = $state['refresh'][ $rhash ];
510 if ( '' !== $client_id && ! hash_equals( (string) $entry['client_id'], $client_id ) ) {
511 return self::oauth_error( 'invalid_grant', 'client_id mismatch.' );
512 }
513
514 // Rotate: drop old refresh + its access token.
515 unset( $state['refresh'][ $rhash ] );
516 if ( isset( $entry['access_hash'] ) ) {
517 unset( $state['tokens'][ $entry['access_hash'] ] );
518 }
519 self::save( $state );
520
521 return self::mint_tokens( $entry['client_id'], $entry['scope'], (int) $entry['user_id'] );
522 }
523
524 /**
525 * Mint an access + refresh token pair, store them hashed, and return
526 * the RFC 6749 token response with the raw values.
527 *
528 * @param string $client_id Client id.
529 * @param string $scope Granted scope string.
530 * @param int $user_id Resource-owner user id.
531 * @return array<string,mixed>
532 */
533 private static function mint_tokens( string $client_id, string $scope, int $user_id ): array {
534 $access = bin2hex( random_bytes( 32 ) );
535 $refresh = bin2hex( random_bytes( 32 ) );
536 $ahash = self::hash( $access );
537 $rhash = self::hash( $refresh );
538
539 $state = self::state_for_write();
540 $state['tokens'][ $ahash ] = array(
541 'client_id' => $client_id,
542 'scope' => $scope,
543 'user_id' => $user_id,
544 'expires' => time() + self::ACCESS_TTL,
545 'refresh' => $rhash,
546 );
547 $state['refresh'][ $rhash ] = array(
548 'access_hash' => $ahash,
549 'client_id' => $client_id,
550 'scope' => $scope,
551 'user_id' => $user_id,
552 'expires' => time() + self::REFRESH_TTL,
553 );
554 self::save( $state );
555
556 return array(
557 'access_token' => $access,
558 'token_type' => 'Bearer',
559 'expires_in' => self::ACCESS_TTL,
560 'refresh_token' => $refresh,
561 'scope' => $scope,
562 );
563 }
564
565 // -- Access-token validation (called by Mcp_Server) ------------------
566
567 /**
568 * Validate a bearer access token presented to the MCP endpoint.
569 * Returns the token's grant record (scope, user_id, client_id) when
570 * valid + unexpired, or null. Constant-time via hashed lookup.
571 *
572 * @param string $token Raw access token from the Authorization header.
573 * @return array{client_id:string,scope:string,user_id:int}|null
574 */
575 public static function validate_token( string $token ): ?array {
576 if ( '' === $token ) {
577 return null;
578 }
579 $state = self::state();
580 $hash = self::hash( $token );
581 if ( ! isset( $state['tokens'][ $hash ] ) ) {
582 return null;
583 }
584 $entry = $state['tokens'][ $hash ];
585 if ( (int) $entry['expires'] < time() ) {
586 return null;
587 }
588 return array(
589 'client_id' => (string) $entry['client_id'],
590 'scope' => (string) $entry['scope'],
591 'user_id' => (int) $entry['user_id'],
592 );
593 }
594
595 /**
596 * Whether a granted scope string is read-only. `mcp` is the umbrella
597 * scope that grants read+write (matching a default pairing token), so
598 * only a grant that carries NEITHER `write` NOR `mcp` -- i.e. `read`
599 * alone -- is read-only.
600 */
601 public static function scope_is_read_only( string $scope ): bool {
602 $parts = preg_split( '/\s+/', trim( $scope ) ) ?: array();
603 return ! in_array( 'write', $parts, true ) && ! in_array( 'mcp', $parts, true );
604 }
605
606 /**
607 * Whether a granted scope string may write credential/secret fields. Unlike
608 * read/write, `configure` is never implied by the `mcp` umbrella — the
609 * client must ask for it by name — so an ordinary read-write connection
610 * cannot rewrite API tokens or passwords. (#116)
611 */
612 public static function scope_allows_configure( string $scope ): bool {
613 $parts = preg_split( '/\s+/', trim( $scope ) ) ?: array();
614 return in_array( 'configure', $parts, true );
615 }
616
617 /** Revoke every OAuth token + client (used by disconnect). */
618 public static function revoke_all(): void {
619 delete_option( self::OPTION );
620 }
621
622 // -- State + helpers -------------------------------------------------
623
624 /**
625 * Load state with defaults, pruning expired codes/tokens/refresh
626 * entries on the way out so the option can't grow unbounded.
627 *
628 * @return array<string,array<string,mixed>>
629 */
630 private static function state(): array {
631 $stored = get_option( self::OPTION, array() );
632 if ( ! is_array( $stored ) ) {
633 $stored = array();
634 }
635 $state = array(
636 'clients' => isset( $stored['clients'] ) && is_array( $stored['clients'] ) ? $stored['clients'] : array(),
637 'codes' => isset( $stored['codes'] ) && is_array( $stored['codes'] ) ? $stored['codes'] : array(),
638 'tokens' => isset( $stored['tokens'] ) && is_array( $stored['tokens'] ) ? $stored['tokens'] : array(),
639 'refresh' => isset( $stored['refresh'] ) && is_array( $stored['refresh'] ) ? $stored['refresh'] : array(),
640 );
641
642 $now = time();
643 foreach ( $state['codes'] as $k => $v ) {
644 if ( ! isset( $v['expires'] ) || $v['expires'] < $now ) {
645 unset( $state['codes'][ $k ] );
646 }
647 }
648 foreach ( $state['tokens'] as $k => $v ) {
649 if ( ! isset( $v['expires'] ) || $v['expires'] < $now ) {
650 unset( $state['tokens'][ $k ] );
651 }
652 }
653 foreach ( $state['refresh'] as $k => $v ) {
654 if ( isset( $v['expires'] ) && $v['expires'] < $now ) {
655 unset( $state['refresh'][ $k ] );
656 }
657 }
658
659 // Clients were the one collection this method never pruned, which is
660 // what let an anonymous caller grow the option without bound. Prune
661 // them here too, AFTER the grant buckets above, so "in use" is
662 // decided against live grants only.
663 $before = count( $state['clients'] );
664 $state['clients'] = self::prune_clients( $state );
665
666 // Persist when pruning ACTUALLY removed something. Pruning in memory
667 // alone left a bloated option on disk until the next write happened to
668 // land — so a site whose flood had stopped kept carrying the weight
669 // indefinitely, which is not the self-heal this was described as.
670 // (QA F1 on #254)
671 //
672 // Guarded on a real reduction, so the common case — nothing to prune —
673 // stays a pure read and adds no write to an OAuth request. $writing
674 // stops a caller that is about to save() anyway from writing twice.
675 if ( ! self::$writing && count( $state['clients'] ) < $before ) {
676 self::save( $state );
677 }
678
679 return $state;
680 }
681
682 /**
683 * Load state for a caller that intends to mutate and save() it.
684 *
685 * Identical to state(), except it suppresses the read-side self-heal
686 * write — the caller's own save() persists the same prune moments later,
687 * so doing it here would write twice per request. (QA F1 on #254)
688 *
689 * @return array<string,array<string,mixed>>
690 */
691 private static function state_for_write(): array {
692 self::$writing = true;
693 try {
694 return self::state();
695 } finally {
696 self::$writing = false;
697 }
698 }
699
700 /** Persist state (autoload off -- this is a hot-write, request-scoped option). */
701 private static function save( array $state ): bool {
702 // Report the outcome rather than discarding it. update_option() returns
703 // false when the DB refuses the write — e.g. a value MySQL rejects as
704 // invalid — and swallowing that let register_client() answer 201 with a
705 // client_id it had never stored. A caller that mints a credential must
706 // be able to tell a real write from a silent no-op. (QA on #254)
707 //
708 // Note update_option() also returns false when the value is UNCHANGED,
709 // so this is "did not write", not "failed" — only callers that just
710 // added something to $state may treat false as an error.
711 return (bool) update_option( self::OPTION, $state, false );
712 }
713
714 /**
715 * Look up a registered client.
716 *
717 * @param string $client_id Client id.
718 * @return array{redirect_uris:string[],name:string,created:int}|null
719 */
720 private static function client( string $client_id ): ?array {
721 if ( '' === $client_id ) {
722 return null;
723 }
724 $clients = self::state()['clients'];
725 if ( ! isset( $clients[ $client_id ] ) || ! is_array( $clients[ $client_id ] ) ) {
726 return null;
727 }
728 $c = $clients[ $client_id ];
729 return array(
730 'redirect_uris' => isset( $c['redirect_uris'] ) && is_array( $c['redirect_uris'] ) ? array_map( 'strval', $c['redirect_uris'] ) : array(),
731 'name' => isset( $c['name'] ) ? (string) $c['name'] : 'MCP Client',
732 'created' => isset( $c['created'] ) ? (int) $c['created'] : 0,
733 );
734 }
735
736 /** SHA-256 hash used to store tokens at rest. */
737 private static function hash( string $value ): string {
738 return hash( 'sha256', $value );
739 }
740
741 /** BASE64URL(SHA256(verifier)) -- the PKCE S256 transformation. */
742 private static function s256( string $verifier ): string {
743 return rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' );
744 }
745
746 /**
747 * Constrain a requested scope to what we support. Defaults to `mcp`
748 * (read+write umbrella). An explicit `mcp:read` / `read`-only request
749 * yields a read-only grant.
750 */
751 private static function normalize_scope( string $requested ): string {
752 $parts = preg_split( '/\s+/', trim( $requested ) ) ?: array();
753 $parts = array_values( array_intersect( $parts, self::SUPPORTED_SCOPES ) );
754 if ( empty( $parts ) ) {
755 return 'mcp';
756 }
757 return implode( ' ', $parts );
758 }
759
760 /** Whether a redirect_uri is structurally acceptable (http(s) or a custom scheme). */
761 private static function is_valid_redirect_uri( string $uri ): bool {
762 $uri = trim( $uri );
763 if ( '' === $uri ) {
764 return false;
765 }
766 // Bound the length: without this a single registration could carry
767 // multi-megabyte URIs straight into the stored option.
768 if ( strlen( $uri ) > self::MAX_REDIRECT_URI_LEN ) {
769 return false;
770 }
771 // Allow standard web redirect URIs and native-client custom schemes.
772 return (bool) preg_match( '#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $uri );
773 }
774
775 /**
776 * Drop registered clients that are neither in use nor recent.
777 *
778 * "In use" means the client still owns a live authorization code, access
779 * token or refresh token — state() has already pruned the expired ones, so
780 * whatever remains is genuinely live. Those are kept whatever their age; a
781 * connected client must never be collected out from under a working
782 * integration.
783 *
784 * Everything else is a registration that never completed a flow. Those are
785 * kept for UNUSED_CLIENT_TTL so a slow but legitimate authorization can
786 * finish, then collected.
787 *
788 * @param array<string,array<string,mixed>> $state Loaded state.
789 * @return array<string,mixed> The surviving clients.
790 */
791 private static function prune_clients( array $state ): array {
792 $clients = isset( $state['clients'] ) && is_array( $state['clients'] ) ? $state['clients'] : array();
793 if ( empty( $clients ) ) {
794 return array();
795 }
796
797 // Which client ids still hold live grants?
798 $in_use = array();
799 foreach ( array( 'codes', 'tokens', 'refresh' ) as $bucket ) {
800 if ( empty( $state[ $bucket ] ) || ! is_array( $state[ $bucket ] ) ) {
801 continue;
802 }
803 foreach ( $state[ $bucket ] as $entry ) {
804 if ( is_array( $entry ) && ! empty( $entry['client_id'] ) ) {
805 $in_use[ (string) $entry['client_id'] ] = true;
806 }
807 }
808 }
809
810 $cutoff = time() - self::UNUSED_CLIENT_TTL;
811 foreach ( $clients as $id => $client ) {
812 if ( isset( $in_use[ (string) $id ] ) ) {
813 continue;
814 }
815 $created = ( is_array( $client ) && isset( $client['created'] ) ) ? (int) $client['created'] : 0;
816 if ( $created < $cutoff ) {
817 unset( $clients[ $id ] );
818 }
819 }
820
821 return $clients;
822 }
823
824 /**
825 * Per-IP sliding-window rate limit for dynamic client registration.
826 *
827 * Deliberately mirrors Mcp_Rate_Limiter's approach (transient counter
828 * keyed on a hashed IP) rather than adding a dependency: that class gates
829 * failed *authentication*, while this gates *creation* of state, and the
830 * two must be tunable apart.
831 *
832 * Fails OPEN when the IP is unavailable — a proxy that hides REMOTE_ADDR
833 * must not lock every client out of registering.
834 */
835 private static function rate_limit_ok( bool $count = true ): bool {
836 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
837 if ( '' === $ip ) {
838 return true;
839 }
840
841 /**
842 * Filter the dynamic-client-registration rate limit.
843 *
844 * @param int $max Registrations allowed per IP inside the window.
845 */
846 $max = (int) apply_filters( 'xspeed_mcp_register_rate_limit', self::RATE_MAX );
847 if ( $max <= 0 ) {
848 return true;
849 }
850
851 // Bucket the IP into a FIXED key space instead of one transient per
852 // IP. Per-IP keys meant a distributed flood grew the options TABLE
853 // without bound — the same CWE-770 shape as the bug this class fixes,
854 // just moved from the option value to the row count. RATE_BUCKETS
855 // caps it at a constant: 64 counters, whatever the traffic.
856 // (QA F2 on #254)
857 //
858 // Collisions make the limit stricter for the colliding IPs, never
859 // looser, so the bound still holds. With 64 buckets a handful of
860 // unrelated clients may share a counter; that is the deliberate trade
861 // for a storage ceiling, and RATE_MAX is generous enough to absorb it.
862 $bucket = hexdec( substr( md5( $ip ), 0, 4 ) ) % self::RATE_BUCKETS;
863 $key = 'xspeed_mcp_reg_' . $bucket;
864
865 $entry = get_transient( $key );
866 $now = time();
867
868 // Window START is stored with the counter, so the window is genuinely
869 // FIXED rather than extending on every hit. set_transient()'s TTL was
870 // previously reset on each accepted request, which quietly turned
871 // "10 per hour" into "10, then locked until an hour after your LAST
872 // attempt". (QA F4 on #254)
873 if ( ! is_array( $entry ) || ! isset( $entry['start'], $entry['count'] ) || ( $now - (int) $entry['start'] ) >= self::RATE_WINDOW ) {
874 $entry = array(
875 'start' => $now,
876 'count' => 0,
877 );
878 }
879
880 if ( (int) $entry['count'] >= $max ) {
881 return false;
882 }
883
884 // CHECK-only mode writes nothing. The caller re-invokes with $count
885 // true once the payload has proven valid, so a malformed request
886 // costs the caller nothing — it never consumed a slot and never
887 // touched the database. (QA F3 on #254)
888 if ( ! $count ) {
889 return true;
890 }
891
892 ++$entry['count'];
893
894 // TTL covers only the REMAINDER of the current window, so the entry
895 // expires when the window does instead of being pushed forward.
896 $remaining = self::RATE_WINDOW - ( $now - (int) $entry['start'] );
897 set_transient( $key, $entry, max( 1, $remaining ) );
898
899 return true;
900 }
901
902 /**
903 * Build a WP_Error whose data carries an OAuth 2.0 `error` code so the
904 * token route can render the RFC 6749 error body.
905 *
906 * @param string $code OAuth error code (invalid_grant, ...).
907 * @param string $message Human-readable description.
908 * @return \WP_Error
909 */
910 private static function oauth_error( string $code, string $message ): \WP_Error {
911 return new \WP_Error(
912 $code,
913 $message,
914 array(
915 'status' => 400,
916 'error' => $code,
917 'error_description' => $message,
918 )
919 );
920 }
921 }
922