PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
betterdocs / includes / Mcp / MCPOAuth.php

MCPOAuth.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.2, at includes/Mcp/MCPOAuth.php

1,129 lines 33.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OAuth 2.1 authorization server for the BetterDocs MCP endpoint.
4 *
5 * @package BetterDocs
6 * @since 4.9.0
7 */
8
9 namespace WPDeveloper\BetterDocs\Mcp;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit; // Exit if accessed directly.
13 }
14
15 /**
16 * The "paste the site URL and nothing else" connection path.
17 *
18 * A pairing token covers clients that accept a Bearer token you paste in.
19 * This class covers the spec-compliant ones — Claude's remote connectors, ChatGPT
20 * — which are given only the MCP endpoint URL and run the OAuth 2.1
21 * authorization-code + PKCE flow themselves:
22 *
23 * unauthenticated MCP call → 401 + `WWW-Authenticate` → client fetches
24 * `/.well-known/oauth-protected-resource` and `…/oauth-authorization-server` →
25 * dynamic registration (RFC 7591) → `/betterdocs/authorize` (a person consents,
26 * with PKCE) → `/token` (code + verifier → access + refresh) → MCP calls with
27 * `Authorization: Bearer …`, validated by {@see self::validate_token()}.
28 *
29 * The security contract, which the rest of the MCP layer relies on:
30 *
31 * - **PKCE S256 is required.** OAuth 2.1 has no implicit grant and no plain
32 * challenge; a request without S256 is refused at `/authorize`, before anything
33 * is issued.
34 * - **Codes are single-use, 60 seconds, and bound** to client id, redirect URI,
35 * challenge and the approving user. The code is burned *before* verification,
36 * so a failed attempt cannot be retried.
37 * - **Only hashes are stored.** Authorization codes, access tokens and refresh
38 * tokens exist in the clear exactly once — in the redirect back to the client
39 * and in the `/token` response; the option holds SHA-256 and nothing else, so a
40 * database dump yields no usable credential (ADR-037). Lookups are by hash and
41 * comparisons are `hash_equals()`.
42 * - **Refresh rotates.** A refresh grant drops the old refresh token *and* the
43 * access token it minted, so a stolen refresh token is detectable by the real
44 * client suddenly failing.
45 * - **Scope decides read vs write** — a `read`-only grant refuses every write
46 * tool, exactly like a read-only pairing token (ADR-017).
47 *
48 * Who may consent is *not* decided here: the consent screen gates on
49 * `edit_docs` (ADR-006), and every ability re-checks its own capability on every
50 * call, so a grant can never exceed what the granting user could do themselves.
51 *
52 * All state lives in one non-autoloaded option, pruned lazily on every read.
53 *
54 * @since 4.9.0
55 */
56 final class MCPOAuth {
57
58 /**
59 * Option holding every OAuth server record.
60 *
61 * @since 4.9.0
62 */
63 const OPTION = 'betterdocs_mcp_oauth';
64
65 /**
66 * Authorization-code lifetime, in seconds. Deliberately short — a real
67 * client exchanges within a second or two.
68 *
69 * @since 4.9.0
70 */
71 const CODE_TTL = 60;
72
73 /**
74 * Access-token lifetime, in seconds. One hour, refreshable.
75 *
76 * @since 4.9.0
77 */
78 const ACCESS_TTL = 3600;
79
80 /**
81 * Refresh-token lifetime, in seconds. Thirty days.
82 *
83 * @since 4.9.0
84 */
85 const REFRESH_TTL = 2592000;
86
87 /**
88 * Scopes advertised and honoured. `mcp` is the umbrella scope MCP clients
89 * ask for; it means read **and** write.
90 *
91 * @since 4.9.0
92 */
93 const SUPPORTED_SCOPES = [ 'mcp', 'read', 'write' ];
94
95 /**
96 * Seconds between `last_used` writes for one client. A busy connector would
97 * otherwise turn every MCP call into a database write.
98 *
99 * @since 4.9.0
100 */
101 const LAST_USED_THROTTLE = 60;
102
103 /**
104 * How many client registrations to keep.
105 *
106 * RFC 7591 registration is necessarily open — a client has to register
107 * before it can hold any credential — so without a cap anyone on the
108 * internet can grow this option without bound, and every read pays for it.
109 * A client holding a live grant is never evicted, so the cap only ever
110 * discards abandoned registrations.
111 *
112 * @since 4.9.0
113 */
114 const MAX_CLIENTS = 50;
115
116 /**
117 * How long an unused registration survives, in seconds. A client that
118 * registers and never completes consent has abandoned the flow; a real one
119 * exchanges a code within the minute.
120 *
121 * @since 4.9.0
122 */
123 const CLIENT_TTL = 86400;
124
125 /**
126 * The OAuth issuer.
127 *
128 * Path-based, which RFC 8414 §2 allows: using the MCP endpoint URL itself
129 * means clients derive the path-suffixed well-known URLs
130 * (`/.well-known/oauth-authorization-server/betterdocs/mcp`) and stay
131 * specific to BetterDocs even when another plugin runs its own MCP OAuth
132 * server at the same site root.
133 *
134 * @since 4.9.0
135 *
136 * @return string
137 */
138 public static function issuer() {
139 return untrailingslashit( home_url( '/betterdocs/mcp' ) );
140 }
141
142 /**
143 * The protected resource identifier — the MCP endpoint URL.
144 *
145 * `MCPPairing` owns the endpoint's address, and the issuer is the same
146 * string by construction.
147 *
148 * @since 4.9.0
149 *
150 * @return string
151 */
152 public static function resource() {
153 if ( class_exists( __NAMESPACE__ . '\\MCPPairing' ) && method_exists( __NAMESPACE__ . '\\MCPPairing', 'site_endpoint' ) ) {
154 return MCPPairing::site_endpoint();
155 }
156
157 return self::issuer();
158 }
159
160 /**
161 * The browser-facing consent page.
162 *
163 * Served outside the REST API, through a rewrite, so ordinary cookie
164 * authentication works after the wp-login round trip. A REST route would see
165 * the cookie without a nonce, treat the visitor as logged out, and loop back
166 * to the login screen.
167 *
168 * @since 4.9.0
169 *
170 * @return string
171 */
172 public static function authorize_url() {
173 return home_url( '/betterdocs/authorize' );
174 }
175
176 /**
177 * The token endpoint.
178 *
179 * @since 4.9.0
180 *
181 * @return string
182 */
183 public static function token_url() {
184 return rest_url( 'betterdocs/v1/mcp/oauth/token' );
185 }
186
187 /**
188 * The dynamic client registration endpoint.
189 *
190 * @since 4.9.0
191 *
192 * @return string
193 */
194 public static function register_url() {
195 return rest_url( 'betterdocs/v1/mcp/oauth/register' );
196 }
197
198 /**
199 * Where the 401 challenge points for protected-resource metadata.
200 *
201 * The REST alias rather than the `/.well-known/` path, because a
202 * non-trivial number of hosts, security plugins and CDNs intercept
203 * `/.well-known/` for ACME and never let WordPress see it (ADR-014). Both
204 * forms are served; this is the one advertised, and it is filterable for the
205 * site where neither is reachable and the document has to come from
206 * somewhere else entirely.
207 *
208 * @since 4.9.0
209 *
210 * @return string
211 */
212 public static function resource_metadata_url() {
213 /**
214 * Filters the protected-resource metadata URL advertised in the
215 * `WWW-Authenticate` challenge.
216 *
217 * @since 4.9.0
218 *
219 * @param string $url The advertised URL.
220 */
221 return apply_filters(
222 'betterdocs_mcp_resource_metadata_url',
223 rest_url( 'betterdocs/v1/mcp/oauth/protected-resource' )
224 );
225 }
226
227 /**
228 * RFC 9728 protected-resource metadata: which authorization server protects
229 * the MCP endpoint.
230 *
231 * @since 4.9.0
232 *
233 * @return array
234 */
235 public static function protected_resource_metadata() {
236 return [
237 'resource' => self::resource(),
238 'authorization_servers' => [ self::issuer() ],
239 'scopes_supported' => self::SUPPORTED_SCOPES,
240 'bearer_methods_supported' => [ 'header' ]
241 ];
242 }
243
244 /**
245 * RFC 8414 authorization-server metadata: the endpoint map, and only the
246 * capabilities actually implemented.
247 *
248 * @since 4.9.0
249 *
250 * @return array
251 */
252 public static function authorization_server_metadata() {
253 return [
254 'issuer' => self::issuer(),
255 'authorization_endpoint' => self::authorize_url(),
256 'token_endpoint' => self::token_url(),
257 'registration_endpoint' => self::register_url(),
258 'scopes_supported' => self::SUPPORTED_SCOPES,
259 'response_types_supported' => [ 'code' ],
260 'grant_types_supported' => [ 'authorization_code', 'refresh_token' ],
261 'code_challenge_methods_supported' => [ 'S256' ],
262 'token_endpoint_auth_methods_supported' => [ 'none' ]
263 ];
264 }
265
266 /**
267 * Register a public client (RFC 7591).
268 *
269 * No client secret is issued: these are public clients and PKCE is what
270 * binds the code to them. Everything in the request is attacker-controlled
271 * and gets persisted, so the record is bounded on every axis — how many
272 * redirect URIs, how long each may be, how long the name may be — and the
273 * client list is pruned on the way in.
274 *
275 * @since 4.9.0
276 *
277 * @param array $body Parsed JSON registration request.
278 * @return array|\WP_Error The RFC 7591 registration response.
279 */
280 public static function register_client( array $body ) {
281 $redirect_uris = isset( $body['redirect_uris'] ) && is_array( $body['redirect_uris'] )
282 ? array_values( array_filter( array_map( 'strval', $body['redirect_uris'] ), [ self::class, 'is_valid_redirect_uri' ] ) )
283 : [];
284
285 $redirect_uris = array_values(
286 array_unique(
287 array_filter(
288 $redirect_uris,
289 static function ( $uri ) {
290 return strlen( $uri ) <= 2048;
291 }
292 )
293 )
294 );
295 $redirect_uris = array_slice( $redirect_uris, 0, 5 );
296
297 if ( empty( $redirect_uris ) ) {
298 return new \WP_Error(
299 'invalid_redirect_uri',
300 __( 'At least one valid redirect_uri is required.', 'betterdocs' ),
301 [ 'status' => 400 ]
302 );
303 }
304
305 $name = isset( $body['client_name'] ) ? sanitize_text_field( (string) $body['client_name'] ) : __( 'MCP Client', 'betterdocs' );
306
307 if ( strlen( $name ) > 128 ) {
308 $name = substr( $name, 0, 128 );
309 }
310
311 $client_id = 'bd_' . bin2hex( random_bytes( 16 ) );
312
313 $state = self::state();
314 $state['clients'][ $client_id ] = [
315 'redirect_uris' => $redirect_uris,
316 'name' => $name,
317 'created' => time()
318 ];
319 $state['clients'] = self::prune_clients( $state );
320
321 self::save( $state );
322
323 return [
324 'client_id' => $client_id,
325 'client_id_issued_at' => time(),
326 'redirect_uris' => $redirect_uris,
327 'client_name' => $name,
328 'token_endpoint_auth_method' => 'none',
329 'grant_types' => [ 'authorization_code', 'refresh_token' ],
330 'response_types' => [ 'code' ]
331 ];
332 }
333
334 /**
335 * Check an `/authorize` request without issuing anything.
336 *
337 * Returns a sanitised parameter bag, or a `WP_Error`. Whether an error may
338 * be reported back to the client by redirect is in the error's
339 * `redirectable` data: a bad `redirect_uri` or an unknown client must **not**
340 * redirect, because at that point the destination is not one this site has
341 * ever trusted — that is the open-redirect guard.
342 *
343 * @since 4.9.0
344 *
345 * @param array $params Query parameters.
346 * @return array|\WP_Error
347 */
348 public static function validate_authorize_request( array $params ) {
349 $client_id = isset( $params['client_id'] ) ? (string) $params['client_id'] : '';
350 $redirect_uri = isset( $params['redirect_uri'] ) ? (string) $params['redirect_uri'] : '';
351 $response_type = isset( $params['response_type'] ) ? (string) $params['response_type'] : '';
352 $challenge = isset( $params['code_challenge'] ) ? (string) $params['code_challenge'] : '';
353 $method = isset( $params['code_challenge_method'] ) ? (string) $params['code_challenge_method'] : '';
354 $scope = isset( $params['scope'] ) ? (string) $params['scope'] : 'mcp';
355 $state = isset( $params['state'] ) ? (string) $params['state'] : '';
356
357 $client = self::client( $client_id );
358
359 if ( null === $client ) {
360 return new \WP_Error( 'invalid_client', __( 'Unknown client_id.', 'betterdocs' ), [ 'status' => 400 ] );
361 }
362
363 if ( ! in_array( $redirect_uri, $client['redirect_uris'], true ) ) {
364 return new \WP_Error( 'invalid_redirect_uri', __( 'redirect_uri does not match a registered value.', 'betterdocs' ), [ 'status' => 400 ] );
365 }
366
367 if ( 'code' !== $response_type ) {
368 return new \WP_Error(
369 'unsupported_response_type',
370 __( 'Only response_type=code is supported.', 'betterdocs' ),
371 [
372 'status' => 400,
373 'redirectable' => true
374 ]
375 );
376 }
377
378 // OAuth 2.1: PKCE with S256 is mandatory for public clients. `plain`
379 // is refused too — it protects nothing against an attacker who can see
380 // the authorization request.
381 if ( 'S256' !== $method || '' === $challenge ) {
382 return new \WP_Error(
383 'invalid_request',
384 __( 'PKCE with code_challenge_method=S256 is required.', 'betterdocs' ),
385 [
386 'status' => 400,
387 'redirectable' => true
388 ]
389 );
390 }
391
392 return [
393 'client_id' => $client_id,
394 'client_name' => $client['name'],
395 'redirect_uri' => $redirect_uri,
396 'code_challenge' => $challenge,
397 'scope' => self::normalize_scope( $scope ),
398 'state' => $state
399 ];
400 }
401
402 /**
403 * Issue an authorization code, once a person has consented.
404 *
405 * Bound to the client, the redirect URI, the PKCE challenge, the granted
406 * scope and the approving user. Single-use, 60 seconds.
407 *
408 * The record is keyed by `hash( 'sha256', $code )`, never by the code
409 * itself, so the option holds no usable credential even during that minute
410 * (ADR-037). The caller receives the only plaintext copy.
411 *
412 * @since 4.9.0
413 *
414 * @param array $req Output of {@see self::validate_authorize_request()}.
415 * @param int $user_id The user who approved.
416 * @return string The authorization code.
417 */
418 public static function issue_code( array $req, $user_id ) {
419 $code = bin2hex( random_bytes( 32 ) );
420 $state = self::state();
421
422 $state['codes'][ self::hash( $code ) ] = [
423 'client_id' => isset( $req['client_id'] ) ? (string) $req['client_id'] : '',
424 'redirect_uri' => isset( $req['redirect_uri'] ) ? (string) $req['redirect_uri'] : '',
425 'challenge' => isset( $req['code_challenge'] ) ? (string) $req['code_challenge'] : '',
426 'scope' => isset( $req['scope'] ) ? (string) $req['scope'] : 'mcp',
427 'user_id' => (int) $user_id,
428 'expires' => time() + self::CODE_TTL
429 ];
430
431 self::save( $state );
432
433 return $code;
434 }
435
436 /**
437 * The token endpoint's two grants.
438 *
439 * @since 4.9.0
440 *
441 * @param array $body POST body parameters.
442 * @return array|\WP_Error RFC 6749 token response, or an OAuth error.
443 */
444 public static function exchange_token( array $body ) {
445 $grant = isset( $body['grant_type'] ) ? (string) $body['grant_type'] : '';
446
447 if ( 'authorization_code' === $grant ) {
448 return self::grant_authorization_code( $body );
449 }
450
451 if ( 'refresh_token' === $grant ) {
452 return self::grant_refresh_token( $body );
453 }
454
455 return self::oauth_error( 'unsupported_grant_type', __( 'Unsupported grant_type.', 'betterdocs' ) );
456 }
457
458 /**
459 * Validate a bearer access token presented to the MCP endpoint.
460 *
461 * @since 4.9.0
462 *
463 * @param string $token Raw access token from the Authorization header.
464 * @return array|null `{client_id, scope, user_id}` when valid, else null.
465 */
466 public static function validate_token( $token ) {
467 $token = (string) $token;
468
469 if ( '' === $token ) {
470 return null;
471 }
472
473 $state = self::state();
474 $hash = self::hash( $token );
475
476 if ( ! isset( $state['tokens'][ $hash ] ) ) {
477 return null;
478 }
479
480 $entry = $state['tokens'][ $hash ];
481
482 if ( (int) $entry['expires'] < time() ) {
483 return null;
484 }
485
486 // Record activity against the client, so "Connected apps" can show a
487 // last-used date. Throttled, and kept on the client record so it
488 // survives access-token rotation.
489 $client_id = (string) $entry['client_id'];
490 $now = time();
491
492 if ( isset( $state['clients'][ $client_id ] ) && is_array( $state['clients'][ $client_id ] ) ) {
493 $last = isset( $state['clients'][ $client_id ]['last_used'] ) ? (int) $state['clients'][ $client_id ]['last_used'] : 0;
494
495 if ( $now - $last >= self::LAST_USED_THROTTLE ) {
496 $state['clients'][ $client_id ]['last_used'] = $now;
497 self::save( $state );
498 }
499 }
500
501 return [
502 'client_id' => $client_id,
503 'scope' => (string) $entry['scope'],
504 'user_id' => (int) $entry['user_id']
505 ];
506 }
507
508 /**
509 * Whether a granted scope is read-only.
510 *
511 * `mcp` is the umbrella scope and includes writing, so only a grant
512 * carrying neither `write` nor `mcp` — `read` alone — is read-only.
513 *
514 * @since 4.9.0
515 *
516 * @param string $scope Space-separated scope string.
517 * @return bool
518 */
519 public static function scope_is_read_only( $scope ) {
520 $parts = self::scope_parts( (string) $scope );
521
522 return ! in_array( 'write', $parts, true ) && ! in_array( 'mcp', $parts, true );
523 }
524
525 /**
526 * Drop every OAuth record: clients, codes, tokens, refresh grants.
527 *
528 * @since 4.9.0
529 *
530 * @return void
531 */
532 public static function revoke_all() {
533 delete_option( self::OPTION );
534 }
535
536 /**
537 * Cut one app off.
538 *
539 * Drops its access tokens, refresh tokens and pending codes, so it
540 * disappears from {@see self::connected_apps()} immediately while every
541 * other connection carries on.
542 *
543 * The registration itself is deliberately **kept**. MCP clients cache the
544 * `client_id` from their first registration and reuse it when reconnecting
545 * rather than registering afresh; deleting the record would answer that
546 * reconnect with "Unknown client_id". Keeping it lets the app come back —
547 * which still needs fresh consent and mints brand-new tokens, so revocation
548 * loses nothing.
549 *
550 * @since 4.9.0
551 *
552 * @param string $client_id Client to revoke.
553 * @return bool Whether any live grant was removed.
554 */
555 public static function revoke_client( $client_id ) {
556 $client_id = (string) $client_id;
557
558 if ( '' === $client_id ) {
559 return false;
560 }
561
562 return self::revoke_where(
563 static function ( array $entry ) use ( $client_id ) {
564 return isset( $entry['client_id'] ) && (string) $entry['client_id'] === $client_id;
565 }
566 );
567 }
568
569 /**
570 * Drop every grant a given user approved.
571 *
572 * A grant can never exceed what the granting user may do, so when that user
573 * is deleted or demoted their grants have to go with them — otherwise a
574 * token keeps acting as a person who no longer has the capability, or no
575 * longer exists. `MCPManager` hooks `deleted_user` and `set_user_role` here.
576 *
577 * The client registrations stay, for the same reason as
578 * {@see self::revoke_client()}: another user may reconnect the same app.
579 *
580 * @since 4.9.0
581 *
582 * @param int $user_id User whose grants to revoke.
583 * @return bool Whether anything was removed.
584 */
585 public static function revoke_user( $user_id ) {
586 $user_id = (int) $user_id;
587
588 if ( $user_id <= 0 ) {
589 return false;
590 }
591
592 return self::revoke_where(
593 static function ( array $entry ) use ( $user_id ) {
594 return isset( $entry['user_id'] ) && (int) $entry['user_id'] === $user_id;
595 }
596 );
597 }
598
599 /**
600 * The apps currently holding a live grant, for the admin's "Connected apps"
601 * list.
602 *
603 * A client counts as connected while it holds an unexpired refresh token —
604 * the durable thirty-day grant — or an access token. One that registered but
605 * never completed consent is not connected and is not listed. Newest first.
606 *
607 * Returns no hash and no token, ever: this feeds an admin screen, and there
608 * is nothing here a person needs a credential to see.
609 *
610 * @since 4.9.0
611 *
612 * @return array[]
613 */
614 public static function connected_apps() {
615 $state = self::state();
616
617 // Refresh tokens are the durable grant, so they are read first; access
618 // tokens fill in a client whose refresh has already expired.
619 $active = [];
620
621 foreach ( [ 'refresh', 'tokens' ] as $bucket ) {
622 foreach ( $state[ $bucket ] as $entry ) {
623 if ( ! is_array( $entry ) ) {
624 continue;
625 }
626
627 $cid = isset( $entry['client_id'] ) ? (string) $entry['client_id'] : '';
628
629 if ( '' === $cid ) {
630 continue;
631 }
632
633 $expires = isset( $entry['expires'] ) ? (int) $entry['expires'] : 0;
634
635 if ( ! isset( $active[ $cid ] ) ) {
636 $active[ $cid ] = [
637 'scope' => isset( $entry['scope'] ) ? (string) $entry['scope'] : 'mcp',
638 'user_id' => isset( $entry['user_id'] ) ? (int) $entry['user_id'] : 0,
639 'expires' => $expires
640 ];
641
642 continue;
643 }
644
645 // Several grants for one app: report the one that lasts longest.
646 if ( $expires > $active[ $cid ]['expires'] ) {
647 $active[ $cid ]['expires'] = $expires;
648 }
649 }
650 }
651
652 $apps = [];
653
654 foreach ( $active as $cid => $info ) {
655 $client = isset( $state['clients'][ $cid ] ) && is_array( $state['clients'][ $cid ] ) ? $state['clients'][ $cid ] : [];
656
657 $apps[] = [
658 'client_id' => $cid,
659 'name' => isset( $client['name'] ) ? (string) $client['name'] : __( 'MCP Client', 'betterdocs' ),
660 'scope' => $info['scope'],
661 'read_only' => self::scope_is_read_only( $info['scope'] ),
662 'user_id' => $info['user_id'],
663 'user_login' => self::user_login( $info['user_id'] ),
664 // The one field an attacker cannot fake: client registration is
665 // open (RFC 7591) and `name` is whatever the registrant typed,
666 // so the admin page shows where a grant actually sends access.
667 'redirect_uris' => isset( $client['redirect_uris'] ) && is_array( $client['redirect_uris'] )
668 ? array_values( array_map( 'strval', $client['redirect_uris'] ) )
669 : [],
670 'created' => isset( $client['created'] ) ? (int) $client['created'] : 0,
671 'last_used' => isset( $client['last_used'] ) ? (int) $client['last_used'] : 0,
672 'expires' => $info['expires']
673 ];
674 }
675
676 usort(
677 $apps,
678 static function ( array $a, array $b ) {
679 return $b['created'] <=> $a['created'];
680 }
681 );
682
683 return $apps;
684 }
685
686 /**
687 * BASE64URL(SHA256(verifier)) — the PKCE S256 transformation.
688 *
689 * Public so the unit suite can pin it against RFC 7636's own vector: get
690 * this wrong and every connection fails, or worse, succeeds without really
691 * checking anything.
692 *
693 * @since 4.9.0
694 *
695 * @param string $verifier PKCE code verifier.
696 * @return string
697 */
698 public static function s256( $verifier ) {
699 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- base64url is the encoding RFC 7636 defines for the S256 challenge; nothing here is being hidden.
700 return rtrim( strtr( base64_encode( hash( 'sha256', (string) $verifier, true ) ), '+/', '-_' ), '=' );
701 }
702
703 /**
704 * The authorization_code grant: verify the code and PKCE, mint tokens.
705 *
706 * @since 4.9.0
707 *
708 * @param array $body POST body.
709 * @return array|\WP_Error
710 */
711 private static function grant_authorization_code( array $body ) {
712 $code = isset( $body['code'] ) ? (string) $body['code'] : '';
713 $client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : '';
714 $redirect_uri = isset( $body['redirect_uri'] ) ? (string) $body['redirect_uri'] : '';
715 $verifier = isset( $body['code_verifier'] ) ? (string) $body['code_verifier'] : '';
716
717 $state = self::state();
718 $chash = self::hash( $code );
719
720 if ( '' === $code || ! isset( $state['codes'][ $chash ] ) ) {
721 return self::oauth_error( 'invalid_grant', __( 'Unknown or expired authorization code.', 'betterdocs' ) );
722 }
723
724 $entry = $state['codes'][ $chash ];
725
726 // Single-use, and burned *before* verification: a code that failed
727 // PKCE must not be available for a second attempt.
728 unset( $state['codes'][ $chash ] );
729 self::save( $state );
730
731 if ( (int) $entry['expires'] < time() ) {
732 return self::oauth_error( 'invalid_grant', __( 'Authorization code expired.', 'betterdocs' ) );
733 }
734
735 if ( ! hash_equals( (string) $entry['client_id'], $client_id ) ) {
736 return self::oauth_error( 'invalid_grant', __( 'client_id mismatch.', 'betterdocs' ) );
737 }
738
739 if ( ! hash_equals( (string) $entry['redirect_uri'], $redirect_uri ) ) {
740 return self::oauth_error( 'invalid_grant', __( 'redirect_uri mismatch.', 'betterdocs' ) );
741 }
742
743 if ( '' === $verifier || ! hash_equals( (string) $entry['challenge'], self::s256( $verifier ) ) ) {
744 return self::oauth_error( 'invalid_grant', __( 'PKCE verification failed.', 'betterdocs' ) );
745 }
746
747 return self::mint_tokens( (string) $entry['client_id'], (string) $entry['scope'], (int) $entry['user_id'] );
748 }
749
750 /**
751 * The refresh_token grant: rotate the refresh token, issue a fresh access
752 * token, and revoke both of the old ones.
753 *
754 * @since 4.9.0
755 *
756 * @param array $body POST body.
757 * @return array|\WP_Error
758 */
759 private static function grant_refresh_token( array $body ) {
760 $refresh = isset( $body['refresh_token'] ) ? (string) $body['refresh_token'] : '';
761 $client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : '';
762
763 $state = self::state();
764 $rhash = self::hash( $refresh );
765
766 if ( '' === $refresh || ! isset( $state['refresh'][ $rhash ] ) ) {
767 return self::oauth_error( 'invalid_grant', __( 'Unknown refresh token.', 'betterdocs' ) );
768 }
769
770 $entry = $state['refresh'][ $rhash ];
771
772 if ( '' !== $client_id && ! hash_equals( (string) $entry['client_id'], $client_id ) ) {
773 return self::oauth_error( 'invalid_grant', __( 'client_id mismatch.', 'betterdocs' ) );
774 }
775
776 unset( $state['refresh'][ $rhash ] );
777
778 if ( isset( $entry['access_hash'] ) ) {
779 unset( $state['tokens'][ $entry['access_hash'] ] );
780 }
781
782 self::save( $state );
783
784 return self::mint_tokens( (string) $entry['client_id'], (string) $entry['scope'], (int) $entry['user_id'] );
785 }
786
787 /**
788 * Mint an access + refresh pair, store the hashes, return the raw values.
789 *
790 * This is the only moment either token exists in the clear.
791 *
792 * @since 4.9.0
793 *
794 * @param string $client_id Client id.
795 * @param string $scope Granted scope.
796 * @param int $user_id Resource owner.
797 * @return array
798 */
799 private static function mint_tokens( $client_id, $scope, $user_id ) {
800 $access = bin2hex( random_bytes( 32 ) );
801 $refresh = bin2hex( random_bytes( 32 ) );
802 $ahash = self::hash( $access );
803 $rhash = self::hash( $refresh );
804
805 $state = self::state();
806
807 $state['tokens'][ $ahash ] = [
808 'client_id' => $client_id,
809 'scope' => $scope,
810 'user_id' => $user_id,
811 'expires' => time() + self::ACCESS_TTL,
812 'refresh' => $rhash
813 ];
814
815 $state['refresh'][ $rhash ] = [
816 'access_hash' => $ahash,
817 'client_id' => $client_id,
818 'scope' => $scope,
819 'user_id' => $user_id,
820 'expires' => time() + self::REFRESH_TTL
821 ];
822
823 self::save( $state );
824
825 return [
826 'access_token' => $access,
827 'token_type' => 'Bearer',
828 'expires_in' => self::ACCESS_TTL,
829 'refresh_token' => $refresh,
830 'scope' => $scope
831 ];
832 }
833
834 /**
835 * Remove every code, token and refresh grant matching a predicate.
836 *
837 * @since 4.9.0
838 *
839 * @param callable $matches Receives one record, returns whether to drop it.
840 * @return bool Whether anything was removed.
841 */
842 private static function revoke_where( callable $matches ) {
843 $state = self::state();
844 $removed = false;
845
846 foreach ( [ 'tokens', 'refresh', 'codes' ] as $bucket ) {
847 foreach ( $state[ $bucket ] as $key => $entry ) {
848 if ( is_array( $entry ) && $matches( $entry ) ) {
849 unset( $state[ $bucket ][ $key ] );
850 $removed = true;
851 }
852 }
853 }
854
855 if ( $removed ) {
856 self::save( $state );
857 }
858
859 return $removed;
860 }
861
862 /**
863 * Load state with defaults, pruning anything expired on the way out so the
864 * option cannot grow without bound.
865 *
866 * @since 4.9.0
867 *
868 * @return array
869 */
870 private static function state() {
871 $stored = get_option( self::OPTION, [] );
872
873 if ( ! is_array( $stored ) ) {
874 $stored = [];
875 }
876
877 $state = [
878 'clients' => isset( $stored['clients'] ) && is_array( $stored['clients'] ) ? $stored['clients'] : [],
879 'codes' => isset( $stored['codes'] ) && is_array( $stored['codes'] ) ? $stored['codes'] : [],
880 'tokens' => isset( $stored['tokens'] ) && is_array( $stored['tokens'] ) ? $stored['tokens'] : [],
881 'refresh' => isset( $stored['refresh'] ) && is_array( $stored['refresh'] ) ? $stored['refresh'] : []
882 ];
883
884 $now = time();
885
886 foreach ( [ 'codes', 'tokens' ] as $bucket ) {
887 foreach ( $state[ $bucket ] as $key => $entry ) {
888 if ( ! isset( $entry['expires'] ) || (int) $entry['expires'] < $now ) {
889 unset( $state[ $bucket ][ $key ] );
890 }
891 }
892 }
893
894 foreach ( $state['refresh'] as $key => $entry ) {
895 if ( isset( $entry['expires'] ) && (int) $entry['expires'] < $now ) {
896 unset( $state['refresh'][ $key ] );
897 }
898 }
899
900 return $state;
901 }
902
903 /**
904 * Persist state. Autoload off — this is a hot, request-scoped option.
905 *
906 * @since 4.9.0
907 *
908 * @param array $state State to store.
909 * @return void
910 */
911 private static function save( array $state ) {
912 update_option( self::OPTION, $state, false );
913 }
914
915 /**
916 * One registered client.
917 *
918 * @since 4.9.0
919 *
920 * @param string $client_id Client id.
921 * @return array|null
922 */
923 private static function client( $client_id ) {
924 $client_id = (string) $client_id;
925
926 if ( '' === $client_id ) {
927 return null;
928 }
929
930 $clients = self::state()['clients'];
931
932 if ( ! isset( $clients[ $client_id ] ) || ! is_array( $clients[ $client_id ] ) ) {
933 return null;
934 }
935
936 $client = $clients[ $client_id ];
937
938 return [
939 'redirect_uris' => isset( $client['redirect_uris'] ) && is_array( $client['redirect_uris'] ) ? array_map( 'strval', $client['redirect_uris'] ) : [],
940 'name' => isset( $client['name'] ) ? (string) $client['name'] : 'MCP Client',
941 'created' => isset( $client['created'] ) ? (int) $client['created'] : 0
942 ];
943 }
944
945 /**
946 * Bound the registered-client list.
947 *
948 * Abandoned registrations past `CLIENT_TTL` go first; if that is not enough,
949 * the oldest of what is left. A client referenced by a live code, access
950 * token or refresh token is **never** dropped — evicting one breaks a
951 * working connection — so a site legitimately holding more than
952 * `MAX_CLIENTS` live grants keeps every one of them and the cap simply stops
953 * applying to that remainder.
954 *
955 * @since 4.9.0
956 *
957 * @param array $state Full state.
958 * @return array The clients array to store.
959 */
960 private static function prune_clients( array $state ) {
961 $clients = $state['clients'];
962 $in_use = [];
963
964 foreach ( [ 'codes', 'tokens', 'refresh' ] as $bucket ) {
965 foreach ( $state[ $bucket ] as $entry ) {
966 if ( is_array( $entry ) && isset( $entry['client_id'] ) ) {
967 $in_use[ (string) $entry['client_id'] ] = true;
968 }
969 }
970 }
971
972 $now = time();
973
974 foreach ( $clients as $id => $client ) {
975 $created = isset( $client['created'] ) ? (int) $client['created'] : 0;
976
977 if ( ! isset( $in_use[ $id ] ) && $created + self::CLIENT_TTL < $now ) {
978 unset( $clients[ $id ] );
979 }
980 }
981
982 if ( count( $clients ) <= self::MAX_CLIENTS ) {
983 return $clients;
984 }
985
986 $evictable = array_filter(
987 $clients,
988 static function ( $id ) use ( $in_use ) {
989 return ! isset( $in_use[ $id ] );
990 },
991 ARRAY_FILTER_USE_KEY
992 );
993
994 uasort(
995 $evictable,
996 static function ( $a, $b ) {
997 return ( isset( $a['created'] ) ? (int) $a['created'] : 0 ) <=> ( isset( $b['created'] ) ? (int) $b['created'] : 0 );
998 }
999 );
1000
1001 foreach ( array_keys( $evictable ) as $id ) {
1002 if ( count( $clients ) <= self::MAX_CLIENTS ) {
1003 break;
1004 }
1005
1006 unset( $clients[ $id ] );
1007 }
1008
1009 return $clients;
1010 }
1011
1012 /**
1013 * SHA-256, the form every token is stored in.
1014 *
1015 * @since 4.9.0
1016 *
1017 * @param string $value Raw secret.
1018 * @return string
1019 */
1020 private static function hash( $value ) {
1021 return hash( 'sha256', (string) $value );
1022 }
1023
1024 /**
1025 * Split a scope string into its parts.
1026 *
1027 * @since 4.9.0
1028 *
1029 * @param string $scope Space-separated scopes.
1030 * @return string[]
1031 */
1032 private static function scope_parts( $scope ) {
1033 $parts = preg_split( '/\s+/', trim( (string) $scope ) );
1034
1035 return is_array( $parts ) ? $parts : [];
1036 }
1037
1038 /**
1039 * Constrain a requested scope to what is supported. Defaults to `mcp`.
1040 *
1041 * @since 4.9.0
1042 *
1043 * @param string $requested Requested scope string.
1044 * @return string
1045 */
1046 private static function normalize_scope( $requested ) {
1047 $parts = array_values( array_intersect( self::scope_parts( $requested ), self::SUPPORTED_SCOPES ) );
1048
1049 if ( empty( $parts ) ) {
1050 return 'mcp';
1051 }
1052
1053 return implode( ' ', $parts );
1054 }
1055
1056 /**
1057 * The login name behind a user id, for the connected-apps list.
1058 *
1059 * @since 4.9.0
1060 *
1061 * @param int $user_id User id.
1062 * @return string Empty when the user no longer exists.
1063 */
1064 private static function user_login( $user_id ) {
1065 $user_id = (int) $user_id;
1066
1067 if ( $user_id <= 0 || ! function_exists( 'get_userdata' ) ) {
1068 return '';
1069 }
1070
1071 $user = get_userdata( $user_id );
1072
1073 return $user && isset( $user->user_login ) ? (string) $user->user_login : '';
1074 }
1075
1076 /**
1077 * Whether a redirect URI is structurally acceptable: http(s), or a native
1078 * client's custom scheme.
1079 *
1080 * @since 4.9.0
1081 *
1082 * @param string $uri Candidate redirect URI.
1083 * @return bool
1084 */
1085 private static function is_valid_redirect_uri( $uri ) {
1086 $uri = trim( (string) $uri );
1087
1088 if ( '' === $uri ) {
1089 return false;
1090 }
1091
1092 if ( ! preg_match( '#^([a-zA-Z][a-zA-Z0-9+.\-]*)://#', $uri, $matches ) ) {
1093 return false;
1094 }
1095
1096 // Registration is unauthenticated, so the scheme is attacker-chosen.
1097 // `javascript://…`, `data://…` and `vbscript://…` all satisfy the shape
1098 // above, and this value is later handed to `wp_redirect()` and rendered
1099 // on the consent screen. Browsers refuse to navigate to those, so this
1100 // is not the last line of defence — but a credential callback has no
1101 // business being one of them either.
1102 $scheme = strtolower( $matches[1] );
1103
1104 return ! in_array( $scheme, [ 'javascript', 'data', 'vbscript', 'file' ], true );
1105 }
1106
1107 /**
1108 * A `WP_Error` whose data carries an RFC 6749 error code, so the token route
1109 * can render the OAuth error body verbatim.
1110 *
1111 * @since 4.9.0
1112 *
1113 * @param string $code OAuth error code.
1114 * @param string $message Human-readable description.
1115 * @return \WP_Error
1116 */
1117 private static function oauth_error( $code, $message ) {
1118 return new \WP_Error(
1119 $code,
1120 $message,
1121 [
1122 'status' => 400,
1123 'error' => $code,
1124 'error_description' => $message
1125 ]
1126 );
1127 }
1128 }
1129