PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.8.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.8.0
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / mcp-server / Auth / OAuth / OAuthServer.php

OAuthServer.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.8.0, at modules/mcp-server/Auth/OAuth/OAuthServer.php

495 lines 16.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Delegated approval for clients that hold only a site address (spec 044,
4 * FR-028–FR-039).
5 *
6 * Some hosted agent clients accept a URL and nothing else — no field to paste a
7 * credential into. This lets them obtain one without the administrator ever
8 * handling a secret.
9 *
10 * @package Templately\Modules\McpServer\Auth\OAuth
11 */
12
13 namespace Templately\Modules\McpServer\Auth\OAuth;
14
15 use Templately\Modules\McpServer\Auth\Credentials;
16 use Templately\Modules\McpServer\Auth\FailedAuthLimiter;
17 use Templately\Modules\McpCore\Registry\ToolDescriptor;
18 use Templately\Modules\McpServer\Server\HttpTransport;
19 use WP_Error;
20 use WP_REST_Request;
21 use WP_REST_Response;
22
23 class OAuthServer {
24
25 /**
26 * Registration input bounds. The endpoint is unauthenticated by design
27 * (RFC 7591 §5 accepts that and asks the server to "take appropriate steps
28 * to mitigate"), so every field it persists has to be bounded.
29 */
30 const MAX_REDIRECT_URIS = 5;
31 const MAX_REDIRECT_URI_LENGTH = 512;
32 const MAX_CLIENT_NAME_LENGTH = 128;
33
34 public static function register_routes(): void {
35 register_rest_route(
36 HttpTransport::NAMESPACE,
37 '/mcp/oauth/register',
38 [
39 'methods' => 'POST',
40 'callback' => [ self::class, 'handle_register' ],
41 'permission_callback' => '__return_true',
42 ]
43 );
44
45 register_rest_route(
46 HttpTransport::NAMESPACE,
47 '/mcp/oauth/token',
48 [
49 'methods' => 'POST',
50 'callback' => [ self::class, 'handle_token' ],
51 'permission_callback' => '__return_true',
52 ]
53 );
54 }
55
56 // ----------------------------------------------------------- discovery
57
58 /**
59 * Serve a discovery document.
60 *
61 * Only served when the site actually holds connection state, and passed
62 * through a filter so another plugin or site owner can decline (FR-043). An
63 * unconfigured site stays transparent to other OAuth-serving plugins — the
64 * reference implementation serves unconditionally and wins by rewrite
65 * priority regardless of which plugin the request was for.
66 *
67 * @param string $which
68 * @return void
69 */
70 public static function serve_discovery( string $which ): void {
71 if ( ! Credentials::site_has_any() ) {
72 return;
73 }
74
75 if ( ! apply_filters( 'templately_mcp_serve_wellknown', true, $which ) ) {
76 return;
77 }
78
79 $document = ( 'authorization-server' === $which )
80 ? self::authorization_server_metadata()
81 : self::protected_resource_metadata();
82
83 status_header( 200 );
84 header( 'Content-Type: application/json; charset=utf-8' );
85 header( 'Cache-Control: public, max-age=3600' );
86
87 echo wp_json_encode( $document );
88
89 exit;
90 }
91
92 /**
93 * @return array
94 */
95 public static function protected_resource_metadata(): array {
96 return [
97 'resource' => rest_url( HttpTransport::NAMESPACE . '/mcp' ),
98 'authorization_servers' => [ RecordStore::issuer() ],
99 // Advertised here too, not only on the authorization server: it is
100 // what tells a client the levels it may ask for. Without it a client
101 // guesses — and a guess that does not include `full` silently gets a
102 // read-only connection.
103 'scopes_supported' => [ ToolDescriptor::ACCESS_READ, ToolDescriptor::ACCESS_FULL ],
104 'bearer_methods_supported' => [ 'header' ],
105 ];
106 }
107
108 /**
109 * @return array
110 */
111 public static function authorization_server_metadata(): array {
112 return [
113 'issuer' => RecordStore::issuer(),
114 'authorization_endpoint' => home_url( '/templately/authorize' ),
115 'token_endpoint' => rest_url( HttpTransport::NAMESPACE . '/mcp/oauth/token' ),
116 'registration_endpoint' => rest_url( HttpTransport::NAMESPACE . '/mcp/oauth/register' ),
117 'response_types_supported' => [ 'code' ],
118 'grant_types_supported' => [ 'authorization_code', 'refresh_token' ],
119 'code_challenge_methods_supported' => [ 'S256' ],
120 'token_endpoint_auth_methods_supported' => [ 'none' ],
121 'scopes_supported' => [ ToolDescriptor::ACCESS_READ, ToolDescriptor::ACCESS_FULL ],
122 ];
123 }
124
125 // -------------------------------------------------------- registration
126
127 /**
128 * @param WP_REST_Request $request
129 * @return WP_REST_Response
130 */
131 public static function handle_register( WP_REST_Request $request ) {
132 if ( FailedAuthLimiter::is_locked( FailedAuthLimiter::BUCKET_OAUTH ) ) {
133 return self::error_response( 'too_many_requests', __( 'Too many requests.', 'templately' ), 429 );
134 }
135
136 $body = json_decode( $request->get_body(), true );
137 $body = is_array( $body ) ? $body : [];
138
139 $uris = isset( $body['redirect_uris'] ) && is_array( $body['redirect_uris'] ) ? $body['redirect_uris'] : [];
140
141 // Bounded BEFORE validation. This endpoint is unauthenticated by design,
142 // and nothing previously capped the number of URIs, their length, or the
143 // client name — so a single request could persist a row the size of
144 // post_max_size, and nothing stopped an attacker repeating it. See
145 // create_client() for the matching expiry that lets the sweep reclaim it.
146 $uris = array_slice( array_map( 'strval', $uris ), 0, self::MAX_REDIRECT_URIS );
147 $uris = array_values(
148 array_filter(
149 $uris,
150 static function ( $uri ) {
151 return strlen( $uri ) <= self::MAX_REDIRECT_URI_LENGTH
152 && self::is_allowed_redirect_uri( $uri );
153 }
154 )
155 );
156
157 if ( empty( $uris ) ) {
158 // Counts against the limiter so registration cannot be used to
159 // enumerate or to fill storage (FR-030).
160 FailedAuthLimiter::record_failure( FailedAuthLimiter::BUCKET_OAUTH );
161
162 return self::error_response(
163 'invalid_redirect_uri',
164 __( 'At least one https (or loopback http) redirect_uri is required.', 'templately' ),
165 400
166 );
167 }
168
169 // A SUCCESSFUL registration also counts — against its own generous QUOTA,
170 // not the strike bucket. Charging only failures left the well-formed
171 // case completely unthrottled (the case an attacker filling the database
172 // would use); charging it to the strike bucket locked legitimate users
173 // out after a handful of ordinary reconnections.
174 if ( FailedAuthLimiter::is_locked( FailedAuthLimiter::BUCKET_REGISTER ) ) {
175 return self::error_response( 'too_many_requests', __( 'Too many client registrations.', 'templately' ), 429 );
176 }
177
178 FailedAuthLimiter::record_failure( FailedAuthLimiter::BUCKET_REGISTER );
179
180 $name = isset( $body['client_name'] ) ? sanitize_text_field( (string) $body['client_name'] ) : '';
181 $name = function_exists( 'mb_substr' )
182 ? mb_substr( $name, 0, self::MAX_CLIENT_NAME_LENGTH )
183 : substr( $name, 0, self::MAX_CLIENT_NAME_LENGTH );
184 $client = RecordStore::create_client( $name, $uris );
185
186 $response = new WP_REST_Response(
187 [
188 'client_id' => $client['client_id'],
189 'client_name' => $client['client_name'],
190 'redirect_uris' => $client['redirect_uris'],
191 'token_endpoint_auth_method' => 'none',
192 ],
193 201
194 );
195
196 $response->header( 'Cache-Control', 'no-store' );
197
198 return $response;
199 }
200
201 /**
202 * https, or http ONLY for loopback (FR-035). The reference implementation
203 * accepts any scheme matching a generic pattern, including `javascript:`.
204 *
205 * @param string $uri
206 * @return bool
207 */
208 public static function is_allowed_redirect_uri( string $uri ): bool {
209 $parts = wp_parse_url( $uri );
210
211 if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
212 return false;
213 }
214
215 $scheme = strtolower( $parts['scheme'] );
216 $host = strtolower( $parts['host'] );
217
218 if ( 'https' === $scheme ) {
219 return true;
220 }
221
222 // wp_parse_url() returns an IPv6 host WITH its brackets ("[::1]"), and
223 // brackets are the only syntactically valid way to write an IPv6 URI —
224 // so matching the bare "::1" alone could never succeed. Strip them.
225 $host = trim( $host, '[]' );
226
227 return 'http' === $scheme && in_array( $host, [ '127.0.0.1', '::1', 'localhost' ], true );
228 }
229
230 // -------------------------------------------------------------- token
231
232 /**
233 * @param WP_REST_Request $request
234 * @return WP_REST_Response
235 */
236 public static function handle_token( WP_REST_Request $request ) {
237 if ( FailedAuthLimiter::is_locked( FailedAuthLimiter::BUCKET_OAUTH ) ) {
238 return self::error_response( 'too_many_requests', __( 'Too many requests.', 'templately' ), 429 );
239 }
240
241 $params = $request->get_params();
242 $grant_type = isset( $params['grant_type'] ) ? (string) $params['grant_type'] : '';
243
244 if ( 'authorization_code' === $grant_type ) {
245 return self::grant_authorization_code( $params );
246 }
247
248 if ( 'refresh_token' === $grant_type ) {
249 return self::grant_refresh_token( $params );
250 }
251
252 return self::error_response( 'unsupported_grant_type', __( 'Unsupported grant type.', 'templately' ), 400 );
253 }
254
255 /**
256 * @param array $params
257 * @return WP_REST_Response
258 */
259 private static function grant_authorization_code( array $params ): WP_REST_Response {
260 $code = isset( $params['code'] ) ? (string) $params['code'] : '';
261
262 // Consumed (and deleted) BEFORE verification — see RecordStore::consume_grant().
263 $grant = RecordStore::consume_grant( $code );
264
265 if ( null === $grant ) {
266 // A code that was already spent: deny (MUST) and revoke everything
267 // issued from it (RFC 6749 §4.1.2 SHOULD), since a replay means the
268 // code reached someone it should not have.
269 $chain = RecordStore::spent_chain( $code );
270
271 if ( '' !== $chain ) {
272 RecordStore::revoke_chain( $chain );
273 }
274
275 FailedAuthLimiter::record_failure( FailedAuthLimiter::BUCKET_OAUTH );
276
277 return self::error_response( 'invalid_grant', __( 'Invalid or expired authorization code.', 'templately' ), 400 );
278 }
279
280 $client_id = isset( $params['client_id'] ) ? (string) $params['client_id'] : '';
281 $redirect_uri = isset( $params['redirect_uri'] ) ? (string) $params['redirect_uri'] : '';
282 $verifier = isset( $params['code_verifier'] ) ? (string) $params['code_verifier'] : '';
283
284 if ( ! hash_equals( (string) $grant['client_id'], $client_id )
285 || ! hash_equals( (string) $grant['redirect_uri'], $redirect_uri ) ) {
286 FailedAuthLimiter::record_failure( FailedAuthLimiter::BUCKET_OAUTH );
287
288 return self::error_response( 'invalid_grant', __( 'Grant does not match this client.', 'templately' ), 400 );
289 }
290
291 // Proof that the client completing the exchange is the one that began it.
292 if ( '' === $verifier || ! hash_equals( (string) $grant['challenge'], self::s256( $verifier ) ) ) {
293 FailedAuthLimiter::record_failure( FailedAuthLimiter::BUCKET_OAUTH );
294
295 return self::error_response( 'invalid_grant', __( 'Proof of origination failed.', 'templately' ), 400 );
296 }
297
298 // RFC 8707 §2: refuse to mint a token for a resource that is not ours,
299 // rather than issuing one the client will believe is scoped elsewhere.
300 $requested_resource = isset( $params['resource'] ) ? (string) $params['resource'] : '';
301
302 if ( ! RecordStore::resource_matches( $requested_resource ) ) {
303 return self::error_response(
304 'invalid_target',
305 __( 'This server does not issue tokens for that resource.', 'templately' ),
306 400
307 );
308 }
309
310 $chain = RecordStore::new_chain();
311
312 // Tombstone the code under the SAME chain as the tokens it produces, so
313 // a later replay of this code can revoke them.
314 RecordStore::tombstone_refresh( hash( 'sha256', $code ), $chain, RecordStore::REFRESH_TTL );
315
316 return self::issue_tokens(
317 (string) $grant['client_id'],
318 (int) $grant['user_id'],
319 (string) $grant['access_level'],
320 $chain
321 );
322 }
323
324 /**
325 * Rotation: the prior refresh token AND its paired access token are
326 * invalidated before new ones are issued (FR-036).
327 *
328 * @param array $params
329 * @return WP_REST_Response
330 */
331 private static function grant_refresh_token( array $params ): WP_REST_Response {
332 $secret = isset( $params['refresh_token'] ) ? (string) $params['refresh_token'] : '';
333 $refresh = RecordStore::find_refresh_token( $secret );
334
335 if ( null === $refresh ) {
336 // REPLAY DETECTION. A token we no longer hold but have a tombstone
337 // for was already spent — so either it leaked and the thief is using
338 // it, or the legitimate client is retrying with a token the thief
339 // already burned. The server cannot tell which, and OAuth 2.1 §4.3.1
340 // answers that by revoking the whole grant chain: the attacker is
341 // stopped, at the cost of making the honest client re-authorize.
342 //
343 // Without this, rotation was cosmetic — whoever redeemed a stolen
344 // token first simply kept the connection, and the real client's
345 // failure looked like an ordinary expiry.
346 $chain = RecordStore::spent_chain( $secret );
347
348 if ( '' !== $chain ) {
349 RecordStore::revoke_chain( $chain );
350
351 FailedAuthLimiter::record_failure( FailedAuthLimiter::BUCKET_OAUTH );
352
353 return self::error_response(
354 'invalid_grant',
355 __( 'This refresh token has already been used. The connection has been revoked; reconnect to continue.', 'templately' ),
356 400
357 );
358 }
359
360 FailedAuthLimiter::record_failure( FailedAuthLimiter::BUCKET_OAUTH );
361
362 return self::error_response( 'invalid_grant', __( 'Invalid or expired refresh token.', 'templately' ), 400 );
363 }
364
365 // RFC 8707 §2.2: on a refresh, the requested resource must stay within
366 // what was originally granted.
367 $requested_resource = isset( $params['resource'] ) ? (string) $params['resource'] : '';
368
369 if ( ! RecordStore::resource_matches( $requested_resource ) ) {
370 return self::error_response(
371 'invalid_target',
372 __( 'This server does not issue tokens for that resource.', 'templately' ),
373 400
374 );
375 }
376
377 // Spend it. The DELETE inside arbitrates concurrent refreshes: exactly
378 // one caller may proceed, where find-then-delete let both mint a pair.
379 $refresh = RecordStore::consume_refresh_token( $secret );
380
381 if ( null === $refresh ) {
382 return self::error_response( 'invalid_grant', __( 'Invalid or expired refresh token.', 'templately' ), 400 );
383 }
384
385 $chain = (string) ( $refresh['chain'] ?? '' );
386
387 if ( ! empty( $refresh['paired_access'] ) ) {
388 // delete_option alone left the index pointing at a deleted row on
389 // every rotation. Harmless in isolation, but the index is what
390 // has_any() reads, so the debris kept the site looking connected.
391 RecordStore::delete_token_by_hash( (string) $refresh['paired_access'] );
392 }
393
394 // Tombstone for the lifetime the token would have had, so a replay
395 // within its original validity window is still recognised as reuse.
396 RecordStore::tombstone_refresh( hash( 'sha256', $secret ), $chain, RecordStore::REFRESH_TTL );
397
398 return self::issue_tokens(
399 (string) $refresh['client_id'],
400 (int) $refresh['user_id'],
401 (string) $refresh['access_level'],
402 $chain
403 );
404 }
405
406 /**
407 * @param string $client_id
408 * @param int $user_id
409 * @param string $access_level
410 * @return WP_REST_Response
411 */
412 private static function issue_tokens( string $client_id, int $user_id, string $access_level, string $chain = '' ): WP_REST_Response {
413 $level = Credentials::normalize_level( $access_level );
414
415 // One chain id per authorization grant, carried forward across every
416 // rotation, so a reuse detected at any point can revoke the lot.
417 $chain = '' !== $chain ? $chain : RecordStore::new_chain();
418
419 $access = RecordStore::create_token(
420 [
421 'type' => 'access',
422 'client_id' => $client_id,
423 'user_id' => $user_id,
424 'access_level' => $level,
425 // The MCP endpoint's canonical URI, NOT the issuer — see
426 // RecordStore::audience().
427 'resource' => RecordStore::audience(),
428 'chain' => $chain,
429 ],
430 RecordStore::ACCESS_TTL
431 );
432
433 $refresh = RecordStore::create_token(
434 [
435 'type' => 'refresh',
436 'client_id' => $client_id,
437 'user_id' => $user_id,
438 'access_level' => $level,
439 'resource' => RecordStore::audience(),
440 'chain' => $chain,
441 'paired_access' => hash( 'sha256', $access ),
442 ],
443 RecordStore::REFRESH_TTL
444 );
445
446 $response = new WP_REST_Response(
447 [
448 'access_token' => $access,
449 'token_type' => 'Bearer',
450 'expires_in' => RecordStore::ACCESS_TTL,
451 'refresh_token' => $refresh,
452 'scope' => $level,
453 ],
454 200
455 );
456
457 $response->header( 'Cache-Control', 'no-store' );
458 $response->header( 'Pragma', 'no-cache' );
459
460 return $response;
461 }
462
463 /**
464 * @param string $verifier
465 * @return string
466 */
467 public static function s256( string $verifier ): string {
468 return rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' );
469 }
470
471 /**
472 * @param string $code
473 * @param string $message
474 * @param int $status
475 * @return WP_REST_Response
476 */
477 private static function error_response( string $code, string $message, int $status ): WP_REST_Response {
478 $response = new WP_REST_Response(
479 [
480 'error' => $code,
481 'error_description' => $message,
482 ],
483 $status
484 );
485
486 $response->header( 'Cache-Control', 'no-store' );
487
488 if ( 429 === $status ) {
489 $response->header( 'Retry-After', (string) FailedAuthLimiter::retry_after( FailedAuthLimiter::BUCKET_OAUTH ) );
490 }
491
492 return $response;
493 }
494 }
495