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

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