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

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