PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.5.1
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.5.1
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 month ago mcp-oauth.php 1 month ago mcp-rest.php 2 months ago mcp.conf 1 year ago mcp.js 8 months ago mcp.md 8 months ago mcp.php 1 month ago wpai-connectors.php 1 month ago wpai-gateway-availability.php 2 months ago wpai-gateway-directory.php 2 months ago wpai-gateway-image-model.php 2 months ago wpai-gateway-model.php 2 months ago wpai-gateway-providers.php 2 months ago wpai-gateway.php 2 months ago
mcp-oauth.php
910 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 public const ACCESS_TOKEN_TTL = 3600; // 1 hour
22 public const REFRESH_TOKEN_TTL = 2592000; // 30 days
23 public const AUTH_CODE_TTL = 60; // seconds
24 public const NONCE_ACTION = 'mwai_mcp_oauth_consent';
25
26 private $core;
27 private $mcp;
28 private $namespace = 'mcp/v1';
29 private $logging = false;
30 private $table_clients;
31 private $table_tokens;
32
33 public function __construct( $core, $mcp ) {
34 global $wpdb;
35 $this->core = $core;
36 $this->mcp = $mcp;
37 $this->logging = method_exists( $mcp, 'is_logging_enabled' ) ? $mcp->is_logging_enabled() : false;
38 $this->table_clients = $wpdb->prefix . 'mwai_mcp_oauth_clients';
39 $this->table_tokens = $wpdb->prefix . 'mwai_mcp_oauth_tokens';
40
41 $this->maybe_upgrade_db();
42
43 add_action( 'rest_api_init', [ $this, 'register_routes' ] );
44 add_filter( 'rest_post_dispatch', [ $this, 'add_www_authenticate_header' ], 10, 3 );
45 // WP's REST cookie nonce check silently downgrades cookie-authed users to guest
46 // when no X-WP-Nonce is sent. The browser-driven authorize flow needs the user's
47 // identity from the cookie without a REST nonce, so we re-validate the auth cookie
48 // for that route. CSRF is enforced separately via our own consent nonce on POST.
49 add_filter( 'rest_authentication_errors', [ $this, 'reauth_for_authorize' ], 200 );
50 // Serve well-known metadata at the host root too. RFC 9728/8414 specify the
51 // well-known URI is built by inserting /.well-known/<suffix> between the host
52 // and the path of the resource/issuer, so strict clients query the host root
53 // rather than the nested REST path. Run very early to short-circuit WP's 404.
54 add_action( 'parse_request', [ $this, 'handle_host_root_wellknown' ], 1 );
55 }
56
57 /**
58 * Serve OAuth well-known metadata from the host root. Handles all three URL
59 * shapes that clients use in the wild: bare host-root, host-root + resource
60 * path (RFC strict), and the nested REST path is already covered by the REST
61 * route registration.
62 */
63 public function handle_host_root_wellknown() {
64 $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) $_SERVER['REQUEST_URI'] : '';
65 $path = strtok( $uri, '?' );
66 if ( $path === false || strpos( $path, '/.well-known/' ) !== 0 ) {
67 return;
68 }
69 if ( strpos( $path, '/.well-known/oauth-protected-resource' ) === 0 ) {
70 if ( $this->logging ) {
71 error_log( '[AI Engine MCP OAuth] Host-root PRM hit: ' . $path );
72 }
73 $this->emit_json( $this->protected_resource_metadata() );
74 }
75 if ( strpos( $path, '/.well-known/oauth-authorization-server' ) === 0 ) {
76 if ( $this->logging ) {
77 error_log( '[AI Engine MCP OAuth] Host-root ASM hit: ' . $path );
78 }
79 $this->emit_json( $this->authorization_server_metadata() );
80 }
81 }
82
83 private function emit_json( $payload ) {
84 status_header( 200 );
85 nocache_headers();
86 header( 'Content-Type: application/json; charset=utf-8' );
87 header( 'Access-Control-Allow-Origin: *' );
88 echo wp_json_encode( $payload );
89 exit;
90 }
91
92 private function protected_resource_metadata() {
93 $issuer = rest_url( $this->namespace );
94 return [
95 'resource' => rest_url( $this->namespace . '/http' ),
96 'authorization_servers' => [ $issuer ],
97 'bearer_methods_supported' => [ 'header' ],
98 'scopes_supported' => [ 'mcp' ],
99 'resource_documentation' => 'https://meowapps.com/ai-engine/',
100 ];
101 }
102
103 private function authorization_server_metadata() {
104 $issuer = rest_url( $this->namespace );
105 return [
106 'issuer' => $issuer,
107 'authorization_endpoint' => rest_url( $this->namespace . '/oauth/authorize' ),
108 'token_endpoint' => rest_url( $this->namespace . '/oauth/token' ),
109 'registration_endpoint' => rest_url( $this->namespace . '/oauth/register' ),
110 'revocation_endpoint' => rest_url( $this->namespace . '/oauth/revoke' ),
111 'response_types_supported' => [ 'code' ],
112 'grant_types_supported' => [ 'authorization_code', 'refresh_token' ],
113 'token_endpoint_auth_methods_supported' => [ 'none', 'client_secret_basic', 'client_secret_post' ],
114 'code_challenge_methods_supported' => [ 'S256' ],
115 'scopes_supported' => [ 'mcp' ],
116 ];
117 }
118
119 public function reauth_for_authorize( $result ) {
120 $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) $_SERVER['REQUEST_URI'] : '';
121 if ( strpos( $uri, '/' . $this->namespace . '/oauth/authorize' ) === false ) {
122 return $result;
123 }
124 if ( !is_user_logged_in() ) {
125 $user_id = wp_validate_auth_cookie( '', 'logged_in' );
126 if ( $user_id ) {
127 wp_set_current_user( (int) $user_id );
128 }
129 }
130 return $result;
131 }
132
133 #region DB schema
134 private function maybe_upgrade_db() {
135 if ( get_option( 'mwai_mcp_oauth_db_version' ) === self::DB_VERSION ) {
136 return;
137 }
138
139 global $wpdb;
140 $charset_collate = $wpdb->get_charset_collate();
141
142 $sql_clients = "CREATE TABLE {$this->table_clients} (
143 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
144 client_id VARCHAR(64) NOT NULL,
145 client_secret_hash VARCHAR(64) NULL,
146 client_name VARCHAR(255) NULL,
147 redirect_uris LONGTEXT NOT NULL,
148 grant_types VARCHAR(255) NOT NULL DEFAULT 'authorization_code,refresh_token',
149 token_endpoint_auth_method VARCHAR(32) NOT NULL DEFAULT 'none',
150 scope VARCHAR(255) NULL,
151 created DATETIME NOT NULL,
152 PRIMARY KEY (id),
153 UNIQUE KEY client_id (client_id)
154 ) {$charset_collate};";
155
156 $sql_tokens = "CREATE TABLE {$this->table_tokens} (
157 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
158 client_id VARCHAR(64) NOT NULL,
159 user_id BIGINT(20) UNSIGNED NOT NULL,
160 access_token_hash VARCHAR(64) NOT NULL,
161 refresh_token_hash VARCHAR(64) NULL,
162 access_expires DATETIME NOT NULL,
163 refresh_expires DATETIME NULL,
164 scope VARCHAR(255) NULL,
165 created DATETIME NOT NULL,
166 last_used DATETIME NULL,
167 revoked TINYINT(1) NOT NULL DEFAULT 0,
168 PRIMARY KEY (id),
169 KEY access_token_hash (access_token_hash),
170 KEY refresh_token_hash (refresh_token_hash),
171 KEY client_id (client_id),
172 KEY user_id (user_id)
173 ) {$charset_collate};";
174
175 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
176 dbDelta( $sql_clients );
177 dbDelta( $sql_tokens );
178
179 update_option( 'mwai_mcp_oauth_db_version', self::DB_VERSION );
180 }
181 #endregion
182
183 #region Route registration
184 public function register_routes() {
185 // RFC 9728 — Protected Resource Metadata
186 register_rest_route( $this->namespace, '/.well-known/oauth-protected-resource', [
187 'methods' => 'GET',
188 'callback' => [ $this, 'handle_resource_metadata' ],
189 'permission_callback' => '__return_true',
190 ] );
191
192 // RFC 8414 — Authorization Server Metadata
193 register_rest_route( $this->namespace, '/.well-known/oauth-authorization-server', [
194 'methods' => 'GET',
195 'callback' => [ $this, 'handle_as_metadata' ],
196 'permission_callback' => '__return_true',
197 ] );
198
199 // RFC 7591 — Dynamic Client Registration
200 register_rest_route( $this->namespace, '/oauth/register', [
201 'methods' => 'POST',
202 'callback' => [ $this, 'handle_register' ],
203 'permission_callback' => '__return_true',
204 ] );
205
206 // Authorization endpoint (browser-driven, returns HTML or 302)
207 register_rest_route( $this->namespace, '/oauth/authorize', [
208 'methods' => [ 'GET', 'POST' ],
209 'callback' => [ $this, 'handle_authorize' ],
210 'permission_callback' => '__return_true',
211 ] );
212
213 // Token endpoint
214 register_rest_route( $this->namespace, '/oauth/token', [
215 'methods' => 'POST',
216 'callback' => [ $this, 'handle_token' ],
217 'permission_callback' => '__return_true',
218 ] );
219
220 // RFC 7009 — Token Revocation
221 register_rest_route( $this->namespace, '/oauth/revoke', [
222 'methods' => 'POST',
223 'callback' => [ $this, 'handle_revoke' ],
224 'permission_callback' => '__return_true',
225 ] );
226
227 // Admin-only: list active grants
228 register_rest_route( $this->namespace, '/oauth/apps', [
229 'methods' => 'GET',
230 'callback' => [ $this, 'handle_apps_list' ],
231 'permission_callback' => function () {
232 return current_user_can( 'manage_options' );
233 },
234 ] );
235
236 // Admin-only: revoke a grant by id
237 register_rest_route( $this->namespace, '/oauth/apps/(?P<id>\d+)', [
238 'methods' => 'DELETE',
239 'callback' => [ $this, 'handle_apps_revoke' ],
240 'permission_callback' => function () {
241 return current_user_can( 'manage_options' );
242 },
243 ] );
244 }
245 #endregion
246
247 #region Discovery (well-known)
248 public function handle_resource_metadata() {
249 return new WP_REST_Response( $this->protected_resource_metadata(), 200 );
250 }
251
252 public function handle_as_metadata() {
253 return new WP_REST_Response( $this->authorization_server_metadata(), 200 );
254 }
255 #endregion
256
257 #region Dynamic Client Registration (RFC 7591)
258 public function handle_register( WP_REST_Request $request ) {
259 $body = json_decode( $request->get_body(), true );
260 if ( !is_array( $body ) ) {
261 return $this->oauth_error( 'invalid_client_metadata', 'Request body must be JSON.', 400 );
262 }
263
264 $redirect_uris = $body['redirect_uris'] ?? null;
265 if ( !is_array( $redirect_uris ) || empty( $redirect_uris ) ) {
266 return $this->oauth_error( 'invalid_redirect_uri', 'redirect_uris is required and must be a non-empty array.', 400 );
267 }
268 foreach ( $redirect_uris as $uri ) {
269 if ( !is_string( $uri ) || $uri === '' ) {
270 return $this->oauth_error( 'invalid_redirect_uri', 'Each redirect_uri must be a non-empty string.', 400 );
271 }
272 // Light validation — allow http(s) and custom schemes (desktop clients use them).
273 if ( !preg_match( '#^[a-z][a-z0-9+.\-]*://#i', $uri ) ) {
274 return $this->oauth_error( 'invalid_redirect_uri', "redirect_uri must include a scheme: {$uri}", 400 );
275 }
276 }
277
278 $auth_method = isset( $body['token_endpoint_auth_method'] ) ? (string) $body['token_endpoint_auth_method'] : 'none';
279 if ( !in_array( $auth_method, [ 'none', 'client_secret_basic', 'client_secret_post' ], true ) ) {
280 return $this->oauth_error( 'invalid_client_metadata', "Unsupported token_endpoint_auth_method: {$auth_method}", 400 );
281 }
282
283 $grant_types = $body['grant_types'] ?? [ 'authorization_code', 'refresh_token' ];
284 if ( !is_array( $grant_types ) ) {
285 $grant_types = [ 'authorization_code', 'refresh_token' ];
286 }
287 foreach ( $grant_types as $gt ) {
288 if ( !in_array( $gt, [ 'authorization_code', 'refresh_token' ], true ) ) {
289 return $this->oauth_error( 'invalid_client_metadata', "Unsupported grant_type: {$gt}", 400 );
290 }
291 }
292
293 $client_id = $this->random_token( 32 );
294 $client_secret = null;
295 $client_secret_hash = null;
296 if ( $auth_method !== 'none' ) {
297 $client_secret = $this->random_token( 48 );
298 $client_secret_hash = hash( 'sha256', $client_secret );
299 }
300
301 $client_name = isset( $body['client_name'] ) ? sanitize_text_field( (string) $body['client_name'] ) : 'Unnamed MCP Client';
302
303 global $wpdb;
304 $inserted = $wpdb->insert( $this->table_clients, [
305 'client_id' => $client_id,
306 'client_secret_hash' => $client_secret_hash,
307 'client_name' => $client_name,
308 'redirect_uris' => wp_json_encode( array_values( $redirect_uris ) ),
309 'grant_types' => implode( ',', $grant_types ),
310 'token_endpoint_auth_method' => $auth_method,
311 'scope' => 'mcp',
312 'created' => current_time( 'mysql', 1 ),
313 ] );
314 if ( !$inserted ) {
315 return $this->oauth_error( 'server_error', 'Could not persist client registration.', 500 );
316 }
317
318 if ( $this->logging ) {
319 error_log( '[AI Engine MCP OAuth] Registered client: ' . $client_name . ' (' . $client_id . ')' );
320 }
321
322 $response = [
323 'client_id' => $client_id,
324 'client_name' => $client_name,
325 'redirect_uris' => array_values( $redirect_uris ),
326 'grant_types' => $grant_types,
327 'token_endpoint_auth_method' => $auth_method,
328 'client_id_issued_at' => time(),
329 ];
330 if ( $client_secret !== null ) {
331 $response['client_secret'] = $client_secret;
332 $response['client_secret_expires_at'] = 0; // never
333 }
334 return new WP_REST_Response( $response, 201 );
335 }
336 #endregion
337
338 #region Authorize (browser flow)
339 public function handle_authorize( WP_REST_Request $request ) {
340 $method = $request->get_method();
341
342 if ( $method === 'POST' ) {
343 $this->handle_authorize_submit( $request );
344 exit;
345 }
346
347 // GET — render consent page or redirect to login
348 $params = [
349 'response_type' => (string) ( $request->get_param( 'response_type' ) ?? '' ),
350 'client_id' => (string) ( $request->get_param( 'client_id' ) ?? '' ),
351 'redirect_uri' => (string) ( $request->get_param( 'redirect_uri' ) ?? '' ),
352 'state' => (string) ( $request->get_param( 'state' ) ?? '' ),
353 'scope' => (string) ( $request->get_param( 'scope' ) ?? 'mcp' ),
354 'code_challenge' => (string) ( $request->get_param( 'code_challenge' ) ?? '' ),
355 'code_challenge_method' => (string) ( $request->get_param( 'code_challenge_method' ) ?? '' ),
356 ];
357
358 if ( $params['response_type'] !== 'code' ) {
359 $this->render_error_page( 'Unsupported response_type. Only "code" is supported.' );
360 exit;
361 }
362 if ( $params['code_challenge'] === '' || $params['code_challenge_method'] !== 'S256' ) {
363 $this->render_error_page( 'PKCE is required: provide code_challenge and code_challenge_method=S256.' );
364 exit;
365 }
366
367 $client = $this->get_client( $params['client_id'] );
368 if ( !$client ) {
369 $this->render_error_page( 'Unknown client_id. The client must register via Dynamic Client Registration first.' );
370 exit;
371 }
372 if ( !$this->redirect_uri_registered( $client, $params['redirect_uri'] ) ) {
373 $this->render_error_page( 'redirect_uri does not match any registered URI for this client.' );
374 exit;
375 }
376
377 // Authentication gate — bounce to wp-login.php if not logged in.
378 if ( !is_user_logged_in() ) {
379 $current_url = rest_url( $this->namespace . '/oauth/authorize' );
380 $current_url = add_query_arg( $params, $current_url );
381 wp_safe_redirect( wp_login_url( $current_url ) );
382 exit;
383 }
384
385 $user = wp_get_current_user();
386 // Capability gate. MCP grants administrative tool access by design; allowing a
387 // non-admin to mint an OAuth token would let them act through the MCP layer with
388 // privileges they do not hold in WordPress itself.
389 if ( !$this->user_can_authorize( $user->ID ) ) {
390 if ( $this->logging ) {
391 error_log( '[AI Engine MCP OAuth] ❌ Non-admin user ' . $user->ID . ' tried to authorize client ' . $params['client_id'] );
392 }
393 $this->render_error_page( 'Only administrators can authorize MCP applications on this site.' );
394 exit;
395 }
396
397 $this->render_consent_page( $client, $params, $user );
398 exit;
399 }
400
401 private function handle_authorize_submit( WP_REST_Request $request ) {
402 if ( !is_user_logged_in() ) {
403 wp_safe_redirect( wp_login_url() );
404 exit;
405 }
406
407 if ( !$this->user_can_authorize( get_current_user_id() ) ) {
408 if ( $this->logging ) {
409 error_log( '[AI Engine MCP OAuth] ❌ Non-admin user ' . get_current_user_id() . ' attempted authorize submit' );
410 }
411 $this->render_error_page( 'Only administrators can authorize MCP applications on this site.' );
412 exit;
413 }
414
415 $nonce = (string) $request->get_param( '_mwai_nonce' );
416 if ( !wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
417 $this->render_error_page( 'Security check failed. Please try again from your application.' );
418 exit;
419 }
420
421 $client_id = (string) $request->get_param( 'client_id' );
422 $redirect_uri = (string) $request->get_param( 'redirect_uri' );
423 $state = (string) ( $request->get_param( 'state' ) ?? '' );
424 $code_challenge = (string) $request->get_param( 'code_challenge' );
425 $code_challenge_method = (string) $request->get_param( 'code_challenge_method' );
426 $scope = (string) ( $request->get_param( 'scope' ) ?? 'mcp' );
427 $action = (string) ( $request->get_param( 'action' ) ?? 'deny' );
428
429 $client = $this->get_client( $client_id );
430 if ( !$client || !$this->redirect_uri_registered( $client, $redirect_uri ) ) {
431 $this->render_error_page( 'Invalid client or redirect_uri.' );
432 exit;
433 }
434
435 if ( $action !== 'approve' ) {
436 $params = [ 'error' => 'access_denied', 'error_description' => 'User denied the request.' ];
437 if ( $state !== '' ) {
438 $params['state'] = $state;
439 }
440 wp_redirect( $this->append_params( $redirect_uri, $params ) );
441 exit;
442 }
443
444 // Generate authorization code and stash everything needed to mint a token later.
445 $code = $this->random_token( 48 );
446 $code_data = [
447 'client_id' => $client_id,
448 'user_id' => get_current_user_id(),
449 'redirect_uri' => $redirect_uri,
450 'code_challenge' => $code_challenge,
451 'code_challenge_method' => $code_challenge_method,
452 'scope' => $scope,
453 ];
454 set_transient( $this->auth_code_key( $code ), $code_data, self::AUTH_CODE_TTL );
455
456 $params = [ 'code' => $code ];
457 if ( $state !== '' ) {
458 $params['state'] = $state;
459 }
460
461 if ( $this->logging ) {
462 error_log( '[AI Engine MCP OAuth] Authorized user ' . get_current_user_id() . ' for client ' . $client_id );
463 }
464
465 wp_redirect( $this->append_params( $redirect_uri, $params ) );
466 exit;
467 }
468
469 private function auth_code_key( $code ) {
470 return 'mwai_mcp_oauth_code_' . hash( 'sha256', $code );
471 }
472 #endregion
473
474 #region Token endpoint
475 public function handle_token( WP_REST_Request $request ) {
476 $grant_type = (string) ( $request->get_param( 'grant_type' ) ?? '' );
477
478 if ( $grant_type === 'authorization_code' ) {
479 return $this->handle_token_auth_code( $request );
480 }
481 if ( $grant_type === 'refresh_token' ) {
482 return $this->handle_token_refresh( $request );
483 }
484 return $this->oauth_error( 'unsupported_grant_type', 'Supported: authorization_code, refresh_token.', 400 );
485 }
486
487 private function handle_token_auth_code( WP_REST_Request $request ) {
488 $code = (string) ( $request->get_param( 'code' ) ?? '' );
489 $redirect_uri = (string) ( $request->get_param( 'redirect_uri' ) ?? '' );
490 $code_verifier = (string) ( $request->get_param( 'code_verifier' ) ?? '' );
491 $client_id = (string) ( $request->get_param( 'client_id' ) ?? '' );
492
493 if ( $code === '' || $redirect_uri === '' || $code_verifier === '' ) {
494 return $this->oauth_error( 'invalid_request', 'Missing code, redirect_uri, or code_verifier.', 400 );
495 }
496
497 $key = $this->auth_code_key( $code );
498 $code_data = get_transient( $key );
499 if ( !is_array( $code_data ) ) {
500 return $this->oauth_error( 'invalid_grant', 'Authorization code is invalid or expired.', 400 );
501 }
502 // Single-use: delete immediately to prevent replay.
503 delete_transient( $key );
504
505 if ( $code_data['redirect_uri'] !== $redirect_uri ) {
506 return $this->oauth_error( 'invalid_grant', 'redirect_uri mismatch.', 400 );
507 }
508
509 $client = $this->get_client( $code_data['client_id'] );
510 if ( !$client ) {
511 return $this->oauth_error( 'invalid_client', 'Client not found.', 401 );
512 }
513 if ( $client_id !== '' && $client_id !== $client->client_id ) {
514 return $this->oauth_error( 'invalid_client', 'client_id mismatch.', 401 );
515 }
516 if ( !$this->authenticate_client_if_required( $client, $request ) ) {
517 return $this->oauth_error( 'invalid_client', 'Client authentication failed.', 401 );
518 }
519
520 // Verify PKCE.
521 $expected_challenge = rtrim( strtr( base64_encode( hash( 'sha256', $code_verifier, true ) ), '+/', '-_' ), '=' );
522 if ( !hash_equals( (string) $code_data['code_challenge'], $expected_challenge ) ) {
523 return $this->oauth_error( 'invalid_grant', 'PKCE verification failed.', 400 );
524 }
525
526 return $this->issue_token_pair( $client->client_id, (int) $code_data['user_id'], (string) $code_data['scope'] );
527 }
528
529 private function handle_token_refresh( WP_REST_Request $request ) {
530 $refresh_token = (string) ( $request->get_param( 'refresh_token' ) ?? '' );
531 $client_id = (string) ( $request->get_param( 'client_id' ) ?? '' );
532 if ( $refresh_token === '' ) {
533 return $this->oauth_error( 'invalid_request', 'Missing refresh_token.', 400 );
534 }
535
536 global $wpdb;
537 $hash = hash( 'sha256', $refresh_token );
538 $row = $wpdb->get_row(
539 $wpdb->prepare(
540 "SELECT * FROM {$this->table_tokens} WHERE refresh_token_hash = %s AND revoked = 0 LIMIT 1",
541 $hash
542 )
543 );
544 if ( !$row ) {
545 return $this->oauth_error( 'invalid_grant', 'Refresh token is invalid or revoked.', 400 );
546 }
547 if ( $row->refresh_expires && strtotime( $row->refresh_expires . ' UTC' ) < time() ) {
548 return $this->oauth_error( 'invalid_grant', 'Refresh token expired.', 400 );
549 }
550
551 $client = $this->get_client( $row->client_id );
552 if ( !$client ) {
553 return $this->oauth_error( 'invalid_client', 'Client not found.', 401 );
554 }
555 if ( $client_id !== '' && $client_id !== $client->client_id ) {
556 return $this->oauth_error( 'invalid_client', 'client_id mismatch.', 401 );
557 }
558 if ( !$this->authenticate_client_if_required( $client, $request ) ) {
559 return $this->oauth_error( 'invalid_client', 'Client authentication failed.', 401 );
560 }
561
562 // Refresh-token rotation (OAuth 2.1 best practice): revoke the old grant and issue a new pair.
563 $wpdb->update( $this->table_tokens, [ 'revoked' => 1 ], [ 'id' => $row->id ] );
564
565 return $this->issue_token_pair( $row->client_id, (int) $row->user_id, (string) $row->scope );
566 }
567
568 private function issue_token_pair( $client_id, $user_id, $scope ) {
569 global $wpdb;
570 $access_token = $this->random_token( 48 );
571 $refresh_token = $this->random_token( 48 );
572 $now = time();
573
574 $wpdb->insert( $this->table_tokens, [
575 'client_id' => $client_id,
576 'user_id' => $user_id,
577 'access_token_hash' => hash( 'sha256', $access_token ),
578 'refresh_token_hash' => hash( 'sha256', $refresh_token ),
579 'access_expires' => gmdate( 'Y-m-d H:i:s', $now + self::ACCESS_TOKEN_TTL ),
580 'refresh_expires' => gmdate( 'Y-m-d H:i:s', $now + self::REFRESH_TOKEN_TTL ),
581 'scope' => $scope,
582 'created' => gmdate( 'Y-m-d H:i:s', $now ),
583 ] );
584
585 $response = new WP_REST_Response( [
586 'access_token' => $access_token,
587 'token_type' => 'Bearer',
588 'expires_in' => self::ACCESS_TOKEN_TTL,
589 'refresh_token' => $refresh_token,
590 'scope' => $scope,
591 ], 200 );
592 $response->header( 'Cache-Control', 'no-store' );
593 $response->header( 'Pragma', 'no-cache' );
594 return $response;
595 }
596
597 private function authenticate_client_if_required( $client, WP_REST_Request $request ) {
598 if ( $client->token_endpoint_auth_method === 'none' ) {
599 return true;
600 }
601 $provided_secret = '';
602 if ( $client->token_endpoint_auth_method === 'client_secret_basic' ) {
603 $auth = $request->get_header( 'authorization' );
604 if ( $auth && preg_match( '#^Basic\s+(.+)$#i', $auth, $m ) ) {
605 $decoded = base64_decode( $m[1], true );
606 if ( $decoded && strpos( $decoded, ':' ) !== false ) {
607 [ $cid, $secret ] = explode( ':', $decoded, 2 );
608 if ( $cid === $client->client_id ) {
609 $provided_secret = $secret;
610 }
611 }
612 }
613 }
614 else {
615 $provided_secret = (string) ( $request->get_param( 'client_secret' ) ?? '' );
616 }
617 if ( $provided_secret === '' || !$client->client_secret_hash ) {
618 return false;
619 }
620 return hash_equals( $client->client_secret_hash, hash( 'sha256', $provided_secret ) );
621 }
622 #endregion
623
624 #region Revocation
625 public function handle_revoke( WP_REST_Request $request ) {
626 $token = (string) ( $request->get_param( 'token' ) ?? '' );
627 if ( $token === '' ) {
628 // RFC 7009: return 200 even on unknown tokens to avoid information leakage.
629 return new WP_REST_Response( null, 200 );
630 }
631 global $wpdb;
632 $hash = hash( 'sha256', $token );
633 $wpdb->query( $wpdb->prepare(
634 "UPDATE {$this->table_tokens} SET revoked = 1 WHERE access_token_hash = %s OR refresh_token_hash = %s",
635 $hash,
636 $hash
637 ) );
638 return new WP_REST_Response( null, 200 );
639 }
640 #endregion
641
642 #region Capability gate
643 /**
644 * Whether a user is allowed to authorize an OAuth client and to use an OAuth
645 * access token against the MCP endpoint. Defaults to administrator only,
646 * matching the documented MCP access model. The filter exists so the planned
647 * multi-user MCP work can broaden this safely once per-token capability
648 * scoping lands; until then, allowing a non-admin here re-opens CVE-class
649 * privilege escalation through tools like wp_create_user.
650 */
651 public function user_can_authorize( $user_id ) {
652 $user_id = (int) $user_id;
653 $allowed = $user_id > 0 && user_can( $user_id, 'administrator' );
654 return (bool) apply_filters( 'mwai_mcp_oauth_user_can_authorize', $allowed, $user_id );
655 }
656 #endregion
657
658 #region Token validation (called from MCP auth path)
659 /**
660 * Validate an access token for protected resource access.
661 * Returns [ 'user_id' => N, 'client_id' => '...', 'scope' => '...' ] on success, null on failure.
662 * Also touches last_used so the admin UI can show recent activity.
663 */
664 public function validate_token( $token ) {
665 if ( !is_string( $token ) || $token === '' ) {
666 return null;
667 }
668 global $wpdb;
669 $hash = hash( 'sha256', $token );
670 $row = $wpdb->get_row(
671 $wpdb->prepare(
672 "SELECT * FROM {$this->table_tokens} WHERE access_token_hash = %s AND revoked = 0 LIMIT 1",
673 $hash
674 )
675 );
676 if ( !$row ) {
677 return null;
678 }
679 if ( strtotime( $row->access_expires . ' UTC' ) < time() ) {
680 return null;
681 }
682 // Touch last_used (non-blocking, single UPDATE).
683 $wpdb->update(
684 $this->table_tokens,
685 [ 'last_used' => current_time( 'mysql', 1 ) ],
686 [ 'id' => $row->id ]
687 );
688 return [
689 'user_id' => (int) $row->user_id,
690 'client_id' => $row->client_id,
691 'scope' => $row->scope,
692 ];
693 }
694 #endregion
695
696 #region Admin: list / revoke grants
697 public function handle_apps_list() {
698 global $wpdb;
699 $rows = $wpdb->get_results(
700 "SELECT t.id, t.client_id, t.user_id, t.created, t.last_used, t.access_expires, t.refresh_expires, t.revoked,
701 c.client_name
702 FROM {$this->table_tokens} t
703 LEFT JOIN {$this->table_clients} c ON c.client_id = t.client_id
704 WHERE t.revoked = 0
705 ORDER BY t.created DESC"
706 );
707 $out = [];
708 foreach ( $rows as $r ) {
709 $user = get_userdata( (int) $r->user_id );
710 $out[] = [
711 'id' => (int) $r->id,
712 'client_id' => $r->client_id,
713 'client_name' => $r->client_name ?: 'Unknown app',
714 'user_id' => (int) $r->user_id,
715 'user_login' => $user ? $user->user_login : 'deleted',
716 'user_display' => $user ? $user->display_name : 'Deleted user',
717 'created' => $r->created,
718 'last_used' => $r->last_used,
719 'access_expires' => $r->access_expires,
720 'refresh_expires' => $r->refresh_expires,
721 ];
722 }
723 return new WP_REST_Response( [ 'apps' => $out ], 200 );
724 }
725
726 public function handle_apps_revoke( WP_REST_Request $request ) {
727 $id = (int) $request->get_param( 'id' );
728 if ( $id <= 0 ) {
729 return new WP_REST_Response( [ 'error' => 'Invalid id.' ], 400 );
730 }
731 global $wpdb;
732 $wpdb->update( $this->table_tokens, [ 'revoked' => 1 ], [ 'id' => $id ] );
733 return new WP_REST_Response( [ 'revoked' => true ], 200 );
734 }
735 #endregion
736
737 #region Helpers
738 private function get_client( $client_id ) {
739 if ( !is_string( $client_id ) || $client_id === '' ) {
740 return null;
741 }
742 global $wpdb;
743 return $wpdb->get_row( $wpdb->prepare(
744 "SELECT * FROM {$this->table_clients} WHERE client_id = %s LIMIT 1",
745 $client_id
746 ) );
747 }
748
749 private function redirect_uri_registered( $client, $redirect_uri ) {
750 if ( !$client || !is_string( $redirect_uri ) || $redirect_uri === '' ) {
751 return false;
752 }
753 $registered = json_decode( $client->redirect_uris, true );
754 if ( !is_array( $registered ) ) {
755 return false;
756 }
757 foreach ( $registered as $uri ) {
758 if ( hash_equals( (string) $uri, $redirect_uri ) ) {
759 return true;
760 }
761 }
762 return false;
763 }
764
765 private function append_params( $url, $params ) {
766 $sep = strpos( $url, '?' ) === false ? '?' : '&';
767 return $url . $sep . http_build_query( $params );
768 }
769
770 private function random_token( $bytes = 32 ) {
771 return bin2hex( random_bytes( (int) $bytes ) );
772 }
773
774 private function oauth_error( $code, $description, $status = 400 ) {
775 $response = new WP_REST_Response( [
776 'error' => $code,
777 'error_description' => $description,
778 ], $status );
779 $response->header( 'Cache-Control', 'no-store' );
780 $response->header( 'Pragma', 'no-cache' );
781 return $response;
782 }
783
784 /**
785 * Add WWW-Authenticate header to 401 responses on the protected MCP route,
786 * pointing clients at the resource metadata document so they can discover
787 * the authorization server automatically.
788 */
789 public function add_www_authenticate_header( $response, $server, $request ) {
790 if ( !( $response instanceof WP_HTTP_Response ) ) {
791 return $response;
792 }
793 $route = $request instanceof WP_REST_Request ? $request->get_route() : '';
794 if ( $route !== '/' . $this->namespace . '/http' ) {
795 return $response;
796 }
797 $status = $response->get_status();
798 if ( $status !== 401 && $status !== 403 ) {
799 return $response;
800 }
801 $resource_metadata = rest_url( $this->namespace . '/.well-known/oauth-protected-resource' );
802 $response->header(
803 'WWW-Authenticate',
804 sprintf( 'Bearer realm="MCP", resource_metadata="%s"', $resource_metadata )
805 );
806 return $response;
807 }
808 #endregion
809
810 #region HTML rendering (consent + error pages)
811 private function render_consent_page( $client, $params, $user ) {
812 $nonce = wp_create_nonce( self::NONCE_ACTION );
813 $action_url = rest_url( $this->namespace . '/oauth/authorize' );
814 $site_name = get_bloginfo( 'name' );
815 $client_name = $client->client_name ?: 'Unnamed MCP Client';
816 $role_label = $this->describe_user_role( $user );
817
818 status_header( 200 );
819 nocache_headers();
820 header( 'Content-Type: text/html; charset=utf-8' );
821
822 $hidden_fields = [
823 'client_id' => $params['client_id'],
824 'redirect_uri' => $params['redirect_uri'],
825 'state' => $params['state'],
826 'scope' => $params['scope'],
827 'code_challenge' => $params['code_challenge'],
828 'code_challenge_method' => $params['code_challenge_method'],
829 '_mwai_nonce' => $nonce,
830 ];
831
832 echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">';
833 echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
834 echo '<title>' . esc_html( sprintf( 'Authorize %s', $client_name ) ) . '</title>';
835 echo $this->consent_styles();
836 echo '</head><body><main class="mwai-oauth-card">';
837
838 echo '<h1>Authorize this app</h1>';
839 echo '<p class="mwai-oauth-app"><strong>' . esc_html( $client_name ) . '</strong> wants to connect to <strong>' . esc_html( $site_name ) . '</strong>.</p>';
840
841 echo '<div class="mwai-oauth-meta">';
842 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>';
843 echo '<div><span class="mwai-oauth-label">Permissions</span><span class="mwai-oauth-value">' . esc_html( $role_label ) . '</span></div>';
844 echo '</div>';
845
846 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>';
847
848 echo '<form method="POST" action="' . esc_url( $action_url ) . '">';
849 foreach ( $hidden_fields as $name => $value ) {
850 echo '<input type="hidden" name="' . esc_attr( $name ) . '" value="' . esc_attr( $value ) . '">';
851 }
852 echo '<div class="mwai-oauth-buttons">';
853 echo '<button type="submit" name="action" value="approve" class="mwai-oauth-approve">Approve</button>';
854 echo '<button type="submit" name="action" value="deny" class="mwai-oauth-deny">Deny</button>';
855 echo '</div>';
856 echo '</form>';
857
858 echo '</main></body></html>';
859 }
860
861 private function render_error_page( $message ) {
862 status_header( 400 );
863 nocache_headers();
864 header( 'Content-Type: text/html; charset=utf-8' );
865 echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">';
866 echo '<title>Authorization error</title>';
867 echo $this->consent_styles();
868 echo '</head><body><main class="mwai-oauth-card">';
869 echo '<h1>Authorization error</h1>';
870 echo '<p class="mwai-oauth-note">' . esc_html( $message ) . '</p>';
871 echo '</main></body></html>';
872 }
873
874 private function describe_user_role( $user ) {
875 if ( !$user || empty( $user->roles ) ) {
876 return 'No role';
877 }
878 $role = $user->roles[0];
879 $names = [
880 'administrator' => 'Administrator (full access)',
881 'editor' => 'Editor',
882 'author' => 'Author',
883 'contributor' => 'Contributor',
884 'subscriber' => 'Subscriber',
885 ];
886 return $names[ $role ] ?? ucfirst( $role );
887 }
888
889 private function consent_styles() {
890 return '<style>
891 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; }
892 .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%; }
893 .mwai-oauth-card h1 { font-size: 22px; margin: 0 0 16px; font-weight: 600; }
894 .mwai-oauth-app { font-size: 15px; line-height: 1.5; margin: 0 0 24px; }
895 .mwai-oauth-meta { background: #f7f8fa; border-radius: 8px; padding: 14px 16px; margin-bottom: 20px; }
896 .mwai-oauth-meta > div { display: flex; justify-content: space-between; align-items: baseline; padding: 6px 0; font-size: 14px; }
897 .mwai-oauth-label { color: #6b7280; }
898 .mwai-oauth-value { color: #1d2330; font-weight: 500; text-align: right; }
899 .mwai-oauth-note { font-size: 13px; color: #6b7280; line-height: 1.5; margin: 0 0 24px; }
900 .mwai-oauth-buttons { display: flex; gap: 10px; }
901 .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; }
902 .mwai-oauth-approve { background: #2271b1; color: #fff; }
903 .mwai-oauth-approve:hover { background: #135e96; }
904 .mwai-oauth-deny { background: #fff; color: #1d2330; border-color: #d0d4da; }
905 .mwai-oauth-deny:hover { background: #f1f2f5; }
906 </style>';
907 }
908 #endregion
909 }
910