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