PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.3
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.1.3, at includes/modules/Mcp/Mcp_OAuth.php

542 lines 19.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 /** Scopes we advertise + honor. `mcp` is the umbrella scope MCP clients request. */
57 private const SUPPORTED_SCOPES = array( 'mcp', 'read', 'write' );
58
59 // -- URLs ------------------------------------------------------------
60
61 /** Base site URL used as the OAuth issuer (no trailing slash). */
62 public static function issuer(): string {
63 return untrailingslashit( home_url() );
64 }
65
66 /** The protected resource identifier -- the MCP endpoint URL. */
67 public static function resource(): string {
68 return Mcp_Pairing::site_endpoint();
69 }
70
71 /**
72 * The browser-facing authorize page. Served OUTSIDE the REST API (via a
73 * rewrite rule) so standard cookie auth works after the wp-login
74 * round-trip — a REST route would see the cookie without a nonce and
75 * treat the admin as logged-out, looping back to login.
76 */
77 public static function authorize_url(): string {
78 return home_url( '/xspeed/authorize' );
79 }
80
81 public static function token_url(): string {
82 return rest_url( 'xspeed/v1/mcp/oauth/token' );
83 }
84
85 public static function register_url(): string {
86 return rest_url( 'xspeed/v1/mcp/oauth/register' );
87 }
88
89 // -- Discovery documents (RFC 8414 / RFC 9728) -----------------------
90
91 /**
92 * RFC 9728 protected-resource metadata -- tells the client which
93 * authorization server(s) protect the MCP endpoint (this site).
94 *
95 * @return array<string,mixed>
96 */
97 public static function protected_resource_metadata(): array {
98 return array(
99 'resource' => self::resource(),
100 'authorization_servers' => array( self::issuer() ),
101 'scopes_supported' => self::SUPPORTED_SCOPES,
102 'bearer_methods_supported' => array( 'header' ),
103 );
104 }
105
106 /**
107 * RFC 8414 authorization-server metadata -- the endpoint map + the
108 * capabilities we actually implement (auth-code grant, PKCE S256,
109 * dynamic registration, refresh tokens).
110 *
111 * @return array<string,mixed>
112 */
113 public static function authorization_server_metadata(): array {
114 return array(
115 'issuer' => self::issuer(),
116 'authorization_endpoint' => self::authorize_url(),
117 'token_endpoint' => self::token_url(),
118 'registration_endpoint' => self::register_url(),
119 'scopes_supported' => self::SUPPORTED_SCOPES,
120 'response_types_supported' => array( 'code' ),
121 'grant_types_supported' => array( 'authorization_code', 'refresh_token' ),
122 'code_challenge_methods_supported' => array( 'S256' ),
123 'token_endpoint_auth_methods_supported' => array( 'none' ),
124 );
125 }
126
127 // -- Dynamic client registration (RFC 7591) --------------------------
128
129 /**
130 * Register a public client. We accept the client's redirect_uris and
131 * mint a client_id (no secret -- public clients rely on PKCE). Minimal
132 * metadata is echoed back per RFC 7591.
133 *
134 * @param array<string,mixed> $body Parsed JSON registration request.
135 * @return array<string,mixed>|\WP_Error
136 */
137 public static function register_client( array $body ) {
138 $redirect_uris = isset( $body['redirect_uris'] ) && is_array( $body['redirect_uris'] )
139 ? array_values( array_filter( array_map( 'strval', $body['redirect_uris'] ), array( self::class, 'is_valid_redirect_uri' ) ) )
140 : array();
141
142 if ( empty( $redirect_uris ) ) {
143 return new \WP_Error(
144 'invalid_redirect_uri',
145 __( 'At least one valid redirect_uri is required.', 'xspeed' ),
146 array( 'status' => 400 )
147 );
148 }
149
150 $name = isset( $body['client_name'] ) ? sanitize_text_field( (string) $body['client_name'] ) : 'MCP Client';
151 $client_id = 'xsc_' . bin2hex( random_bytes( 16 ) );
152
153 $state = self::state();
154 $state['clients'][ $client_id ] = array(
155 'redirect_uris' => $redirect_uris,
156 'name' => $name,
157 'created' => time(),
158 );
159 self::save( $state );
160
161 return array(
162 'client_id' => $client_id,
163 'client_id_issued_at' => time(),
164 'redirect_uris' => $redirect_uris,
165 'client_name' => $name,
166 'token_endpoint_auth_method' => 'none',
167 'grant_types' => array( 'authorization_code', 'refresh_token' ),
168 'response_types' => array( 'code' ),
169 );
170 }
171
172 // -- Authorization endpoint ------------------------------------------
173
174 /**
175 * Validate an /authorize request's parameters WITHOUT issuing anything.
176 * Returns a sanitized param bag on success, or WP_Error on a protocol
177 * violation the client must fix. The caller (route handler) decides how
178 * to surface it (redirect vs error page) based on whether redirect_uri
179 * is trustworthy.
180 *
181 * @param array<string,string> $params Query params.
182 * @return array<string,string>|\WP_Error
183 */
184 public static function validate_authorize_request( array $params ) {
185 $client_id = isset( $params['client_id'] ) ? (string) $params['client_id'] : '';
186 $redirect_uri = isset( $params['redirect_uri'] ) ? (string) $params['redirect_uri'] : '';
187 $response_type = isset( $params['response_type'] ) ? (string) $params['response_type'] : '';
188 $challenge = isset( $params['code_challenge'] ) ? (string) $params['code_challenge'] : '';
189 $method = isset( $params['code_challenge_method'] ) ? (string) $params['code_challenge_method'] : '';
190 $scope = isset( $params['scope'] ) ? (string) $params['scope'] : 'mcp';
191 $state = isset( $params['state'] ) ? (string) $params['state'] : '';
192
193 $client = self::client( $client_id );
194 if ( null === $client ) {
195 return new \WP_Error( 'invalid_client', __( 'Unknown client_id.', 'xspeed' ), array( 'status' => 400 ) );
196 }
197 if ( ! in_array( $redirect_uri, $client['redirect_uris'], true ) ) {
198 // redirect_uri mismatch must NOT redirect (open-redirect guard).
199 return new \WP_Error( 'invalid_redirect_uri', __( 'redirect_uri does not match a registered value.', 'xspeed' ), array( 'status' => 400 ) );
200 }
201 if ( 'code' !== $response_type ) {
202 return new \WP_Error( 'unsupported_response_type', __( 'Only response_type=code is supported.', 'xspeed' ), array( 'status' => 400, 'redirectable' => true ) );
203 }
204 // OAuth 2.1: PKCE S256 is mandatory for public clients.
205 if ( 'S256' !== $method || '' === $challenge ) {
206 return new \WP_Error( 'invalid_request', __( 'PKCE with code_challenge_method=S256 is required.', 'xspeed' ), array( 'status' => 400, 'redirectable' => true ) );
207 }
208
209 return array(
210 'client_id' => $client_id,
211 'client_name' => $client['name'],
212 'redirect_uri' => $redirect_uri,
213 'code_challenge' => $challenge,
214 'scope' => self::normalize_scope( $scope ),
215 'state' => $state,
216 );
217 }
218
219 /**
220 * Issue an authorization code after the admin approves consent. Binds
221 * the code to the client, redirect_uri, PKCE challenge, granted scope,
222 * and the approving user. Single-use, 60 s TTL.
223 *
224 * @param array<string,string> $req Output of validate_authorize_request().
225 * @param int $user_id Approving admin user id.
226 * @return string The authorization code.
227 */
228 public static function issue_code( array $req, int $user_id ): string {
229 $code = bin2hex( random_bytes( 32 ) );
230 $state = self::state();
231 $state['codes'][ $code ] = array(
232 'client_id' => $req['client_id'],
233 'redirect_uri' => $req['redirect_uri'],
234 'challenge' => $req['code_challenge'],
235 'scope' => $req['scope'],
236 'user_id' => $user_id,
237 'expires' => time() + self::CODE_TTL,
238 );
239 self::save( $state );
240 return $code;
241 }
242
243 // -- Token endpoint --------------------------------------------------
244
245 /**
246 * Exchange an authorization code (+ PKCE verifier) for tokens, or a
247 * refresh token for a fresh access token. Returns the RFC 6749 token
248 * response or a WP_Error whose data carries the OAuth error code.
249 *
250 * @param array<string,string> $body POST body params.
251 * @return array<string,mixed>|\WP_Error
252 */
253 public static function exchange_token( array $body ) {
254 $grant = isset( $body['grant_type'] ) ? (string) $body['grant_type'] : '';
255
256 if ( 'authorization_code' === $grant ) {
257 return self::grant_authorization_code( $body );
258 }
259 if ( 'refresh_token' === $grant ) {
260 return self::grant_refresh_token( $body );
261 }
262 return self::oauth_error( 'unsupported_grant_type', 'Unsupported grant_type.' );
263 }
264
265 /**
266 * authorization_code grant: verify the code + PKCE, mint tokens.
267 *
268 * @param array<string,string> $body POST body.
269 * @return array<string,mixed>|\WP_Error
270 */
271 private static function grant_authorization_code( array $body ) {
272 $code = isset( $body['code'] ) ? (string) $body['code'] : '';
273 $client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : '';
274 $redirect_uri = isset( $body['redirect_uri'] ) ? (string) $body['redirect_uri'] : '';
275 $verifier = isset( $body['code_verifier'] ) ? (string) $body['code_verifier'] : '';
276
277 $state = self::state();
278 if ( '' === $code || ! isset( $state['codes'][ $code ] ) ) {
279 return self::oauth_error( 'invalid_grant', 'Unknown or expired authorization code.' );
280 }
281 $entry = $state['codes'][ $code ];
282
283 // Single-use: remove immediately whether or not verification passes.
284 unset( $state['codes'][ $code ] );
285 self::save( $state );
286
287 if ( $entry['expires'] < time() ) {
288 return self::oauth_error( 'invalid_grant', 'Authorization code expired.' );
289 }
290 if ( ! hash_equals( (string) $entry['client_id'], $client_id ) ) {
291 return self::oauth_error( 'invalid_grant', 'client_id mismatch.' );
292 }
293 if ( ! hash_equals( (string) $entry['redirect_uri'], $redirect_uri ) ) {
294 return self::oauth_error( 'invalid_grant', 'redirect_uri mismatch.' );
295 }
296 // PKCE S256: BASE64URL(SHA256(verifier)) must equal the stored challenge.
297 if ( '' === $verifier || ! hash_equals( (string) $entry['challenge'], self::s256( $verifier ) ) ) {
298 return self::oauth_error( 'invalid_grant', 'PKCE verification failed.' );
299 }
300
301 return self::mint_tokens( $entry['client_id'], $entry['scope'], (int) $entry['user_id'] );
302 }
303
304 /**
305 * refresh_token grant: rotate the refresh token, issue a fresh access
306 * token. The old refresh + its access token are revoked.
307 *
308 * @param array<string,string> $body POST body.
309 * @return array<string,mixed>|\WP_Error
310 */
311 private static function grant_refresh_token( array $body ) {
312 $refresh = isset( $body['refresh_token'] ) ? (string) $body['refresh_token'] : '';
313 $client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : '';
314
315 $state = self::state();
316 $rhash = self::hash( $refresh );
317 if ( '' === $refresh || ! isset( $state['refresh'][ $rhash ] ) ) {
318 return self::oauth_error( 'invalid_grant', 'Unknown refresh token.' );
319 }
320 $entry = $state['refresh'][ $rhash ];
321 if ( '' !== $client_id && ! hash_equals( (string) $entry['client_id'], $client_id ) ) {
322 return self::oauth_error( 'invalid_grant', 'client_id mismatch.' );
323 }
324
325 // Rotate: drop old refresh + its access token.
326 unset( $state['refresh'][ $rhash ] );
327 if ( isset( $entry['access_hash'] ) ) {
328 unset( $state['tokens'][ $entry['access_hash'] ] );
329 }
330 self::save( $state );
331
332 return self::mint_tokens( $entry['client_id'], $entry['scope'], (int) $entry['user_id'] );
333 }
334
335 /**
336 * Mint an access + refresh token pair, store them hashed, and return
337 * the RFC 6749 token response with the raw values.
338 *
339 * @param string $client_id Client id.
340 * @param string $scope Granted scope string.
341 * @param int $user_id Resource-owner user id.
342 * @return array<string,mixed>
343 */
344 private static function mint_tokens( string $client_id, string $scope, int $user_id ): array {
345 $access = bin2hex( random_bytes( 32 ) );
346 $refresh = bin2hex( random_bytes( 32 ) );
347 $ahash = self::hash( $access );
348 $rhash = self::hash( $refresh );
349
350 $state = self::state();
351 $state['tokens'][ $ahash ] = array(
352 'client_id' => $client_id,
353 'scope' => $scope,
354 'user_id' => $user_id,
355 'expires' => time() + self::ACCESS_TTL,
356 'refresh' => $rhash,
357 );
358 $state['refresh'][ $rhash ] = array(
359 'access_hash' => $ahash,
360 'client_id' => $client_id,
361 'scope' => $scope,
362 'user_id' => $user_id,
363 'expires' => time() + self::REFRESH_TTL,
364 );
365 self::save( $state );
366
367 return array(
368 'access_token' => $access,
369 'token_type' => 'Bearer',
370 'expires_in' => self::ACCESS_TTL,
371 'refresh_token' => $refresh,
372 'scope' => $scope,
373 );
374 }
375
376 // -- Access-token validation (called by Mcp_Server) ------------------
377
378 /**
379 * Validate a bearer access token presented to the MCP endpoint.
380 * Returns the token's grant record (scope, user_id, client_id) when
381 * valid + unexpired, or null. Constant-time via hashed lookup.
382 *
383 * @param string $token Raw access token from the Authorization header.
384 * @return array{client_id:string,scope:string,user_id:int}|null
385 */
386 public static function validate_token( string $token ): ?array {
387 if ( '' === $token ) {
388 return null;
389 }
390 $state = self::state();
391 $hash = self::hash( $token );
392 if ( ! isset( $state['tokens'][ $hash ] ) ) {
393 return null;
394 }
395 $entry = $state['tokens'][ $hash ];
396 if ( (int) $entry['expires'] < time() ) {
397 return null;
398 }
399 return array(
400 'client_id' => (string) $entry['client_id'],
401 'scope' => (string) $entry['scope'],
402 'user_id' => (int) $entry['user_id'],
403 );
404 }
405
406 /**
407 * Whether a granted scope string is read-only. `mcp` is the umbrella
408 * scope that grants read+write (matching a default pairing token), so
409 * only a grant that carries NEITHER `write` NOR `mcp` -- i.e. `read`
410 * alone -- is read-only.
411 */
412 public static function scope_is_read_only( string $scope ): bool {
413 $parts = preg_split( '/\s+/', trim( $scope ) ) ?: array();
414 return ! in_array( 'write', $parts, true ) && ! in_array( 'mcp', $parts, true );
415 }
416
417 /** Revoke every OAuth token + client (used by disconnect). */
418 public static function revoke_all(): void {
419 delete_option( self::OPTION );
420 }
421
422 // -- State + helpers -------------------------------------------------
423
424 /**
425 * Load state with defaults, pruning expired codes/tokens/refresh
426 * entries on the way out so the option can't grow unbounded.
427 *
428 * @return array<string,array<string,mixed>>
429 */
430 private static function state(): array {
431 $stored = get_option( self::OPTION, array() );
432 if ( ! is_array( $stored ) ) {
433 $stored = array();
434 }
435 $state = array(
436 'clients' => isset( $stored['clients'] ) && is_array( $stored['clients'] ) ? $stored['clients'] : array(),
437 'codes' => isset( $stored['codes'] ) && is_array( $stored['codes'] ) ? $stored['codes'] : array(),
438 'tokens' => isset( $stored['tokens'] ) && is_array( $stored['tokens'] ) ? $stored['tokens'] : array(),
439 'refresh' => isset( $stored['refresh'] ) && is_array( $stored['refresh'] ) ? $stored['refresh'] : array(),
440 );
441
442 $now = time();
443 foreach ( $state['codes'] as $k => $v ) {
444 if ( ! isset( $v['expires'] ) || $v['expires'] < $now ) {
445 unset( $state['codes'][ $k ] );
446 }
447 }
448 foreach ( $state['tokens'] as $k => $v ) {
449 if ( ! isset( $v['expires'] ) || $v['expires'] < $now ) {
450 unset( $state['tokens'][ $k ] );
451 }
452 }
453 foreach ( $state['refresh'] as $k => $v ) {
454 if ( isset( $v['expires'] ) && $v['expires'] < $now ) {
455 unset( $state['refresh'][ $k ] );
456 }
457 }
458 return $state;
459 }
460
461 /** Persist state (autoload off -- this is a hot-write, request-scoped option). */
462 private static function save( array $state ): void {
463 update_option( self::OPTION, $state, false );
464 }
465
466 /**
467 * Look up a registered client.
468 *
469 * @param string $client_id Client id.
470 * @return array{redirect_uris:string[],name:string,created:int}|null
471 */
472 private static function client( string $client_id ): ?array {
473 if ( '' === $client_id ) {
474 return null;
475 }
476 $clients = self::state()['clients'];
477 if ( ! isset( $clients[ $client_id ] ) || ! is_array( $clients[ $client_id ] ) ) {
478 return null;
479 }
480 $c = $clients[ $client_id ];
481 return array(
482 'redirect_uris' => isset( $c['redirect_uris'] ) && is_array( $c['redirect_uris'] ) ? array_map( 'strval', $c['redirect_uris'] ) : array(),
483 'name' => isset( $c['name'] ) ? (string) $c['name'] : 'MCP Client',
484 'created' => isset( $c['created'] ) ? (int) $c['created'] : 0,
485 );
486 }
487
488 /** SHA-256 hash used to store tokens at rest. */
489 private static function hash( string $value ): string {
490 return hash( 'sha256', $value );
491 }
492
493 /** BASE64URL(SHA256(verifier)) -- the PKCE S256 transformation. */
494 private static function s256( string $verifier ): string {
495 return rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' );
496 }
497
498 /**
499 * Constrain a requested scope to what we support. Defaults to `mcp`
500 * (read+write umbrella). An explicit `mcp:read` / `read`-only request
501 * yields a read-only grant.
502 */
503 private static function normalize_scope( string $requested ): string {
504 $parts = preg_split( '/\s+/', trim( $requested ) ) ?: array();
505 $parts = array_values( array_intersect( $parts, self::SUPPORTED_SCOPES ) );
506 if ( empty( $parts ) ) {
507 return 'mcp';
508 }
509 return implode( ' ', $parts );
510 }
511
512 /** Whether a redirect_uri is structurally acceptable (http(s) or a custom scheme). */
513 private static function is_valid_redirect_uri( string $uri ): bool {
514 $uri = trim( $uri );
515 if ( '' === $uri ) {
516 return false;
517 }
518 // Allow standard web redirect URIs and native-client custom schemes.
519 return (bool) preg_match( '#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $uri );
520 }
521
522 /**
523 * Build a WP_Error whose data carries an OAuth 2.0 `error` code so the
524 * token route can render the RFC 6749 error body.
525 *
526 * @param string $code OAuth error code (invalid_grant, ...).
527 * @param string $message Human-readable description.
528 * @return \WP_Error
529 */
530 private static function oauth_error( string $code, string $message ): \WP_Error {
531 return new \WP_Error(
532 $code,
533 $message,
534 array(
535 'status' => 400,
536 'error' => $code,
537 'error_description' => $message,
538 )
539 );
540 }
541 }
542