PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / trunk
AI Engine – The Chatbot, AI Framework & MCP for WordPress vtrunk
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 11 hours ago mcp-oauth.php 1 month ago mcp-rest.php 2 weeks ago mcp.conf 1 year ago mcp.js 8 months ago mcp.md 8 months ago mcp.php 5 days 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
913 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 t.*, c.client_name FROM {$this->table_tokens} t
673 LEFT JOIN {$this->table_clients} c ON c.client_id = t.client_id
674 WHERE t.access_token_hash = %s AND t.revoked = 0 LIMIT 1",
675 $hash
676 )
677 );
678 if ( !$row ) {
679 return null;
680 }
681 if ( strtotime( $row->access_expires . ' UTC' ) < time() ) {
682 return null;
683 }
684 // Touch last_used (non-blocking, single UPDATE).
685 $wpdb->update(
686 $this->table_tokens,
687 [ 'last_used' => current_time( 'mysql', 1 ) ],
688 [ 'id' => $row->id ]
689 );
690 return [
691 'user_id' => (int) $row->user_id,
692 'client_id' => $row->client_id,
693 'client_name' => $row->client_name,
694 'scope' => $row->scope,
695 ];
696 }
697 #endregion
698
699 #region Admin: list / revoke grants
700 public function handle_apps_list() {
701 global $wpdb;
702 $rows = $wpdb->get_results(
703 "SELECT t.id, t.client_id, t.user_id, t.created, t.last_used, t.access_expires, t.refresh_expires, t.revoked,
704 c.client_name
705 FROM {$this->table_tokens} t
706 LEFT JOIN {$this->table_clients} c ON c.client_id = t.client_id
707 WHERE t.revoked = 0
708 ORDER BY t.created DESC"
709 );
710 $out = [];
711 foreach ( $rows as $r ) {
712 $user = get_userdata( (int) $r->user_id );
713 $out[] = [
714 'id' => (int) $r->id,
715 'client_id' => $r->client_id,
716 'client_name' => $r->client_name ?: 'Unknown app',
717 'user_id' => (int) $r->user_id,
718 'user_login' => $user ? $user->user_login : 'deleted',
719 'user_display' => $user ? $user->display_name : 'Deleted user',
720 'created' => $r->created,
721 'last_used' => $r->last_used,
722 'access_expires' => $r->access_expires,
723 'refresh_expires' => $r->refresh_expires,
724 ];
725 }
726 return new WP_REST_Response( [ 'apps' => $out ], 200 );
727 }
728
729 public function handle_apps_revoke( WP_REST_Request $request ) {
730 $id = (int) $request->get_param( 'id' );
731 if ( $id <= 0 ) {
732 return new WP_REST_Response( [ 'error' => 'Invalid id.' ], 400 );
733 }
734 global $wpdb;
735 $wpdb->update( $this->table_tokens, [ 'revoked' => 1 ], [ 'id' => $id ] );
736 return new WP_REST_Response( [ 'revoked' => true ], 200 );
737 }
738 #endregion
739
740 #region Helpers
741 private function get_client( $client_id ) {
742 if ( !is_string( $client_id ) || $client_id === '' ) {
743 return null;
744 }
745 global $wpdb;
746 return $wpdb->get_row( $wpdb->prepare(
747 "SELECT * FROM {$this->table_clients} WHERE client_id = %s LIMIT 1",
748 $client_id
749 ) );
750 }
751
752 private function redirect_uri_registered( $client, $redirect_uri ) {
753 if ( !$client || !is_string( $redirect_uri ) || $redirect_uri === '' ) {
754 return false;
755 }
756 $registered = json_decode( $client->redirect_uris, true );
757 if ( !is_array( $registered ) ) {
758 return false;
759 }
760 foreach ( $registered as $uri ) {
761 if ( hash_equals( (string) $uri, $redirect_uri ) ) {
762 return true;
763 }
764 }
765 return false;
766 }
767
768 private function append_params( $url, $params ) {
769 $sep = strpos( $url, '?' ) === false ? '?' : '&';
770 return $url . $sep . http_build_query( $params );
771 }
772
773 private function random_token( $bytes = 32 ) {
774 return bin2hex( random_bytes( (int) $bytes ) );
775 }
776
777 private function oauth_error( $code, $description, $status = 400 ) {
778 $response = new WP_REST_Response( [
779 'error' => $code,
780 'error_description' => $description,
781 ], $status );
782 $response->header( 'Cache-Control', 'no-store' );
783 $response->header( 'Pragma', 'no-cache' );
784 return $response;
785 }
786
787 /**
788 * Add WWW-Authenticate header to 401 responses on the protected MCP route,
789 * pointing clients at the resource metadata document so they can discover
790 * the authorization server automatically.
791 */
792 public function add_www_authenticate_header( $response, $server, $request ) {
793 if ( !( $response instanceof WP_HTTP_Response ) ) {
794 return $response;
795 }
796 $route = $request instanceof WP_REST_Request ? $request->get_route() : '';
797 if ( $route !== '/' . $this->namespace . '/http' ) {
798 return $response;
799 }
800 $status = $response->get_status();
801 if ( $status !== 401 && $status !== 403 ) {
802 return $response;
803 }
804 $resource_metadata = rest_url( $this->namespace . '/.well-known/oauth-protected-resource' );
805 $response->header(
806 'WWW-Authenticate',
807 sprintf( 'Bearer realm="MCP", resource_metadata="%s"', $resource_metadata )
808 );
809 return $response;
810 }
811 #endregion
812
813 #region HTML rendering (consent + error pages)
814 private function render_consent_page( $client, $params, $user ) {
815 $nonce = wp_create_nonce( self::NONCE_ACTION );
816 $action_url = rest_url( $this->namespace . '/oauth/authorize' );
817 $site_name = get_bloginfo( 'name' );
818 $client_name = $client->client_name ?: 'Unnamed MCP Client';
819 $role_label = $this->describe_user_role( $user );
820
821 status_header( 200 );
822 nocache_headers();
823 header( 'Content-Type: text/html; charset=utf-8' );
824
825 $hidden_fields = [
826 'client_id' => $params['client_id'],
827 'redirect_uri' => $params['redirect_uri'],
828 'state' => $params['state'],
829 'scope' => $params['scope'],
830 'code_challenge' => $params['code_challenge'],
831 'code_challenge_method' => $params['code_challenge_method'],
832 '_mwai_nonce' => $nonce,
833 ];
834
835 echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">';
836 echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
837 echo '<title>' . esc_html( sprintf( 'Authorize %s', $client_name ) ) . '</title>';
838 echo $this->consent_styles();
839 echo '</head><body><main class="mwai-oauth-card">';
840
841 echo '<h1>Authorize this app</h1>';
842 echo '<p class="mwai-oauth-app"><strong>' . esc_html( $client_name ) . '</strong> wants to connect to <strong>' . esc_html( $site_name ) . '</strong>.</p>';
843
844 echo '<div class="mwai-oauth-meta">';
845 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>';
846 echo '<div><span class="mwai-oauth-label">Permissions</span><span class="mwai-oauth-value">' . esc_html( $role_label ) . '</span></div>';
847 echo '</div>';
848
849 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>';
850
851 echo '<form method="POST" action="' . esc_url( $action_url ) . '">';
852 foreach ( $hidden_fields as $name => $value ) {
853 echo '<input type="hidden" name="' . esc_attr( $name ) . '" value="' . esc_attr( $value ) . '">';
854 }
855 echo '<div class="mwai-oauth-buttons">';
856 echo '<button type="submit" name="action" value="approve" class="mwai-oauth-approve">Approve</button>';
857 echo '<button type="submit" name="action" value="deny" class="mwai-oauth-deny">Deny</button>';
858 echo '</div>';
859 echo '</form>';
860
861 echo '</main></body></html>';
862 }
863
864 private function render_error_page( $message ) {
865 status_header( 400 );
866 nocache_headers();
867 header( 'Content-Type: text/html; charset=utf-8' );
868 echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">';
869 echo '<title>Authorization error</title>';
870 echo $this->consent_styles();
871 echo '</head><body><main class="mwai-oauth-card">';
872 echo '<h1>Authorization error</h1>';
873 echo '<p class="mwai-oauth-note">' . esc_html( $message ) . '</p>';
874 echo '</main></body></html>';
875 }
876
877 private function describe_user_role( $user ) {
878 if ( !$user || empty( $user->roles ) ) {
879 return 'No role';
880 }
881 $role = $user->roles[0];
882 $names = [
883 'administrator' => 'Administrator (full access)',
884 'editor' => 'Editor',
885 'author' => 'Author',
886 'contributor' => 'Contributor',
887 'subscriber' => 'Subscriber',
888 ];
889 return $names[ $role ] ?? ucfirst( $role );
890 }
891
892 private function consent_styles() {
893 return '<style>
894 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; }
895 .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%; }
896 .mwai-oauth-card h1 { font-size: 22px; margin: 0 0 16px; font-weight: 600; }
897 .mwai-oauth-app { font-size: 15px; line-height: 1.5; margin: 0 0 24px; }
898 .mwai-oauth-meta { background: #f7f8fa; border-radius: 8px; padding: 14px 16px; margin-bottom: 20px; }
899 .mwai-oauth-meta > div { display: flex; justify-content: space-between; align-items: baseline; padding: 6px 0; font-size: 14px; }
900 .mwai-oauth-label { color: #6b7280; }
901 .mwai-oauth-value { color: #1d2330; font-weight: 500; text-align: right; }
902 .mwai-oauth-note { font-size: 13px; color: #6b7280; line-height: 1.5; margin: 0 0 24px; }
903 .mwai-oauth-buttons { display: flex; gap: 10px; }
904 .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; }
905 .mwai-oauth-approve { background: #2271b1; color: #fff; }
906 .mwai-oauth-approve:hover { background: #135e96; }
907 .mwai-oauth-deny { background: #fff; color: #1d2330; border-color: #d0d4da; }
908 .mwai-oauth-deny:hover { background: #f1f2f5; }
909 </style>';
910 }
911 #endregion
912 }
913