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

1,106 lines 37.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 * - Authorization codes and access/refresh tokens stored only as SHA-256
21 * hashes; the raw value exists solely in the response that hands it out.
22 * Constant-time comparison.
23 * - Tokens carry the read/write scope model; a read-only grant refuses
24 * every write tool, exactly like a read-only pairing token.
25 *
26 * State lives in the `thinkrank_mcp_oauth` option (clients, codes, tokens,
27 * refresh — keyed by id or sha256 of the secret); expired entries are pruned
28 * lazily on every read.
29 *
30 * @package ThinkRank\Mcp
31 */
32
33 declare(strict_types=1);
34
35 namespace ThinkRank\Mcp;
36
37 if ( ! defined( 'ABSPATH' ) ) {
38 exit; // Exit if accessed directly.
39 }
40
41 /**
42 * Minimal OAuth 2.1 authorization server for the ThinkRank MCP endpoint.
43 */
44 final class Mcp_OAuth {
45
46 /**
47 * Option key holding all OAuth server state.
48 */
49 public const OPTION = 'thinkrank_mcp_oauth';
50
51 /**
52 * Per-client "last used" stamps, kept OUT of self::OPTION.
53 *
54 * Every authenticated MCP call used to stamp this inside the credential
55 * option, which meant ordinary tool traffic did a read-modify-write of the
56 * whole client/code/token/refresh store. A tool call overlapping a token
57 * refresh could write back its stale snapshot and erase a token the server
58 * had just minted — the client then holds an access token the server has
59 * no record of, and every later call 401s (#485).
60 *
61 * A cosmetic timestamp has no business sharing a store with credentials,
62 * so it lives in its own option. Losing a race here costs one stamp.
63 *
64 * @since 2.1.0
65 */
66 public const LAST_USED_OPTION = 'thinkrank_mcp_oauth_last_used';
67
68 /**
69 * Authorization-code lifetime (seconds). Deliberately short.
70 */
71 private const CODE_TTL = 60;
72
73 /**
74 * Access-token lifetime (seconds) — 1 hour, refreshable.
75 */
76 private const ACCESS_TTL = 3600;
77
78 /**
79 * Refresh-token lifetime (seconds) — 30 days.
80 */
81 private const REFRESH_TTL = 2592000;
82
83 /**
84 * Scopes we advertise + honor. `mcp` is the umbrella scope MCP clients request.
85 */
86 private const SUPPORTED_SCOPES = [ 'mcp', 'read', 'write' ];
87
88 /**
89 * Throttle window (seconds) for per-client last-used writes — at most one
90 * option write per minute per client, so a busy connector can't turn every
91 * MCP call into a database write.
92 */
93 private const LAST_USED_THROTTLE = 60;
94
95 /**
96 * Seconds to wait for the advisory lock before giving up and proceeding
97 * unguarded. Short: these are user-facing OAuth endpoints, and waiting is
98 * worse than the small race we are narrowing.
99 *
100 * @since 2.1.0
101 */
102 private const LOCK_TIMEOUT = 3;
103
104 /**
105 * Nesting depth of mutate() on this request, so a mutation that calls
106 * another (grant -> mint) releases the lock once, at the outermost exit.
107 *
108 * @since 2.1.0
109 * @var int
110 */
111 private static int $lock_depth = 0;
112
113 /**
114 * How many registered clients to keep. RFC 7591 registration is open by
115 * necessity — a client must register BEFORE it can hold any credential —
116 * so without a cap anyone on the internet can grow this option without
117 * bound, and every state() read pays for it. Clients holding a live token
118 * are never evicted, so the cap only ever discards abandoned registrations.
119 */
120 private const MAX_CLIENTS = 50;
121
122 /**
123 * How long an unused client registration survives (seconds). A client that
124 * registers and never completes the flow is abandoned; real ones exchange
125 * a code within a minute.
126 */
127 private const CLIENT_TTL = 86400; // 24 hours.
128
129 // -- URLs ------------------------------------------------------------
130
131 /**
132 * The OAuth issuer identifier. Path-based (RFC 8414 §2 allows an issuer
133 * with a path component): using the MCP endpoint URL itself means clients
134 * derive the path-suffixed well-known URLs
135 * (/.well-known/oauth-authorization-server/thinkrank/mcp), which stay
136 * specific to ThinkRank even when another plugin runs its own MCP OAuth
137 * server at the same site root.
138 *
139 * @return string
140 */
141 public static function issuer(): string {
142 return untrailingslashit( home_url( '/thinkrank/mcp' ) );
143 }
144
145 /**
146 * The protected resource identifier — the MCP endpoint URL.
147 *
148 * @return string
149 */
150 public static function resource(): string {
151 return Mcp_Pairing::site_endpoint();
152 }
153
154 /**
155 * The browser-facing authorize page. Served OUTSIDE the REST API (via a
156 * rewrite rule) so standard cookie auth works after the wp-login
157 * round-trip — a REST route would see the cookie without a nonce and
158 * treat the admin as logged-out, looping back to login.
159 *
160 * @return string
161 */
162 public static function authorize_url(): string {
163 return home_url( '/thinkrank/authorize' );
164 }
165
166 /**
167 * The token endpoint URL.
168 *
169 * @return string
170 */
171 public static function token_url(): string {
172 return rest_url( 'thinkrank/v1/mcp/oauth/token' );
173 }
174
175 /**
176 * The dynamic client registration endpoint URL.
177 *
178 * @return string
179 */
180 public static function register_url(): string {
181 return rest_url( 'thinkrank/v1/mcp/oauth/register' );
182 }
183
184 /**
185 * The protected-resource metadata URL the 401 challenge advertises.
186 *
187 * REST-served, NOT the RFC 9728 path-insert form. The path-insert URL
188 * lives under the site root's /.well-known/ directory, and some hosts
189 * (SiteGround shared hosting confirmed, see #374) resolve that directory
190 * at their Nginx edge as physical files — the request 404s before
191 * WordPress runs, and the connecting client reports "server does not
192 * implement OAuth" on its very first fetch. The challenge parameter is an
193 * explicit pointer (that is what it exists for), so pointing it at a
194 * /wp-json/ URL is spec-clean and reaches WordPress on every host and
195 * permalink structure. The well-known variants stay served for clients
196 * that ignore the pointer and derive the URL themselves.
197 *
198 * @return string
199 */
200 public static function resource_metadata_url(): string {
201 /**
202 * Filter the resource_metadata URL advertised in the WWW-Authenticate
203 * challenge, for hosts where neither the REST route nor the
204 * /.well-known/ forms are reachable and the metadata must be served
205 * from somewhere custom (a CDN, a static file, another domain).
206 *
207 * @since 1.32.0
208 *
209 * @param string $url The advertised protected-resource metadata URL.
210 */
211 return apply_filters(
212 'thinkrank_mcp_resource_metadata_url',
213 rest_url( 'thinkrank/v1/mcp/oauth/protected-resource' )
214 );
215 }
216
217 // -- Discovery documents (RFC 8414 / RFC 9728) -----------------------
218
219 /**
220 * RFC 9728 protected-resource metadata — tells the client which
221 * authorization server(s) protect the MCP endpoint (this site).
222 *
223 * @return array<string,mixed>
224 */
225 public static function protected_resource_metadata(): array {
226 return [
227 'resource' => self::resource(),
228 'authorization_servers' => [ self::issuer() ],
229 'scopes_supported' => self::SUPPORTED_SCOPES,
230 'bearer_methods_supported' => [ 'header' ],
231 ];
232 }
233
234 /**
235 * RFC 8414 authorization-server metadata — the endpoint map + the
236 * capabilities we actually implement.
237 *
238 * @return array<string,mixed>
239 */
240 public static function authorization_server_metadata(): array {
241 return [
242 'issuer' => self::issuer(),
243 'authorization_endpoint' => self::authorize_url(),
244 'token_endpoint' => self::token_url(),
245 'registration_endpoint' => self::register_url(),
246 'scopes_supported' => self::SUPPORTED_SCOPES,
247 'response_types_supported' => [ 'code' ],
248 'grant_types_supported' => [ 'authorization_code', 'refresh_token' ],
249 'code_challenge_methods_supported' => [ 'S256' ],
250 'token_endpoint_auth_methods_supported' => [ 'none' ],
251 ];
252 }
253
254 // -- Dynamic client registration (RFC 7591) --------------------------
255
256 /**
257 * Register a public client. We accept the client's redirect_uris and
258 * mint a client_id (no secret — public clients rely on PKCE).
259 *
260 * @param array<string,mixed> $body Parsed JSON registration request.
261 * @return array<string,mixed>|\WP_Error
262 */
263 public static function register_client( array $body ) {
264 $redirect_uris = isset( $body['redirect_uris'] ) && is_array( $body['redirect_uris'] )
265 ? array_values( array_filter( array_map( 'strval', $body['redirect_uris'] ), [ self::class, 'is_valid_redirect_uri' ] ) )
266 : [];
267
268 if ( empty( $redirect_uris ) ) {
269 return new \WP_Error(
270 'invalid_redirect_uri',
271 __( 'At least one valid redirect_uri is required.', 'thinkrank' ),
272 [ 'status' => 400 ]
273 );
274 }
275
276 $name = isset( $body['client_name'] ) ? sanitize_text_field( (string) $body['client_name'] ) : 'MCP Client';
277 $client_id = 'trk_' . bin2hex( random_bytes( 16 ) );
278
279 self::mutate(
280 static function ( array &$state ) use ( $client_id, $redirect_uris, $name ): void {
281 $state['clients'][ $client_id ] = [
282 'redirect_uris' => $redirect_uris,
283 'name' => $name,
284 'created' => time(),
285 ];
286 $state['clients'] = self::prune_clients( $state );
287 }
288 );
289
290 return [
291 'client_id' => $client_id,
292 'client_id_issued_at' => time(),
293 'redirect_uris' => $redirect_uris,
294 'client_name' => $name,
295 'token_endpoint_auth_method' => 'none',
296 'grant_types' => [ 'authorization_code', 'refresh_token' ],
297 'response_types' => [ 'code' ],
298 ];
299 }
300
301 // -- Authorization endpoint ------------------------------------------
302
303 /**
304 * Validate an /authorize request's parameters WITHOUT issuing anything.
305 * Returns a sanitized param bag on success, or WP_Error on a protocol
306 * violation. The caller decides how to surface it (redirect vs error
307 * page) based on whether redirect_uri is trustworthy.
308 *
309 * @param array<string,string> $params Query params.
310 * @return array<string,string>|\WP_Error
311 */
312 public static function validate_authorize_request( array $params ) {
313 $client_id = isset( $params['client_id'] ) ? (string) $params['client_id'] : '';
314 $redirect_uri = isset( $params['redirect_uri'] ) ? (string) $params['redirect_uri'] : '';
315 $response_type = isset( $params['response_type'] ) ? (string) $params['response_type'] : '';
316 $challenge = isset( $params['code_challenge'] ) ? (string) $params['code_challenge'] : '';
317 $method = isset( $params['code_challenge_method'] ) ? (string) $params['code_challenge_method'] : '';
318 $scope = isset( $params['scope'] ) ? (string) $params['scope'] : 'mcp';
319 $state = isset( $params['state'] ) ? (string) $params['state'] : '';
320
321 $client = self::client( $client_id );
322 if ( null === $client ) {
323 return new \WP_Error( 'invalid_client', __( 'Unknown client_id.', 'thinkrank' ), [ 'status' => 400 ] );
324 }
325 if ( ! in_array( $redirect_uri, $client['redirect_uris'], true ) ) {
326 // redirect_uri mismatch must NOT redirect (open-redirect guard).
327 return new \WP_Error( 'invalid_redirect_uri', __( 'redirect_uri does not match a registered value.', 'thinkrank' ), [ 'status' => 400 ] );
328 }
329 if ( 'code' !== $response_type ) {
330 return new \WP_Error(
331 'unsupported_response_type',
332 __( 'Only response_type=code is supported.', 'thinkrank' ),
333 [
334 'status' => 400,
335 'redirectable' => true,
336 ]
337 );
338 }
339 // OAuth 2.1: PKCE S256 is mandatory for public clients.
340 if ( 'S256' !== $method || '' === $challenge ) {
341 return new \WP_Error(
342 'invalid_request',
343 __( 'PKCE with code_challenge_method=S256 is required.', 'thinkrank' ),
344 [
345 'status' => 400,
346 'redirectable' => true,
347 ]
348 );
349 }
350 // The challenge reaches us verbatim now (#487), so it is checked
351 // against its own character set rather than cleaned as display text.
352 // RFC 7636 unreserved base64url; an S256 challenge is 43 characters,
353 // the wider bound leaves room for a client that pads.
354 if ( ! preg_match( '/^[A-Za-z0-9\-._~]{43,128}$/', $challenge ) ) {
355 return new \WP_Error(
356 'invalid_request',
357 __( 'code_challenge is not a valid S256 challenge.', 'thinkrank' ),
358 [
359 'status' => 400,
360 'redirectable' => true,
361 ]
362 );
363 }
364
365 return [
366 'client_id' => $client_id,
367 'client_name' => $client['name'],
368 'redirect_uri' => $redirect_uri,
369 'code_challenge' => $challenge,
370 'scope' => self::normalize_scope( $scope ),
371 'state' => $state,
372 ];
373 }
374
375 /**
376 * Issue an authorization code after the admin approves consent. Binds
377 * the code to the client, redirect_uri, PKCE challenge, granted scope,
378 * and the approving user. Single-use, 60 s TTL.
379 *
380 * @param array<string,string> $req Output of validate_authorize_request().
381 * @param int $user_id Approving admin user id.
382 * @return string The authorization code.
383 */
384 public static function issue_code( array $req, int $user_id ): string {
385 $code = bin2hex( random_bytes( 32 ) );
386
387 // Keyed by hash, like access and refresh tokens. The authorization
388 // code is a bearer credential too, and this file's own contract says
389 // the raw value exists solely in the response that hands it out — the
390 // code was the one exception (#488). The exposure is small (60 s TTL,
391 // single use, bound to client_id + redirect_uri + PKCE) but #396 made
392 // exactly that argument about the pairing token and still hashed it.
393 self::mutate(
394 static function ( array &$state ) use ( $code, $req, $user_id ): void {
395 $state['codes'][ self::hash( $code ) ] = [
396 'client_id' => $req['client_id'],
397 'redirect_uri' => $req['redirect_uri'],
398 'challenge' => $req['code_challenge'],
399 'scope' => $req['scope'],
400 'user_id' => $user_id,
401 'expires' => time() + self::CODE_TTL,
402 ];
403 }
404 );
405
406 return $code;
407 }
408
409 // -- Token endpoint --------------------------------------------------
410
411 /**
412 * Exchange an authorization code (+ PKCE verifier) for tokens, or a
413 * refresh token for a fresh access token.
414 *
415 * @param array<string,string> $body POST body params.
416 * @return array<string,mixed>|\WP_Error
417 */
418 public static function exchange_token( array $body ) {
419 $grant = isset( $body['grant_type'] ) ? (string) $body['grant_type'] : '';
420
421 if ( 'authorization_code' === $grant ) {
422 return self::grant_authorization_code( $body );
423 }
424 if ( 'refresh_token' === $grant ) {
425 return self::grant_refresh_token( $body );
426 }
427 return self::oauth_error( 'unsupported_grant_type', 'Unsupported grant_type.' );
428 }
429
430 /**
431 * authorization_code grant: verify the code + PKCE, mint tokens.
432 *
433 * @param array<string,string> $body POST body.
434 * @return array<string,mixed>|\WP_Error
435 */
436 private static function grant_authorization_code( array $body ) {
437 $code = isset( $body['code'] ) ? (string) $body['code'] : '';
438 $client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : '';
439 $redirect_uri = isset( $body['redirect_uri'] ) ? (string) $body['redirect_uri'] : '';
440 $verifier = isset( $body['code_verifier'] ) ? (string) $body['code_verifier'] : '';
441
442 // Claim the code and remove it in one guarded read-modify-write.
443 // Single-use has to mean single-use: looking it up, saving the removal,
444 // and letting a concurrent writer restore its pre-removal snapshot put
445 // a spent code back in the store (#485). Looked up by hash, because
446 // that is how issue_code() stores it (#488).
447 $entry = self::mutate(
448 static function ( array &$state ) use ( $code ) {
449 $chash = self::hash( $code );
450
451 if ( '' === $code || ! isset( $state['codes'][ $chash ] ) ) {
452 return null;
453 }
454
455 $claimed = $state['codes'][ $chash ];
456
457 // Removed whether or not verification below passes.
458 unset( $state['codes'][ $chash ] );
459
460 return $claimed;
461 }
462 );
463
464 if ( null === $entry ) {
465 return self::oauth_error( 'invalid_grant', 'Unknown or expired authorization code.' );
466 }
467
468 if ( $entry['expires'] < time() ) {
469 return self::oauth_error( 'invalid_grant', 'Authorization code expired.' );
470 }
471 if ( ! hash_equals( (string) $entry['client_id'], $client_id ) ) {
472 return self::oauth_error( 'invalid_grant', 'client_id mismatch.' );
473 }
474 if ( ! hash_equals( (string) $entry['redirect_uri'], $redirect_uri ) ) {
475 return self::oauth_error( 'invalid_grant', 'redirect_uri mismatch.' );
476 }
477 // PKCE S256: BASE64URL(SHA256(verifier)) must equal the stored challenge.
478 if ( '' === $verifier || ! hash_equals( (string) $entry['challenge'], self::s256( $verifier ) ) ) {
479 return self::oauth_error( 'invalid_grant', 'PKCE verification failed.' );
480 }
481
482 return self::mint_tokens( (string) $entry['client_id'], (string) $entry['scope'], (int) $entry['user_id'] );
483 }
484
485 /**
486 * refresh_token grant: rotate the refresh token, issue a fresh access
487 * token. The old refresh + its access token are revoked.
488 *
489 * @param array<string,string> $body POST body.
490 * @return array<string,mixed>|\WP_Error
491 */
492 private static function grant_refresh_token( array $body ) {
493 $refresh = isset( $body['refresh_token'] ) ? (string) $body['refresh_token'] : '';
494 $client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : '';
495
496 $rhash = self::hash( $refresh );
497
498 // Look up and rotate under one guard. A mismatched client_id must not
499 // consume the token, so the check happens inside the mutation.
500 $claim = self::mutate(
501 static function ( array &$state ) use ( $refresh, $rhash, $client_id ): array {
502 if ( '' === $refresh || ! isset( $state['refresh'][ $rhash ] ) ) {
503 return [ 'error' => 'Unknown refresh token.' ];
504 }
505
506 $entry = $state['refresh'][ $rhash ];
507
508 if ( '' !== $client_id && ! hash_equals( (string) $entry['client_id'], $client_id ) ) {
509 return [ 'error' => 'client_id mismatch.' ];
510 }
511
512 // Rotate: drop old refresh + its access token.
513 unset( $state['refresh'][ $rhash ] );
514 if ( isset( $entry['access_hash'] ) ) {
515 unset( $state['tokens'][ $entry['access_hash'] ] );
516 }
517
518 return [ 'entry' => $entry ];
519 }
520 );
521
522 if ( isset( $claim['error'] ) ) {
523 return self::oauth_error( 'invalid_grant', (string) $claim['error'] );
524 }
525
526 $entry = $claim['entry'];
527
528 return self::mint_tokens( (string) $entry['client_id'], (string) $entry['scope'], (int) $entry['user_id'] );
529 }
530
531 /**
532 * Mint an access + refresh token pair, store them hashed, and return
533 * the RFC 6749 token response with the raw values.
534 *
535 * @param string $client_id Client id.
536 * @param string $scope Granted scope string.
537 * @param int $user_id Resource-owner user id.
538 * @return array<string,mixed>
539 */
540 private static function mint_tokens( string $client_id, string $scope, int $user_id ): array {
541 $access = bin2hex( random_bytes( 32 ) );
542 $refresh = bin2hex( random_bytes( 32 ) );
543 $ahash = self::hash( $access );
544 $rhash = self::hash( $refresh );
545
546 self::mutate(
547 static function ( array &$state ) use ( $ahash, $rhash, $client_id, $scope, $user_id ): void {
548 $state['tokens'][ $ahash ] = [
549 'client_id' => $client_id,
550 'scope' => $scope,
551 'user_id' => $user_id,
552 'expires' => time() + self::ACCESS_TTL,
553 'refresh' => $rhash,
554 ];
555 $state['refresh'][ $rhash ] = [
556 'access_hash' => $ahash,
557 'client_id' => $client_id,
558 'scope' => $scope,
559 'user_id' => $user_id,
560 'expires' => time() + self::REFRESH_TTL,
561 ];
562 }
563 );
564
565 return [
566 'access_token' => $access,
567 'token_type' => 'Bearer',
568 'expires_in' => self::ACCESS_TTL,
569 'refresh_token' => $refresh,
570 'scope' => $scope,
571 ];
572 }
573
574 // -- Access-token validation (called by Mcp_Server) ------------------
575
576 /**
577 * Validate a bearer access token presented to the MCP endpoint.
578 * Returns the token's grant record (scope, user_id, client_id) when
579 * valid + unexpired, or null. Constant-time via hashed lookup.
580 *
581 * @param string $token Raw access token from the Authorization header.
582 * @return array{client_id:string,scope:string,user_id:int}|null
583 */
584 public static function validate_token( string $token ): ?array {
585 if ( '' === $token ) {
586 return null;
587 }
588 $state = self::state();
589 $hash = self::hash( $token );
590 if ( ! isset( $state['tokens'][ $hash ] ) ) {
591 return null;
592 }
593 $entry = $state['tokens'][ $hash ];
594 if ( (int) $entry['expires'] < time() ) {
595 return null;
596 }
597
598 // Record activity against the owning client so the "Connected AI apps"
599 // list can show a last-used date. Throttled, and written to its own
600 // option: this runs on every authenticated MCP call, and writing it
601 // back into the credential store meant ordinary tool traffic could
602 // erase a token minted by an overlapping refresh (#485).
603 $client_id = (string) $entry['client_id'];
604 if ( isset( $state['clients'][ $client_id ] ) && is_array( $state['clients'][ $client_id ] ) ) {
605 self::touch_last_used( $client_id, array_keys( $state['clients'] ) );
606 }
607
608 return [
609 'client_id' => $client_id,
610 'scope' => (string) $entry['scope'],
611 'user_id' => (int) $entry['user_id'],
612 ];
613 }
614
615 /**
616 * Whether a granted scope string is read-only. `mcp` is the umbrella
617 * scope that grants read+write, so only a grant that carries NEITHER
618 * `write` NOR `mcp` — i.e. `read` alone — is read-only.
619 *
620 * @param string $scope Space-separated scope string.
621 * @return bool
622 */
623 public static function scope_is_read_only( string $scope ): bool {
624 $parts = preg_split( '/\s+/', trim( $scope ) );
625 $parts = is_array( $parts ) ? $parts : [];
626 return ! in_array( 'write', $parts, true ) && ! in_array( 'mcp', $parts, true );
627 }
628
629 /**
630 * Revoke every OAuth token + client (used by disconnect).
631 *
632 * @return void
633 */
634 public static function revoke_all(): void {
635 delete_option( self::OPTION );
636 delete_option( self::LAST_USED_OPTION );
637 }
638
639 /**
640 * The OAuth clients currently holding a live grant, for the "Connected AI
641 * apps" list. A client counts as connected while it holds an unexpired
642 * refresh token (the durable 30-day grant) or access token; a client that
643 * only registered but never completed consent is excluded. One entry per
644 * client_id, newest connection first.
645 *
646 * @return array<int,array{client_id:string,name:string,scope:string,read_only:bool,user_id:int,connected_at:int,last_used:int}>
647 */
648 public static function connected_apps(): array {
649 $state = self::state();
650 $last_used = self::last_used_map();
651
652 // Collect the scope + approving user per active client. Refresh tokens
653 // are the durable grant, so prefer them; fall back to access tokens.
654 $active = [];
655 foreach ( [ 'refresh', 'tokens' ] as $bucket ) {
656 foreach ( $state[ $bucket ] as $entry ) {
657 $cid = isset( $entry['client_id'] ) ? (string) $entry['client_id'] : '';
658 if ( '' === $cid || isset( $active[ $cid ] ) ) {
659 continue;
660 }
661 $active[ $cid ] = [
662 'scope' => isset( $entry['scope'] ) ? (string) $entry['scope'] : 'mcp',
663 'user_id' => isset( $entry['user_id'] ) ? (int) $entry['user_id'] : 0,
664 ];
665 }
666 }
667
668 $apps = [];
669 foreach ( $active as $cid => $info ) {
670 $client = isset( $state['clients'][ $cid ] ) && is_array( $state['clients'][ $cid ] ) ? $state['clients'][ $cid ] : [];
671 $apps[] = [
672 'client_id' => $cid,
673 'name' => isset( $client['name'] ) ? (string) $client['name'] : __( 'MCP Client', 'thinkrank' ),
674 'scope' => $info['scope'],
675 'read_only' => self::scope_is_read_only( $info['scope'] ),
676 'user_id' => $info['user_id'],
677 'connected_at' => isset( $client['created'] ) ? (int) $client['created'] : 0,
678 // Legacy fallback: stamps written before #485 still sit on the
679 // client record, so an existing install keeps its dates.
680 'last_used' => isset( $last_used[ $cid ] )
681 ? (int) $last_used[ $cid ]
682 : ( isset( $client['last_used'] ) ? (int) $client['last_used'] : 0 ),
683 ];
684 }
685
686 // Newest connection first.
687 usort(
688 $apps,
689 static function ( array $a, array $b ): int {
690 return $b['connected_at'] <=> $a['connected_at'];
691 }
692 );
693
694 return $apps;
695 }
696
697 /**
698 * Revoke a single OAuth client's ACCESS — drops its access tokens, refresh
699 * tokens, and any pending codes, cutting that one app off immediately while
700 * leaving every other connection intact. It disappears from
701 * connected_apps() (which keys off live tokens), so the UI shows it gone.
702 *
703 * The client's dynamic registration (its client_id + redirect_uris) is
704 * intentionally KEPT: MCP clients such as ChatGPT cache the client_id from
705 * their first registration and reuse it on reconnect, hitting /authorize
706 * with that id rather than registering afresh. If we deleted the
707 * registration, that reconnect would fail with "Unknown client_id". Keeping
708 * it lets the app re-authorize — which still requires fresh admin consent
709 * (and mints brand-new tokens), so revocation loses nothing.
710 *
711 * @param string $client_id The client whose access to revoke.
712 * @return bool True if any live grant was removed.
713 */
714 public static function revoke_client( string $client_id ): bool {
715 if ( '' === $client_id ) {
716 return false;
717 }
718 $removed = self::mutate(
719 static function ( array &$state ) use ( $client_id ): bool {
720 $found = false;
721
722 foreach ( [ 'tokens', 'refresh', 'codes' ] as $bucket ) {
723 foreach ( $state[ $bucket ] as $key => $entry ) {
724 if ( isset( $entry['client_id'] ) && (string) $entry['client_id'] === $client_id ) {
725 unset( $state[ $bucket ][ $key ] );
726 $found = true;
727 }
728 }
729 }
730
731 return $found;
732 }
733 );
734
735 if ( $removed ) {
736 $map = self::last_used_map();
737 unset( $map[ $client_id ] );
738 update_option( self::LAST_USED_OPTION, $map, false );
739 }
740
741 return $removed;
742 }
743
744 // -- State + helpers -------------------------------------------------
745
746 /**
747 * Load state with defaults, pruning expired codes/tokens/refresh
748 * entries on the way out so the option can't grow unbounded.
749 *
750 * @return array<string,array<string,mixed>>
751 */
752 private static function state(): array {
753 $stored = get_option( self::OPTION, [] );
754 if ( ! is_array( $stored ) ) {
755 $stored = [];
756 }
757 $state = [
758 'clients' => isset( $stored['clients'] ) && is_array( $stored['clients'] ) ? $stored['clients'] : [],
759 'codes' => isset( $stored['codes'] ) && is_array( $stored['codes'] ) ? $stored['codes'] : [],
760 'tokens' => isset( $stored['tokens'] ) && is_array( $stored['tokens'] ) ? $stored['tokens'] : [],
761 'refresh' => isset( $stored['refresh'] ) && is_array( $stored['refresh'] ) ? $stored['refresh'] : [],
762 ];
763
764 $now = time();
765 foreach ( $state['codes'] as $k => $v ) {
766 if ( ! isset( $v['expires'] ) || $v['expires'] < $now ) {
767 unset( $state['codes'][ $k ] );
768 }
769 }
770 foreach ( $state['tokens'] as $k => $v ) {
771 if ( ! isset( $v['expires'] ) || $v['expires'] < $now ) {
772 unset( $state['tokens'][ $k ] );
773 }
774 }
775 foreach ( $state['refresh'] as $k => $v ) {
776 if ( isset( $v['expires'] ) && $v['expires'] < $now ) {
777 unset( $state['refresh'][ $k ] );
778 }
779 }
780 return $state;
781 }
782
783 /**
784 * Persist state (autoload off — hot-write, request-scoped option).
785 *
786 * Private on purpose: every mutation goes through mutate(), so that the
787 * state being written was read inside the same guard.
788 *
789 * @param array<string,mixed> $state State to persist.
790 * @return void
791 */
792 private static function save( array $state ): void {
793 update_option( self::OPTION, $state, false );
794 }
795
796 /**
797 * Read-modify-write the OAuth state under a guard, re-reading inside it.
798 *
799 * Clients, codes, access tokens and refresh tokens share one option, and
800 * every mutation used to read a snapshot at the top of the request and
801 * write the whole thing back later. Two overlapping requests therefore had
802 * one silently erase the other's work — the damaging order being a tool
803 * call writing back a pre-refresh snapshot over a token pair that had just
804 * been minted, leaving the client holding an access token the server has no
805 * record of (#485).
806 *
807 * The mutator receives the state by reference and may return a value, which
808 * is handed back to the caller — so a caller can claim-and-remove (a
809 * single-use code, a rotating refresh token) without the lookup and the
810 * removal being separate writes.
811 *
812 * @param callable $mutator function ( array &$state ): mixed
813 * @return mixed Whatever the mutator returned.
814 */
815 private static function mutate( callable $mutator ) {
816 $locked = self::lock();
817
818 try {
819 $state = self::state();
820 $result = $mutator( $state );
821 self::save( $state );
822 } finally {
823 if ( $locked ) {
824 self::unlock();
825 }
826 }
827
828 return $result;
829 }
830
831 /**
832 * Take the cross-request advisory lock guarding self::OPTION.
833 *
834 * MySQL GET_LOCK is what WordPress gives us that actually holds ACROSS
835 * processes — wp_cache_add() is per-request without a persistent object
836 * cache, which is exactly the configuration this bug bites hardest on.
837 * The name is namespaced by database + table prefix because GET_LOCK names
838 * are server-wide and shared MySQL hosts are the common case.
839 *
840 * Best-effort by design: a host where the lock cannot be taken (SQLite
841 * drop-in, a proxy that does not support session locks, contention past
842 * the timeout) proceeds unguarded, which is exactly today's behaviour
843 * rather than a new failure.
844 *
845 * @return bool Whether the lock is held.
846 */
847 private static function lock(): bool {
848 global $wpdb;
849
850 // Already inside a guarded mutation on this request (grant -> mint).
851 // MySQL's lock is re-entrant per session; the depth counter is what
852 // keeps the release paired with the outermost acquire.
853 if ( self::$lock_depth > 0 ) {
854 ++self::$lock_depth;
855 return true;
856 }
857
858 if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
859 return false;
860 }
861
862 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- advisory lock, not cacheable data.
863 $got = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', self::lock_name(), self::LOCK_TIMEOUT ) );
864
865 if ( '1' !== (string) $got ) {
866 return false;
867 }
868
869 self::$lock_depth = 1;
870
871 return true;
872 }
873
874 /**
875 * Release the advisory lock taken by lock(). Only the outermost mutation
876 * actually releases it.
877 *
878 * @return void
879 */
880 private static function unlock(): void {
881 global $wpdb;
882
883 if ( self::$lock_depth <= 0 ) {
884 return;
885 }
886
887 --self::$lock_depth;
888
889 if ( self::$lock_depth > 0 || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
890 return;
891 }
892
893 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- advisory lock, not cacheable data.
894 $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', self::lock_name() ) );
895 }
896
897 /**
898 * Lock name, inside MySQL's 64-character limit and unique per install.
899 *
900 * @return string
901 */
902 private static function lock_name(): string {
903 global $wpdb;
904
905 $prefix = isset( $wpdb ) && is_object( $wpdb ) ? (string) $wpdb->prefix : '';
906
907 return 'trk_mcp_oauth_' . md5( ( defined( 'DB_NAME' ) ? (string) DB_NAME : '' ) . '|' . $prefix );
908 }
909
910 /**
911 * Per-client last-used stamps, client_id => unix timestamp.
912 *
913 * @return array<string,int>
914 */
915 private static function last_used_map(): array {
916 $stored = get_option( self::LAST_USED_OPTION, [] );
917
918 return is_array( $stored ) ? $stored : [];
919 }
920
921 /**
922 * Stamp a client as having just been used, at most once per throttle
923 * window. Writes its own option, never the credential store.
924 *
925 * @param string $client_id Client to stamp.
926 * @param string[] $known_clients Client ids that still exist, so the map
927 * cannot outgrow the store it describes.
928 * @return void
929 */
930 private static function touch_last_used( string $client_id, array $known_clients ): void {
931 $map = self::last_used_map();
932 $now = time();
933 $last = isset( $map[ $client_id ] ) ? (int) $map[ $client_id ] : 0;
934
935 if ( $now - $last < self::LAST_USED_THROTTLE ) {
936 return;
937 }
938
939 $map[ $client_id ] = $now;
940
941 // Drop stamps for clients that are gone (revoked, pruned, expired).
942 $known = array_flip( $known_clients );
943 foreach ( array_keys( $map ) as $id ) {
944 if ( ! isset( $known[ $id ] ) ) {
945 unset( $map[ $id ] );
946 }
947 }
948
949 update_option( self::LAST_USED_OPTION, $map, false );
950 }
951
952 /**
953 * Look up a registered client.
954 *
955 * @param string $client_id Client id.
956 * @return array{redirect_uris:string[],name:string,created:int}|null
957 */
958 private static function client( string $client_id ): ?array {
959 if ( '' === $client_id ) {
960 return null;
961 }
962 $clients = self::state()['clients'];
963 if ( ! isset( $clients[ $client_id ] ) || ! is_array( $clients[ $client_id ] ) ) {
964 return null;
965 }
966 $c = $clients[ $client_id ];
967 return [
968 'redirect_uris' => isset( $c['redirect_uris'] ) && is_array( $c['redirect_uris'] ) ? array_map( 'strval', $c['redirect_uris'] ) : [],
969 'name' => isset( $c['name'] ) ? (string) $c['name'] : 'MCP Client',
970 'created' => isset( $c['created'] ) ? (int) $c['created'] : 0,
971 ];
972 }
973
974 /**
975 * Bound the registered-client list. Drops abandoned registrations past
976 * CLIENT_TTL first, then — if still over MAX_CLIENTS — the oldest of what
977 * is left. A client referenced by a live code, access token, or refresh
978 * token is NEVER dropped: evicting one would break a working connection,
979 * so a site legitimately holding more than MAX_CLIENTS live grants keeps
980 * them all and the cap simply stops applying to that remainder.
981 *
982 * @param array<string,array<string,mixed>> $state Full state (clients + grant buckets).
983 * @return array<string,array<string,mixed>> The clients array to store.
984 */
985 private static function prune_clients( array $state ): array {
986 $clients = $state['clients'];
987
988 $in_use = [];
989 foreach ( [ 'codes', 'tokens', 'refresh' ] as $bucket ) {
990 foreach ( $state[ $bucket ] as $entry ) {
991 if ( is_array( $entry ) && isset( $entry['client_id'] ) ) {
992 $in_use[ (string) $entry['client_id'] ] = true;
993 }
994 }
995 }
996
997 $now = time();
998 foreach ( $clients as $id => $client ) {
999 $created = isset( $client['created'] ) ? (int) $client['created'] : 0;
1000 if ( ! isset( $in_use[ $id ] ) && $created + self::CLIENT_TTL < $now ) {
1001 unset( $clients[ $id ] );
1002 }
1003 }
1004
1005 if ( count( $clients ) <= self::MAX_CLIENTS ) {
1006 return $clients;
1007 }
1008
1009 // Still over the cap — evict the oldest unused registrations.
1010 $evictable = array_filter(
1011 $clients,
1012 static function ( $id ) use ( $in_use ) {
1013 return ! isset( $in_use[ $id ] );
1014 },
1015 ARRAY_FILTER_USE_KEY
1016 );
1017 uasort(
1018 $evictable,
1019 static function ( $a, $b ) {
1020 return ( isset( $a['created'] ) ? (int) $a['created'] : 0 ) <=> ( isset( $b['created'] ) ? (int) $b['created'] : 0 );
1021 }
1022 );
1023 foreach ( array_keys( $evictable ) as $id ) {
1024 if ( count( $clients ) <= self::MAX_CLIENTS ) {
1025 break;
1026 }
1027 unset( $clients[ $id ] );
1028 }
1029
1030 return $clients;
1031 }
1032
1033 /**
1034 * SHA-256 hash used to store tokens at rest.
1035 *
1036 * @param string $value Raw secret.
1037 * @return string
1038 */
1039 private static function hash( string $value ): string {
1040 return hash( 'sha256', $value );
1041 }
1042
1043 /**
1044 * BASE64URL(SHA256(verifier)) — the PKCE S256 transformation.
1045 *
1046 * @param string $verifier PKCE code verifier.
1047 * @return string
1048 */
1049 private static function s256( string $verifier ): string {
1050 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- base64url of the PKCE challenge, mandated by RFC 7636.
1051 return rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' );
1052 }
1053
1054 /**
1055 * Constrain a requested scope to what we support. Defaults to `mcp`
1056 * (read+write umbrella).
1057 *
1058 * @param string $requested Requested scope string.
1059 * @return string
1060 */
1061 private static function normalize_scope( string $requested ): string {
1062 $parts = preg_split( '/\s+/', trim( $requested ) );
1063 $parts = is_array( $parts ) ? $parts : [];
1064 $parts = array_values( array_intersect( $parts, self::SUPPORTED_SCOPES ) );
1065 if ( empty( $parts ) ) {
1066 return 'mcp';
1067 }
1068 return implode( ' ', $parts );
1069 }
1070
1071 /**
1072 * Whether a redirect_uri is structurally acceptable (http(s) or a
1073 * native-client custom scheme).
1074 *
1075 * @param string $uri Candidate redirect URI.
1076 * @return bool
1077 */
1078 private static function is_valid_redirect_uri( string $uri ): bool {
1079 $uri = trim( $uri );
1080 if ( '' === $uri ) {
1081 return false;
1082 }
1083 return (bool) preg_match( '#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $uri );
1084 }
1085
1086 /**
1087 * Build a WP_Error whose data carries an OAuth 2.0 `error` code so the
1088 * token route can render the RFC 6749 error body.
1089 *
1090 * @param string $code OAuth error code (invalid_grant, ...).
1091 * @param string $message Human-readable description.
1092 * @return \WP_Error
1093 */
1094 private static function oauth_error( string $code, string $message ): \WP_Error {
1095 return new \WP_Error(
1096 $code,
1097 $message,
1098 [
1099 'status' => 400,
1100 'error' => $code,
1101 'error_description' => $message,
1102 ]
1103 );
1104 }
1105 }
1106