PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.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 1.1.8 All 29 releases
xspeed / includes / modules / Mcp / Mcp_OAuth.php

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

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