PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.28.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.28.0
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 1.28.0, at includes/mcp/class-mcp-oauth.php

807 lines 27.2 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 // -- Discovery documents (RFC 8414 / RFC 9728) -----------------------
149
150 /**
151 * RFC 9728 protected-resource metadata — tells the client which
152 * authorization server(s) protect the MCP endpoint (this site).
153 *
154 * @return array<string,mixed>
155 */
156 public static function protected_resource_metadata(): array {
157 return [
158 'resource' => self::resource(),
159 'authorization_servers' => [ self::issuer() ],
160 'scopes_supported' => self::SUPPORTED_SCOPES,
161 'bearer_methods_supported' => [ 'header' ],
162 ];
163 }
164
165 /**
166 * RFC 8414 authorization-server metadata — the endpoint map + the
167 * capabilities we actually implement.
168 *
169 * @return array<string,mixed>
170 */
171 public static function authorization_server_metadata(): array {
172 return [
173 'issuer' => self::issuer(),
174 'authorization_endpoint' => self::authorize_url(),
175 'token_endpoint' => self::token_url(),
176 'registration_endpoint' => self::register_url(),
177 'scopes_supported' => self::SUPPORTED_SCOPES,
178 'response_types_supported' => [ 'code' ],
179 'grant_types_supported' => [ 'authorization_code', 'refresh_token' ],
180 'code_challenge_methods_supported' => [ 'S256' ],
181 'token_endpoint_auth_methods_supported' => [ 'none' ],
182 ];
183 }
184
185 // -- Dynamic client registration (RFC 7591) --------------------------
186
187 /**
188 * Register a public client. We accept the client's redirect_uris and
189 * mint a client_id (no secret — public clients rely on PKCE).
190 *
191 * @param array<string,mixed> $body Parsed JSON registration request.
192 * @return array<string,mixed>|\WP_Error
193 */
194 public static function register_client( array $body ) {
195 $redirect_uris = isset( $body['redirect_uris'] ) && is_array( $body['redirect_uris'] )
196 ? array_values( array_filter( array_map( 'strval', $body['redirect_uris'] ), [ self::class, 'is_valid_redirect_uri' ] ) )
197 : [];
198
199 if ( empty( $redirect_uris ) ) {
200 return new \WP_Error(
201 'invalid_redirect_uri',
202 __( 'At least one valid redirect_uri is required.', 'thinkrank' ),
203 [ 'status' => 400 ]
204 );
205 }
206
207 $name = isset( $body['client_name'] ) ? sanitize_text_field( (string) $body['client_name'] ) : 'MCP Client';
208 $client_id = 'trk_' . bin2hex( random_bytes( 16 ) );
209
210 $state = self::state();
211 $state['clients'][ $client_id ] = [
212 'redirect_uris' => $redirect_uris,
213 'name' => $name,
214 'created' => time(),
215 ];
216 $state['clients'] = self::prune_clients( $state );
217 self::save( $state );
218
219 return [
220 'client_id' => $client_id,
221 'client_id_issued_at' => time(),
222 'redirect_uris' => $redirect_uris,
223 'client_name' => $name,
224 'token_endpoint_auth_method' => 'none',
225 'grant_types' => [ 'authorization_code', 'refresh_token' ],
226 'response_types' => [ 'code' ],
227 ];
228 }
229
230 // -- Authorization endpoint ------------------------------------------
231
232 /**
233 * Validate an /authorize request's parameters WITHOUT issuing anything.
234 * Returns a sanitized param bag on success, or WP_Error on a protocol
235 * violation. The caller decides how to surface it (redirect vs error
236 * page) based on whether redirect_uri is trustworthy.
237 *
238 * @param array<string,string> $params Query params.
239 * @return array<string,string>|\WP_Error
240 */
241 public static function validate_authorize_request( array $params ) {
242 $client_id = isset( $params['client_id'] ) ? (string) $params['client_id'] : '';
243 $redirect_uri = isset( $params['redirect_uri'] ) ? (string) $params['redirect_uri'] : '';
244 $response_type = isset( $params['response_type'] ) ? (string) $params['response_type'] : '';
245 $challenge = isset( $params['code_challenge'] ) ? (string) $params['code_challenge'] : '';
246 $method = isset( $params['code_challenge_method'] ) ? (string) $params['code_challenge_method'] : '';
247 $scope = isset( $params['scope'] ) ? (string) $params['scope'] : 'mcp';
248 $state = isset( $params['state'] ) ? (string) $params['state'] : '';
249
250 $client = self::client( $client_id );
251 if ( null === $client ) {
252 return new \WP_Error( 'invalid_client', __( 'Unknown client_id.', 'thinkrank' ), [ 'status' => 400 ] );
253 }
254 if ( ! in_array( $redirect_uri, $client['redirect_uris'], true ) ) {
255 // redirect_uri mismatch must NOT redirect (open-redirect guard).
256 return new \WP_Error( 'invalid_redirect_uri', __( 'redirect_uri does not match a registered value.', 'thinkrank' ), [ 'status' => 400 ] );
257 }
258 if ( 'code' !== $response_type ) {
259 return new \WP_Error(
260 'unsupported_response_type',
261 __( 'Only response_type=code is supported.', 'thinkrank' ),
262 [
263 'status' => 400,
264 'redirectable' => true,
265 ]
266 );
267 }
268 // OAuth 2.1: PKCE S256 is mandatory for public clients.
269 if ( 'S256' !== $method || '' === $challenge ) {
270 return new \WP_Error(
271 'invalid_request',
272 __( 'PKCE with code_challenge_method=S256 is required.', 'thinkrank' ),
273 [
274 'status' => 400,
275 'redirectable' => true,
276 ]
277 );
278 }
279
280 return [
281 'client_id' => $client_id,
282 'client_name' => $client['name'],
283 'redirect_uri' => $redirect_uri,
284 'code_challenge' => $challenge,
285 'scope' => self::normalize_scope( $scope ),
286 'state' => $state,
287 ];
288 }
289
290 /**
291 * Issue an authorization code after the admin approves consent. Binds
292 * the code to the client, redirect_uri, PKCE challenge, granted scope,
293 * and the approving user. Single-use, 60 s TTL.
294 *
295 * @param array<string,string> $req Output of validate_authorize_request().
296 * @param int $user_id Approving admin user id.
297 * @return string The authorization code.
298 */
299 public static function issue_code( array $req, int $user_id ): string {
300 $code = bin2hex( random_bytes( 32 ) );
301 $state = self::state();
302 $state['codes'][ $code ] = [
303 'client_id' => $req['client_id'],
304 'redirect_uri' => $req['redirect_uri'],
305 'challenge' => $req['code_challenge'],
306 'scope' => $req['scope'],
307 'user_id' => $user_id,
308 'expires' => time() + self::CODE_TTL,
309 ];
310 self::save( $state );
311 return $code;
312 }
313
314 // -- Token endpoint --------------------------------------------------
315
316 /**
317 * Exchange an authorization code (+ PKCE verifier) for tokens, or a
318 * refresh token for a fresh access token.
319 *
320 * @param array<string,string> $body POST body params.
321 * @return array<string,mixed>|\WP_Error
322 */
323 public static function exchange_token( array $body ) {
324 $grant = isset( $body['grant_type'] ) ? (string) $body['grant_type'] : '';
325
326 if ( 'authorization_code' === $grant ) {
327 return self::grant_authorization_code( $body );
328 }
329 if ( 'refresh_token' === $grant ) {
330 return self::grant_refresh_token( $body );
331 }
332 return self::oauth_error( 'unsupported_grant_type', 'Unsupported grant_type.' );
333 }
334
335 /**
336 * authorization_code grant: verify the code + PKCE, mint tokens.
337 *
338 * @param array<string,string> $body POST body.
339 * @return array<string,mixed>|\WP_Error
340 */
341 private static function grant_authorization_code( array $body ) {
342 $code = isset( $body['code'] ) ? (string) $body['code'] : '';
343 $client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : '';
344 $redirect_uri = isset( $body['redirect_uri'] ) ? (string) $body['redirect_uri'] : '';
345 $verifier = isset( $body['code_verifier'] ) ? (string) $body['code_verifier'] : '';
346
347 $state = self::state();
348 if ( '' === $code || ! isset( $state['codes'][ $code ] ) ) {
349 return self::oauth_error( 'invalid_grant', 'Unknown or expired authorization code.' );
350 }
351 $entry = $state['codes'][ $code ];
352
353 // Single-use: remove immediately whether or not verification passes.
354 unset( $state['codes'][ $code ] );
355 self::save( $state );
356
357 if ( $entry['expires'] < time() ) {
358 return self::oauth_error( 'invalid_grant', 'Authorization code expired.' );
359 }
360 if ( ! hash_equals( (string) $entry['client_id'], $client_id ) ) {
361 return self::oauth_error( 'invalid_grant', 'client_id mismatch.' );
362 }
363 if ( ! hash_equals( (string) $entry['redirect_uri'], $redirect_uri ) ) {
364 return self::oauth_error( 'invalid_grant', 'redirect_uri mismatch.' );
365 }
366 // PKCE S256: BASE64URL(SHA256(verifier)) must equal the stored challenge.
367 if ( '' === $verifier || ! hash_equals( (string) $entry['challenge'], self::s256( $verifier ) ) ) {
368 return self::oauth_error( 'invalid_grant', 'PKCE verification failed.' );
369 }
370
371 return self::mint_tokens( (string) $entry['client_id'], (string) $entry['scope'], (int) $entry['user_id'] );
372 }
373
374 /**
375 * refresh_token grant: rotate the refresh token, issue a fresh access
376 * token. The old refresh + its access token are revoked.
377 *
378 * @param array<string,string> $body POST body.
379 * @return array<string,mixed>|\WP_Error
380 */
381 private static function grant_refresh_token( array $body ) {
382 $refresh = isset( $body['refresh_token'] ) ? (string) $body['refresh_token'] : '';
383 $client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : '';
384
385 $state = self::state();
386 $rhash = self::hash( $refresh );
387 if ( '' === $refresh || ! isset( $state['refresh'][ $rhash ] ) ) {
388 return self::oauth_error( 'invalid_grant', 'Unknown refresh token.' );
389 }
390 $entry = $state['refresh'][ $rhash ];
391 if ( '' !== $client_id && ! hash_equals( (string) $entry['client_id'], $client_id ) ) {
392 return self::oauth_error( 'invalid_grant', 'client_id mismatch.' );
393 }
394
395 // Rotate: drop old refresh + its access token.
396 unset( $state['refresh'][ $rhash ] );
397 if ( isset( $entry['access_hash'] ) ) {
398 unset( $state['tokens'][ $entry['access_hash'] ] );
399 }
400 self::save( $state );
401
402 return self::mint_tokens( (string) $entry['client_id'], (string) $entry['scope'], (int) $entry['user_id'] );
403 }
404
405 /**
406 * Mint an access + refresh token pair, store them hashed, and return
407 * the RFC 6749 token response with the raw values.
408 *
409 * @param string $client_id Client id.
410 * @param string $scope Granted scope string.
411 * @param int $user_id Resource-owner user id.
412 * @return array<string,mixed>
413 */
414 private static function mint_tokens( string $client_id, string $scope, int $user_id ): array {
415 $access = bin2hex( random_bytes( 32 ) );
416 $refresh = bin2hex( random_bytes( 32 ) );
417 $ahash = self::hash( $access );
418 $rhash = self::hash( $refresh );
419
420 $state = self::state();
421 $state['tokens'][ $ahash ] = [
422 'client_id' => $client_id,
423 'scope' => $scope,
424 'user_id' => $user_id,
425 'expires' => time() + self::ACCESS_TTL,
426 'refresh' => $rhash,
427 ];
428 $state['refresh'][ $rhash ] = [
429 'access_hash' => $ahash,
430 'client_id' => $client_id,
431 'scope' => $scope,
432 'user_id' => $user_id,
433 'expires' => time() + self::REFRESH_TTL,
434 ];
435 self::save( $state );
436
437 return [
438 'access_token' => $access,
439 'token_type' => 'Bearer',
440 'expires_in' => self::ACCESS_TTL,
441 'refresh_token' => $refresh,
442 'scope' => $scope,
443 ];
444 }
445
446 // -- Access-token validation (called by Mcp_Server) ------------------
447
448 /**
449 * Validate a bearer access token presented to the MCP endpoint.
450 * Returns the token's grant record (scope, user_id, client_id) when
451 * valid + unexpired, or null. Constant-time via hashed lookup.
452 *
453 * @param string $token Raw access token from the Authorization header.
454 * @return array{client_id:string,scope:string,user_id:int}|null
455 */
456 public static function validate_token( string $token ): ?array {
457 if ( '' === $token ) {
458 return null;
459 }
460 $state = self::state();
461 $hash = self::hash( $token );
462 if ( ! isset( $state['tokens'][ $hash ] ) ) {
463 return null;
464 }
465 $entry = $state['tokens'][ $hash ];
466 if ( (int) $entry['expires'] < time() ) {
467 return null;
468 }
469
470 // Record activity against the owning client so the "Connected AI apps"
471 // list can show a last-used date. Throttled + stored on the client
472 // record so it survives access-token rotation.
473 $client_id = (string) $entry['client_id'];
474 $now = time();
475 if ( isset( $state['clients'][ $client_id ] ) && is_array( $state['clients'][ $client_id ] ) ) {
476 $last = isset( $state['clients'][ $client_id ]['last_used'] ) ? (int) $state['clients'][ $client_id ]['last_used'] : 0;
477 if ( $now - $last >= self::LAST_USED_THROTTLE ) {
478 $state['clients'][ $client_id ]['last_used'] = $now;
479 self::save( $state );
480 }
481 }
482
483 return [
484 'client_id' => $client_id,
485 'scope' => (string) $entry['scope'],
486 'user_id' => (int) $entry['user_id'],
487 ];
488 }
489
490 /**
491 * Whether a granted scope string is read-only. `mcp` is the umbrella
492 * scope that grants read+write, so only a grant that carries NEITHER
493 * `write` NOR `mcp` — i.e. `read` alone — is read-only.
494 *
495 * @param string $scope Space-separated scope string.
496 * @return bool
497 */
498 public static function scope_is_read_only( string $scope ): bool {
499 $parts = preg_split( '/\s+/', trim( $scope ) );
500 $parts = is_array( $parts ) ? $parts : [];
501 return ! in_array( 'write', $parts, true ) && ! in_array( 'mcp', $parts, true );
502 }
503
504 /**
505 * Revoke every OAuth token + client (used by disconnect).
506 *
507 * @return void
508 */
509 public static function revoke_all(): void {
510 delete_option( self::OPTION );
511 }
512
513 /**
514 * The OAuth clients currently holding a live grant, for the "Connected AI
515 * apps" list. A client counts as connected while it holds an unexpired
516 * refresh token (the durable 30-day grant) or access token; a client that
517 * only registered but never completed consent is excluded. One entry per
518 * client_id, newest connection first.
519 *
520 * @return array<int,array{client_id:string,name:string,scope:string,read_only:bool,user_id:int,connected_at:int,last_used:int}>
521 */
522 public static function connected_apps(): array {
523 $state = self::state();
524
525 // Collect the scope + approving user per active client. Refresh tokens
526 // are the durable grant, so prefer them; fall back to access tokens.
527 $active = [];
528 foreach ( [ 'refresh', 'tokens' ] as $bucket ) {
529 foreach ( $state[ $bucket ] as $entry ) {
530 $cid = isset( $entry['client_id'] ) ? (string) $entry['client_id'] : '';
531 if ( '' === $cid || isset( $active[ $cid ] ) ) {
532 continue;
533 }
534 $active[ $cid ] = [
535 'scope' => isset( $entry['scope'] ) ? (string) $entry['scope'] : 'mcp',
536 'user_id' => isset( $entry['user_id'] ) ? (int) $entry['user_id'] : 0,
537 ];
538 }
539 }
540
541 $apps = [];
542 foreach ( $active as $cid => $info ) {
543 $client = isset( $state['clients'][ $cid ] ) && is_array( $state['clients'][ $cid ] ) ? $state['clients'][ $cid ] : [];
544 $apps[] = [
545 'client_id' => $cid,
546 'name' => isset( $client['name'] ) ? (string) $client['name'] : __( 'MCP Client', 'thinkrank' ),
547 'scope' => $info['scope'],
548 'read_only' => self::scope_is_read_only( $info['scope'] ),
549 'user_id' => $info['user_id'],
550 'connected_at' => isset( $client['created'] ) ? (int) $client['created'] : 0,
551 'last_used' => isset( $client['last_used'] ) ? (int) $client['last_used'] : 0,
552 ];
553 }
554
555 // Newest connection first.
556 usort(
557 $apps,
558 static function ( array $a, array $b ): int {
559 return $b['connected_at'] <=> $a['connected_at'];
560 }
561 );
562
563 return $apps;
564 }
565
566 /**
567 * Revoke a single OAuth client's ACCESS — drops its access tokens, refresh
568 * tokens, and any pending codes, cutting that one app off immediately while
569 * leaving every other connection intact. It disappears from
570 * connected_apps() (which keys off live tokens), so the UI shows it gone.
571 *
572 * The client's dynamic registration (its client_id + redirect_uris) is
573 * intentionally KEPT: MCP clients such as ChatGPT cache the client_id from
574 * their first registration and reuse it on reconnect, hitting /authorize
575 * with that id rather than registering afresh. If we deleted the
576 * registration, that reconnect would fail with "Unknown client_id". Keeping
577 * it lets the app re-authorize — which still requires fresh admin consent
578 * (and mints brand-new tokens), so revocation loses nothing.
579 *
580 * @param string $client_id The client whose access to revoke.
581 * @return bool True if any live grant was removed.
582 */
583 public static function revoke_client( string $client_id ): bool {
584 if ( '' === $client_id ) {
585 return false;
586 }
587 $state = self::state();
588 $removed = false;
589
590 foreach ( [ 'tokens', 'refresh', 'codes' ] as $bucket ) {
591 foreach ( $state[ $bucket ] as $key => $entry ) {
592 if ( isset( $entry['client_id'] ) && (string) $entry['client_id'] === $client_id ) {
593 unset( $state[ $bucket ][ $key ] );
594 $removed = true;
595 }
596 }
597 }
598
599 if ( $removed ) {
600 self::save( $state );
601 }
602 return $removed;
603 }
604
605 // -- State + helpers -------------------------------------------------
606
607 /**
608 * Load state with defaults, pruning expired codes/tokens/refresh
609 * entries on the way out so the option can't grow unbounded.
610 *
611 * @return array<string,array<string,mixed>>
612 */
613 private static function state(): array {
614 $stored = get_option( self::OPTION, [] );
615 if ( ! is_array( $stored ) ) {
616 $stored = [];
617 }
618 $state = [
619 'clients' => isset( $stored['clients'] ) && is_array( $stored['clients'] ) ? $stored['clients'] : [],
620 'codes' => isset( $stored['codes'] ) && is_array( $stored['codes'] ) ? $stored['codes'] : [],
621 'tokens' => isset( $stored['tokens'] ) && is_array( $stored['tokens'] ) ? $stored['tokens'] : [],
622 'refresh' => isset( $stored['refresh'] ) && is_array( $stored['refresh'] ) ? $stored['refresh'] : [],
623 ];
624
625 $now = time();
626 foreach ( $state['codes'] as $k => $v ) {
627 if ( ! isset( $v['expires'] ) || $v['expires'] < $now ) {
628 unset( $state['codes'][ $k ] );
629 }
630 }
631 foreach ( $state['tokens'] as $k => $v ) {
632 if ( ! isset( $v['expires'] ) || $v['expires'] < $now ) {
633 unset( $state['tokens'][ $k ] );
634 }
635 }
636 foreach ( $state['refresh'] as $k => $v ) {
637 if ( isset( $v['expires'] ) && $v['expires'] < $now ) {
638 unset( $state['refresh'][ $k ] );
639 }
640 }
641 return $state;
642 }
643
644 /**
645 * Persist state (autoload off — hot-write, request-scoped option).
646 *
647 * @param array<string,mixed> $state State to persist.
648 * @return void
649 */
650 private static function save( array $state ): void {
651 update_option( self::OPTION, $state, false );
652 }
653
654 /**
655 * Look up a registered client.
656 *
657 * @param string $client_id Client id.
658 * @return array{redirect_uris:string[],name:string,created:int}|null
659 */
660 private static function client( string $client_id ): ?array {
661 if ( '' === $client_id ) {
662 return null;
663 }
664 $clients = self::state()['clients'];
665 if ( ! isset( $clients[ $client_id ] ) || ! is_array( $clients[ $client_id ] ) ) {
666 return null;
667 }
668 $c = $clients[ $client_id ];
669 return [
670 'redirect_uris' => isset( $c['redirect_uris'] ) && is_array( $c['redirect_uris'] ) ? array_map( 'strval', $c['redirect_uris'] ) : [],
671 'name' => isset( $c['name'] ) ? (string) $c['name'] : 'MCP Client',
672 'created' => isset( $c['created'] ) ? (int) $c['created'] : 0,
673 ];
674 }
675
676 /**
677 * Bound the registered-client list. Drops abandoned registrations past
678 * CLIENT_TTL first, then — if still over MAX_CLIENTS — the oldest of what
679 * is left. A client referenced by a live code, access token, or refresh
680 * token is NEVER dropped: evicting one would break a working connection,
681 * so a site legitimately holding more than MAX_CLIENTS live grants keeps
682 * them all and the cap simply stops applying to that remainder.
683 *
684 * @param array<string,array<string,mixed>> $state Full state (clients + grant buckets).
685 * @return array<string,array<string,mixed>> The clients array to store.
686 */
687 private static function prune_clients( array $state ): array {
688 $clients = $state['clients'];
689
690 $in_use = [];
691 foreach ( [ 'codes', 'tokens', 'refresh' ] as $bucket ) {
692 foreach ( $state[ $bucket ] as $entry ) {
693 if ( is_array( $entry ) && isset( $entry['client_id'] ) ) {
694 $in_use[ (string) $entry['client_id'] ] = true;
695 }
696 }
697 }
698
699 $now = time();
700 foreach ( $clients as $id => $client ) {
701 $created = isset( $client['created'] ) ? (int) $client['created'] : 0;
702 if ( ! isset( $in_use[ $id ] ) && $created + self::CLIENT_TTL < $now ) {
703 unset( $clients[ $id ] );
704 }
705 }
706
707 if ( count( $clients ) <= self::MAX_CLIENTS ) {
708 return $clients;
709 }
710
711 // Still over the cap — evict the oldest unused registrations.
712 $evictable = array_filter(
713 $clients,
714 static function ( $id ) use ( $in_use ) {
715 return ! isset( $in_use[ $id ] );
716 },
717 ARRAY_FILTER_USE_KEY
718 );
719 uasort(
720 $evictable,
721 static function ( $a, $b ) {
722 return ( isset( $a['created'] ) ? (int) $a['created'] : 0 ) <=> ( isset( $b['created'] ) ? (int) $b['created'] : 0 );
723 }
724 );
725 foreach ( array_keys( $evictable ) as $id ) {
726 if ( count( $clients ) <= self::MAX_CLIENTS ) {
727 break;
728 }
729 unset( $clients[ $id ] );
730 }
731
732 return $clients;
733 }
734
735 /**
736 * SHA-256 hash used to store tokens at rest.
737 *
738 * @param string $value Raw secret.
739 * @return string
740 */
741 private static function hash( string $value ): string {
742 return hash( 'sha256', $value );
743 }
744
745 /**
746 * BASE64URL(SHA256(verifier)) — the PKCE S256 transformation.
747 *
748 * @param string $verifier PKCE code verifier.
749 * @return string
750 */
751 private static function s256( string $verifier ): string {
752 return rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' );
753 }
754
755 /**
756 * Constrain a requested scope to what we support. Defaults to `mcp`
757 * (read+write umbrella).
758 *
759 * @param string $requested Requested scope string.
760 * @return string
761 */
762 private static function normalize_scope( string $requested ): string {
763 $parts = preg_split( '/\s+/', trim( $requested ) );
764 $parts = is_array( $parts ) ? $parts : [];
765 $parts = array_values( array_intersect( $parts, self::SUPPORTED_SCOPES ) );
766 if ( empty( $parts ) ) {
767 return 'mcp';
768 }
769 return implode( ' ', $parts );
770 }
771
772 /**
773 * Whether a redirect_uri is structurally acceptable (http(s) or a
774 * native-client custom scheme).
775 *
776 * @param string $uri Candidate redirect URI.
777 * @return bool
778 */
779 private static function is_valid_redirect_uri( string $uri ): bool {
780 $uri = trim( $uri );
781 if ( '' === $uri ) {
782 return false;
783 }
784 return (bool) preg_match( '#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $uri );
785 }
786
787 /**
788 * Build a WP_Error whose data carries an OAuth 2.0 `error` code so the
789 * token route can render the RFC 6749 error body.
790 *
791 * @param string $code OAuth error code (invalid_grant, ...).
792 * @param string $message Human-readable description.
793 * @return \WP_Error
794 */
795 private static function oauth_error( string $code, string $message ): \WP_Error {
796 return new \WP_Error(
797 $code,
798 $message,
799 [
800 'status' => 400,
801 'error' => $code,
802 'error_description' => $message,
803 ]
804 );
805 }
806 }
807