PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.7.5
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.7.5
3.7.5 3.7.4 3.7.3 3.7.2 3.7.1 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 2 weeks ago mcp-oauth.php 2 days ago mcp-rest.php 2 months ago mcp.conf 1 year ago mcp.php 2 days ago model-audit.php 1 week ago workspace-mock.html 1 month ago wpai-connectors.php 3 months ago wpai-gateway-availability.php 4 months ago wpai-gateway-directory.php 4 months ago wpai-gateway-image-model.php 4 months ago wpai-gateway-model.php 4 months ago wpai-gateway-providers.php 4 months ago wpai-gateway.php 4 months ago
mcp-oauth.php
1139 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 /**
101 * Purge the OAuth discovery URLs from whatever page cache sits in front of WordPress.
102 *
103 * A cache that stored a 404 for these paths keeps serving it long after the plugin
104 * can answer properly, and the way sites get into that state is the plugin being
105 * inactive for a moment: an update, or a manual deactivation. Our no-cache headers
106 * cannot help, because our code is not running when that 404 is produced, and the
107 * result is an MCP connection that fails intermittently for days with nothing wrong
108 * on the site. Purging the two URLs the moment we come back is the only cure.
109 *
110 * Static, and called from a real activation hook, because during activation
111 * WordPress includes the plugin long after plugins_loaded has fired, so none of the
112 * usual module instances exist yet.
113 *
114 * LiteSpeed is handled directly, since that is where this was diagnosed. Any other
115 * cache can listen to mwai_mcp_purge_discovery_urls, which carries the same list.
116 */
117 public static function purge_discovery_cache() {
118 $urls = [
119 home_url( '/.well-known/oauth-protected-resource' ),
120 home_url( '/.well-known/oauth-authorization-server' ),
121 ];
122 foreach ( $urls as $url ) {
123 do_action( 'litespeed_purge_url', $url );
124 }
125 do_action( 'mwai_mcp_purge_discovery_urls', $urls );
126 }
127
128 private function emit_json( $payload ) {
129 status_header( 200 );
130 nocache_headers();
131 header( 'Content-Type: application/json; charset=utf-8' );
132 header( 'Access-Control-Allow-Origin: *' );
133 echo wp_json_encode( $payload );
134 exit;
135 }
136
137 private function protected_resource_metadata() {
138 $issuer = rest_url( $this->namespace );
139 return [
140 'resource' => rest_url( $this->namespace . '/http' ),
141 'authorization_servers' => [ $issuer ],
142 'bearer_methods_supported' => [ 'header' ],
143 'scopes_supported' => [ 'mcp' ],
144 'resource_documentation' => 'https://meowapps.com/ai-engine/',
145 ];
146 }
147
148 private function authorization_server_metadata() {
149 $issuer = rest_url( $this->namespace );
150 return [
151 'issuer' => $issuer,
152 'authorization_endpoint' => rest_url( $this->namespace . '/oauth/authorize' ),
153 'token_endpoint' => rest_url( $this->namespace . '/oauth/token' ),
154 'registration_endpoint' => rest_url( $this->namespace . '/oauth/register' ),
155 'revocation_endpoint' => rest_url( $this->namespace . '/oauth/revoke' ),
156 'response_types_supported' => [ 'code' ],
157 'grant_types_supported' => [ 'authorization_code', 'refresh_token' ],
158 'token_endpoint_auth_methods_supported' => [ 'none', 'client_secret_basic', 'client_secret_post' ],
159 'code_challenge_methods_supported' => [ 'S256' ],
160 'scopes_supported' => [ 'mcp' ],
161 ];
162 }
163
164 public function reauth_for_authorize( $result ) {
165 // Match the RESOLVED REST route, exactly. This used to be a substring test against
166 // $_SERVER['REQUEST_URI'], which includes the query string: appending
167 // "?x=/mcp/v1/oauth/authorize" to ANY REST request made this fire, restoring the
168 // cookie user's full identity on a route that WP had deliberately downgraded to
169 // guest for lack of an X-WP-Nonce. That turned every authenticated REST endpoint
170 // into a CSRF sink, e.g. a top-level navigation to /wp/v2/users with _method=POST
171 // creating an administrator (CVE-2026-15988). WP dispatches the request using this
172 // same query var, so an exact comparison against it cannot disagree with the route
173 // that actually runs.
174 $route = isset( $GLOBALS['wp']->query_vars['rest_route'] )
175 ? (string) $GLOBALS['wp']->query_vars['rest_route'] : '';
176 if ( $route === '' ) {
177 return $result;
178 }
179 $route = '/' . trim( $route, '/' );
180 if ( $route !== '/' . $this->namespace . '/oauth/authorize' ) {
181 return $result;
182 }
183 if ( !is_user_logged_in() ) {
184 $user_id = wp_validate_auth_cookie( '', 'logged_in' );
185 if ( $user_id ) {
186 wp_set_current_user( (int) $user_id );
187 }
188 }
189 return $result;
190 }
191
192 #region DB schema
193 private function maybe_upgrade_db() {
194 if ( get_option( 'mwai_mcp_oauth_db_version' ) === self::DB_VERSION ) {
195 return;
196 }
197
198 global $wpdb;
199 $charset_collate = $wpdb->get_charset_collate();
200
201 $sql_clients = "CREATE TABLE {$this->table_clients} (
202 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
203 client_id VARCHAR(64) NOT NULL,
204 client_secret_hash VARCHAR(64) NULL,
205 client_name VARCHAR(255) NULL,
206 redirect_uris LONGTEXT NOT NULL,
207 grant_types VARCHAR(255) NOT NULL DEFAULT 'authorization_code,refresh_token',
208 token_endpoint_auth_method VARCHAR(32) NOT NULL DEFAULT 'none',
209 scope VARCHAR(255) NULL,
210 created DATETIME NOT NULL,
211 PRIMARY KEY (id),
212 UNIQUE KEY client_id (client_id)
213 ) {$charset_collate};";
214
215 $sql_tokens = "CREATE TABLE {$this->table_tokens} (
216 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
217 client_id VARCHAR(64) NOT NULL,
218 user_id BIGINT(20) UNSIGNED NOT NULL,
219 access_token_hash VARCHAR(64) NOT NULL,
220 refresh_token_hash VARCHAR(64) NULL,
221 access_expires DATETIME NOT NULL,
222 refresh_expires DATETIME NULL,
223 scope VARCHAR(255) NULL,
224 created DATETIME NOT NULL,
225 last_used DATETIME NULL,
226 revoked TINYINT(1) NOT NULL DEFAULT 0,
227 PRIMARY KEY (id),
228 KEY access_token_hash (access_token_hash),
229 KEY refresh_token_hash (refresh_token_hash),
230 KEY client_id (client_id),
231 KEY user_id (user_id)
232 ) {$charset_collate};";
233
234 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
235 dbDelta( $sql_clients );
236 dbDelta( $sql_tokens );
237
238 update_option( 'mwai_mcp_oauth_db_version', self::DB_VERSION );
239 }
240 #endregion
241
242 #region Route registration
243 public function register_routes() {
244 // RFC 9728 — Protected Resource Metadata
245 register_rest_route( $this->namespace, '/.well-known/oauth-protected-resource', [
246 'methods' => 'GET',
247 'callback' => [ $this, 'handle_resource_metadata' ],
248 'permission_callback' => '__return_true',
249 ] );
250
251 // RFC 8414 — Authorization Server Metadata
252 register_rest_route( $this->namespace, '/.well-known/oauth-authorization-server', [
253 'methods' => 'GET',
254 'callback' => [ $this, 'handle_as_metadata' ],
255 'permission_callback' => '__return_true',
256 ] );
257
258 // RFC 7591 — Dynamic Client Registration
259 register_rest_route( $this->namespace, '/oauth/register', [
260 'methods' => 'POST',
261 'callback' => [ $this, 'handle_register' ],
262 'permission_callback' => '__return_true',
263 ] );
264
265 // Authorization endpoint (browser-driven, returns HTML or 302)
266 register_rest_route( $this->namespace, '/oauth/authorize', [
267 'methods' => [ 'GET', 'POST' ],
268 'callback' => [ $this, 'handle_authorize' ],
269 'permission_callback' => '__return_true',
270 ] );
271
272 // Token endpoint
273 register_rest_route( $this->namespace, '/oauth/token', [
274 'methods' => 'POST',
275 'callback' => [ $this, 'handle_token' ],
276 'permission_callback' => '__return_true',
277 ] );
278
279 // RFC 7009 — Token Revocation
280 register_rest_route( $this->namespace, '/oauth/revoke', [
281 'methods' => 'POST',
282 'callback' => [ $this, 'handle_revoke' ],
283 'permission_callback' => '__return_true',
284 ] );
285
286 // Admin-only: list active grants
287 register_rest_route( $this->namespace, '/oauth/apps', [
288 'methods' => 'GET',
289 'callback' => [ $this, 'handle_apps_list' ],
290 'permission_callback' => function () {
291 return current_user_can( 'manage_options' );
292 },
293 ] );
294
295 // Admin-only: revoke a grant by id
296 register_rest_route( $this->namespace, '/oauth/apps/(?P<id>\d+)', [
297 'methods' => 'DELETE',
298 'callback' => [ $this, 'handle_apps_revoke' ],
299 'permission_callback' => function () {
300 return current_user_can( 'manage_options' );
301 },
302 ] );
303 }
304 #endregion
305
306 #region Discovery (well-known)
307 public function handle_resource_metadata() {
308 return new WP_REST_Response( $this->protected_resource_metadata(), 200 );
309 }
310
311 public function handle_as_metadata() {
312 return new WP_REST_Response( $this->authorization_server_metadata(), 200 );
313 }
314 #endregion
315
316 #region Dynamic Client Registration (RFC 7591)
317 public function handle_register( WP_REST_Request $request ) {
318 $body = json_decode( $request->get_body(), true );
319 if ( !is_array( $body ) ) {
320 return $this->oauth_error( 'invalid_client_metadata', 'Request body must be JSON.', 400 );
321 }
322
323 $redirect_uris = $body['redirect_uris'] ?? null;
324 if ( !is_array( $redirect_uris ) || empty( $redirect_uris ) ) {
325 return $this->oauth_error( 'invalid_redirect_uri', 'redirect_uris is required and must be a non-empty array.', 400 );
326 }
327 foreach ( $redirect_uris as $uri ) {
328 if ( !is_string( $uri ) || $uri === '' ) {
329 return $this->oauth_error( 'invalid_redirect_uri', 'Each redirect_uri must be a non-empty string.', 400 );
330 }
331 // Light validation — allow http(s) and custom schemes (desktop clients use them).
332 if ( !preg_match( '#^[a-z][a-z0-9+.\-]*://#i', $uri ) ) {
333 return $this->oauth_error( 'invalid_redirect_uri', "redirect_uri must include a scheme: {$uri}", 400 );
334 }
335 }
336
337 $auth_method = isset( $body['token_endpoint_auth_method'] ) ? (string) $body['token_endpoint_auth_method'] : 'none';
338 if ( !in_array( $auth_method, [ 'none', 'client_secret_basic', 'client_secret_post' ], true ) ) {
339 return $this->oauth_error( 'invalid_client_metadata', "Unsupported token_endpoint_auth_method: {$auth_method}", 400 );
340 }
341
342 $grant_types = $body['grant_types'] ?? [ 'authorization_code', 'refresh_token' ];
343 if ( !is_array( $grant_types ) ) {
344 $grant_types = [ 'authorization_code', 'refresh_token' ];
345 }
346 foreach ( $grant_types as $gt ) {
347 if ( !in_array( $gt, [ 'authorization_code', 'refresh_token' ], true ) ) {
348 return $this->oauth_error( 'invalid_client_metadata', "Unsupported grant_type: {$gt}", 400 );
349 }
350 }
351
352 $client_id = $this->random_token( 32 );
353 $client_secret = null;
354 $client_secret_hash = null;
355 if ( $auth_method !== 'none' ) {
356 $client_secret = $this->random_token( 48 );
357 $client_secret_hash = hash( 'sha256', $client_secret );
358 }
359
360 $client_name = isset( $body['client_name'] ) ? sanitize_text_field( (string) $body['client_name'] ) : 'Unnamed MCP Client';
361
362 global $wpdb;
363 $inserted = $wpdb->insert( $this->table_clients, [
364 'client_id' => $client_id,
365 'client_secret_hash' => $client_secret_hash,
366 'client_name' => $client_name,
367 'redirect_uris' => wp_json_encode( array_values( $redirect_uris ) ),
368 'grant_types' => implode( ',', $grant_types ),
369 'token_endpoint_auth_method' => $auth_method,
370 'scope' => 'mcp',
371 'created' => current_time( 'mysql', 1 ),
372 ] );
373 if ( !$inserted ) {
374 return $this->oauth_error( 'server_error', 'Could not persist client registration.', 500 );
375 }
376
377 if ( $this->logging ) {
378 error_log( '[AI Engine MCP OAuth] Registered client: ' . $client_name . ' (' . $client_id . ')' );
379 }
380
381 $this->prune_orphan_clients();
382
383 $response = [
384 'client_id' => $client_id,
385 'client_name' => $client_name,
386 'redirect_uris' => array_values( $redirect_uris ),
387 'grant_types' => $grant_types,
388 'token_endpoint_auth_method' => $auth_method,
389 'client_id_issued_at' => time(),
390 ];
391 if ( $client_secret !== null ) {
392 $response['client_secret'] = $client_secret;
393 $response['client_secret_expires_at'] = 0; // never
394 }
395 return new WP_REST_Response( $response, 201 );
396 }
397 #endregion
398
399 #region Authorize (browser flow)
400 public function handle_authorize( WP_REST_Request $request ) {
401 $method = $request->get_method();
402
403 if ( $method === 'POST' ) {
404 $this->handle_authorize_submit( $request );
405 exit;
406 }
407
408 // GET — render consent page or redirect to login
409 $params = [
410 'response_type' => (string) ( $request->get_param( 'response_type' ) ?? '' ),
411 'client_id' => (string) ( $request->get_param( 'client_id' ) ?? '' ),
412 'redirect_uri' => (string) ( $request->get_param( 'redirect_uri' ) ?? '' ),
413 'state' => (string) ( $request->get_param( 'state' ) ?? '' ),
414 'scope' => (string) ( $request->get_param( 'scope' ) ?? 'mcp' ),
415 'code_challenge' => (string) ( $request->get_param( 'code_challenge' ) ?? '' ),
416 'code_challenge_method' => (string) ( $request->get_param( 'code_challenge_method' ) ?? '' ),
417 // RFC 8707. ChatGPT and other current clients send this; we carry it through the
418 // consent POST so it survives to the token exchange. Deliberately not enforced:
419 // we expose exactly one resource, so a mismatch cannot widen a token's reach, and
420 // rejecting on it would break any client whose idea of the URL differs harmlessly
421 // (a trailing slash, www against apex). It is recorded, and logged when it differs.
422 'resource' => (string) ( $request->get_param( 'resource' ) ?? '' ),
423 ];
424
425 // Log the hit itself. Without this, a client that registers and then stops is
426 // indistinguishable from a client that reached the consent screen and was refused,
427 // because every branch below only renders a page in the user's browser. That
428 // ambiguity has cost several support rounds.
429 if ( $this->logging ) {
430 error_log( '[AI Engine MCP OAuth] → GET /oauth/authorize client_id='
431 . ( $params['client_id'] ?: '(none)' ) . ' redirect_uri=' . ( $params['redirect_uri'] ?: '(none)' )
432 . ' scope=' . $params['scope'] );
433 }
434
435 if ( $params['response_type'] !== 'code' ) {
436 $this->log_authorize_refusal( 'unsupported response_type: ' . ( $params['response_type'] ?: '(none)' ) );
437 $this->render_error_page( 'Unsupported response_type. Only "code" is supported.' );
438 exit;
439 }
440 if ( $params['code_challenge'] === '' || $params['code_challenge_method'] !== 'S256' ) {
441 $this->log_authorize_refusal( 'PKCE missing or not S256 (method: '
442 . ( $params['code_challenge_method'] ?: '(none)' ) . ')' );
443 $this->render_error_page( 'PKCE is required: provide code_challenge and code_challenge_method=S256.' );
444 exit;
445 }
446
447 $client = $this->get_client( $params['client_id'] );
448 if ( !$client ) {
449 $this->log_authorize_refusal( 'unknown client_id ' . ( $params['client_id'] ?: '(none)' ) );
450 $this->render_error_page( 'Unknown client_id. The client must register via Dynamic Client Registration first.' );
451 exit;
452 }
453 if ( !$this->redirect_uri_registered( $client, $params['redirect_uri'] ) ) {
454 $this->log_authorize_refusal( 'redirect_uri not registered for this client: ' . $params['redirect_uri'] );
455 $this->render_error_page( 'redirect_uri does not match any registered URI for this client.' );
456 exit;
457 }
458
459 // Authentication gate — bounce to wp-login.php if not logged in.
460 if ( !is_user_logged_in() ) {
461 $current_url = rest_url( $this->namespace . '/oauth/authorize' );
462 $current_url = add_query_arg( $params, $current_url );
463 // Never let this redirect be cached. Full-page caches that also cache the REST
464 // API (LiteSpeed Cache's "Cache REST API", for one) key it on the request URL,
465 // so the first hit on a given authorize URL wins. That first hit is typically a
466 // backend probe from the connector infrastructure, with no cookies, and the
467 // user's real browser is then served the cached bounce-to-login instead of the
468 // consent screen. Every attempt fails, and nothing reaches PHP to be logged.
469 nocache_headers();
470 wp_safe_redirect( wp_login_url( $current_url ) );
471 exit;
472 }
473
474 $user = wp_get_current_user();
475 // Capability gate. MCP grants administrative tool access by design; allowing a
476 // non-admin to mint an OAuth token would let them act through the MCP layer with
477 // privileges they do not hold in WordPress itself.
478 if ( !$this->user_can_authorize( $user->ID ) ) {
479 if ( $this->logging ) {
480 error_log( '[AI Engine MCP OAuth] ❌ Non-admin user ' . $user->ID . ' tried to authorize client ' . $params['client_id'] );
481 }
482 $this->render_error_page( 'Only administrators can authorize MCP applications on this site.' );
483 exit;
484 }
485
486 $this->render_consent_page( $client, $params, $user );
487 exit;
488 }
489
490 private function log_authorize_refusal( $reason ) {
491 if ( $this->logging ) {
492 error_log( '[AI Engine MCP OAuth] ❌ Authorize refused: ' . $reason );
493 }
494 }
495
496 private function handle_authorize_submit( WP_REST_Request $request ) {
497 if ( !is_user_logged_in() ) {
498 nocache_headers();
499 wp_safe_redirect( wp_login_url() );
500 exit;
501 }
502
503 if ( !$this->user_can_authorize( get_current_user_id() ) ) {
504 if ( $this->logging ) {
505 error_log( '[AI Engine MCP OAuth] ❌ Non-admin user ' . get_current_user_id() . ' attempted authorize submit' );
506 }
507 $this->render_error_page( 'Only administrators can authorize MCP applications on this site.' );
508 exit;
509 }
510
511 $nonce = (string) $request->get_param( '_mwai_nonce' );
512 if ( !wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
513 $this->render_error_page( 'Security check failed. Please try again from your application.' );
514 exit;
515 }
516
517 $client_id = (string) $request->get_param( 'client_id' );
518 $redirect_uri = (string) $request->get_param( 'redirect_uri' );
519 $state = (string) ( $request->get_param( 'state' ) ?? '' );
520 $code_challenge = (string) $request->get_param( 'code_challenge' );
521 $code_challenge_method = (string) $request->get_param( 'code_challenge_method' );
522 $scope = (string) ( $request->get_param( 'scope' ) ?? 'mcp' );
523 $action = (string) ( $request->get_param( 'action' ) ?? 'deny' );
524
525 $client = $this->get_client( $client_id );
526 if ( !$client || !$this->redirect_uri_registered( $client, $redirect_uri ) ) {
527 $this->render_error_page( 'Invalid client or redirect_uri.' );
528 exit;
529 }
530
531 if ( $action !== 'approve' ) {
532 $params = [ 'error' => 'access_denied', 'error_description' => 'User denied the request.' ];
533 if ( $state !== '' ) {
534 $params['state'] = $state;
535 }
536 wp_redirect( $this->append_params( $redirect_uri, $params ) );
537 exit;
538 }
539
540 // Generate authorization code and stash everything needed to mint a token later.
541 $code = $this->random_token( 48 );
542 $code_data = [
543 'client_id' => $client_id,
544 'user_id' => get_current_user_id(),
545 'redirect_uri' => $redirect_uri,
546 'code_challenge' => $code_challenge,
547 'code_challenge_method' => $code_challenge_method,
548 'scope' => $scope,
549 'resource' => (string) ( $request->get_param( 'resource' ) ?? '' ),
550 ];
551 if ( $this->logging && $code_data['resource'] !== ''
552 && $code_data['resource'] !== rest_url( $this->namespace . '/http' ) ) {
553 error_log( '[AI Engine MCP OAuth] Client asked for resource ' . $code_data['resource']
554 . ', we serve ' . rest_url( $this->namespace . '/http' ) . '. Accepted anyway.' );
555 }
556 set_transient( $this->auth_code_key( $code ), $code_data, self::AUTH_CODE_TTL );
557
558 $params = [ 'code' => $code ];
559 if ( $state !== '' ) {
560 $params['state'] = $state;
561 }
562
563 if ( $this->logging ) {
564 error_log( '[AI Engine MCP OAuth] Authorized user ' . get_current_user_id() . ' for client ' . $client_id );
565 }
566
567 wp_redirect( $this->append_params( $redirect_uri, $params ) );
568 exit;
569 }
570
571 private function auth_code_key( $code ) {
572 return 'mwai_mcp_oauth_code_' . hash( 'sha256', $code );
573 }
574 #endregion
575
576 #region Token endpoint
577 public function handle_token( WP_REST_Request $request ) {
578 $grant_type = (string) ( $request->get_param( 'grant_type' ) ?? '' );
579
580 // A failing refresh used to be completely silent, which made "the connector stops
581 // working after a while and re-authorizes itself" impossible to diagnose: every
582 // branch below returns a bare OAuth error to a client that reports it as a generic
583 // permission problem. Tokens are never logged, only a short hash prefix so two lines
584 // can be tied to the same grant.
585 if ( $this->logging ) {
586 error_log( '[AI Engine MCP OAuth] → /oauth/token grant_type=' . ( $grant_type ?: '(none)' ) );
587 }
588
589 if ( $grant_type === 'authorization_code' ) {
590 return $this->handle_token_auth_code( $request );
591 }
592 if ( $grant_type === 'refresh_token' ) {
593 return $this->handle_token_refresh( $request );
594 }
595 if ( $this->logging ) {
596 error_log( '[AI Engine MCP OAuth] ❌ Unsupported grant_type: ' . ( $grant_type ?: '(none)' ) );
597 }
598 return $this->oauth_error( 'unsupported_grant_type', 'Supported: authorization_code, refresh_token.', 400 );
599 }
600
601 /**
602 * Short, non-reversible marker for a token, so log lines can be correlated without
603 * ever writing a usable credential to disk.
604 */
605 private function token_marker( $token ) {
606 return substr( hash( 'sha256', (string) $token ), 0, 8 );
607 }
608
609 private function handle_token_auth_code( WP_REST_Request $request ) {
610 $code = (string) ( $request->get_param( 'code' ) ?? '' );
611 $redirect_uri = (string) ( $request->get_param( 'redirect_uri' ) ?? '' );
612 $code_verifier = (string) ( $request->get_param( 'code_verifier' ) ?? '' );
613 $client_id = (string) ( $request->get_param( 'client_id' ) ?? '' );
614
615 if ( $code === '' || $redirect_uri === '' || $code_verifier === '' ) {
616 return $this->oauth_error( 'invalid_request', 'Missing code, redirect_uri, or code_verifier.', 400 );
617 }
618
619 $key = $this->auth_code_key( $code );
620 $code_data = get_transient( $key );
621 if ( !is_array( $code_data ) ) {
622 return $this->oauth_error( 'invalid_grant', 'Authorization code is invalid or expired.', 400 );
623 }
624 // Single-use: delete immediately to prevent replay.
625 delete_transient( $key );
626
627 if ( $code_data['redirect_uri'] !== $redirect_uri ) {
628 return $this->oauth_error( 'invalid_grant', 'redirect_uri mismatch.', 400 );
629 }
630
631 $client = $this->get_client( $code_data['client_id'] );
632 if ( !$client ) {
633 return $this->oauth_error( 'invalid_client', 'Client not found.', 401 );
634 }
635 if ( $client_id !== '' && $client_id !== $client->client_id ) {
636 return $this->oauth_error( 'invalid_client', 'client_id mismatch.', 401 );
637 }
638 if ( !$this->authenticate_client_if_required( $client, $request ) ) {
639 return $this->oauth_error( 'invalid_client', 'Client authentication failed.', 401 );
640 }
641
642 // Verify PKCE.
643 $expected_challenge = rtrim( strtr( base64_encode( hash( 'sha256', $code_verifier, true ) ), '+/', '-_' ), '=' );
644 if ( !hash_equals( (string) $code_data['code_challenge'], $expected_challenge ) ) {
645 return $this->oauth_error( 'invalid_grant', 'PKCE verification failed.', 400 );
646 }
647
648 return $this->issue_token_pair( $client->client_id, (int) $code_data['user_id'], (string) $code_data['scope'] );
649 }
650
651 private function handle_token_refresh( WP_REST_Request $request ) {
652 $refresh_token = (string) ( $request->get_param( 'refresh_token' ) ?? '' );
653 $client_id = (string) ( $request->get_param( 'client_id' ) ?? '' );
654 if ( $refresh_token === '' ) {
655 if ( $this->logging ) {
656 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected: no refresh_token in the request.' );
657 }
658 return $this->oauth_error( 'invalid_request', 'Missing refresh_token.', 400 );
659 }
660
661 global $wpdb;
662 $hash = hash( 'sha256', $refresh_token );
663 $marker = $this->token_marker( $refresh_token );
664 $row = $wpdb->get_row(
665 $wpdb->prepare(
666 "SELECT * FROM {$this->table_tokens} WHERE refresh_token_hash = %s AND revoked = 0 LIMIT 1",
667 $hash
668 )
669 );
670 if ( !$row ) {
671 if ( $this->logging ) {
672 // Distinguish "never existed" from "already rotated or revoked": the second is the
673 // signature of a client refreshing twice with the same token, which rotation kills.
674 $revoked = $wpdb->get_var( $wpdb->prepare(
675 "SELECT id FROM {$this->table_tokens} WHERE refresh_token_hash = %s LIMIT 1",
676 $hash
677 ) );
678 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker . ': ' . ( $revoked
679 ? 'the grant exists but is revoked (already rotated, or revoked in Connected Apps).'
680 : 'no grant matches this refresh token.' ) );
681 }
682 return $this->oauth_error( 'invalid_grant', 'Refresh token is invalid or revoked.', 400 );
683 }
684 if ( $row->refresh_expires && strtotime( $row->refresh_expires . ' UTC' ) < time() ) {
685 if ( $this->logging ) {
686 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker
687 . ': refresh token expired on ' . $row->refresh_expires . ' UTC.' );
688 }
689 return $this->oauth_error( 'invalid_grant', 'Refresh token expired.', 400 );
690 }
691
692 $client = $this->get_client( $row->client_id );
693 if ( !$client ) {
694 if ( $this->logging ) {
695 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker
696 . ': client ' . $row->client_id . ' no longer exists.' );
697 }
698 return $this->oauth_error( 'invalid_client', 'Client not found.', 401 );
699 }
700 if ( $client_id !== '' && $client_id !== $client->client_id ) {
701 if ( $this->logging ) {
702 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker
703 . ': client_id in the request does not match the one on the grant.' );
704 }
705 return $this->oauth_error( 'invalid_client', 'client_id mismatch.', 401 );
706 }
707 if ( !$this->authenticate_client_if_required( $client, $request ) ) {
708 if ( $this->logging ) {
709 error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker
710 . ': client authentication failed (method ' . $client->token_endpoint_auth_method
711 . '). If this client authenticates with client_secret_basic, check that the host'
712 . ' forwards the Authorization header on POST requests.' );
713 }
714 return $this->oauth_error( 'invalid_client', 'Client authentication failed.', 401 );
715 }
716
717 // Refresh-token rotation (OAuth 2.1 best practice): the old refresh token is
718 // spent, but the access token issued with it is NOT. See TOKEN_ROTATED.
719 $wpdb->update( $this->table_tokens, [ 'revoked' => self::TOKEN_ROTATED ], [ 'id' => $row->id ] );
720
721 if ( $this->logging ) {
722 error_log( '[AI Engine MCP OAuth] �
723 Refresh accepted for token ' . $marker
724 . ', user ' . (int) $row->user_id . ', client ' . $client->client_id
725 . '. New pair issued; the previous access token stays valid until '
726 . $row->access_expires . ' UTC.' );
727 }
728
729 return $this->issue_token_pair( $row->client_id, (int) $row->user_id, (string) $row->scope );
730 }
731
732 private function issue_token_pair( $client_id, $user_id, $scope ) {
733 global $wpdb;
734 $access_token = $this->random_token( 48 );
735 $refresh_token = $this->random_token( 48 );
736 $now = time();
737
738 $wpdb->insert( $this->table_tokens, [
739 'client_id' => $client_id,
740 'user_id' => $user_id,
741 'access_token_hash' => hash( 'sha256', $access_token ),
742 'refresh_token_hash' => hash( 'sha256', $refresh_token ),
743 'access_expires' => gmdate( 'Y-m-d H:i:s', $now + self::ACCESS_TOKEN_TTL ),
744 'refresh_expires' => gmdate( 'Y-m-d H:i:s', $now + self::REFRESH_TOKEN_TTL ),
745 'scope' => $scope,
746 'created' => gmdate( 'Y-m-d H:i:s', $now ),
747 ] );
748
749 $response = new WP_REST_Response( [
750 'access_token' => $access_token,
751 'token_type' => 'Bearer',
752 'expires_in' => self::ACCESS_TOKEN_TTL,
753 'refresh_token' => $refresh_token,
754 'scope' => $scope,
755 ], 200 );
756 $response->header( 'Cache-Control', 'no-store' );
757 $response->header( 'Pragma', 'no-cache' );
758 return $response;
759 }
760
761 private function authenticate_client_if_required( $client, WP_REST_Request $request ) {
762 if ( $client->token_endpoint_auth_method === 'none' ) {
763 return true;
764 }
765 $provided_secret = '';
766 if ( $client->token_endpoint_auth_method === 'client_secret_basic' ) {
767 $auth = $request->get_header( 'authorization' );
768 if ( $auth && preg_match( '#^Basic\s+(.+)$#i', $auth, $m ) ) {
769 $decoded = base64_decode( $m[1], true );
770 if ( $decoded && strpos( $decoded, ':' ) !== false ) {
771 [ $cid, $secret ] = explode( ':', $decoded, 2 );
772 if ( $cid === $client->client_id ) {
773 $provided_secret = $secret;
774 }
775 }
776 }
777 elseif ( isset( $_SERVER['PHP_AUTH_USER'] ) && $_SERVER['PHP_AUTH_USER'] === $client->client_id ) {
778 // Apache with mod_php performs HTTP Basic auth itself: it moves the credentials
779 // into PHP_AUTH_USER/PHP_AUTH_PW and never exposes the header, so get_header()
780 // above finds nothing even though the client sent one. A Bearer header is left
781 // alone, which is exactly why MCP tool calls keep working on these hosts while
782 // every single token refresh is rejected as invalid_client: the connection dies
783 // once the access token ages out and never comes back.
784 $provided_secret = (string) ( $_SERVER['PHP_AUTH_PW'] ?? '' );
785 }
786 }
787 else {
788 $provided_secret = (string) ( $request->get_param( 'client_secret' ) ?? '' );
789 }
790 if ( $provided_secret === '' || !$client->client_secret_hash ) {
791 return false;
792 }
793 return hash_equals( $client->client_secret_hash, hash( 'sha256', $provided_secret ) );
794 }
795 #endregion
796
797 #region Revocation
798 public function handle_revoke( WP_REST_Request $request ) {
799 $token = (string) ( $request->get_param( 'token' ) ?? '' );
800 if ( $token === '' ) {
801 // RFC 7009: return 200 even on unknown tokens to avoid information leakage.
802 return new WP_REST_Response( null, 200 );
803 }
804 global $wpdb;
805 $hash = hash( 'sha256', $token );
806 $wpdb->query( $wpdb->prepare(
807 "UPDATE {$this->table_tokens} SET revoked = 1 WHERE access_token_hash = %s OR refresh_token_hash = %s",
808 $hash,
809 $hash
810 ) );
811 return new WP_REST_Response( null, 200 );
812 }
813 #endregion
814
815 #region Capability gate
816 /**
817 * Whether a user is allowed to authorize an OAuth client and to use an OAuth
818 * access token against the MCP endpoint. Defaults to administrator only,
819 * matching the documented MCP access model. The filter exists so the planned
820 * multi-user MCP work can broaden this safely once per-token capability
821 * scoping lands; until then, allowing a non-admin here re-opens CVE-class
822 * privilege escalation through tools like wp_create_user.
823 *
824 * Test manage_options, not the 'administrator' role name. Passing a role name
825 * to user_can() only matches when that exact key sits in the user's
826 * capabilities meta, so admin-equivalent accounts (custom roles, caps granted
827 * individually, or a plugin filtering user_has_cap) were refused at the
828 * consent screen while every wp-admin settings page loaded fine for them.
829 * manage_options keeps the privilege-escalation fix intact: editors and below
830 * do not hold it, and multisite super admins pass via WP_User::has_cap().
831 */
832 public function user_can_authorize( $user_id ) {
833 $user_id = (int) $user_id;
834 $allowed = $user_id > 0 && user_can( $user_id, 'manage_options' );
835 return (bool) apply_filters( 'mwai_mcp_oauth_user_can_authorize', $allowed, $user_id );
836 }
837 #endregion
838
839 #region Token validation (called from MCP auth path)
840 /**
841 * Validate an access token for protected resource access.
842 * Returns [ 'user_id' => N, 'client_id' => '...', 'scope' => '...' ] on success, null on failure.
843 * Also touches last_used so the admin UI can show recent activity.
844 */
845 public function validate_token( $token ) {
846 if ( !is_string( $token ) || $token === '' ) {
847 return null;
848 }
849 global $wpdb;
850 $hash = hash( 'sha256', $token );
851 // Rotated grants still serve their access token; only an explicit revocation
852 // kills it on the spot.
853 $row = $wpdb->get_row(
854 $wpdb->prepare(
855 "SELECT t.*, c.client_name FROM {$this->table_tokens} t
856 LEFT JOIN {$this->table_clients} c ON c.client_id = t.client_id
857 WHERE t.access_token_hash = %s AND t.revoked <> %d LIMIT 1",
858 $hash,
859 self::TOKEN_REVOKED
860 )
861 );
862 // A rejected token used to be silent, which made "it works, then it doesn't"
863 // reports impossible to answer: nothing anywhere said why. Each branch below
864 // names the reason, and the marker lets one client's calls be followed across
865 // a log without ever writing a usable credential to disk.
866 if ( !$row ) {
867 if ( $this->logging ) {
868 $marker = $this->token_marker( $token );
869 $revoked = $wpdb->get_var( $wpdb->prepare(
870 "SELECT id FROM {$this->table_tokens} WHERE access_token_hash = %s LIMIT 1",
871 $hash
872 ) );
873 error_log( '[AI Engine MCP OAuth] ❌ Access token ' . $marker . ' rejected: ' . ( $revoked
874 ? 'this grant was revoked (from Connected Apps, or by the client signing out).'
875 : 'no grant matches this token. The client is using a credential this site never issued, or one whose grant has been deleted.' ) );
876 }
877 return null;
878 }
879 if ( strtotime( $row->access_expires . ' UTC' ) < time() ) {
880 if ( $this->logging ) {
881 error_log( '[AI Engine MCP OAuth] ❌ Access token ' . $this->token_marker( $token )
882 . ' rejected: it expired on ' . $row->access_expires . ' UTC. The client should refresh it.' );
883 }
884 return null;
885 }
886 // Touch last_used (non-blocking, single UPDATE).
887 $wpdb->update(
888 $this->table_tokens,
889 [ 'last_used' => current_time( 'mysql', 1 ) ],
890 [ 'id' => $row->id ]
891 );
892 return [
893 'user_id' => (int) $row->user_id,
894 'client_id' => $row->client_id,
895 'client_name' => $row->client_name,
896 'scope' => $row->scope,
897 ];
898 }
899 #endregion
900
901 #region Admin: list / revoke grants
902 public function handle_apps_list() {
903 global $wpdb;
904 $rows = $wpdb->get_results(
905 "SELECT t.id, t.client_id, t.user_id, t.created, t.last_used, t.access_expires, t.refresh_expires, t.revoked,
906 c.client_name
907 FROM {$this->table_tokens} t
908 LEFT JOIN {$this->table_clients} c ON c.client_id = t.client_id
909 WHERE t.revoked = 0
910 ORDER BY t.created DESC"
911 );
912 $out = [];
913 foreach ( $rows as $r ) {
914 $user = get_userdata( (int) $r->user_id );
915 $out[] = [
916 'id' => (int) $r->id,
917 'client_id' => $r->client_id,
918 'client_name' => $r->client_name ?: 'Unknown app',
919 'user_id' => (int) $r->user_id,
920 'user_login' => $user ? $user->user_login : 'deleted',
921 'user_display' => $user ? $user->display_name : 'Deleted user',
922 'created' => $r->created,
923 'last_used' => $r->last_used,
924 'access_expires' => $r->access_expires,
925 'refresh_expires' => $r->refresh_expires,
926 ];
927 }
928 return new WP_REST_Response( [ 'apps' => $out ], 200 );
929 }
930
931 public function handle_apps_revoke( WP_REST_Request $request ) {
932 $id = (int) $request->get_param( 'id' );
933 if ( $id <= 0 ) {
934 return new WP_REST_Response( [ 'error' => 'Invalid id.' ], 400 );
935 }
936 global $wpdb;
937 $wpdb->update( $this->table_tokens, [ 'revoked' => 1 ], [ 'id' => $id ] );
938 return new WP_REST_Response( [ 'revoked' => true ], 200 );
939 }
940 #endregion
941
942 #region Helpers
943 /**
944 * Drop client registrations that never led to anything.
945 *
946 * A client row is created before the user approves anything, so every abandoned
947 * connection attempt, every diagnostic and every reconnect leaves one behind, and
948 * nothing ever removed them. One user finished a debugging session with a dozen and
949 * no way to clear them short of SQL.
950 *
951 * Only rows with no grant at all, older than a month, are removed: a registration
952 * still waiting for its consent screen after that long is not coming back, and
953 * anything the user actually authorized is untouched whatever its age. This runs on
954 * registration, the only moment new rows appear, so it needs no schedule.
955 */
956 private function prune_orphan_clients() {
957 global $wpdb;
958 $wpdb->query( $wpdb->prepare(
959 "DELETE c FROM {$this->table_clients} c
960 LEFT JOIN {$this->table_tokens} t ON t.client_id = c.client_id
961 WHERE t.id IS NULL AND c.created < %s",
962 gmdate( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS )
963 ) );
964 }
965
966 private function get_client( $client_id ) {
967 if ( !is_string( $client_id ) || $client_id === '' ) {
968 return null;
969 }
970 global $wpdb;
971 return $wpdb->get_row( $wpdb->prepare(
972 "SELECT * FROM {$this->table_clients} WHERE client_id = %s LIMIT 1",
973 $client_id
974 ) );
975 }
976
977 private function redirect_uri_registered( $client, $redirect_uri ) {
978 if ( !$client || !is_string( $redirect_uri ) || $redirect_uri === '' ) {
979 return false;
980 }
981 $registered = json_decode( $client->redirect_uris, true );
982 if ( !is_array( $registered ) ) {
983 return false;
984 }
985 foreach ( $registered as $uri ) {
986 if ( hash_equals( (string) $uri, $redirect_uri ) ) {
987 return true;
988 }
989 }
990 return false;
991 }
992
993 private function append_params( $url, $params ) {
994 $sep = strpos( $url, '?' ) === false ? '?' : '&';
995 return $url . $sep . http_build_query( $params );
996 }
997
998 private function random_token( $bytes = 32 ) {
999 return bin2hex( random_bytes( (int) $bytes ) );
1000 }
1001
1002 private function oauth_error( $code, $description, $status = 400 ) {
1003 $response = new WP_REST_Response( [
1004 'error' => $code,
1005 'error_description' => $description,
1006 ], $status );
1007 $response->header( 'Cache-Control', 'no-store' );
1008 $response->header( 'Pragma', 'no-cache' );
1009 return $response;
1010 }
1011
1012 /**
1013 * Add WWW-Authenticate header to 401 responses on the protected MCP route,
1014 * pointing clients at the resource metadata document so they can discover
1015 * the authorization server automatically.
1016 */
1017 public function add_www_authenticate_header( $response, $server, $request ) {
1018 if ( !( $response instanceof WP_HTTP_Response ) ) {
1019 return $response;
1020 }
1021 $route = $request instanceof WP_REST_Request ? $request->get_route() : '';
1022 if ( $route !== '/' . $this->namespace . '/http' ) {
1023 return $response;
1024 }
1025 $status = $response->get_status();
1026 if ( $status !== 401 && $status !== 403 ) {
1027 return $response;
1028 }
1029 $resource_metadata = rest_url( $this->namespace . '/.well-known/oauth-protected-resource' );
1030 $response->header(
1031 'WWW-Authenticate',
1032 sprintf( 'Bearer realm="MCP", resource_metadata="%s"', $resource_metadata )
1033 );
1034 return $response;
1035 }
1036 #endregion
1037
1038 #region HTML rendering (consent + error pages)
1039 private function render_consent_page( $client, $params, $user ) {
1040 $nonce = wp_create_nonce( self::NONCE_ACTION );
1041 $action_url = rest_url( $this->namespace . '/oauth/authorize' );
1042 $site_name = get_bloginfo( 'name' );
1043 $client_name = $client->client_name ?: 'Unnamed MCP Client';
1044 $role_label = $this->describe_user_role( $user );
1045
1046 status_header( 200 );
1047 nocache_headers();
1048 header( 'Content-Type: text/html; charset=utf-8' );
1049
1050 $hidden_fields = [
1051 'client_id' => $params['client_id'],
1052 'redirect_uri' => $params['redirect_uri'],
1053 'state' => $params['state'],
1054 'scope' => $params['scope'],
1055 'code_challenge' => $params['code_challenge'],
1056 'code_challenge_method' => $params['code_challenge_method'],
1057 'resource' => $params['resource'],
1058 '_mwai_nonce' => $nonce,
1059 ];
1060
1061 echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">';
1062 echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
1063 echo '<title>' . esc_html( sprintf( 'Authorize %s', $client_name ) ) . '</title>';
1064 echo $this->consent_styles();
1065 echo '</head><body><main class="mwai-oauth-card">';
1066
1067 echo '<h1>Authorize this app</h1>';
1068 echo '<p class="mwai-oauth-app"><strong>' . esc_html( $client_name ) . '</strong> wants to connect to <strong>' . esc_html( $site_name ) . '</strong>.</p>';
1069
1070 echo '<div class="mwai-oauth-meta">';
1071 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>';
1072 echo '<div><span class="mwai-oauth-label">Permissions</span><span class="mwai-oauth-value">' . esc_html( $role_label ) . '</span></div>';
1073 echo '</div>';
1074
1075 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>';
1076
1077 echo '<form method="POST" action="' . esc_url( $action_url ) . '">';
1078 foreach ( $hidden_fields as $name => $value ) {
1079 echo '<input type="hidden" name="' . esc_attr( $name ) . '" value="' . esc_attr( $value ) . '">';
1080 }
1081 echo '<div class="mwai-oauth-buttons">';
1082 echo '<button type="submit" name="action" value="approve" class="mwai-oauth-approve">Approve</button>';
1083 echo '<button type="submit" name="action" value="deny" class="mwai-oauth-deny">Deny</button>';
1084 echo '</div>';
1085 echo '</form>';
1086
1087 echo '</main></body></html>';
1088 }
1089
1090 private function render_error_page( $message ) {
1091 status_header( 400 );
1092 nocache_headers();
1093 header( 'Content-Type: text/html; charset=utf-8' );
1094 echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">';
1095 echo '<title>Authorization error</title>';
1096 echo $this->consent_styles();
1097 echo '</head><body><main class="mwai-oauth-card">';
1098 echo '<h1>Authorization error</h1>';
1099 echo '<p class="mwai-oauth-note">' . esc_html( $message ) . '</p>';
1100 echo '</main></body></html>';
1101 }
1102
1103 private function describe_user_role( $user ) {
1104 if ( !$user || empty( $user->roles ) ) {
1105 return 'No role';
1106 }
1107 $role = $user->roles[0];
1108 $names = [
1109 'administrator' => 'Administrator (full access)',
1110 'editor' => 'Editor',
1111 'author' => 'Author',
1112 'contributor' => 'Contributor',
1113 'subscriber' => 'Subscriber',
1114 ];
1115 return $names[ $role ] ?? ucfirst( $role );
1116 }
1117
1118 private function consent_styles() {
1119 return '<style>
1120 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; }
1121 .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%; }
1122 .mwai-oauth-card h1 { font-size: 22px; margin: 0 0 16px; font-weight: 600; }
1123 .mwai-oauth-app { font-size: 15px; line-height: 1.5; margin: 0 0 24px; }
1124 .mwai-oauth-meta { background: #f7f8fa; border-radius: 8px; padding: 14px 16px; margin-bottom: 20px; }
1125 .mwai-oauth-meta > div { display: flex; justify-content: space-between; align-items: baseline; padding: 6px 0; font-size: 14px; }
1126 .mwai-oauth-label { color: #6b7280; }
1127 .mwai-oauth-value { color: #1d2330; font-weight: 500; text-align: right; }
1128 .mwai-oauth-note { font-size: 13px; color: #6b7280; line-height: 1.5; margin: 0 0 24px; }
1129 .mwai-oauth-buttons { display: flex; gap: 10px; }
1130 .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; }
1131 .mwai-oauth-approve { background: #2271b1; color: #fff; }
1132 .mwai-oauth-approve:hover { background: #135e96; }
1133 .mwai-oauth-deny { background: #fff; color: #1d2330; border-color: #d0d4da; }
1134 .mwai-oauth-deny:hover { background: #f1f2f5; }
1135 </style>';
1136 }
1137 #endregion
1138 }
1139