PluginProbe
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF / trunk
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF vtrunk
2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.9 2.2.8 trunk 1.10 1.3.3 1.3.4 1.3.5 1.3.5.1 1.3.5.2 1.3.6 1.3.6.1 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.5 All 103 releases
imagify / vendor / wp-media / mcp-oauth / inc / Auth / TokenEndpoint.php

TokenEndpoint.php in Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF trunk, at vendor/wp-media/mcp-oauth/inc/Auth/TokenEndpoint.php

518 lines 17.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Token Endpoint.
4 *
5 * Handles POST /oauth/token for two grant types:
6 * - authorization_code: PKCE verification → create Application Password → issue JWT pair.
7 * - refresh_token: verify refresh JWT → revocation check → issue new access JWT.
8 *
9 * Raw Application Passwords are never stored and are discarded immediately
10 * after creation. Only the UUID is retained inside the JWT claims for
11 * revocation checking.
12 */
13
14 declare(strict_types=1);
15
16 namespace WPMedia\MCP\OAuth\Auth;
17
18 use WPMedia\MCP\OAuth\Logging\McpLogger;
19
20 class TokenEndpoint {
21 use ParseBodyTrait;
22
23 /**
24 * User-meta key prefix storing the currently valid refresh-token id (jti)
25 * for a session. The session is identified by its Application Password UUID,
26 * so the full key is REFRESH_JTI_META_PREFIX . $app_pass_uuid.
27 */
28 const REFRESH_JTI_META_PREFIX = 'mcp_refresh_jti_';
29
30 /**
31 * Maximum number of concurrent MCP sessions (Application Passwords) retained
32 * per user per client. Every successful code exchange mints a new Application
33 * Password, so a client that re-runs the authorize→token flow repeatedly would
34 * otherwise accumulate unbounded rows — bloating the user's Application
35 * Passwords screen and slowing the per-request revocation lookup in
36 * OAuthHttpTransport, which scans all of the user's Application Passwords. When
37 * a new session would exceed this cap, the oldest sessions for that client are
38 * evicted first.
39 */
40 const MAX_SESSIONS_PER_CLIENT = 5;
41
42 /**
43 * Handle the token request.
44 *
45 * @return void
46 */
47 public function handle_request(): void {
48 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
49 $content_type = isset( $_SERVER['CONTENT_TYPE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['CONTENT_TYPE'] ) ) : '';
50
51 McpLogger::log(
52 'TOKEN',
53 'token request received',
54 [
55 'method' => $request_method,
56 'content_type' => $content_type,
57 'remote_addr' => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',
58 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
59 'headers' => McpLogger::safe_request_headers(),
60 'body' => McpLogger::safe_request_body(),
61 ]
62 );
63
64 if ( 'POST' !== $request_method ) {
65 McpLogger::log( 'TOKEN', 'rejected: wrong method', [ 'method' => $request_method ] );
66 $this->send_error( 405, 'invalid_request', 'Method not allowed.' );
67 return;
68 }
69
70 $body = $this->parse_body();
71
72 $grant_type = sanitize_text_field( $body['grant_type'] ?? '' );
73
74 McpLogger::log( 'TOKEN', 'grant_type received', [ 'grant_type' => $grant_type ] );
75
76 if ( 'authorization_code' === $grant_type ) {
77 $this->handle_authorization_code( $body );
78 } elseif ( 'refresh_token' === $grant_type ) {
79 $this->handle_refresh_token( $body );
80 } else {
81 McpLogger::log( 'TOKEN', 'rejected: unsupported grant_type', [ 'grant_type' => $grant_type ] );
82 $this->send_error( 400, 'unsupported_grant_type' );
83 }
84 }
85
86 /**
87 * Exchange an auth code for a JWT pair.
88 *
89 * @param array<string, mixed> $body Parsed request body.
90 * @return void
91 */
92 private function handle_authorization_code( array $body ): void {
93 $code = sanitize_text_field( $body['code'] ?? '' );
94 $code_verifier = sanitize_text_field( $body['code_verifier'] ?? '' );
95 $redirect_uri = esc_url_raw( $body['redirect_uri'] ?? '' );
96
97 McpLogger::log(
98 'TOKEN',
99 'authorization_code exchange: params',
100 [
101 'has_code' => '' !== $code ? 'yes' : 'no',
102 'has_code_verifier' => '' !== $code_verifier ? 'yes' : 'no',
103 'redirect_uri' => $redirect_uri,
104 ]
105 );
106
107 if ( '' === $code || '' === $code_verifier ) {
108 McpLogger::log( 'TOKEN', 'rejected: missing code or code_verifier' );
109 $this->send_error( 400, 'invalid_request', 'code and code_verifier are required.' );
110 return;
111 }
112
113 // Look up the single-use code, then atomically consume it. delete_transient()
114 // returns true for only one caller when two requests race for the same code
115 // (delete_option / wp_cache_delete report the row/key they actually removed),
116 // so a double-submitted or replayed code is rejected here rather than minting
117 // a second session.
118 $code_key = 'mcp_oauth_code_' . $code;
119 $code_data = get_transient( $code_key );
120
121 if ( false === $code_data || ! is_array( $code_data ) ) {
122 McpLogger::log( 'TOKEN', 'rejected: auth code transient missing or expired (60 s window)' );
123 $this->send_error( 400, 'invalid_grant', 'Code is invalid or has expired.' );
124 return;
125 }
126
127 if ( ! delete_transient( $code_key ) ) {
128 McpLogger::log( 'TOKEN', 'rejected: auth code already consumed (concurrent redemption)' );
129 $this->send_error( 400, 'invalid_grant', 'Code is invalid or has expired.' );
130 return;
131 }
132
133 // Verify PKCE S256: BASE64URL(SHA256(verifier)) must equal stored challenge.
134 $expected = JWT::base64url_encode( hash( 'sha256', $code_verifier, true ) );
135
136 if ( ! hash_equals( (string) $code_data['code_challenge'], $expected ) ) {
137 McpLogger::log( 'TOKEN', 'rejected: PKCE code_verifier does not match challenge' );
138 $this->send_error( 400, 'invalid_grant', 'PKCE code_verifier does not match challenge.' );
139 return;
140 }
141
142 // redirect_uri is required whenever it was included in the authorization request
143 // (OAuth 2.1 §4.1.3). AuthorizeEndpoint always requires it, so code_data always
144 // contains a non-empty value. Exact match is enforced; the client must send the
145 // same runtime URI (including ephemeral port for loopback) it used at authorize time.
146 if ( $redirect_uri !== (string) $code_data['redirect_uri'] ) {
147 McpLogger::log(
148 'TOKEN',
149 'rejected: redirect_uri missing or does not match authorization request',
150 [
151 'provided' => $redirect_uri,
152 'stored' => $code_data['redirect_uri'],
153 ]
154 );
155 $this->send_error( 400, 'invalid_grant', 'redirect_uri is required and must match the authorization request.' );
156 return;
157 }
158
159 $user_id = (int) $code_data['user_id'];
160 $client_id = (string) ( $code_data['client_id'] ?? '' );
161
162 // The Application Password name is required by WordPress core and must be
163 // non-empty. Fall back to the client host when the CIMD document omitted a
164 // client_name so a sparse-but-valid client cannot break the token exchange.
165 $client_name = (string) ( $code_data['client_name'] ?? '' );
166 if ( '' === $client_name ) {
167 $client_name = (string) wp_parse_url( $client_id, PHP_URL_HOST );
168 }
169 if ( '' === $client_name ) {
170 $client_name = 'MCP Client';
171 }
172
173 McpLogger::log( 'TOKEN', 'PKCE verified — creating Application Password', [ 'user_id' => $user_id ] );
174
175 // Bound the number of sessions this client can stockpile before adding one more.
176 $this->prune_sessions( $user_id, $client_id );
177
178 // Create a WordPress Application Password (raw password is discarded).
179 $result = \WP_Application_Passwords::create_new_application_password(
180 $user_id,
181 [
182 'name' => $client_name,
183 'app_id' => $client_id,
184 ],
185 );
186
187 if ( is_wp_error( $result ) ) {
188 McpLogger::log(
189 'TOKEN',
190 'server_error: Application Password creation failed',
191 [
192 'wp_error_code' => $result->get_error_code(),
193 'wp_error_message' => $result->get_error_message(),
194 'user_id' => $user_id,
195 ]
196 );
197 $this->send_error( 500, 'server_error', 'Could not create MCP session.' );
198 return;
199 }
200
201 // create_new_application_password() returns [raw_password, metadata]. Raw password is discarded.
202 $app_pass_uuid = (string) $result[1]['uuid'];
203
204 McpLogger::log(
205 'TOKEN',
206 'Application Password created — issuing token pair',
207 [
208 'user_id' => $user_id,
209 'app_pass_uuid' => $app_pass_uuid,
210 ]
211 );
212
213 $this->issue_token_pair( $user_id, $app_pass_uuid, $client_id );
214 }
215
216 /**
217 * Refresh an access token using a valid refresh JWT.
218 *
219 * @param array<string, mixed> $body Parsed request body.
220 * @return void
221 */
222 private function handle_refresh_token( array $body ): void {
223 $refresh_token = sanitize_text_field( $body['refresh_token'] ?? '' );
224
225 McpLogger::log( 'TOKEN', 'refresh_token grant: validating', [ 'has_refresh_token' => '' !== $refresh_token ? 'yes' : 'no' ] );
226
227 if ( '' === $refresh_token ) {
228 McpLogger::log( 'TOKEN', 'rejected: refresh_token missing' );
229 $this->send_error( 400, 'invalid_request', 'refresh_token is required.' );
230 return;
231 }
232
233 $secret = SecretManager::get_secret();
234 $claims = JWT::decode( $refresh_token, $secret );
235
236 if ( null === $claims || 'refresh' !== ( $claims['type'] ?? '' ) ) {
237 McpLogger::log(
238 'TOKEN',
239 'rejected: refresh token decode failed or wrong type',
240 [
241 'claims_null' => null === $claims ? 'yes' : 'no',
242 'type' => null !== $claims ? ( $claims['type'] ?? '(missing)' ) : 'n/a',
243 ]
244 );
245 $this->send_error( 401, 'invalid_token', 'Refresh token is invalid or expired.' );
246 return;
247 }
248
249 // Verify the token was issued by this site. A staging clone sharing the same
250 // JWT secret would otherwise allow cross-site token replay.
251 $token_iss = (string) ( $claims['iss'] ?? '' );
252 if ( home_url() !== $token_iss ) {
253 McpLogger::log(
254 'TOKEN',
255 'rejected: refresh token issuer mismatch',
256 [
257 'token_iss' => $token_iss,
258 'expected_iss' => home_url(),
259 ]
260 );
261 $this->send_error( 401, 'invalid_token', 'Refresh token was not issued by this server.' );
262 return;
263 }
264
265 $user_id = (int) $claims['sub'];
266 $app_pass_uuid = (string) ( $claims['app_pass_id'] ?? '' );
267 $client_id = (string) ( $claims['client_id'] ?? '' );
268
269 McpLogger::log(
270 'TOKEN',
271 'refresh token decoded — checking revocation',
272 [
273 'user_id' => $user_id,
274 'app_pass_uuid' => $app_pass_uuid,
275 ]
276 );
277
278 // Revocation check: if the Application Password was deleted the session is gone.
279 $app_pass = \WP_Application_Passwords::get_user_application_password( $user_id, $app_pass_uuid );
280
281 if ( ! is_array( $app_pass ) ) {
282 // Session is gone; drop the now-orphaned rotation marker too.
283 delete_user_meta( $user_id, self::REFRESH_JTI_META_PREFIX . $app_pass_uuid );
284 McpLogger::log(
285 'TOKEN',
286 'rejected: Application Password revoked or not found',
287 [
288 'user_id' => $user_id,
289 'app_pass_uuid' => $app_pass_uuid,
290 ]
291 );
292 $this->send_error( 401, 'invalid_token', 'MCP session has been revoked.' );
293 return;
294 }
295
296 // Refresh-token rotation / reuse detection (OAuth 2.1 §4.3.1, RFC 6819 §5.2.2.3).
297 // Each issued refresh token carries a unique jti; only the most recently
298 // issued jti for this session is accepted. Presenting any other (i.e. an
299 // already-rotated, previously-consumed) refresh token means the token has
300 // leaked and is being replayed — revoke the whole session so the attacker
301 // and the legitimate client are both forced to re-authenticate.
302 $presented_jti = (string) ( $claims['jti'] ?? '' );
303 $current_jti = (string) get_user_meta( $user_id, self::REFRESH_JTI_META_PREFIX . $app_pass_uuid, true );
304
305 if ( '' === $presented_jti || '' === $current_jti || ! hash_equals( $current_jti, $presented_jti ) ) {
306 McpLogger::log(
307 'TOKEN',
308 'SECURITY: refresh token reuse detected — revoking session',
309 [
310 'user_id' => $user_id,
311 'app_pass_uuid' => $app_pass_uuid,
312 'has_presented' => '' !== $presented_jti ? 'yes' : 'no',
313 'has_current' => '' !== $current_jti ? 'yes' : 'no',
314 ]
315 );
316 // Deleting the Application Password fires wp_delete_application_password,
317 // which purges the rotation marker via purge_refresh_jti_meta().
318 \WP_Application_Passwords::delete_application_password( $user_id, $app_pass_uuid );
319 $this->send_error( 401, 'invalid_grant', 'Refresh token has been revoked.' );
320 return;
321 }
322
323 McpLogger::log(
324 'TOKEN',
325 'refresh token valid — issuing new token pair',
326 [
327 'user_id' => $user_id,
328 'app_pass_uuid' => $app_pass_uuid,
329 ]
330 );
331
332 // Issue a new access token and rotate the refresh token: issue_token_pair()
333 // mints a fresh jti and overwrites the stored marker, invalidating the
334 // refresh token just presented.
335 $this->issue_token_pair( $user_id, $app_pass_uuid, $client_id );
336 }
337
338 /**
339 * Build and return an access + refresh JWT pair.
340 *
341 * @param int $user_id WordPress user ID.
342 * @param string $app_pass_uuid UUID of the Application Password.
343 * @param string $client_id Client ID URL from the CIMD record.
344 * @return void
345 */
346 private function issue_token_pair( int $user_id, string $app_pass_uuid, string $client_id = '' ): void {
347 $secret = SecretManager::get_secret();
348 // iss is home_url() (the Site Address), matching the base get_rest_url()
349 // uses for aud and where the OAuth/.well-known routes are actually served.
350 $issuer = home_url();
351 $now = time();
352 $aud = get_rest_url( null, 'mcp/mcp-oauth-server' );
353
354 // Mint a fresh refresh-token id and persist it as the only one accepted
355 // for this session. This overwrites any prior marker, so the previously
356 // issued refresh token (if any) is invalidated the instant this returns.
357 $refresh_jti = bin2hex( random_bytes( 16 ) );
358 update_user_meta( $user_id, self::REFRESH_JTI_META_PREFIX . $app_pass_uuid, $refresh_jti );
359
360 $access_payload = [
361 'iss' => $issuer,
362 'aud' => $aud,
363 'sub' => (string) $user_id,
364 'app_pass_id' => $app_pass_uuid,
365 'client_id' => $client_id,
366 'scope' => 'mcp',
367 'iat' => $now,
368 'exp' => $now + HOUR_IN_SECONDS,
369 ];
370
371 $refresh_payload = [
372 'iss' => $issuer,
373 'sub' => (string) $user_id,
374 'app_pass_id' => $app_pass_uuid,
375 'client_id' => $client_id,
376 'type' => 'refresh',
377 'jti' => $refresh_jti,
378 'iat' => $now,
379 'exp' => $now + ( 30 * DAY_IN_SECONDS ),
380 ];
381
382 $access_token = JWT::encode( $access_payload, $secret );
383 $refresh_token = JWT::encode( $refresh_payload, $secret );
384
385 McpLogger::log(
386 'TOKEN',
387 'token pair issued',
388 [
389 'user_id' => $user_id,
390 'app_pass_uuid' => $app_pass_uuid,
391 'access_exp' => $access_payload['exp'],
392 'refresh_exp' => $refresh_payload['exp'],
393 'access_token_len' => strlen( $access_token ),
394 ]
395 );
396
397 nocache_headers();
398 wp_send_json(
399 [
400 'access_token' => $access_token,
401 'token_type' => 'Bearer',
402 'expires_in' => HOUR_IN_SECONDS,
403 'refresh_token' => $refresh_token,
404 'scope' => 'mcp',
405 ]
406 );
407 }
408
409 /**
410 * Evict the oldest MCP sessions for a user+client so that creating one more
411 * stays within MAX_SESSIONS_PER_CLIENT.
412 *
413 * Only Application Passwords this feature created are considered — they are
414 * matched by the `app_id` we set to the client_id at creation time, so
415 * Application Passwords the user created for other integrations are never
416 * touched. Deleting one fires `wp_delete_application_password`, which removes
417 * its `mcp_refresh_jti_*` meta via purge_refresh_jti_meta(), so no orphaned
418 * rows are left behind.
419 *
420 * @param int $user_id WordPress user ID.
421 * @param string $client_id Client ID URL the sessions belong to.
422 * @return void
423 */
424 private function prune_sessions( int $user_id, string $client_id ): void {
425 if ( '' === $client_id ) {
426 return;
427 }
428
429 $passwords = \WP_Application_Passwords::get_user_application_passwords( $user_id );
430
431 // Keep only this feature's Application Passwords for this client.
432 $ours = array_values(
433 array_filter(
434 $passwords,
435 static function ( $item ) use ( $client_id ) {
436 return $item['app_id'] === $client_id;
437 }
438 )
439 );
440
441 // Below the cap: the new session about to be created still fits.
442 if ( count( $ours ) < self::MAX_SESSIONS_PER_CLIENT ) {
443 return;
444 }
445
446 // Oldest first.
447 usort(
448 $ours,
449 static function ( $a, $b ) {
450 return $a['created'] <=> $b['created'];
451 }
452 );
453
454 // Evict enough of the oldest so that, after the new one is created, the
455 // total sits at exactly MAX_SESSIONS_PER_CLIENT.
456 $evict_count = ( count( $ours ) - self::MAX_SESSIONS_PER_CLIENT ) + 1;
457
458 for ( $i = 0; $i < $evict_count; $i++ ) {
459 $uuid = (string) ( $ours[ $i ]['uuid'] ?? '' );
460 if ( '' === $uuid ) {
461 continue;
462 }
463
464 \WP_Application_Passwords::delete_application_password( $user_id, $uuid );
465
466 McpLogger::log(
467 'TOKEN',
468 'evicted oldest MCP session to enforce per-client cap',
469 [
470 'user_id' => $user_id,
471 'client_id' => $client_id,
472 'app_pass_uuid' => $uuid,
473 ]
474 );
475 }
476 }
477
478 /**
479 * Purge the stored refresh-token rotation marker for a deleted session.
480 *
481 * Hooked on WordPress's `wp_delete_application_password` action so the
482 * per-session user meta is removed wherever the Application Password that
483 * anchors the session is deleted — the revoke endpoint, the admin page, or
484 * WordPress core — leaving no orphaned rows behind.
485 *
486 * @param int $user_id WordPress user ID.
487 * @param array<string, mixed> $item The Application Password record being deleted.
488 * @return void
489 */
490 public function purge_refresh_jti_meta( int $user_id, array $item ): void {
491 $uuid = (string) ( $item['uuid'] ?? '' );
492
493 if ( '' === $uuid ) {
494 return;
495 }
496
497 delete_user_meta( $user_id, self::REFRESH_JTI_META_PREFIX . $uuid );
498 }
499
500 /**
501 * Send a JSON error response and exit.
502 *
503 * @param int $status HTTP status code.
504 * @param string $error OAuth error code.
505 * @param string $description Optional human-readable description.
506 * @return void
507 */
508 private function send_error( int $status, string $error, string $description = '' ): void {
509 status_header( $status );
510 nocache_headers();
511 $body = [ 'error' => $error ];
512 if ( '' !== $description ) {
513 $body['error_description'] = $description;
514 }
515 wp_send_json( $body );
516 }
517 }
518