PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.1
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / mcp / class-mcp-oauth.php

class-mcp-oauth.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.0.1, at includes/mcp/class-mcp-oauth.php

841 lines 28.7 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.
9 *
10 * Flow: unauthenticated MCP call → 401 + WWW-Authenticate (Mcp_Server) →
11 * client fetches /.well-known/oauth-protected-resource + oauth-authorization-
12 * server → dynamic registration (RFC 7591) → /authorize (admin consent +
13 * PKCE) → /token (code + verifier → access + refresh) → MCP calls with
14 * `Authorization: Bearer <access>` validated by validate_token().
15 *
16 * Security contract:
17 * - PKCE S256 REQUIRED (OAuth 2.1 public clients); codes are single-use,
18 * 60 s TTL, bound to client_id + redirect_uri + challenge.
19 * - /authorize gates on manage_options — only an admin can grant access.
20 * - Access/refresh tokens stored only as SHA-256 hashes; the raw value
21 * exists solely in the /token response. Constant-time comparison.
22 * - Tokens carry the read/write scope model; a read-only grant refuses
23 * every write tool, exactly like a read-only pairing token.
24 *
25 * State lives in the `thinkrank_mcp_oauth` option (clients, codes, tokens,
26 * refresh — keyed by id or sha256 of the secret); expired entries are pruned
27 * lazily on every read.
28 *
29 * @package ThinkRank\Mcp
30 */
31
32 declare(strict_types=1);
33
34 namespace ThinkRank\Mcp;
35
36 if ( ! defined( 'ABSPATH' ) ) {
37 exit; // Exit if accessed directly.
38 }
39
40 /**
41 * Minimal OAuth 2.1 authorization server for the ThinkRank MCP endpoint.
42 */
43 final class Mcp_OAuth {
44
45 /**
46 * Option key holding all OAuth server state.
47 */
48 public const OPTION = 'thinkrank_mcp_oauth';
49
50 /**
51 * Authorization-code lifetime (seconds). Deliberately short.
52 */
53 private const CODE_TTL = 60;
54
55 /**
56 * Access-token lifetime (seconds) — 1 hour, refreshable.
57 */
58 private const ACCESS_TTL = 3600;
59
60 /**
61 * Refresh-token lifetime (seconds) — 30 days.
62 */
63 private const REFRESH_TTL = 2592000;
64
65 /**
66 * Scopes we advertise + honor. `mcp` is the umbrella scope MCP clients request.
67 */
68 private const SUPPORTED_SCOPES = [ 'mcp', 'read', 'write' ];
69
70 /**
71 * Throttle window (seconds) for per-client last-used writes — at most one
72 * option write per minute per client, so a busy connector can't turn every
73 * MCP call into a database write.
74 */
75 private const LAST_USED_THROTTLE = 60;
76
77 /**
78 * How many registered clients to keep. RFC 7591 registration is open by
79 * necessity — a client must register BEFORE it can hold any credential —
80 * so without a cap anyone on the internet can grow this option without
81 * bound, and every state() read pays for it. Clients holding a live token
82 * are never evicted, so the cap only ever discards abandoned registrations.
83 */
84 private const MAX_CLIENTS = 50;
85
86 /**
87 * How long an unused client registration survives (seconds). A client that
88 * registers and never completes the flow is abandoned; real ones exchange
89 * a code within a minute.
90 */
91 private const CLIENT_TTL = 86400; // 24 hours.
92
93 // -- URLs ------------------------------------------------------------
94
95 /**
96 * The OAuth issuer identifier. Path-based (RFC 8414 §2 allows an issuer
97 * with a path component): using the MCP endpoint URL itself means clients
98 * derive the path-suffixed well-known URLs
99 * (/.well-known/oauth-authorization-server/thinkrank/mcp), which stay
100 * specific to ThinkRank even when another plugin runs its own MCP OAuth
101 * server at the same site root.
102 *
103 * @return string
104 */
105 public static function issuer(): string {
106 return untrailingslashit( home_url( '/thinkrank/mcp' ) );
107 }
108
109 /**
110 * The protected resource identifier — the MCP endpoint URL.
111 *
112 * @return string
113 */
114 public static function resource(): string {
115 return Mcp_Pairing::site_endpoint();
116 }
117
118 /**
119 * The browser-facing authorize page. Served OUTSIDE the REST API (via a
120 * rewrite rule) so standard cookie auth works after the wp-login
121 * round-trip — a REST route would see the cookie without a nonce and
122 * treat the admin as logged-out, looping back to login.
123 *
124 * @return string
125 */
126 public static function authorize_url(): string {
127 return home_url( '/thinkrank/authorize' );
128 }
129
130 /**
131 * The token endpoint URL.
132 *
133 * @return string
134 */
135 public static function token_url(): string {
136 return rest_url( 'thinkrank/v1/mcp/oauth/token' );
137 }
138
139 /**
140 * The dynamic client registration endpoint URL.
141 *
142 * @return string
143 */
144 public static function register_url(): string {
145 return rest_url( 'thinkrank/v1/mcp/oauth/register' );
146 }
147
148 /**
149 * The protected-resource metadata URL the 401 challenge advertises.
150 *
151 * REST-served, NOT the RFC 9728 path-insert form. The path-insert URL
152 * lives under the site root's /.well-known/ directory, and some hosts
153 * (SiteGround shared hosting confirmed, see #374) resolve that directory
154 * at their Nginx edge as physical files — the request 404s before
155 * WordPress runs, and the connecting client reports "server does not
156 * implement OAuth" on its very first fetch. The challenge parameter is an
157 * explicit pointer (that is what it exists for), so pointing it at a
158 * /wp-json/ URL is spec-clean and reaches WordPress on every host and
159 * permalink structure. The well-known variants stay served for clients
160 * that ignore the pointer and derive the URL themselves.
161 *
162 * @return string
163 */
164 public static function resource_metadata_url(): string {
165 /**
166 * Filter the resource_metadata URL advertised in the WWW-Authenticate
167 * challenge, for hosts where neither the REST route nor the
168 * /.well-known/ forms are reachable and the metadata must be served
169 * from somewhere custom (a CDN, a static file, another domain).
170 *
171 * @since 1.32.0
172 *
173 * @param string $url The advertised protected-resource metadata URL.
174 */
175 return apply_filters(
176 'thinkrank_mcp_resource_metadata_url',
177 rest_url( 'thinkrank/v1/mcp/oauth/protected-resource' )
178 );
179 }
180
181 // -- Discovery documents (RFC 8414 / RFC 9728) -----------------------
182
183 /**
184 * RFC 9728 protected-resource metadata — tells the client which
185 * authorization server(s) protect the MCP endpoint (this site).
186 *
187 * @return array<string,mixed>
188 */
189 public static function protected_resource_metadata(): array {
190 return [
191 'resource' => self::resource(),
192 'authorization_servers' => [ self::issuer() ],
193 'scopes_supported' => self::SUPPORTED_SCOPES,
194 'bearer_methods_supported' => [ 'header' ],
195 ];
196 }
197
198 /**
199 * RFC 8414 authorization-server metadata — the endpoint map + the
200 * capabilities we actually implement.
201 *
202 * @return array<string,mixed>
203 */
204 public static function authorization_server_metadata(): array {
205 return [
206 'issuer' => self::issuer(),
207 'authorization_endpoint' => self::authorize_url(),
208 'token_endpoint' => self::token_url(),
209 'registration_endpoint' => self::register_url(),
210 'scopes_supported' => self::SUPPORTED_SCOPES,
211 'response_types_supported' => [ 'code' ],
212 'grant_types_supported' => [ 'authorization_code', 'refresh_token' ],
213 'code_challenge_methods_supported' => [ 'S256' ],
214 'token_endpoint_auth_methods_supported' => [ 'none' ],
215 ];
216 }
217
218 // -- Dynamic client registration (RFC 7591) --------------------------
219
220 /**
221 * Register a public client. We accept the client's redirect_uris and
222 * mint a client_id (no secret — public clients rely on PKCE).
223 *
224 * @param array<string,mixed> $body Parsed JSON registration request.
225 * @return array<string,mixed>|\WP_Error
226 */
227 public static function register_client( array $body ) {
228 $redirect_uris = isset( $body['redirect_uris'] ) && is_array( $body['redirect_uris'] )
229 ? array_values( array_filter( array_map( 'strval', $body['redirect_uris'] ), [ self::class, 'is_valid_redirect_uri' ] ) )
230 : [];
231
232 if ( empty( $redirect_uris ) ) {
233 return new \WP_Error(
234 'invalid_redirect_uri',
235 __( 'At least one valid redirect_uri is required.', 'thinkrank' ),
236 [ 'status' => 400 ]
237 );
238 }
239
240 $name = isset( $body['client_name'] ) ? sanitize_text_field( (string) $body['client_name'] ) : 'MCP Client';
241 $client_id = 'trk_' . bin2hex( random_bytes( 16 ) );
242
243 $state = self::state();
244 $state['clients'][ $client_id ] = [
245 'redirect_uris' => $redirect_uris,
246 'name' => $name,
247 'created' => time(),
248 ];
249 $state['clients'] = self::prune_clients( $state );
250 self::save( $state );
251
252 return [
253 'client_id' => $client_id,
254 'client_id_issued_at' => time(),
255 'redirect_uris' => $redirect_uris,
256 'client_name' => $name,
257 'token_endpoint_auth_method' => 'none',
258 'grant_types' => [ 'authorization_code', 'refresh_token' ],
259 'response_types' => [ 'code' ],
260 ];
261 }
262
263 // -- Authorization endpoint ------------------------------------------
264
265 /**
266 * Validate an /authorize request's parameters WITHOUT issuing anything.
267 * Returns a sanitized param bag on success, or WP_Error on a protocol
268 * violation. The caller decides how to surface it (redirect vs error
269 * page) based on whether redirect_uri is trustworthy.
270 *
271 * @param array<string,string> $params Query params.
272 * @return array<string,string>|\WP_Error
273 */
274 public static function validate_authorize_request( array $params ) {
275 $client_id = isset( $params['client_id'] ) ? (string) $params['client_id'] : '';
276 $redirect_uri = isset( $params['redirect_uri'] ) ? (string) $params['redirect_uri'] : '';
277 $response_type = isset( $params['response_type'] ) ? (string) $params['response_type'] : '';
278 $challenge = isset( $params['code_challenge'] ) ? (string) $params['code_challenge'] : '';
279 $method = isset( $params['code_challenge_method'] ) ? (string) $params['code_challenge_method'] : '';
280 $scope = isset( $params['scope'] ) ? (string) $params['scope'] : 'mcp';
281 $state = isset( $params['state'] ) ? (string) $params['state'] : '';
282
283 $client = self::client( $client_id );
284 if ( null === $client ) {
285 return new \WP_Error( 'invalid_client', __( 'Unknown client_id.', 'thinkrank' ), [ 'status' => 400 ] );
286 }
287 if ( ! in_array( $redirect_uri, $client['redirect_uris'], true ) ) {
288 // redirect_uri mismatch must NOT redirect (open-redirect guard).
289 return new \WP_Error( 'invalid_redirect_uri', __( 'redirect_uri does not match a registered value.', 'thinkrank' ), [ 'status' => 400 ] );
290 }
291 if ( 'code' !== $response_type ) {
292 return new \WP_Error(
293 'unsupported_response_type',
294 __( 'Only response_type=code is supported.', 'thinkrank' ),
295 [
296 'status' => 400,
297 'redirectable' => true,
298 ]
299 );
300 }
301 // OAuth 2.1: PKCE S256 is mandatory for public clients.
302 if ( 'S256' !== $method || '' === $challenge ) {
303 return new \WP_Error(
304 'invalid_request',
305 __( 'PKCE with code_challenge_method=S256 is required.', 'thinkrank' ),
306 [
307 'status' => 400,
308 'redirectable' => true,
309 ]
310 );
311 }
312
313 return [
314 'client_id' => $client_id,
315 'client_name' => $client['name'],
316 'redirect_uri' => $redirect_uri,
317 'code_challenge' => $challenge,
318 'scope' => self::normalize_scope( $scope ),
319 'state' => $state,
320 ];
321 }
322
323 /**
324 * Issue an authorization code after the admin approves consent. Binds
325 * the code to the client, redirect_uri, PKCE challenge, granted scope,
326 * and the approving user. Single-use, 60 s TTL.
327 *
328 * @param array<string,string> $req Output of validate_authorize_request().
329 * @param int $user_id Approving admin user id.
330 * @return string The authorization code.
331 */
332 public static function issue_code( array $req, int $user_id ): string {
333 $code = bin2hex( random_bytes( 32 ) );
334 $state = self::state();
335 $state['codes'][ $code ] = [
336 'client_id' => $req['client_id'],
337 'redirect_uri' => $req['redirect_uri'],
338 'challenge' => $req['code_challenge'],
339 'scope' => $req['scope'],
340 'user_id' => $user_id,
341 'expires' => time() + self::CODE_TTL,
342 ];
343 self::save( $state );
344 return $code;
345 }
346
347 // -- Token endpoint --------------------------------------------------
348
349 /**
350 * Exchange an authorization code (+ PKCE verifier) for tokens, or a
351 * refresh token for a fresh access token.
352 *
353 * @param array<string,string> $body POST body params.
354 * @return array<string,mixed>|\WP_Error
355 */
356 public static function exchange_token( array $body ) {
357 $grant = isset( $body['grant_type'] ) ? (string) $body['grant_type'] : '';
358
359 if ( 'authorization_code' === $grant ) {
360 return self::grant_authorization_code( $body );
361 }
362 if ( 'refresh_token' === $grant ) {
363 return self::grant_refresh_token( $body );
364 }
365 return self::oauth_error( 'unsupported_grant_type', 'Unsupported grant_type.' );
366 }
367
368 /**
369 * authorization_code grant: verify the code + PKCE, mint tokens.
370 *
371 * @param array<string,string> $body POST body.
372 * @return array<string,mixed>|\WP_Error
373 */
374 private static function grant_authorization_code( array $body ) {
375 $code = isset( $body['code'] ) ? (string) $body['code'] : '';
376 $client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : '';
377 $redirect_uri = isset( $body['redirect_uri'] ) ? (string) $body['redirect_uri'] : '';
378 $verifier = isset( $body['code_verifier'] ) ? (string) $body['code_verifier'] : '';
379
380 $state = self::state();
381 if ( '' === $code || ! isset( $state['codes'][ $code ] ) ) {
382 return self::oauth_error( 'invalid_grant', 'Unknown or expired authorization code.' );
383 }
384 $entry = $state['codes'][ $code ];
385
386 // Single-use: remove immediately whether or not verification passes.
387 unset( $state['codes'][ $code ] );
388 self::save( $state );
389
390 if ( $entry['expires'] < time() ) {
391 return self::oauth_error( 'invalid_grant', 'Authorization code expired.' );
392 }
393 if ( ! hash_equals( (string) $entry['client_id'], $client_id ) ) {
394 return self::oauth_error( 'invalid_grant', 'client_id mismatch.' );
395 }
396 if ( ! hash_equals( (string) $entry['redirect_uri'], $redirect_uri ) ) {
397 return self::oauth_error( 'invalid_grant', 'redirect_uri mismatch.' );
398 }
399 // PKCE S256: BASE64URL(SHA256(verifier)) must equal the stored challenge.
400 if ( '' === $verifier || ! hash_equals( (string) $entry['challenge'], self::s256( $verifier ) ) ) {
401 return self::oauth_error( 'invalid_grant', 'PKCE verification failed.' );
402 }
403
404 return self::mint_tokens( (string) $entry['client_id'], (string) $entry['scope'], (int) $entry['user_id'] );
405 }
406
407 /**
408 * refresh_token grant: rotate the refresh token, issue a fresh access
409 * token. The old refresh + its access token are revoked.
410 *
411 * @param array<string,string> $body POST body.
412 * @return array<string,mixed>|\WP_Error
413 */
414 private static function grant_refresh_token( array $body ) {
415 $refresh = isset( $body['refresh_token'] ) ? (string) $body['refresh_token'] : '';
416 $client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : '';
417
418 $state = self::state();
419 $rhash = self::hash( $refresh );
420 if ( '' === $refresh || ! isset( $state['refresh'][ $rhash ] ) ) {
421 return self::oauth_error( 'invalid_grant', 'Unknown refresh token.' );
422 }
423 $entry = $state['refresh'][ $rhash ];
424 if ( '' !== $client_id && ! hash_equals( (string) $entry['client_id'], $client_id ) ) {
425 return self::oauth_error( 'invalid_grant', 'client_id mismatch.' );
426 }
427
428 // Rotate: drop old refresh + its access token.
429 unset( $state['refresh'][ $rhash ] );
430 if ( isset( $entry['access_hash'] ) ) {
431 unset( $state['tokens'][ $entry['access_hash'] ] );
432 }
433 self::save( $state );
434
435 return self::mint_tokens( (string) $entry['client_id'], (string) $entry['scope'], (int) $entry['user_id'] );
436 }
437
438 /**
439 * Mint an access + refresh token pair, store them hashed, and return
440 * the RFC 6749 token response with the raw values.
441 *
442 * @param string $client_id Client id.
443 * @param string $scope Granted scope string.
444 * @param int $user_id Resource-owner user id.
445 * @return array<string,mixed>
446 */
447 private static function mint_tokens( string $client_id, string $scope, int $user_id ): array {
448 $access = bin2hex( random_bytes( 32 ) );
449 $refresh = bin2hex( random_bytes( 32 ) );
450 $ahash = self::hash( $access );
451 $rhash = self::hash( $refresh );
452
453 $state = self::state();
454 $state['tokens'][ $ahash ] = [
455 'client_id' => $client_id,
456 'scope' => $scope,
457 'user_id' => $user_id,
458 'expires' => time() + self::ACCESS_TTL,
459 'refresh' => $rhash,
460 ];
461 $state['refresh'][ $rhash ] = [
462 'access_hash' => $ahash,
463 'client_id' => $client_id,
464 'scope' => $scope,
465 'user_id' => $user_id,
466 'expires' => time() + self::REFRESH_TTL,
467 ];
468 self::save( $state );
469
470 return [
471 'access_token' => $access,
472 'token_type' => 'Bearer',
473 'expires_in' => self::ACCESS_TTL,
474 'refresh_token' => $refresh,
475 'scope' => $scope,
476 ];
477 }
478
479 // -- Access-token validation (called by Mcp_Server) ------------------
480
481 /**
482 * Validate a bearer access token presented to the MCP endpoint.
483 * Returns the token's grant record (scope, user_id, client_id) when
484 * valid + unexpired, or null. Constant-time via hashed lookup.
485 *
486 * @param string $token Raw access token from the Authorization header.
487 * @return array{client_id:string,scope:string,user_id:int}|null
488 */
489 public static function validate_token( string $token ): ?array {
490 if ( '' === $token ) {
491 return null;
492 }
493 $state = self::state();
494 $hash = self::hash( $token );
495 if ( ! isset( $state['tokens'][ $hash ] ) ) {
496 return null;
497 }
498 $entry = $state['tokens'][ $hash ];
499 if ( (int) $entry['expires'] < time() ) {
500 return null;
501 }
502
503 // Record activity against the owning client so the "Connected AI apps"
504 // list can show a last-used date. Throttled + stored on the client
505 // record so it survives access-token rotation.
506 $client_id = (string) $entry['client_id'];
507 $now = time();
508 if ( isset( $state['clients'][ $client_id ] ) && is_array( $state['clients'][ $client_id ] ) ) {
509 $last = isset( $state['clients'][ $client_id ]['last_used'] ) ? (int) $state['clients'][ $client_id ]['last_used'] : 0;
510 if ( $now - $last >= self::LAST_USED_THROTTLE ) {
511 $state['clients'][ $client_id ]['last_used'] = $now;
512 self::save( $state );
513 }
514 }
515
516 return [
517 'client_id' => $client_id,
518 'scope' => (string) $entry['scope'],
519 'user_id' => (int) $entry['user_id'],
520 ];
521 }
522
523 /**
524 * Whether a granted scope string is read-only. `mcp` is the umbrella
525 * scope that grants read+write, so only a grant that carries NEITHER
526 * `write` NOR `mcp` — i.e. `read` alone — is read-only.
527 *
528 * @param string $scope Space-separated scope string.
529 * @return bool
530 */
531 public static function scope_is_read_only( string $scope ): bool {
532 $parts = preg_split( '/\s+/', trim( $scope ) );
533 $parts = is_array( $parts ) ? $parts : [];
534 return ! in_array( 'write', $parts, true ) && ! in_array( 'mcp', $parts, true );
535 }
536
537 /**
538 * Revoke every OAuth token + client (used by disconnect).
539 *
540 * @return void
541 */
542 public static function revoke_all(): void {
543 delete_option( self::OPTION );
544 }
545
546 /**
547 * The OAuth clients currently holding a live grant, for the "Connected AI
548 * apps" list. A client counts as connected while it holds an unexpired
549 * refresh token (the durable 30-day grant) or access token; a client that
550 * only registered but never completed consent is excluded. One entry per
551 * client_id, newest connection first.
552 *
553 * @return array<int,array{client_id:string,name:string,scope:string,read_only:bool,user_id:int,connected_at:int,last_used:int}>
554 */
555 public static function connected_apps(): array {
556 $state = self::state();
557
558 // Collect the scope + approving user per active client. Refresh tokens
559 // are the durable grant, so prefer them; fall back to access tokens.
560 $active = [];
561 foreach ( [ 'refresh', 'tokens' ] as $bucket ) {
562 foreach ( $state[ $bucket ] as $entry ) {
563 $cid = isset( $entry['client_id'] ) ? (string) $entry['client_id'] : '';
564 if ( '' === $cid || isset( $active[ $cid ] ) ) {
565 continue;
566 }
567 $active[ $cid ] = [
568 'scope' => isset( $entry['scope'] ) ? (string) $entry['scope'] : 'mcp',
569 'user_id' => isset( $entry['user_id'] ) ? (int) $entry['user_id'] : 0,
570 ];
571 }
572 }
573
574 $apps = [];
575 foreach ( $active as $cid => $info ) {
576 $client = isset( $state['clients'][ $cid ] ) && is_array( $state['clients'][ $cid ] ) ? $state['clients'][ $cid ] : [];
577 $apps[] = [
578 'client_id' => $cid,
579 'name' => isset( $client['name'] ) ? (string) $client['name'] : __( 'MCP Client', 'thinkrank' ),
580 'scope' => $info['scope'],
581 'read_only' => self::scope_is_read_only( $info['scope'] ),
582 'user_id' => $info['user_id'],
583 'connected_at' => isset( $client['created'] ) ? (int) $client['created'] : 0,
584 'last_used' => isset( $client['last_used'] ) ? (int) $client['last_used'] : 0,
585 ];
586 }
587
588 // Newest connection first.
589 usort(
590 $apps,
591 static function ( array $a, array $b ): int {
592 return $b['connected_at'] <=> $a['connected_at'];
593 }
594 );
595
596 return $apps;
597 }
598
599 /**
600 * Revoke a single OAuth client's ACCESS — drops its access tokens, refresh
601 * tokens, and any pending codes, cutting that one app off immediately while
602 * leaving every other connection intact. It disappears from
603 * connected_apps() (which keys off live tokens), so the UI shows it gone.
604 *
605 * The client's dynamic registration (its client_id + redirect_uris) is
606 * intentionally KEPT: MCP clients such as ChatGPT cache the client_id from
607 * their first registration and reuse it on reconnect, hitting /authorize
608 * with that id rather than registering afresh. If we deleted the
609 * registration, that reconnect would fail with "Unknown client_id". Keeping
610 * it lets the app re-authorize — which still requires fresh admin consent
611 * (and mints brand-new tokens), so revocation loses nothing.
612 *
613 * @param string $client_id The client whose access to revoke.
614 * @return bool True if any live grant was removed.
615 */
616 public static function revoke_client( string $client_id ): bool {
617 if ( '' === $client_id ) {
618 return false;
619 }
620 $state = self::state();
621 $removed = false;
622
623 foreach ( [ 'tokens', 'refresh', 'codes' ] as $bucket ) {
624 foreach ( $state[ $bucket ] as $key => $entry ) {
625 if ( isset( $entry['client_id'] ) && (string) $entry['client_id'] === $client_id ) {
626 unset( $state[ $bucket ][ $key ] );
627 $removed = true;
628 }
629 }
630 }
631
632 if ( $removed ) {
633 self::save( $state );
634 }
635 return $removed;
636 }
637
638 // -- State + helpers -------------------------------------------------
639
640 /**
641 * Load state with defaults, pruning expired codes/tokens/refresh
642 * entries on the way out so the option can't grow unbounded.
643 *
644 * @return array<string,array<string,mixed>>
645 */
646 private static function state(): array {
647 $stored = get_option( self::OPTION, [] );
648 if ( ! is_array( $stored ) ) {
649 $stored = [];
650 }
651 $state = [
652 'clients' => isset( $stored['clients'] ) && is_array( $stored['clients'] ) ? $stored['clients'] : [],
653 'codes' => isset( $stored['codes'] ) && is_array( $stored['codes'] ) ? $stored['codes'] : [],
654 'tokens' => isset( $stored['tokens'] ) && is_array( $stored['tokens'] ) ? $stored['tokens'] : [],
655 'refresh' => isset( $stored['refresh'] ) && is_array( $stored['refresh'] ) ? $stored['refresh'] : [],
656 ];
657
658 $now = time();
659 foreach ( $state['codes'] as $k => $v ) {
660 if ( ! isset( $v['expires'] ) || $v['expires'] < $now ) {
661 unset( $state['codes'][ $k ] );
662 }
663 }
664 foreach ( $state['tokens'] as $k => $v ) {
665 if ( ! isset( $v['expires'] ) || $v['expires'] < $now ) {
666 unset( $state['tokens'][ $k ] );
667 }
668 }
669 foreach ( $state['refresh'] as $k => $v ) {
670 if ( isset( $v['expires'] ) && $v['expires'] < $now ) {
671 unset( $state['refresh'][ $k ] );
672 }
673 }
674 return $state;
675 }
676
677 /**
678 * Persist state (autoload off — hot-write, request-scoped option).
679 *
680 * @param array<string,mixed> $state State to persist.
681 * @return void
682 */
683 private static function save( array $state ): void {
684 update_option( self::OPTION, $state, false );
685 }
686
687 /**
688 * Look up a registered client.
689 *
690 * @param string $client_id Client id.
691 * @return array{redirect_uris:string[],name:string,created:int}|null
692 */
693 private static function client( string $client_id ): ?array {
694 if ( '' === $client_id ) {
695 return null;
696 }
697 $clients = self::state()['clients'];
698 if ( ! isset( $clients[ $client_id ] ) || ! is_array( $clients[ $client_id ] ) ) {
699 return null;
700 }
701 $c = $clients[ $client_id ];
702 return [
703 'redirect_uris' => isset( $c['redirect_uris'] ) && is_array( $c['redirect_uris'] ) ? array_map( 'strval', $c['redirect_uris'] ) : [],
704 'name' => isset( $c['name'] ) ? (string) $c['name'] : 'MCP Client',
705 'created' => isset( $c['created'] ) ? (int) $c['created'] : 0,
706 ];
707 }
708
709 /**
710 * Bound the registered-client list. Drops abandoned registrations past
711 * CLIENT_TTL first, then — if still over MAX_CLIENTS — the oldest of what
712 * is left. A client referenced by a live code, access token, or refresh
713 * token is NEVER dropped: evicting one would break a working connection,
714 * so a site legitimately holding more than MAX_CLIENTS live grants keeps
715 * them all and the cap simply stops applying to that remainder.
716 *
717 * @param array<string,array<string,mixed>> $state Full state (clients + grant buckets).
718 * @return array<string,array<string,mixed>> The clients array to store.
719 */
720 private static function prune_clients( array $state ): array {
721 $clients = $state['clients'];
722
723 $in_use = [];
724 foreach ( [ 'codes', 'tokens', 'refresh' ] as $bucket ) {
725 foreach ( $state[ $bucket ] as $entry ) {
726 if ( is_array( $entry ) && isset( $entry['client_id'] ) ) {
727 $in_use[ (string) $entry['client_id'] ] = true;
728 }
729 }
730 }
731
732 $now = time();
733 foreach ( $clients as $id => $client ) {
734 $created = isset( $client['created'] ) ? (int) $client['created'] : 0;
735 if ( ! isset( $in_use[ $id ] ) && $created + self::CLIENT_TTL < $now ) {
736 unset( $clients[ $id ] );
737 }
738 }
739
740 if ( count( $clients ) <= self::MAX_CLIENTS ) {
741 return $clients;
742 }
743
744 // Still over the cap — evict the oldest unused registrations.
745 $evictable = array_filter(
746 $clients,
747 static function ( $id ) use ( $in_use ) {
748 return ! isset( $in_use[ $id ] );
749 },
750 ARRAY_FILTER_USE_KEY
751 );
752 uasort(
753 $evictable,
754 static function ( $a, $b ) {
755 return ( isset( $a['created'] ) ? (int) $a['created'] : 0 ) <=> ( isset( $b['created'] ) ? (int) $b['created'] : 0 );
756 }
757 );
758 foreach ( array_keys( $evictable ) as $id ) {
759 if ( count( $clients ) <= self::MAX_CLIENTS ) {
760 break;
761 }
762 unset( $clients[ $id ] );
763 }
764
765 return $clients;
766 }
767
768 /**
769 * SHA-256 hash used to store tokens at rest.
770 *
771 * @param string $value Raw secret.
772 * @return string
773 */
774 private static function hash( string $value ): string {
775 return hash( 'sha256', $value );
776 }
777
778 /**
779 * BASE64URL(SHA256(verifier)) — the PKCE S256 transformation.
780 *
781 * @param string $verifier PKCE code verifier.
782 * @return string
783 */
784 private static function s256( string $verifier ): string {
785 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- base64url of the PKCE challenge, mandated by RFC 7636.
786 return rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' );
787 }
788
789 /**
790 * Constrain a requested scope to what we support. Defaults to `mcp`
791 * (read+write umbrella).
792 *
793 * @param string $requested Requested scope string.
794 * @return string
795 */
796 private static function normalize_scope( string $requested ): string {
797 $parts = preg_split( '/\s+/', trim( $requested ) );
798 $parts = is_array( $parts ) ? $parts : [];
799 $parts = array_values( array_intersect( $parts, self::SUPPORTED_SCOPES ) );
800 if ( empty( $parts ) ) {
801 return 'mcp';
802 }
803 return implode( ' ', $parts );
804 }
805
806 /**
807 * Whether a redirect_uri is structurally acceptable (http(s) or a
808 * native-client custom scheme).
809 *
810 * @param string $uri Candidate redirect URI.
811 * @return bool
812 */
813 private static function is_valid_redirect_uri( string $uri ): bool {
814 $uri = trim( $uri );
815 if ( '' === $uri ) {
816 return false;
817 }
818 return (bool) preg_match( '#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $uri );
819 }
820
821 /**
822 * Build a WP_Error whose data carries an OAuth 2.0 `error` code so the
823 * token route can render the RFC 6749 error body.
824 *
825 * @param string $code OAuth error code (invalid_grant, ...).
826 * @param string $message Human-readable description.
827 * @return \WP_Error
828 */
829 private static function oauth_error( string $code, string $message ): \WP_Error {
830 return new \WP_Error(
831 $code,
832 $message,
833 [
834 'status' => 400,
835 'error' => $code,
836 'error_description' => $message,
837 ]
838 );
839 }
840 }
841