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