PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.7.0
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.7.0
3.7.0 3.6.9 3.6.8 3.6.7 3.6.6 3.6.4 3.6.5 3.6.3 3.6.2 3.6.1 3.6.0 3.5.9 3.5.8 3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / labs / mcp-oauth.php
ai-engine / labs Last commit date
mcp-core.php 1 week ago mcp-oauth.php 6 days ago mcp-rest.php 1 month ago mcp.conf 1 year ago mcp.php 6 days ago model-audit.php 1 week ago workspace-mock.html 1 week ago wpai-connectors.php 3 months ago wpai-gateway-availability.php 3 months ago wpai-gateway-directory.php 3 months ago wpai-gateway-image-model.php 3 months ago wpai-gateway-model.php 3 months ago wpai-gateway-providers.php 3 months ago wpai-gateway.php 3 months ago
mcp-oauth.php
1044 lines
1 <?php
2
3 if ( !defined( 'ABSPATH' ) ) {
4 exit;
5 }
6
7 /**
8 * AI Engine MCP OAuth 2.1 module.
9 *
10 * Implements OAuth 2.1 with Dynamic Client Registration (RFC 7591),
11 * PKCE (RFC 7636, S256 only), Authorization Server Metadata (RFC 8414),
12 * Protected Resource Metadata (RFC 9728), and Token Revocation (RFC 7009),
13 * matching the MCP authorization specification.
14 *
15 * This module is additive: the legacy static bearer token continues to work
16 * for developer tooling. OAuth is the consumer-facing path used by clients
17 * like Claude Desktop that drive the user through a browser authorize flow.
18 */
19 class Meow_MWAI_Labs_MCP_OAuth {
20 public const DB_VERSION = '1.0.0';
21 /**
22 * States of the `revoked` column on a token row.
23 *
24 * ROTATED exists because a refresh must not kill the access token that was issued
25 * alongside the refresh token. Clients refresh before they switch over, and some
26 * keep using the previous access token for a while afterwards. Revoking the whole
27 * row at that moment made a token with up to an hour of life left start returning
28 * 401 mid-conversation, which clients report as "this connector requires
29 * additional permissions, reconnect it" and which reconnecting never fixes,
30 * because the next refresh does the same thing again. It looks random, it is
31 * entirely server-side, and it has nothing to do with the host.
32 *
33 * A rotated row can no longer refresh, but its access token stays valid until it
34 * expires on its own. REVOKED still means what it says: both halves die at once.
35 */
36 private const TOKEN_REVOKED = 1;
37 private const TOKEN_ROTATED = 2;
38 public const ACCESS_TOKEN_TTL = 3600; // 1 hour
39 public const REFRESH_TOKEN_TTL = 2592000; // 30 days
40 public const AUTH_CODE_TTL = 60; // seconds
41 public const NONCE_ACTION = 'mwai_mcp_oauth_consent';
42
43 private $core;
44 private $mcp;
45 private $namespace = 'mcp/v1';
46 private $logging = false;
47 private $table_clients;
48 private $table_tokens;
49
50 public function __construct( $core, $mcp ) {
51 global $wpdb;
52 $this->core = $core;
53 $this->mcp = $mcp;
54 $this->logging = method_exists( $mcp, 'is_logging_enabled' ) ? $mcp->is_logging_enabled() : false;
55 $this->table_clients = $wpdb->prefix . 'mwai_mcp_oauth_clients';
56 $this->table_tokens = $wpdb->prefix . 'mwai_mcp_oauth_tokens';
57
58 $this->maybe_upgrade_db();
59
60 add_action( 'rest_api_init', [ $this, 'register_routes' ] );
61 add_filter( 'rest_post_dispatch', [ $this, 'add_www_authenticate_header' ], 10, 3 );
62 // WP's REST cookie nonce check silently downgrades cookie-authed users to guest
63 // when no X-WP-Nonce is sent. The browser-driven authorize flow needs the user's
64 // identity from the cookie without a REST nonce, so we re-validate the auth cookie
65 // for that route. CSRF is enforced separately via our own consent nonce on POST.
66 add_filter( 'rest_authentication_errors', [ $this, 'reauth_for_authorize' ], 200 );
67 // Serve well-known metadata at the host root too. RFC 9728/8414 specify the
68 // well-known URI is built by inserting /.well-known/<suffix> between the host
69 // and the path of the resource/issuer, so strict clients query the host root
70 // rather than the nested REST path. Run very early to short-circuit WP's 404.
71 add_action( 'parse_request', [ $this, 'handle_host_root_wellknown' ], 1 );
72 }
73
74 /**
75 * Serve OAuth well-known metadata from the host root. Handles all three URL
76 * shapes that clients use in the wild: bare host-root, host-root + resource
77 * path (RFC strict), and the nested REST path is already covered by the REST
78 * route registration.
79 */
80 public function handle_host_root_wellknown() {
81 $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) $_SERVER['REQUEST_URI'] : '';
82 $path = strtok( $uri, '?' );
83 if ( $path === false || strpos( $path, '/.well-known/' ) !== 0 ) {
84 return;
85 }
86 if ( strpos( $path, '/.well-known/oauth-protected-resource' ) === 0 ) {
87 if ( $this->logging ) {
88 error_log( '[AI Engine MCP OAuth] Host-root PRM hit: ' . $path );
89 }
90 $this->emit_json( $this->protected_resource_metadata() );
91 }
92 if ( strpos( $path, '/.well-known/oauth-authorization-server' ) === 0 ) {
93 if ( $this->logging ) {
94 error_log( '[AI Engine MCP OAuth] Host-root ASM hit: ' . $path );
95 }
96 $this->emit_json( $this->authorization_server_metadata() );
97 }
98 }
99
100 private function emit_json( $payload ) {
101 status_header( 200 );
102 nocache_headers();
103 header( 'Content-Type: application/json; charset=utf-8' );
104 header( 'Access-Control-Allow-Origin: *' );
105 echo wp_json_encode( $payload );
106 exit;
107 }
108
109 private function protected_resource_metadata() {
110 $issuer = rest_url( $this->namespace );
111 return [
112 'resource' => rest_url( $this->namespace . '/http' ),
113 'authorization_servers' => [ $issuer ],
114 'bearer_methods_supported' => [ 'header' ],
115 'scopes_supported' => [ 'mcp' ],
116 'resource_documentation' => 'https://meowapps.com/ai-engine/',
117 ];
118 }
119
120 private function authorization_server_metadata() {
121 $issuer = rest_url( $this->namespace );
122 return [
123 'issuer' => $issuer,
124 'authorization_endpoint' => rest_url( $this->namespace . '/oauth/authorize' ),
125 'token_endpoint' => rest_url( $this->namespace . '/oauth/token' ),
126 'registration_endpoint' => rest_url( $this->namespace . '/oauth/register' ),
127 'revocation_endpoint' => rest_url( $this->namespace . '/oauth/revoke' ),
128 'response_types_supported' => [ 'code' ],
129 'grant_types_supported' => [ 'authorization_code', 'refresh_token' ],
130 'token_endpoint_auth_methods_supported' => [ 'none', 'client_secret_basic', 'client_secret_post' ],
131 'code_challenge_methods_supported' => [ 'S256' ],
132 'scopes_supported' => [ 'mcp' ],
133 ];
134 }
135
136 public function reauth_for_authorize( $result ) {
137 // Match the RESOLVED REST route, exactly. This used to be a substring test against
138 // $_SERVER['REQUEST_URI'], which includes the query string: appending
139 // "?x=/mcp/v1/oauth/authorize" to ANY REST request made this fire, restoring the
140 // cookie user's full identity on a route that WP had deliberately downgraded to
141 // guest for lack of an X-WP-Nonce. That turned every authenticated REST endpoint
142 // into a CSRF sink, e.g. a top-level navigation to /wp/v2/users with _method=POST
143 // creating an administrator (CVE-2026-15988). WP dispatches the request using this
144 // same query var, so an exact comparison against it cannot disagree with the route
145 // that actually runs.
146 $route = isset( $GLOBALS['wp']->query_vars['rest_route'] )
147 ? (string) $GLOBALS['wp']->query_vars['rest_route'] : '';
148 if ( $route === '' ) {
149 return $result;
150 }
151 $route = '/' . trim( $route, '/' );
152 if ( $route !== '/' . $this->namespace . '/oauth/authorize' ) {
153 return $result;
154 }
155 if ( !is_user_logged_in() ) {
156 $user_id = wp_validate_auth_cookie( '', 'logged_in' );
157 if ( $user_id ) {
158 wp_set_current_user( (int) $user_id );
159 }
160 }
161 return $result;
162 }
163
164 #region DB schema
165 private function maybe_upgrade_db() {
166 if ( get_option( 'mwai_mcp_oauth_db_version' ) === self::DB_VERSION ) {
167 return;
168 }
169
170 global $wpdb;
171 $charset_collate = $wpdb->get_charset_collate();
172
173 $sql_clients = "CREATE TABLE {$this->table_clients} (
174 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
175 client_id VARCHAR(64) NOT NULL,
176 client_secret_hash VARCHAR(64) NULL,
177 client_name VARCHAR(255) NULL,
178 redirect_uris LONGTEXT NOT NULL,
179 grant_types VARCHAR(255) NOT NULL DEFAULT 'authorization_code,refresh_token',
180 token_endpoint_auth_method VARCHAR(32) NOT NULL DEFAULT 'none',
181 scope VARCHAR(255) NULL,
182 created DATETIME NOT NULL,
183 PRIMARY KEY (id),
184 UNIQUE KEY client_id (client_id)
185 ) {$charset_collate};";
186
187 $sql_tokens = "CREATE TABLE {$this->table_tokens} (
188 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
189 client_id VARCHAR(64) NOT NULL,
190 user_id BIGINT(20) UNSIGNED NOT NULL,
191 access_token_hash VARCHAR(64) NOT NULL,
192 refresh_token_hash VARCHAR(64) NULL,
193 access_expires DATETIME NOT NULL,
194 refresh_expires DATETIME NULL,
195 scope VARCHAR(255) NULL,
196 created DATETIME NOT NULL,
197 last_used DATETIME NULL,
198 revoked TINYINT(1) NOT NULL DEFAULT 0,
199 PRIMARY KEY (id),
200 KEY access_token_hash (access_token_hash),
201 KEY refresh_token_hash (refresh_token_hash),
202 KEY client_id (client_id),
203 KEY user_id (user_id)
204 ) {$charset_collate};";
205
206 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
207 dbDelta( $sql_clients );
208 dbDelta( $sql_tokens );
209
210 update_option( 'mwai_mcp_oauth_db_version', self::DB_VERSION );
211 }
212 #endregion
213
214 #region Route registration
215 public function register_routes() {
216 // RFC 9728 — Protected Resource Metadata
217 register_rest_route( $this->namespace, '/.well-known/oauth-protected-resource', [
218 'methods' => 'GET',
219 'callback' => [ $this, 'handle_resource_metadata' ],
220 'permission_callback' => '__return_true',
221 ] );
222
223 // RFC 8414 — Authorization Server Metadata
224 register_rest_route( $this->namespace, '/.well-known/oauth-authorization-server', [
225 'methods' => 'GET',
226 'callback' => [ $this, 'handle_as_metadata' ],
227 'permission_callback' => '__return_true',
228 ] );
229
230 // RFC 7591 — Dynamic Client Registration
231 register_rest_route( $this->namespace, '/oauth/register', [
232 'methods' => 'POST',
233 'callback' => [ $this, 'handle_register' ],
234 'permission_callback' => '__return_true',
235 ] );
236
237 // Authorization endpoint (browser-driven, returns HTML or 302)
238 register_rest_route( $this->namespace, '/oauth/authorize', [
239 'methods' => [ 'GET', 'POST' ],
240 'callback' => [ $this, 'handle_authorize' ],
241 'permission_callback' => '__return_true',
242 ] );
243
244 // Token endpoint
245 register_rest_route( $this->namespace, '/oauth/token', [
246 'methods' => 'POST',
247 'callback' => [ $this, 'handle_token' ],
248 'permission_callback' => '__return_true',
249 ] );
250
251 // RFC 7009 — Token Revocation
252 register_rest_route( $this->namespace, '/oauth/revoke', [
253 'methods' => 'POST',
254 'callback' => [ $this, 'handle_revoke' ],
255 'permission_callback' => '__return_true',
256 ] );
257
258 // Admin-only: list active grants
259 register_rest_route( $this->namespace, '/oauth/apps', [
260 'methods' => 'GET',
261 'callback' => [ $this, 'handle_apps_list' ],
262 'permission_callback' => function () {
263 return current_user_can( 'manage_options' );
264 },
265 ] );
266
267 // Admin-only: revoke a grant by id
268 register_rest_route( $this->namespace, '/oauth/apps/(?P<id>\d+)', [
269 'methods' => 'DELETE',
270 'callback' => [ $this, 'handle_apps_revoke' ],
271 'permission_callback' => function () {
272 return current_user_can( 'manage_options' );
273 },
274 ] );
275 }
276 #endregion
277
278 #region Discovery (well-known)
279 public function handle_resource_metadata() {
280 return new WP_REST_Response( $this->protected_resource_metadata(), 200 );
281 }
282
283 public function handle_as_metadata() {
284 return new WP_REST_Response( $this->authorization_server_metadata(), 200 );
285 }
286 #endregion
287
288 #region Dynamic Client Registration (RFC 7591)
289 public function handle_register( WP_REST_Request $request ) {
290 $body = json_decode( $request->get_body(), true );
291 if ( !is_array( $body ) ) {
292 return $this->oauth_error( 'invalid_client_metadata', 'Request body must be JSON.', 400 );
293 }
294
295 $redirect_uris = $body['redirect_uris'] ?? null;
296 if ( !is_array( $redirect_uris ) || empty( $redirect_uris ) ) {
297 return $this->oauth_error( 'invalid_redirect_uri', 'redirect_uris is required and must be a non-empty array.', 400 );
298 }
299 foreach ( $redirect_uris as $uri ) {
300 if ( !is_string( $uri ) || $uri === '' ) {
301 return $this->oauth_error( 'invalid_redirect_uri', 'Each redirect_uri must be a non-empty string.', 400 );
302 }
303 // Light validation — allow http(s) and custom schemes (desktop clients use them).
304 if ( !preg_match( '#^[a-z][a-z0-9+.\-]*://#i', $uri ) ) {
305 return $this->oauth_error( 'invalid_redirect_uri', "redirect_uri must include a scheme: {$uri}", 400 );
306 }
307 }
308
309 $auth_method = isset( $body['token_endpoint_auth_method'] ) ? (string) $body['token_endpoint_auth_method'] : 'none';
310 if ( !in_array( $auth_method, [ 'none', 'client_secret_basic', 'client_secret_post' ], true ) ) {
311 return $this->oauth_error( 'invalid_client_metadata', "Unsupported token_endpoint_auth_method: {$auth_method}", 400 );
312 }
313
314 $grant_types = $body['grant_types'] ?? [ 'authorization_code', 'refresh_token' ];
315 if ( !is_array( $grant_types ) ) {
316 $grant_types = [ 'authorization_code', 'refresh_token' ];
317 }
318 foreach ( $grant_types as $gt ) {
319 if ( !in_array( $gt, [ 'authorization_code', 'refresh_token' ], true ) ) {
320 return $this->oauth_error( 'invalid_client_metadata', "Unsupported grant_type: {$gt}", 400 );
321 }
322 }
323
324 $client_id = $this->random_token( 32 );
325 $client_secret = null;
326 $client_secret_hash = null;
327 if ( $auth_method !== 'none' ) {
328 $client_secret = $this->random_token( 48 );
329 $client_secret_hash = hash( 'sha256', $client_secret );
330 }
331
332 $client_name = isset( $body['client_name'] ) ? sanitize_text_field( (string) $body['client_name'] ) : 'Unnamed MCP Client';
333
334 global $wpdb;
335 $inserted = $wpdb->insert( $this->table_clients, [
336 'client_id' => $client_id,
337 'client_secret_hash' => $client_secret_hash,
338 'client_name' => $client_name,
339 'redirect_uris' => wp_json_encode( array_values( $redirect_uris ) ),
340 'grant_types' => implode( ',', $grant_types ),
341 'token_endpoint_auth_method' => $auth_method,
342 'scope' => 'mcp',
343 'created' => current_time( 'mysql', 1 ),
344 ] );
345 if ( !$inserted ) {
346 return $this->oauth_error( 'server_error', 'Could not persist client registration.', 500 );
347 }
348
349 if ( $this->logging ) {
350 error_log( '[AI Engine MCP OAuth] Registered client: ' . $client_name . ' (' . $client_id . ')' );
351 }
352
353 $response = [
354 'client_id' => $client_id,
355 'client_name' => $client_name,
356 'redirect_uris' => array_values( $redirect_uris ),
357 'grant_types' => $grant_types,
358 'token_endpoint_auth_method' => $auth_method,
359 'client_id_issued_at' => time(),
360 ];
361 if ( $client_secret !== null ) {
362 $response['client_secret'] = $client_secret;
363 $response['client_secret_expires_at'] = 0; // never
364 }
365 return new WP_REST_Response( $response, 201 );
366 }
367 #endregion
368
369 #region Authorize (browser flow)
370 public function handle_authorize( WP_REST_Request $request ) {
371 $method = $request->get_method();
372
373 if ( $method === 'POST' ) {
374 $this->handle_authorize_submit( $request );
375 exit;
376 }
377
378 // GET — render consent page or redirect to login
379 $params = [
380 'response_type' => (string) ( $request->get_param( 'response_type' ) ?? '' ),
381 'client_id' => (string) ( $request->get_param( 'client_id' ) ?? '' ),
382 'redirect_uri' => (string) ( $request->get_param( 'redirect_uri' ) ?? '' ),
383 'state' => (string) ( $request->get_param( 'state' ) ?? '' ),
384 'scope' => (string) ( $request->get_param( 'scope' ) ?? 'mcp' ),
385 'code_challenge' => (string) ( $request->get_param( 'code_challenge' ) ?? '' ),
386 'code_challenge_method' => (string) ( $request->get_param( 'code_challenge_method' ) ?? '' ),
387 ];
388
389 if ( $params['response_type'] !== 'code' ) {
390 $this->render_error_page( 'Unsupported response_type. Only "code" is supported.' );
391 exit;
392 }
393 if ( $params['code_challenge'] === '' || $params['code_challenge_method'] !== 'S256' ) {
394 $this->render_error_page( 'PKCE is required: provide code_challenge and code_challenge_method=S256.' );
395 exit;
396 }
397
398 $client = $this->get_client( $params['client_id'] );
399 if ( !$client ) {
400 $this->render_error_page( 'Unknown client_id. The client must register via Dynamic Client Registration first.' );
401 exit;
402 }
403 if ( !$this->redirect_uri_registered( $client, $params['redirect_uri'] ) ) {
404 $this->render_error_page( 'redirect_uri does not match any registered URI for this client.' );
405 exit;
406 }
407
408 // Authentication gate — bounce to wp-login.php if not logged in.
409 if ( !is_user_logged_in() ) {
410 $current_url = rest_url( $this->namespace . '/oauth/authorize' );
411 $current_url = add_query_arg( $params, $current_url );
412 wp_safe_redirect( wp_login_url( $current_url ) );
413 exit;
414 }
415
416 $user = wp_get_current_user();
417 // Capability gate. MCP grants administrative tool access by design; allowing a
418 // non-admin to mint an OAuth token would let them act through the MCP layer with
419 // privileges they do not hold in WordPress itself.
420 if ( !$this->user_can_authorize( $user->ID ) ) {
421 if ( $this->logging ) {
422 error_log( '[AI Engine MCP OAuth] ❌ Non-admin user ' . $user->ID . ' tried to authorize client ' . $params['client_id'] );
423 }
424 $this->render_error_page( 'Only administrators can authorize MCP applications on this site.' );
425 exit;
426 }
427
428 $this->render_consent_page( $client, $params, $user );
429 exit;
430 }
431
432 private function handle_authorize_submit( WP_REST_Request $request ) {
433 if ( !is_user_logged_in() ) {
434 wp_safe_redirect( wp_login_url() );
435 exit;
436 }
437
438 if ( !$this->user_can_authorize( get_current_user_id() ) ) {
439 if ( $this->logging ) {
440 error_log( '[AI Engine MCP OAuth] ❌ Non-admin user ' . get_current_user_id() . ' attempted authorize submit' );
441 }
442 $this->render_error_page( 'Only administrators can authorize MCP applications on this site.' );
443 exit;
444 }
445
446 $nonce = (string) $request->get_param( '_mwai_nonce' );
447 if ( !wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
448 $this->render_error_page( 'Security check failed. Please try again from your application.' );
449 exit;
450 }
451
452 $client_id = (string) $request->get_param( 'client_id' );
453 $redirect_uri = (string) $request->get_param( 'redirect_uri' );
454 $state = (string) ( $request->get_param( 'state' ) ?? '' );
455 $code_challenge = (string) $request->get_param( 'code_challenge' );
456 $code_challenge_method = (string) $request->get_param( 'code_challenge_method' );
457 $scope = (string) ( $request->get_param( 'scope' ) ?? 'mcp' );
458 $action = (string) ( $request->get_param( 'action' ) ?? 'deny' );
459
460 $client = $this->get_client( $client_id );
461 if ( !$client || !$this->redirect_uri_registered( $client, $redirect_uri ) ) {
462 $this->render_error_page( 'Invalid client or redirect_uri.' );
463 exit;
464 }
465
466 if ( $action !== 'approve' ) {
467 $params = [ 'error' => 'access_denied', 'error_description' => 'User denied the request.' ];
468 if ( $state !== '' ) {
469 $params['state'] = $state;
470 }
471 wp_redirect( $this->append_params( $redirect_uri, $params ) );
472 exit;
473 }
474
475 // Generate authorization code and stash everything needed to mint a token later.
476 $code = $this->random_token( 48 );
477 $code_data = [
478 'client_id' => $client_id,
479 'user_id' => get_current_user_id(),
480 'redirect_uri' => $redirect_uri,
481 'code_challenge' => $code_challenge,
482 'code_challenge_method' => $code_challenge_method,
483 'scope' => $scope,
484 ];
485 set_transient( $this->auth_code_key( $code ), $code_data, self::AUTH_CODE_TTL );
486
487 $params = [ 'code' => $code ];
488 if ( $state !== '' ) {
489 $params['state'] = $state;
490 }
491
492 if ( $this->logging ) {
493 error_log( '[AI Engine MCP OAuth] Authorized user ' . get_current_user_id() . ' for client ' . $client_id );
494 }
495
496 wp_redirect( $this->append_params( $redirect_uri, $params ) );
497 exit;
498 }
499
500 private function auth_code_key( $code ) {
501 return 'mwai_mcp_oauth_code_' . hash( 'sha256', $code );
502 }
503 #endregion
504
505 #region Token endpoint
506 public function handle_token( WP_REST_Request $request ) {
507 $grant_type = (string) ( $request->get_param( 'grant_type' ) ?? '' );
508
509 // A failing refresh used to be completely silent, which made "the connector stops
510 // working after a while and re-authorizes itself" impossible to diagnose: every
511 // branch below returns a bare OAuth error to a client that reports it as a generic
512 // permission problem. Tokens are never logged, only a short hash prefix so two lines
513 // can be tied to the same grant.
514 if ( $this->logging ) {
515 error_log( '[AI Engine MCP OAuth] → /oauth/token grant_type=' . ( $grant_type ?: '(none)' ) );
516 }
517
518 if ( $grant_type === 'authorization_code' ) {
519 return $this->handle_token_auth_code( $request );
520 }
521 if ( $grant_type === 'refresh_token' ) {
522 return $this->handle_token_refresh( $request );
523 }
524 if ( $this->logging ) {
525 error_log( '[AI Engine MCP OAuth] ❌ Unsupported grant_type: ' . ( $grant_type ?: '(none)' ) );
526 }
527 return $this->oauth_error( 'unsupported_grant_type', 'Supported: authorization_code, refresh_token.', 400 );
528 }
529
530 /**
531 * Short, non-reversible marker for a token, so log lines can be correlated without
532 * ever writing a usable credential to disk.
533 */
534 private function token_marker( $token ) {
535 return substr( hash( 'sha256', (string) $token ), 0, 8 );
536 }
537
538 private function handle_token_auth_code( WP_REST_Request $request ) {
539 $code = (string) ( $request->get_param( 'code' ) ?? '' );
540 $redirect_uri = (string) ( $request->get_param( 'redirect_uri' ) ?? '' );
541 $code_verifier = (string) ( $request->get_param( 'code_verifier' ) ?? '' );
542 $client_id = (string) ( $request->get_param( 'client_id' ) ?? '' );
543
544 if ( $code === '' || $redirect_uri === '' || $code_verifier === '' ) {
545 return $this->oauth_error( 'invalid_request', 'Missing code, redirect_uri, or code_verifier.', 400 );
546 }
547
548 $key = $this->auth_code_key( $code );
549 $code_data = get_transient( $key );
550 if ( !is_array( $code_data ) ) {
551 return $this->oauth_error( 'invalid_grant', 'Authorization code is invalid or expired.', 400 );
552 }
553 // Single-use: delete immediately to prevent replay.
554 delete_transient( $key );
555
556 if ( $code_data['redirect_uri'] !== $redirect_uri ) {
557 return $this->oauth_error( 'invalid_grant', 'redirect_uri mismatch.', 400 );
558 }
559
560 $client = $this->get_client( $code_data['client_id'] );
561 if ( !$client ) {
562 return $this->oauth_error( 'invalid_client', 'Client not found.', 401 );
563 }
564 if ( $client_id !== '' && $client_id !== $client->client_id ) {
565 return $this->oauth_error( 'invalid_client', 'client_id mismatch.', 401 );
566 }
567 if ( !$this->authenticate_client_if_required( $client, $request ) ) {
568 return $this->oauth_error( 'invalid_client', 'Client authentication failed.', 401 );
569 }
570
571 // Verify PKCE.
572 $expected_challenge = rtrim( strtr( base64_encode( hash( 'sha256', $code_verifier, true ) ), '+/', '-_' ), '=' );
573 if ( !hash_equals( (string) $code_data['code_challenge'], $expected_challenge ) ) {
574 return $this->oauth_error( 'invalid_grant', 'PKCE verification failed.', 400 );
575 }
576
577 return $this->issue_token_pair( $client->client_id, (int) $code_data['user_id'], (string) $code_data['scope'] );
578 }
579
580 private function handle_token_refresh( WP_REST_Request $request ) {
581 $refresh_token = (string) ( $request->get_param( 'refresh_token' ) ?? '' );
582 $client_id = (string) ( $request->get_param( 'client_id' ) ?? '' );
583 if ( $refresh_token === '' ) {
584 if ( $this->logging ) {
585 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected: no refresh_token in the request.' );
586 }
587 return $this->oauth_error( 'invalid_request', 'Missing refresh_token.', 400 );
588 }
589
590 global $wpdb;
591 $hash = hash( 'sha256', $refresh_token );
592 $marker = $this->token_marker( $refresh_token );
593 $row = $wpdb->get_row(
594 $wpdb->prepare(
595 "SELECT * FROM {$this->table_tokens} WHERE refresh_token_hash = %s AND revoked = 0 LIMIT 1",
596 $hash
597 )
598 );
599 if ( !$row ) {
600 if ( $this->logging ) {
601 // Distinguish "never existed" from "already rotated or revoked": the second is the
602 // signature of a client refreshing twice with the same token, which rotation kills.
603 $revoked = $wpdb->get_var( $wpdb->prepare(
604 "SELECT id FROM {$this->table_tokens} WHERE refresh_token_hash = %s LIMIT 1",
605 $hash
606 ) );
607 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker . ': ' . ( $revoked
608 ? 'the grant exists but is revoked (already rotated, or revoked in Connected Apps).'
609 : 'no grant matches this refresh token.' ) );
610 }
611 return $this->oauth_error( 'invalid_grant', 'Refresh token is invalid or revoked.', 400 );
612 }
613 if ( $row->refresh_expires && strtotime( $row->refresh_expires . ' UTC' ) < time() ) {
614 if ( $this->logging ) {
615 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker
616 . ': refresh token expired on ' . $row->refresh_expires . ' UTC.' );
617 }
618 return $this->oauth_error( 'invalid_grant', 'Refresh token expired.', 400 );
619 }
620
621 $client = $this->get_client( $row->client_id );
622 if ( !$client ) {
623 if ( $this->logging ) {
624 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker
625 . ': client ' . $row->client_id . ' no longer exists.' );
626 }
627 return $this->oauth_error( 'invalid_client', 'Client not found.', 401 );
628 }
629 if ( $client_id !== '' && $client_id !== $client->client_id ) {
630 if ( $this->logging ) {
631 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker
632 . ': client_id in the request does not match the one on the grant.' );
633 }
634 return $this->oauth_error( 'invalid_client', 'client_id mismatch.', 401 );
635 }
636 if ( !$this->authenticate_client_if_required( $client, $request ) ) {
637 if ( $this->logging ) {
638 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker
639 . ': client authentication failed (method ' . $client->token_endpoint_auth_method
640 . '). If this client authenticates with client_secret_basic, check that the host'
641 . ' forwards the Authorization header on POST requests.' );
642 }
643 return $this->oauth_error( 'invalid_client', 'Client authentication failed.', 401 );
644 }
645
646 // Refresh-token rotation (OAuth 2.1 best practice): the old refresh token is
647 // spent, but the access token issued with it is NOT. See TOKEN_ROTATED.
648 $wpdb->update( $this->table_tokens, [ 'revoked' => self::TOKEN_ROTATED ], [ 'id' => $row->id ] );
649
650 if ( $this->logging ) {
651 error_log( '[AI Engine MCP OAuth] �
652 Refresh accepted for token ' . $marker
653 . ', user ' . (int) $row->user_id . ', client ' . $client->client_id
654 . '. New pair issued; the previous access token stays valid until '
655 . $row->access_expires . ' UTC.' );
656 }
657
658 return $this->issue_token_pair( $row->client_id, (int) $row->user_id, (string) $row->scope );
659 }
660
661 private function issue_token_pair( $client_id, $user_id, $scope ) {
662 global $wpdb;
663 $access_token = $this->random_token( 48 );
664 $refresh_token = $this->random_token( 48 );
665 $now = time();
666
667 $wpdb->insert( $this->table_tokens, [
668 'client_id' => $client_id,
669 'user_id' => $user_id,
670 'access_token_hash' => hash( 'sha256', $access_token ),
671 'refresh_token_hash' => hash( 'sha256', $refresh_token ),
672 'access_expires' => gmdate( 'Y-m-d H:i:s', $now + self::ACCESS_TOKEN_TTL ),
673 'refresh_expires' => gmdate( 'Y-m-d H:i:s', $now + self::REFRESH_TOKEN_TTL ),
674 'scope' => $scope,
675 'created' => gmdate( 'Y-m-d H:i:s', $now ),
676 ] );
677
678 $response = new WP_REST_Response( [
679 'access_token' => $access_token,
680 'token_type' => 'Bearer',
681 'expires_in' => self::ACCESS_TOKEN_TTL,
682 'refresh_token' => $refresh_token,
683 'scope' => $scope,
684 ], 200 );
685 $response->header( 'Cache-Control', 'no-store' );
686 $response->header( 'Pragma', 'no-cache' );
687 return $response;
688 }
689
690 private function authenticate_client_if_required( $client, WP_REST_Request $request ) {
691 if ( $client->token_endpoint_auth_method === 'none' ) {
692 return true;
693 }
694 $provided_secret = '';
695 if ( $client->token_endpoint_auth_method === 'client_secret_basic' ) {
696 $auth = $request->get_header( 'authorization' );
697 if ( $auth && preg_match( '#^Basic\s+(.+)$#i', $auth, $m ) ) {
698 $decoded = base64_decode( $m[1], true );
699 if ( $decoded && strpos( $decoded, ':' ) !== false ) {
700 [ $cid, $secret ] = explode( ':', $decoded, 2 );
701 if ( $cid === $client->client_id ) {
702 $provided_secret = $secret;
703 }
704 }
705 }
706 elseif ( isset( $_SERVER['PHP_AUTH_USER'] ) && $_SERVER['PHP_AUTH_USER'] === $client->client_id ) {
707 // Apache with mod_php performs HTTP Basic auth itself: it moves the credentials
708 // into PHP_AUTH_USER/PHP_AUTH_PW and never exposes the header, so get_header()
709 // above finds nothing even though the client sent one. A Bearer header is left
710 // alone, which is exactly why MCP tool calls keep working on these hosts while
711 // every single token refresh is rejected as invalid_client: the connection dies
712 // once the access token ages out and never comes back.
713 $provided_secret = (string) ( $_SERVER['PHP_AUTH_PW'] ?? '' );
714 }
715 }
716 else {
717 $provided_secret = (string) ( $request->get_param( 'client_secret' ) ?? '' );
718 }
719 if ( $provided_secret === '' || !$client->client_secret_hash ) {
720 return false;
721 }
722 return hash_equals( $client->client_secret_hash, hash( 'sha256', $provided_secret ) );
723 }
724 #endregion
725
726 #region Revocation
727 public function handle_revoke( WP_REST_Request $request ) {
728 $token = (string) ( $request->get_param( 'token' ) ?? '' );
729 if ( $token === '' ) {
730 // RFC 7009: return 200 even on unknown tokens to avoid information leakage.
731 return new WP_REST_Response( null, 200 );
732 }
733 global $wpdb;
734 $hash = hash( 'sha256', $token );
735 $wpdb->query( $wpdb->prepare(
736 "UPDATE {$this->table_tokens} SET revoked = 1 WHERE access_token_hash = %s OR refresh_token_hash = %s",
737 $hash,
738 $hash
739 ) );
740 return new WP_REST_Response( null, 200 );
741 }
742 #endregion
743
744 #region Capability gate
745 /**
746 * Whether a user is allowed to authorize an OAuth client and to use an OAuth
747 * access token against the MCP endpoint. Defaults to administrator only,
748 * matching the documented MCP access model. The filter exists so the planned
749 * multi-user MCP work can broaden this safely once per-token capability
750 * scoping lands; until then, allowing a non-admin here re-opens CVE-class
751 * privilege escalation through tools like wp_create_user.
752 *
753 * Test manage_options, not the 'administrator' role name. Passing a role name
754 * to user_can() only matches when that exact key sits in the user's
755 * capabilities meta, so admin-equivalent accounts (custom roles, caps granted
756 * individually, or a plugin filtering user_has_cap) were refused at the
757 * consent screen while every wp-admin settings page loaded fine for them.
758 * manage_options keeps the privilege-escalation fix intact: editors and below
759 * do not hold it, and multisite super admins pass via WP_User::has_cap().
760 */
761 public function user_can_authorize( $user_id ) {
762 $user_id = (int) $user_id;
763 $allowed = $user_id > 0 && user_can( $user_id, 'manage_options' );
764 return (bool) apply_filters( 'mwai_mcp_oauth_user_can_authorize', $allowed, $user_id );
765 }
766 #endregion
767
768 #region Token validation (called from MCP auth path)
769 /**
770 * Validate an access token for protected resource access.
771 * Returns [ 'user_id' => N, 'client_id' => '...', 'scope' => '...' ] on success, null on failure.
772 * Also touches last_used so the admin UI can show recent activity.
773 */
774 public function validate_token( $token ) {
775 if ( !is_string( $token ) || $token === '' ) {
776 return null;
777 }
778 global $wpdb;
779 $hash = hash( 'sha256', $token );
780 // Rotated grants still serve their access token; only an explicit revocation
781 // kills it on the spot.
782 $row = $wpdb->get_row(
783 $wpdb->prepare(
784 "SELECT t.*, c.client_name FROM {$this->table_tokens} t
785 LEFT JOIN {$this->table_clients} c ON c.client_id = t.client_id
786 WHERE t.access_token_hash = %s AND t.revoked <> %d LIMIT 1",
787 $hash,
788 self::TOKEN_REVOKED
789 )
790 );
791 // A rejected token used to be silent, which made "it works, then it doesn't"
792 // reports impossible to answer: nothing anywhere said why. Each branch below
793 // names the reason, and the marker lets one client's calls be followed across
794 // a log without ever writing a usable credential to disk.
795 if ( !$row ) {
796 if ( $this->logging ) {
797 $marker = $this->token_marker( $token );
798 $revoked = $wpdb->get_var( $wpdb->prepare(
799 "SELECT id FROM {$this->table_tokens} WHERE access_token_hash = %s LIMIT 1",
800 $hash
801 ) );
802 error_log( '[AI Engine MCP OAuth] ❌ Access token ' . $marker . ' rejected: ' . ( $revoked
803 ? 'this grant was revoked (from Connected Apps, or by the client signing out).'
804 : 'no grant matches this token. The client is using a credential this site never issued, or one whose grant has been deleted.' ) );
805 }
806 return null;
807 }
808 if ( strtotime( $row->access_expires . ' UTC' ) < time() ) {
809 if ( $this->logging ) {
810 error_log( '[AI Engine MCP OAuth] ❌ Access token ' . $this->token_marker( $token )
811 . ' rejected: it expired on ' . $row->access_expires . ' UTC. The client should refresh it.' );
812 }
813 return null;
814 }
815 // Touch last_used (non-blocking, single UPDATE).
816 $wpdb->update(
817 $this->table_tokens,
818 [ 'last_used' => current_time( 'mysql', 1 ) ],
819 [ 'id' => $row->id ]
820 );
821 return [
822 'user_id' => (int) $row->user_id,
823 'client_id' => $row->client_id,
824 'client_name' => $row->client_name,
825 'scope' => $row->scope,
826 ];
827 }
828 #endregion
829
830 #region Admin: list / revoke grants
831 public function handle_apps_list() {
832 global $wpdb;
833 $rows = $wpdb->get_results(
834 "SELECT t.id, t.client_id, t.user_id, t.created, t.last_used, t.access_expires, t.refresh_expires, t.revoked,
835 c.client_name
836 FROM {$this->table_tokens} t
837 LEFT JOIN {$this->table_clients} c ON c.client_id = t.client_id
838 WHERE t.revoked = 0
839 ORDER BY t.created DESC"
840 );
841 $out = [];
842 foreach ( $rows as $r ) {
843 $user = get_userdata( (int) $r->user_id );
844 $out[] = [
845 'id' => (int) $r->id,
846 'client_id' => $r->client_id,
847 'client_name' => $r->client_name ?: 'Unknown app',
848 'user_id' => (int) $r->user_id,
849 'user_login' => $user ? $user->user_login : 'deleted',
850 'user_display' => $user ? $user->display_name : 'Deleted user',
851 'created' => $r->created,
852 'last_used' => $r->last_used,
853 'access_expires' => $r->access_expires,
854 'refresh_expires' => $r->refresh_expires,
855 ];
856 }
857 return new WP_REST_Response( [ 'apps' => $out ], 200 );
858 }
859
860 public function handle_apps_revoke( WP_REST_Request $request ) {
861 $id = (int) $request->get_param( 'id' );
862 if ( $id <= 0 ) {
863 return new WP_REST_Response( [ 'error' => 'Invalid id.' ], 400 );
864 }
865 global $wpdb;
866 $wpdb->update( $this->table_tokens, [ 'revoked' => 1 ], [ 'id' => $id ] );
867 return new WP_REST_Response( [ 'revoked' => true ], 200 );
868 }
869 #endregion
870
871 #region Helpers
872 private function get_client( $client_id ) {
873 if ( !is_string( $client_id ) || $client_id === '' ) {
874 return null;
875 }
876 global $wpdb;
877 return $wpdb->get_row( $wpdb->prepare(
878 "SELECT * FROM {$this->table_clients} WHERE client_id = %s LIMIT 1",
879 $client_id
880 ) );
881 }
882
883 private function redirect_uri_registered( $client, $redirect_uri ) {
884 if ( !$client || !is_string( $redirect_uri ) || $redirect_uri === '' ) {
885 return false;
886 }
887 $registered = json_decode( $client->redirect_uris, true );
888 if ( !is_array( $registered ) ) {
889 return false;
890 }
891 foreach ( $registered as $uri ) {
892 if ( hash_equals( (string) $uri, $redirect_uri ) ) {
893 return true;
894 }
895 }
896 return false;
897 }
898
899 private function append_params( $url, $params ) {
900 $sep = strpos( $url, '?' ) === false ? '?' : '&';
901 return $url . $sep . http_build_query( $params );
902 }
903
904 private function random_token( $bytes = 32 ) {
905 return bin2hex( random_bytes( (int) $bytes ) );
906 }
907
908 private function oauth_error( $code, $description, $status = 400 ) {
909 $response = new WP_REST_Response( [
910 'error' => $code,
911 'error_description' => $description,
912 ], $status );
913 $response->header( 'Cache-Control', 'no-store' );
914 $response->header( 'Pragma', 'no-cache' );
915 return $response;
916 }
917
918 /**
919 * Add WWW-Authenticate header to 401 responses on the protected MCP route,
920 * pointing clients at the resource metadata document so they can discover
921 * the authorization server automatically.
922 */
923 public function add_www_authenticate_header( $response, $server, $request ) {
924 if ( !( $response instanceof WP_HTTP_Response ) ) {
925 return $response;
926 }
927 $route = $request instanceof WP_REST_Request ? $request->get_route() : '';
928 if ( $route !== '/' . $this->namespace . '/http' ) {
929 return $response;
930 }
931 $status = $response->get_status();
932 if ( $status !== 401 && $status !== 403 ) {
933 return $response;
934 }
935 $resource_metadata = rest_url( $this->namespace . '/.well-known/oauth-protected-resource' );
936 $response->header(
937 'WWW-Authenticate',
938 sprintf( 'Bearer realm="MCP", resource_metadata="%s"', $resource_metadata )
939 );
940 return $response;
941 }
942 #endregion
943
944 #region HTML rendering (consent + error pages)
945 private function render_consent_page( $client, $params, $user ) {
946 $nonce = wp_create_nonce( self::NONCE_ACTION );
947 $action_url = rest_url( $this->namespace . '/oauth/authorize' );
948 $site_name = get_bloginfo( 'name' );
949 $client_name = $client->client_name ?: 'Unnamed MCP Client';
950 $role_label = $this->describe_user_role( $user );
951
952 status_header( 200 );
953 nocache_headers();
954 header( 'Content-Type: text/html; charset=utf-8' );
955
956 $hidden_fields = [
957 'client_id' => $params['client_id'],
958 'redirect_uri' => $params['redirect_uri'],
959 'state' => $params['state'],
960 'scope' => $params['scope'],
961 'code_challenge' => $params['code_challenge'],
962 'code_challenge_method' => $params['code_challenge_method'],
963 '_mwai_nonce' => $nonce,
964 ];
965
966 echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">';
967 echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
968 echo '<title>' . esc_html( sprintf( 'Authorize %s', $client_name ) ) . '</title>';
969 echo $this->consent_styles();
970 echo '</head><body><main class="mwai-oauth-card">';
971
972 echo '<h1>Authorize this app</h1>';
973 echo '<p class="mwai-oauth-app"><strong>' . esc_html( $client_name ) . '</strong> wants to connect to <strong>' . esc_html( $site_name ) . '</strong>.</p>';
974
975 echo '<div class="mwai-oauth-meta">';
976 echo '<div><span class="mwai-oauth-label">Signed in as</span><span class="mwai-oauth-value">' . esc_html( $user->display_name ) . ' (' . esc_html( $user->user_login ) . ')</span></div>';
977 echo '<div><span class="mwai-oauth-label">Permissions</span><span class="mwai-oauth-value">' . esc_html( $role_label ) . '</span></div>';
978 echo '</div>';
979
980 echo '<p class="mwai-oauth-note">The app will be able to call MCP tools using your account. You can revoke access at any time from AI Engine settings.</p>';
981
982 echo '<form method="POST" action="' . esc_url( $action_url ) . '">';
983 foreach ( $hidden_fields as $name => $value ) {
984 echo '<input type="hidden" name="' . esc_attr( $name ) . '" value="' . esc_attr( $value ) . '">';
985 }
986 echo '<div class="mwai-oauth-buttons">';
987 echo '<button type="submit" name="action" value="approve" class="mwai-oauth-approve">Approve</button>';
988 echo '<button type="submit" name="action" value="deny" class="mwai-oauth-deny">Deny</button>';
989 echo '</div>';
990 echo '</form>';
991
992 echo '</main></body></html>';
993 }
994
995 private function render_error_page( $message ) {
996 status_header( 400 );
997 nocache_headers();
998 header( 'Content-Type: text/html; charset=utf-8' );
999 echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">';
1000 echo '<title>Authorization error</title>';
1001 echo $this->consent_styles();
1002 echo '</head><body><main class="mwai-oauth-card">';
1003 echo '<h1>Authorization error</h1>';
1004 echo '<p class="mwai-oauth-note">' . esc_html( $message ) . '</p>';
1005 echo '</main></body></html>';
1006 }
1007
1008 private function describe_user_role( $user ) {
1009 if ( !$user || empty( $user->roles ) ) {
1010 return 'No role';
1011 }
1012 $role = $user->roles[0];
1013 $names = [
1014 'administrator' => 'Administrator (full access)',
1015 'editor' => 'Editor',
1016 'author' => 'Author',
1017 'contributor' => 'Contributor',
1018 'subscriber' => 'Subscriber',
1019 ];
1020 return $names[ $role ] ?? ucfirst( $role );
1021 }
1022
1023 private function consent_styles() {
1024 return '<style>
1025 body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f1f2f5; color: #1d2330; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 20px; }
1026 .mwai-oauth-card { background: #fff; border-radius: 12px; box-shadow: 0 12px 40px rgba(0,0,0,0.08); padding: 36px 36px 28px; max-width: 440px; width: 100%; }
1027 .mwai-oauth-card h1 { font-size: 22px; margin: 0 0 16px; font-weight: 600; }
1028 .mwai-oauth-app { font-size: 15px; line-height: 1.5; margin: 0 0 24px; }
1029 .mwai-oauth-meta { background: #f7f8fa; border-radius: 8px; padding: 14px 16px; margin-bottom: 20px; }
1030 .mwai-oauth-meta > div { display: flex; justify-content: space-between; align-items: baseline; padding: 6px 0; font-size: 14px; }
1031 .mwai-oauth-label { color: #6b7280; }
1032 .mwai-oauth-value { color: #1d2330; font-weight: 500; text-align: right; }
1033 .mwai-oauth-note { font-size: 13px; color: #6b7280; line-height: 1.5; margin: 0 0 24px; }
1034 .mwai-oauth-buttons { display: flex; gap: 10px; }
1035 .mwai-oauth-buttons button { flex: 1; padding: 11px 14px; border-radius: 8px; border: 1px solid transparent; font-size: 14px; font-weight: 600; cursor: pointer; transition: background .15s; }
1036 .mwai-oauth-approve { background: #2271b1; color: #fff; }
1037 .mwai-oauth-approve:hover { background: #135e96; }
1038 .mwai-oauth-deny { background: #fff; color: #1d2330; border-color: #d0d4da; }
1039 .mwai-oauth-deny:hover { background: #f1f2f5; }
1040 </style>';
1041 }
1042 #endregion
1043 }
1044