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 / Transport / OAuthHttpTransport.php

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

325 lines 10.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP OAuth HTTP Transport.
4 *
5 * Custom transport that validates a JWT Bearer token before delegating to the
6 * standard MCP HttpRequestHandler. Auth is performed inside handle_request()
7 * (rather than check_permission()) so we can return a proper 401 response with
8 * a WWW-Authenticate challenge header — something WordPress's permission-callback
9 * mechanism cannot produce.
10 */
11
12 declare( strict_types=1 );
13
14 namespace WPMedia\MCP\OAuth\Transport;
15
16 use WP\MCP\Transport\Contracts\McpRestTransportInterface;
17 use WP\MCP\Transport\Infrastructure\HttpRequestContext;
18 use WP\MCP\Transport\Infrastructure\HttpRequestHandler;
19 use WP\MCP\Transport\Infrastructure\McpTransportContext;
20 use WP\MCP\Transport\Infrastructure\McpTransportHelperTrait;
21 use WPMedia\MCP\OAuth\Auth\JWT;
22 use WPMedia\MCP\OAuth\Auth\SecretManager;
23 use WPMedia\MCP\OAuth\Logging\McpLogger;
24
25 /**
26 * OAuth HTTP Transport for the MCP OAuth server.
27 */
28 class OAuthHttpTransport implements McpRestTransportInterface {
29 use McpTransportHelperTrait;
30
31 /**
32 * Delegates all MCP protocol handling (sessions, JSON-RPC, etc.).
33 *
34 * @var HttpRequestHandler
35 */
36 protected HttpRequestHandler $request_handler;
37
38 /**
39 * Constructor — registers the REST route on rest_api_init.
40 *
41 * @param McpTransportContext $transport_context Transport context provided by the MCP adapter.
42 */
43 public function __construct( McpTransportContext $transport_context ) {
44 $this->request_handler = new HttpRequestHandler( $transport_context );
45 add_action( 'rest_api_init', [ $this, 'register_routes' ], 16 );
46 }
47
48 /**
49 * Register the MCP OAuth REST route.
50 *
51 * @return void
52 */
53 public function register_routes(): void {
54 $server = $this->request_handler->transport_context->mcp_server;
55
56 register_rest_route(
57 $server->get_server_route_namespace(),
58 $server->get_server_route(),
59 [
60 'methods' => [ 'POST', 'GET', 'DELETE' ],
61 'callback' => [ $this, 'handle_request' ],
62 'permission_callback' => [ $this, 'check_permission' ],
63 ]
64 );
65 }
66
67 /**
68 * Permission callback — always returns true.
69 *
70 * Authentication is performed in handle_request() so we can return a 401
71 * response with the required WWW-Authenticate header. WordPress's built-in
72 * permission-callback mechanism only allows bool|\WP_Error returns and
73 * cannot set custom response headers.
74 *
75 * @param \WP_REST_Request $request Incoming request.
76 * @return true
77 */
78 public function check_permission( \WP_REST_Request $request ): bool {
79 return true;
80 }
81
82 /**
83 * Handle an incoming MCP request.
84 *
85 * Validates the JWT Bearer token first. On failure, returns a 401 response
86 * with a WWW-Authenticate challenge so OAuth 2.1 clients know how to
87 * authenticate. On success, establishes the WordPress user context and
88 * delegates to HttpRequestHandler for full MCP protocol compliance.
89 *
90 * @param \WP_REST_Request $request Incoming REST request.
91 * @return \WP_REST_Response MCP response.
92 */
93 public function handle_request( \WP_REST_Request $request ): \WP_REST_Response {
94 $user_or_error = $this->validate_bearer_token( $request );
95
96 if ( is_wp_error( $user_or_error ) ) {
97 return $this->build_auth_error_response( $user_or_error );
98 }
99
100 McpLogger::log(
101 'TRANSPORT',
102 'JWT validated, delegating to MCP handler',
103 [
104 'user_id' => $user_or_error->ID,
105 'method' => $request->get_method(),
106 ]
107 );
108
109 $context = new HttpRequestContext( $request );
110 return $this->request_handler->handle_request( $context );
111 }
112
113 /**
114 * Validate the JWT Bearer token from the Authorization header.
115 *
116 * Extracts the token, verifies the JWT signature and expiry, checks audience,
117 * confirms the anchoring Application Password has not been revoked, and calls
118 * wp_set_current_user() on success.
119 *
120 * @param \WP_REST_Request $request Incoming REST request.
121 * @return \WP_User|\WP_Error Authenticated user, or an error.
122 */
123 private function validate_bearer_token( \WP_REST_Request $request ) {
124 $authorization = $request->get_header( 'Authorization' );
125 $route = $request->get_route();
126 $method = $request->get_method();
127
128 McpLogger::log(
129 'TRANSPORT',
130 'validate_bearer_token called',
131 [
132 'method' => $method,
133 'route' => $route,
134 'headers' => McpLogger::safe_request_headers(),
135 'body' => McpLogger::safe_request_body(),
136 ]
137 );
138
139 if ( empty( $authorization ) || 0 !== strpos( $authorization, 'Bearer ' ) ) {
140 McpLogger::log(
141 'TRANSPORT',
142 'unauthenticated: no Bearer token',
143 [
144 'authorization_header' => $authorization ? substr( $authorization, 0, 20 ) . '...' : '(empty)',
145 ]
146 );
147 return $this->unauthenticated_error();
148 }
149
150 $token = substr( $authorization, 7 );
151 $secret = SecretManager::get_secret();
152 $claims = JWT::decode( $token, $secret );
153
154 if ( null === $claims ) {
155 McpLogger::log( 'TRANSPORT', 'rejected: JWT decode failed (signature invalid or token expired)' );
156 return $this->unauthenticated_error( 'invalid_token', 'JWT signature invalid or token expired.' );
157 }
158
159 // Audience must match the MCP OAuth REST endpoint.
160 $expected_aud = get_rest_url( null, 'mcp/mcp-oauth-server' );
161 $token_aud = $claims['aud'] ?? '';
162
163 if ( $token_aud !== $expected_aud ) {
164 McpLogger::log(
165 'TRANSPORT',
166 'rejected: JWT audience mismatch',
167 [
168 'token_aud' => $token_aud,
169 'expected_aud' => $expected_aud,
170 ]
171 );
172 return $this->unauthenticated_error( 'invalid_token', 'JWT audience mismatch.' );
173 }
174
175 // Issuer must be this site. A staging clone sharing the same JWT secret
176 // would otherwise allow cross-site token replay — the audience already
177 // embeds the site URL, but verifying iss explicitly keeps this check in
178 // step with the refresh-token flow in TokenEndpoint::handle_refresh_token().
179 // home_url() matches what TokenEndpoint mints into iss and the home-based
180 // base get_rest_url() uses for aud.
181 $expected_iss = home_url();
182 $token_iss = (string) ( $claims['iss'] ?? '' );
183
184 if ( $token_iss !== $expected_iss ) {
185 McpLogger::log(
186 'TRANSPORT',
187 'rejected: JWT issuer mismatch',
188 [
189 'token_iss' => $token_iss,
190 'expected_iss' => $expected_iss,
191 ]
192 );
193 return $this->unauthenticated_error( 'invalid_token', 'JWT issuer mismatch.' );
194 }
195
196 $user_id = (int) ( $claims['sub'] ?? 0 );
197 $app_pass_uuid = (string) ( $claims['app_pass_id'] ?? '' );
198
199 if ( 0 === $user_id || '' === $app_pass_uuid ) {
200 McpLogger::log(
201 'TRANSPORT',
202 'rejected: malformed JWT claims',
203 [
204 'has_sub' => isset( $claims['sub'] ) ? 'yes' : 'no',
205 'has_app_pass_id' => isset( $claims['app_pass_id'] ) ? 'yes' : 'no',
206 'claims_keys' => array_keys( $claims ),
207 ]
208 );
209 return $this->unauthenticated_error( 'invalid_token', 'Malformed JWT claims.' );
210 }
211
212 // Application Password revocation check.
213 $app_pass = \WP_Application_Passwords::get_user_application_password( $user_id, $app_pass_uuid );
214
215 if ( ! is_array( $app_pass ) ) {
216 McpLogger::log(
217 'TRANSPORT',
218 'rejected: Application Password revoked or not found',
219 [
220 'user_id' => $user_id,
221 'app_pass_uuid' => $app_pass_uuid,
222 ]
223 );
224 return $this->unauthenticated_error( 'invalid_token', 'MCP session has been revoked.' );
225 }
226
227 $user = get_user_by( 'id', $user_id );
228
229 if ( false === $user ) {
230 McpLogger::log( 'TRANSPORT', 'rejected: user not found', [ 'user_id' => $user_id ] );
231 return $this->unauthenticated_error( 'invalid_token', 'User not found.' );
232 }
233
234 // Set the current user so that get_current_user_id() returns the correct
235 // user for any library code that runs after authentication (e.g.
236 // HttpSessionValidator::create_session() in wordpress/mcp-adapter).
237 wp_set_current_user( $user_id );
238
239 McpLogger::log(
240 'TRANSPORT',
241 'authentication successful',
242 [
243 'user_id' => $user_id,
244 'user_login' => $user->user_login,
245 'app_pass_uuid' => $app_pass_uuid,
246 'token_exp' => $claims['exp'] ?? 'unknown',
247 'token_scope' => $claims['scope'] ?? 'unknown',
248 ]
249 );
250
251 return $user;
252 }
253
254 /**
255 * Build a WP_Error representing a 401 Unauthorized response.
256 *
257 * Includes a WWW-Authenticate Bearer challenge so OAuth 2.1 clients know
258 * where to find the protected-resource metadata.
259 *
260 * @param string $code OAuth error code (default 'unauthorized').
261 * @param string $description Human-readable message.
262 * @return \WP_Error
263 */
264 private function unauthenticated_error( string $code = 'unauthorized', string $description = '' ): \WP_Error {
265 // home_url(): the .well-known document is served via a rewrite rule, so the
266 // realm and resource_metadata URL must use the Site Address base.
267 $base_url = home_url();
268
269 $www_auth = sprintf(
270 'Bearer realm="%s", resource_metadata="%s/.well-known/oauth-protected-resource"',
271 esc_url( $base_url ),
272 esc_url( $base_url )
273 );
274
275 if ( '' !== $description ) {
276 $www_auth .= sprintf( ', error="%s", error_description="%s"', $code, $description );
277 }
278
279 return new \WP_Error(
280 'mcp_unauthorized',
281 '' !== $description ? $description : __( 'MCP authentication required.', 'mcp-oauth' ),
282 [
283 'status' => 401,
284 'WWW-Authenticate' => $www_auth,
285 ]
286 );
287 }
288
289 /**
290 * Build a 401 REST response with a WWW-Authenticate header.
291 *
292 * @param \WP_Error $error Authentication error.
293 * @return \WP_REST_Response
294 */
295 private function build_auth_error_response( \WP_Error $error ): \WP_REST_Response {
296 $error_data = $error->get_error_data();
297 $www_auth = is_array( $error_data ) ? ( $error_data['WWW-Authenticate'] ?? '' ) : '';
298 $status = is_array( $error_data ) ? (int) ( $error_data['status'] ?? 401 ) : 401;
299
300 $response = new \WP_REST_Response(
301 [
302 'code' => $error->get_error_code(),
303 'message' => $error->get_error_message(),
304 'data' => [ 'status' => $status ],
305 ],
306 $status
307 );
308
309 if ( '' !== $www_auth ) {
310 $response->header( 'WWW-Authenticate', $www_auth );
311 }
312
313 McpLogger::log(
314 'TRANSPORT',
315 'JWT validation failed, returning 401',
316 [
317 'error_code' => $error->get_error_code(),
318 'has_www_auth' => '' !== $www_auth ? 'yes' : 'no',
319 ]
320 );
321
322 return $response;
323 }
324 }
325