PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.8
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.8
4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 4.2.0 All 139 releases
← All changes | inc/MCP/Auth/ApiKeyAuthenticator.php +395 -425 4.3.9.14.4.8 View file →
@@ -1,425 +1,395 @@
1 -<?php
2 -
3 -namespace LearnPress\MCP\Auth;
4 -
5 -use LP_Helper;
6 -use WP_Error;
7 -use WP_REST_Request;
8 -
9 -defined( 'ABSPATH' ) || exit;
10 -
11 -/**
12 - * Authenticates MCP HTTP transport requests using LearnPress API keys.
13 - */
14 -class ApiKeyAuthenticator {
15 - /**
16 - * Core MCP REST route path.
17 - */
18 - public const MCP_ROUTE = '/mcp/mcp-adapter-default-server';
19 -
20 - /**
21 - * LearnPress MCP alias route.
22 - */
23 - public const MCP_ALIAS_ROUTE = '/lp/v1/mcp';
24 - /**
25 - * @var self|null
26 - */
27 - protected static $instance;
28 -
29 - /**
30 - * @var ApiKeysRepository
31 - */
32 - protected $keys_repository;
33 -
34 - /**
35 - * @var WP_Error|null
36 - */
37 - protected $auth_error;
38 -
39 - /**
40 - * @var bool
41 - */
42 - protected $api_key_present = false;
43 -
44 - /**
45 - * @var bool
46 - */
47 - protected $is_target_rest_request = false;
48 - /**
49 - * Bootstrap singleton.
50 - *
51 - * @return void
52 - */
53 - public static function init(): void {
54 -
55 - if ( self::$instance ) {
56 - return;
57 - }
58 -
59 - self::$instance = new self();
60 - }
61 -
62 - /**
63 - * Register repository and auth lifecycle hooks.
64 - *
65 - * @return void
66 - */
67 - protected function __construct() {
68 -
69 - $this->keys_repository = new ApiKeysRepository();
70 -
71 - add_filter( 'determine_current_user', array( $this, 'determine_current_user' ), 15 );
72 - add_filter( 'rest_authentication_errors', array( $this, 'rest_authentication_errors' ), 15 );
73 - add_filter( 'rest_post_dispatch', array( $this, 'rest_post_dispatch' ), 10, 3 );
74 - }
75 -
76 - /**
77 - * Determine current user for MCP route using API key credentials.
78 - *
79 - * @param int|false $user_id Previously resolved user ID.
80 - *
81 - * @return int|false
82 - */
83 - public function determine_current_user( $user_id ) {
84 - return $this->authenticate_request( $user_id );
85 - }
86 - /**
87 - * Normalize auth errors for invalid API key attempts.
88 - *
89 - * @param WP_Error|null|bool $error Existing error from other authenticators.
90 - *
91 - * @return WP_Error|null|bool
92 - */
93 - public function rest_authentication_errors( $error ) {
94 - if ( ! $this->is_target_rest_request && ! $this->is_target_rest_request() ) {
95 - return $error;
96 - }
97 -
98 - if ( ! empty( $error ) ) {
99 - return $error;
100 - }
101 -
102 - if ( ! AuthContext::is_api_key_auth() && ! ( $this->auth_error instanceof WP_Error ) ) {
103 - $resolved_user_id = $this->authenticate_request( 0 );
104 - if ( is_numeric( $resolved_user_id ) && (int) $resolved_user_id > 0 ) {
105 - wp_set_current_user( (int) $resolved_user_id );
106 - }
107 - }
108 -
109 - if ( $this->auth_error instanceof WP_Error ) {
110 - return $this->auth_error;
111 - }
112 -
113 - if ( ! AuthContext::is_api_key_auth() ) {
114 - return new WP_Error(
115 - 'learnpress_mcp_api_key_required',
116 - __( 'MCP API key authentication is required.', 'learnpress' ),
117 - array( 'status' => 401 )
118 - );
119 - }
120 -
121 - return $error;
122 - }
123 -
124 - /**
125 - * Attempt API-key authentication for current MCP request.
126 - *
127 - * @param int|false $user_id Previously resolved user ID.
128 - *
129 - * @return int|false
130 - */
131 - protected function authenticate_request( $user_id ) {
132 -
133 - $this->auth_error = null;
134 - $this->api_key_present = false;
135 - $this->is_target_rest_request = $this->is_target_rest_request();
136 - if ( ! $this->is_target_rest_request ) {
137 - return $user_id;
138 - }
139 -
140 - AuthContext::reset();
141 -
142 - $credentials = $this->parse_credentials();
143 - if ( ! $credentials['present'] ) {
144 - return $user_id;
145 - }
146 - $this->api_key_present = true;
147 -
148 - $consumer_key = $credentials['consumer_key'];
149 - $consumer_secret = $credentials['consumer_secret'];
150 -
151 - if ( '' === $consumer_key || '' === $consumer_secret ) {
152 - $this->auth_error = $this->invalid_credentials_error();
153 - return 0;
154 - }
155 -
156 - $key = $this->keys_repository->find_by_consumer_key( $consumer_key );
157 - if ( ! $key || empty( $key->consumer_secret ) || ! $this->keys_repository->verify_secret_hash( (string) $key->consumer_secret, $consumer_secret ) ) {
158 - $this->auth_error = $this->invalid_credentials_error();
159 - return 0;
160 - }
161 -
162 - $resolved_user_id = absint( $key->user_id );
163 - if ( $resolved_user_id <= 0 || ! get_user_by( 'id', $resolved_user_id ) ) {
164 - $this->auth_error = $this->invalid_credentials_error();
165 - return 0;
166 - }
167 -
168 - AuthContext::set_api_key_auth(
169 - absint( $key->key_id ),
170 - $resolved_user_id,
171 - (string) $key->permissions
172 - );
173 -
174 - return $resolved_user_id;
175 - }
176 -
177 - /**
178 - * Post-dispatch behavior: update usage metrics for API-key-authenticated requests.
179 - *
180 - * @param mixed $result REST response object.
181 - * @param mixed $server REST server instance.
182 - * @param WP_REST_Request $request Request object.
183 - *
184 - * @return mixed
185 - */
186 - public function rest_post_dispatch( $result, $server, $request ) {
187 - unset( $server );
188 -
189 - if ( ! ( $request instanceof WP_REST_Request ) ) {
190 - return $result;
191 - }
192 -
193 - if ( ! $this->is_target_route_from_request( $request ) ) {
194 - return $result;
195 - }
196 -
197 - if ( AuthContext::is_api_key_auth() && ! AuthContext::is_usage_touched() ) {
198 - $key_id = AuthContext::get_key_id();
199 - if ( $key_id > 0 ) {
200 - $this->keys_repository->touch_usage( $key_id );
201 - AuthContext::mark_usage_touched();
202 - }
203 - }
204 -
205 - return $result;
206 - }
207 - /**
208 - * Parse API key credentials from query params or Basic auth.
209 - *
210 - * @return array<string, mixed>
211 - */
212 - protected function parse_credentials(): array {
213 - $consumer_key_present = isset( $_GET['consumer_key'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
214 - $consumer_secret_present = isset( $_GET['consumer_secret'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
215 -
216 - $consumer_key = $consumer_key_present ? LP_Helper::sanitize_params_submitted( $_GET['consumer_key'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
217 - $consumer_secret = $consumer_secret_present ? LP_Helper::sanitize_params_submitted( $_GET['consumer_secret'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
218 -
219 - if ( $consumer_key_present || $consumer_secret_present ) {
220 - return array(
221 - 'present' => true,
222 - 'consumer_key' => $consumer_key,
223 - 'consumer_secret' => $consumer_secret,
224 - );
225 - }
226 -
227 - $has_php_auth_user = isset( $_SERVER['PHP_AUTH_USER'] );
228 - $has_php_auth_pw = isset( $_SERVER['PHP_AUTH_PW'] );
229 -
230 - if ( $has_php_auth_user || $has_php_auth_pw ) {
231 - $basic_user = LP_Helper::sanitize_params_submitted( $_SERVER['PHP_AUTH_USER'] ?? '' );
232 - if ( ! $this->looks_like_consumer_key( $basic_user ) ) {
233 - return array(
234 - 'present' => false,
235 - 'consumer_key' => '',
236 - 'consumer_secret' => '',
237 - );
238 - }
239 -
240 - return array(
241 - 'present' => true,
242 - 'consumer_key' => $basic_user,
243 - 'consumer_secret' => LP_Helper::sanitize_params_submitted( $_SERVER['PHP_AUTH_PW'] ?? '' ),
244 - );
245 - }
246 -
247 - $authorization = $this->get_authorization_header();
248 - if ( stripos( $authorization, 'Basic ' ) !== 0 ) {
249 - return array(
250 - 'present' => false,
251 - 'consumer_key' => '',
252 - 'consumer_secret' => '',
253 - );
254 - }
255 -
256 - $decoded = base64_decode( trim( substr( $authorization, 6 ) ), true );
257 - if ( false === $decoded || strpos( $decoded, ':' ) === false ) {
258 - return array(
259 - 'present' => true,
260 - 'consumer_key' => '',
261 - 'consumer_secret' => '',
262 - );
263 - }
264 -
265 - list( $consumer_key, $consumer_secret ) = explode( ':', $decoded, 2 );
266 - $consumer_key = LP_Helper::sanitize_params_submitted( $consumer_key, 'text', false );
267 - if ( ! $this->looks_like_consumer_key( $consumer_key ) ) {
268 - return array(
269 - 'present' => false,
270 - 'consumer_key' => '',
271 - 'consumer_secret' => '',
272 - );
273 - }
274 -
275 - return array(
276 - 'present' => true,
277 - 'consumer_key' => $consumer_key,
278 - 'consumer_secret' => LP_Helper::sanitize_params_submitted( $consumer_secret, 'text', false ),
279 - );
280 - }
281 -
282 - /**
283 - * Read Authorization header from server/global headers.
284 - *
285 - * @return string
286 - */
287 - protected function get_authorization_header(): string {
288 -
289 - $server_header_candidates = array(
290 - 'HTTP_AUTHORIZATION',
291 - 'REDIRECT_HTTP_AUTHORIZATION',
292 - 'REDIRECT_REDIRECT_HTTP_AUTHORIZATION',
293 - );
294 - foreach ( $server_header_candidates as $server_key ) {
295 - if ( ! empty( $_SERVER[ $server_key ] ) ) {
296 - return (string) wp_unslash( $_SERVER[ $server_key ] );
297 - }
298 - }
299 -
300 - if ( function_exists( 'getallheaders' ) ) {
301 - $headers = getallheaders();
302 - if ( is_array( $headers ) ) {
303 - foreach ( $headers as $key => $value ) {
304 - if ( 'authorization' === strtolower( (string) $key ) ) {
305 - return (string) $value;
306 - }
307 - }
308 - }
309 - }
310 -
311 - if ( function_exists( 'apache_request_headers' ) ) {
312 - $headers = apache_request_headers();
313 - if ( is_array( $headers ) ) {
314 - foreach ( $headers as $key => $value ) {
315 - if ( 'authorization' === strtolower( (string) $key ) ) {
316 - return (string) $value;
317 - }
318 - }
319 - }
320 - }
321 -
322 - return '';
323 - }
324 -
325 - /**
326 - * Whether current request targets the MCP default route.
327 - *
328 - * @return bool
329 - */
330 - protected function is_target_rest_request(): bool {
331 - $rest_route = isset( $_GET['rest_route'] ) ? LP_Helper::sanitize_params_submitted( $_GET['rest_route'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
332 - if ( '' !== $rest_route && $this->route_matches_mcp_target( $rest_route ) ) {
333 - return (bool) apply_filters( 'learn-press/mcp/api-keys/is-target-rest-request', true, $rest_route, self::MCP_ROUTE );
334 - }
335 - $request_uri = '';
336 - if ( ! empty( $_SERVER['REQUEST_URI'] ) ) {
337 - $request_uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
338 - }
339 -
340 - if ( '' === $request_uri ) {
341 - return false;
342 - }
343 -
344 - $rest_prefix = trailingslashit( rest_get_url_prefix() );
345 - $is_mcp_target = false;
346 - foreach ( $this->get_target_routes() as $route ) {
347 - $target_path = $rest_prefix . ltrim( $route, '/' );
348 - if ( false !== strpos( $request_uri, $target_path ) ) {
349 - $is_mcp_target = true;
350 - break;
351 - }
352 - }
353 -
354 - return (bool) apply_filters( 'learn-press/mcp/api-keys/is-target-rest-request', $is_mcp_target, $request_uri, self::MCP_ROUTE );
355 - }
356 - /**
357 - * Whether a WP_REST_Request route is the MCP endpoint.
358 - *
359 - * @param WP_REST_Request $request Current REST request object.
360 - *
361 - * @return bool
362 - */
363 - protected function is_target_route_from_request( WP_REST_Request $request ): bool {
364 -
365 - $route = (string) $request->get_route();
366 -
367 - return $this->route_matches_mcp_target( $route );
368 - }
369 -
370 - /**
371 - * Target routes that should use LearnPress MCP auth behavior.
372 - *
373 - * @return array<int, string>
374 - */
375 - protected function get_target_routes(): array {
376 -
377 - return array(
378 - self::MCP_ROUTE,
379 - self::MCP_ALIAS_ROUTE,
380 - );
381 - }
382 -
383 - /**
384 - * Whether a route path matches one of MCP target routes.
385 - *
386 - * @param string $route Route path from request.
387 - *
388 - * @return bool
389 - */
390 - protected function route_matches_mcp_target( string $route ): bool {
391 -
392 - foreach ( $this->get_target_routes() as $target_route ) {
393 - if ( 0 === strpos( $route, $target_route ) ) {
394 - return true;
395 - }
396 - }
397 -
398 - return false;
399 - }
400 - /**
401 - * Standardized invalid credentials error.
402 - *
403 - * @return WP_Error
404 - */
405 - protected function invalid_credentials_error(): WP_Error {
406 -
407 - return new WP_Error(
408 - 'learnpress_mcp_invalid_api_key_credentials',
409 - __( 'Invalid MCP API credentials.', 'learnpress' ),
410 - array( 'status' => 401 )
411 - );
412 - }
413 -
414 - /**
415 - * Validate expected consumer key format.
416 - *
417 - * @param string $consumer_key Plaintext consumer key.
418 - *
419 - * @return bool
420 - */
421 - protected function looks_like_consumer_key( string $consumer_key ): bool {
422 -
423 - return 1 === preg_match( '/^ck_[a-f0-9]{40}$/', $consumer_key );
424 - }
425 -}
1 +<?php
2 +
3 +namespace LearnPress\MCP\Auth;
4 +
5 +use LearnPress\MCP\Support\Errors;
6 +use LP_Helper;
7 +use WP_Error;
8 +use WP_REST_Request;
9 +
10 +defined( 'ABSPATH' ) || exit;
11 +
12 +/**
13 + * Authenticates MCP HTTP transport requests using LearnPress API keys.
14 + */
15 +class ApiKeyAuthenticator {
16 + /**
17 + * Core MCP REST route path.
18 + */
19 + public const MCP_ROUTE = '/mcp/mcp-adapter-default-server';
20 +
21 + /**
22 + * LearnPress MCP alias route.
23 + */
24 + public const MCP_ALIAS_ROUTE = '/lp/v1/mcp';
25 + /**
26 + * @var self|null
27 + */
28 + protected static $instance;
29 +
30 + /**
31 + * @var ApiKeysRepository
32 + */
33 + protected $keys_repository;
34 +
35 + /**
36 + * @var WP_Error|null
37 + */
38 + protected $auth_error;
39 +
40 + /**
41 + * @var bool
42 + */
43 + protected $api_key_present = false;
44 +
45 + /**
46 + * @var bool
47 + */
48 + protected $is_target_rest_request = false;
49 + /**
50 + * Bootstrap singleton.
51 + *
52 + * @return void
53 + */
54 + public static function init(): void {
55 +
56 + if ( self::$instance ) {
57 + return;
58 + }
59 +
60 + self::$instance = new self();
61 + }
62 +
63 + /**
64 + * Register repository and auth lifecycle hooks.
65 + *
66 + * @return void
67 + */
68 + protected function __construct() {
69 +
70 + $this->keys_repository = new ApiKeysRepository();
71 +
72 + add_filter( 'determine_current_user', array( $this, 'determine_current_user' ), 15 );
73 + add_filter( 'rest_authentication_errors', array( $this, 'rest_authentication_errors' ), 15 );
74 + add_filter( 'rest_post_dispatch', array( $this, 'rest_post_dispatch' ), 10, 3 );
75 + }
76 +
77 + /**
78 + * Determine current user for MCP route using API key credentials.
79 + *
80 + * @param int|false $user_id Previously resolved user ID.
81 + *
82 + * @return int|false
83 + */
84 + public function determine_current_user( $user_id ) {
85 + return $this->authenticate_request( $user_id );
86 + }
87 + /**
88 + * Normalize auth errors for invalid API key attempts.
89 + *
90 + * @param WP_Error|null|bool $error Existing error from other authenticators.
91 + *
92 + * @return WP_Error|null|bool
93 + */
94 + public function rest_authentication_errors( $error ) {
95 + if ( ! $this->is_target_rest_request && ! $this->is_target_rest_request() ) {
96 + return $error;
97 + }
98 +
99 + if ( ! empty( $error ) ) {
100 + return $error;
101 + }
102 +
103 + if ( ! AuthContext::is_api_key_auth() && ! ( $this->auth_error instanceof WP_Error ) ) {
104 + $resolved_user_id = $this->authenticate_request( 0 );
105 + if ( is_numeric( $resolved_user_id ) && (int) $resolved_user_id > 0 ) {
106 + wp_set_current_user( (int) $resolved_user_id );
107 + }
108 + }
109 +
110 + if ( $this->auth_error instanceof WP_Error ) {
111 + return $this->auth_error;
112 + }
113 +
114 + if ( ! AuthContext::is_api_key_auth() ) {
115 + return Errors::api_key_required();
116 + }
117 +
118 + return $error;
119 + }
120 +
121 + /**
122 + * Attempt API-key authentication for current MCP request.
123 + *
124 + * @param int|false $user_id Previously resolved user ID.
125 + *
126 + * @return int|false
127 + */
128 + protected function authenticate_request( $user_id ) {
129 +
130 + $this->auth_error = null;
131 + $this->api_key_present = false;
132 + $this->is_target_rest_request = $this->is_target_rest_request();
133 + if ( ! $this->is_target_rest_request ) {
134 + return $user_id;
135 + }
136 +
137 + AuthContext::reset();
138 +
139 + $credentials = $this->parse_credentials();
140 + if ( ! $credentials['present'] ) {
141 + return $user_id;
142 + }
143 + $this->api_key_present = true;
144 +
145 + $consumer_key = $credentials['consumer_key'];
146 + $consumer_secret = $credentials['consumer_secret'];
147 +
148 + if ( '' === $consumer_key || '' === $consumer_secret ) {
149 + $this->auth_error = Errors::invalid_api_credentials();
150 + return 0;
151 + }
152 +
153 + $key = $this->keys_repository->find_by_consumer_key( $consumer_key );
154 + if ( ! $key || empty( $key->consumer_secret ) || ! $this->keys_repository->verify_secret_hash( (string) $key->consumer_secret, $consumer_secret ) ) {
155 + $this->auth_error = Errors::invalid_api_credentials();
156 + return 0;
157 + }
158 +
159 + $resolved_user_id = absint( $key->user_id );
160 + if ( $resolved_user_id <= 0 || ! get_user_by( 'id', $resolved_user_id ) ) {
161 + $this->auth_error = Errors::invalid_api_credentials();
162 + return 0;
163 + }
164 +
165 + AuthContext::set_api_key_auth(
166 + absint( $key->key_id ),
167 + $resolved_user_id,
168 + (string) $key->permissions
169 + );
170 +
171 + return $resolved_user_id;
172 + }
173 +
174 + /**
175 + * Post-dispatch behavior: update usage metrics for API-key-authenticated requests.
176 + *
177 + * @param mixed $result REST response object.
178 + * @param mixed $server REST server instance.
179 + * @param WP_REST_Request $request Request object.
180 + *
181 + * @return mixed
182 + */
183 + public function rest_post_dispatch( $result, $server, $request ) {
184 + unset( $server );
185 +
186 + if ( ! ( $request instanceof WP_REST_Request ) ) {
187 + return $result;
188 + }
189 +
190 + if ( ! $this->is_target_route_from_request( $request ) ) {
191 + return $result;
192 + }
193 +
194 + if ( AuthContext::is_api_key_auth() && ! AuthContext::is_usage_touched() ) {
195 + $key_id = AuthContext::get_key_id();
196 + if ( $key_id > 0 ) {
197 + $this->keys_repository->touch_usage( $key_id );
198 + AuthContext::mark_usage_touched();
199 + }
200 + }
201 +
202 + return $result;
203 + }
204 + /**
205 + * Parse API key credentials from query params or Basic auth.
206 + *
207 + * @return array<string, mixed>
208 + */
209 + protected function parse_credentials(): array {
210 +
211 + $has_php_auth_user = isset( $_SERVER['PHP_AUTH_USER'] );
212 + $has_php_auth_pw = isset( $_SERVER['PHP_AUTH_PW'] );
213 +
214 + if ( $has_php_auth_user || $has_php_auth_pw ) {
215 + $basic_user = LP_Helper::sanitize_params_submitted( $_SERVER['PHP_AUTH_USER'] ?? '' );
216 + if ( ! $this->looks_like_consumer_key( $basic_user ) ) {
217 + return array(
218 + 'present' => false,
219 + 'consumer_key' => '',
220 + 'consumer_secret' => '',
221 + );
222 + }
223 +
224 + return array(
225 + 'present' => true,
226 + 'consumer_key' => $basic_user,
227 + 'consumer_secret' => LP_Helper::sanitize_params_submitted( $_SERVER['PHP_AUTH_PW'] ?? '' ),
228 + );
229 + }
230 +
231 + $authorization = $this->get_authorization_header();
232 + if ( stripos( $authorization, 'Basic ' ) !== 0 ) {
233 + return array(
234 + 'present' => false,
235 + 'consumer_key' => '',
236 + 'consumer_secret' => '',
237 + );
238 + }
239 +
240 + $decoded = base64_decode( trim( substr( $authorization, 6 ) ), true );
241 + if ( false === $decoded || strpos( $decoded, ':' ) === false ) {
242 + return array(
243 + 'present' => true,
244 + 'consumer_key' => '',
245 + 'consumer_secret' => '',
246 + );
247 + }
248 +
249 + list( $consumer_key, $consumer_secret ) = explode( ':', $decoded, 2 );
250 + $consumer_key = LP_Helper::sanitize_params_submitted( $consumer_key, 'text', false );
251 + if ( ! $this->looks_like_consumer_key( $consumer_key ) ) {
252 + return array(
253 + 'present' => false,
254 + 'consumer_key' => '',
255 + 'consumer_secret' => '',
256 + );
257 + }
258 +
259 + return array(
260 + 'present' => true,
261 + 'consumer_key' => $consumer_key,
262 + 'consumer_secret' => LP_Helper::sanitize_params_submitted( $consumer_secret, 'text', false ),
263 + );
264 + }
265 +
266 + /**
267 + * Read Authorization header from server/global headers.
268 + *
269 + * @return string
270 + */
271 + protected function get_authorization_header(): string {
272 +
273 + $server_header_candidates = array(
274 + 'HTTP_AUTHORIZATION',
275 + 'REDIRECT_HTTP_AUTHORIZATION',
276 + 'REDIRECT_REDIRECT_HTTP_AUTHORIZATION',
277 + );
278 + foreach ( $server_header_candidates as $server_key ) {
279 + if ( ! empty( $_SERVER[ $server_key ] ) ) {
280 + return (string) wp_unslash( $_SERVER[ $server_key ] );
281 + }
282 + }
283 +
284 + if ( function_exists( 'getallheaders' ) ) {
285 + $headers = getallheaders();
286 + if ( is_array( $headers ) ) {
287 + foreach ( $headers as $key => $value ) {
288 + if ( 'authorization' === strtolower( (string) $key ) ) {
289 + return (string) $value;
290 + }
291 + }
292 + }
293 + }
294 +
295 + if ( function_exists( 'apache_request_headers' ) ) {
296 + $headers = apache_request_headers();
297 + if ( is_array( $headers ) ) {
298 + foreach ( $headers as $key => $value ) {
299 + if ( 'authorization' === strtolower( (string) $key ) ) {
300 + return (string) $value;
301 + }
302 + }
303 + }
304 + }
305 +
306 + return '';
307 + }
308 +
309 + /**
310 + * Whether current request targets the MCP default route.
311 + *
312 + * @return bool
313 + */
314 + protected function is_target_rest_request(): bool {
315 + $rest_route = isset( $_GET['rest_route'] ) ? LP_Helper::sanitize_params_submitted( $_GET['rest_route'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
316 + if ( '' !== $rest_route && $this->route_matches_mcp_target( $rest_route ) ) {
317 + return (bool) apply_filters( 'learn-press/mcp/api-keys/is-target-rest-request', true, $rest_route, self::MCP_ROUTE );
318 + }
319 + $request_uri = '';
320 + if ( ! empty( $_SERVER['REQUEST_URI'] ) ) {
321 + $request_uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
322 + }
323 +
324 + if ( '' === $request_uri ) {
325 + return false;
326 + }
327 +
328 + $rest_prefix = trailingslashit( rest_get_url_prefix() );
329 + $is_mcp_target = false;
330 + foreach ( $this->get_target_routes() as $route ) {
331 + $target_path = $rest_prefix . ltrim( $route, '/' );
332 + if ( false !== strpos( $request_uri, $target_path ) ) {
333 + $is_mcp_target = true;
334 + break;
335 + }
336 + }
337 +
338 + return (bool) apply_filters( 'learn-press/mcp/api-keys/is-target-rest-request', $is_mcp_target, $request_uri, self::MCP_ROUTE );
339 + }
340 + /**
341 + * Whether a WP_REST_Request route is the MCP endpoint.
342 + *
343 + * @param WP_REST_Request $request Current REST request object.
344 + *
345 + * @return bool
346 + */
347 + protected function is_target_route_from_request( WP_REST_Request $request ): bool {
348 +
349 + $route = (string) $request->get_route();
350 +
351 + return $this->route_matches_mcp_target( $route );
352 + }
353 +
354 + /**
355 + * Target routes that should use LearnPress MCP auth behavior.
356 + *
357 + * @return array<int, string>
358 + */
359 + protected function get_target_routes(): array {
360 +
361 + return array(
362 + self::MCP_ROUTE,
363 + self::MCP_ALIAS_ROUTE,
364 + );
365 + }
366 +
367 + /**
368 + * Whether a route path matches one of MCP target routes.
369 + *
370 + * @param string $route Route path from request.
371 + *
372 + * @return bool
373 + */
374 + protected function route_matches_mcp_target( string $route ): bool {
375 +
376 + foreach ( $this->get_target_routes() as $target_route ) {
377 + if ( 0 === strpos( $route, $target_route ) ) {
378 + return true;
379 + }
380 + }
381 +
382 + return false;
383 + }
384 + /**
385 + * Validate expected consumer key format.
386 + *
387 + * @param string $consumer_key Plaintext consumer key.
388 + *
389 + * @return bool
390 + */
391 + protected function looks_like_consumer_key( string $consumer_key ): bool {
392 +
393 + return 1 === preg_match( '/^ck_[a-f0-9]{40}$/', $consumer_key );
394 + }
395 +}