PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.6.8
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.6.8
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
995 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 }
688 else {
689 $provided_secret = (string) ( $request->get_param( 'client_secret' ) ?? '' );
690 }
691 if ( $provided_secret === '' || !$client->client_secret_hash ) {
692 return false;
693 }
694 return hash_equals( $client->client_secret_hash, hash( 'sha256', $provided_secret ) );
695 }
696 #endregion
697
698 #region Revocation
699 public function handle_revoke( WP_REST_Request $request ) {
700 $token = (string) ( $request->get_param( 'token' ) ?? '' );
701 if ( $token === '' ) {
702 // RFC 7009: return 200 even on unknown tokens to avoid information leakage.
703 return new WP_REST_Response( null, 200 );
704 }
705 global $wpdb;
706 $hash = hash( 'sha256', $token );
707 $wpdb->query( $wpdb->prepare(
708 "UPDATE {$this->table_tokens} SET revoked = 1 WHERE access_token_hash = %s OR refresh_token_hash = %s",
709 $hash,
710 $hash
711 ) );
712 return new WP_REST_Response( null, 200 );
713 }
714 #endregion
715
716 #region Capability gate
717 /**
718 * Whether a user is allowed to authorize an OAuth client and to use an OAuth
719 * access token against the MCP endpoint. Defaults to administrator only,
720 * matching the documented MCP access model. The filter exists so the planned
721 * multi-user MCP work can broaden this safely once per-token capability
722 * scoping lands; until then, allowing a non-admin here re-opens CVE-class
723 * privilege escalation through tools like wp_create_user.
724 *
725 * Test manage_options, not the 'administrator' role name. Passing a role name
726 * to user_can() only matches when that exact key sits in the user's
727 * capabilities meta, so admin-equivalent accounts (custom roles, caps granted
728 * individually, or a plugin filtering user_has_cap) were refused at the
729 * consent screen while every wp-admin settings page loaded fine for them.
730 * manage_options keeps the privilege-escalation fix intact: editors and below
731 * do not hold it, and multisite super admins pass via WP_User::has_cap().
732 */
733 public function user_can_authorize( $user_id ) {
734 $user_id = (int) $user_id;
735 $allowed = $user_id > 0 && user_can( $user_id, 'manage_options' );
736 return (bool) apply_filters( 'mwai_mcp_oauth_user_can_authorize', $allowed, $user_id );
737 }
738 #endregion
739
740 #region Token validation (called from MCP auth path)
741 /**
742 * Validate an access token for protected resource access.
743 * Returns [ 'user_id' => N, 'client_id' => '...', 'scope' => '...' ] on success, null on failure.
744 * Also touches last_used so the admin UI can show recent activity.
745 */
746 public function validate_token( $token ) {
747 if ( !is_string( $token ) || $token === '' ) {
748 return null;
749 }
750 global $wpdb;
751 $hash = hash( 'sha256', $token );
752 $row = $wpdb->get_row(
753 $wpdb->prepare(
754 "SELECT t.*, c.client_name FROM {$this->table_tokens} t
755 LEFT JOIN {$this->table_clients} c ON c.client_id = t.client_id
756 WHERE t.access_token_hash = %s AND t.revoked = 0 LIMIT 1",
757 $hash
758 )
759 );
760 if ( !$row ) {
761 return null;
762 }
763 if ( strtotime( $row->access_expires . ' UTC' ) < time() ) {
764 return null;
765 }
766 // Touch last_used (non-blocking, single UPDATE).
767 $wpdb->update(
768 $this->table_tokens,
769 [ 'last_used' => current_time( 'mysql', 1 ) ],
770 [ 'id' => $row->id ]
771 );
772 return [
773 'user_id' => (int) $row->user_id,
774 'client_id' => $row->client_id,
775 'client_name' => $row->client_name,
776 'scope' => $row->scope,
777 ];
778 }
779 #endregion
780
781 #region Admin: list / revoke grants
782 public function handle_apps_list() {
783 global $wpdb;
784 $rows = $wpdb->get_results(
785 "SELECT t.id, t.client_id, t.user_id, t.created, t.last_used, t.access_expires, t.refresh_expires, t.revoked,
786 c.client_name
787 FROM {$this->table_tokens} t
788 LEFT JOIN {$this->table_clients} c ON c.client_id = t.client_id
789 WHERE t.revoked = 0
790 ORDER BY t.created DESC"
791 );
792 $out = [];
793 foreach ( $rows as $r ) {
794 $user = get_userdata( (int) $r->user_id );
795 $out[] = [
796 'id' => (int) $r->id,
797 'client_id' => $r->client_id,
798 'client_name' => $r->client_name ?: 'Unknown app',
799 'user_id' => (int) $r->user_id,
800 'user_login' => $user ? $user->user_login : 'deleted',
801 'user_display' => $user ? $user->display_name : 'Deleted user',
802 'created' => $r->created,
803 'last_used' => $r->last_used,
804 'access_expires' => $r->access_expires,
805 'refresh_expires' => $r->refresh_expires,
806 ];
807 }
808 return new WP_REST_Response( [ 'apps' => $out ], 200 );
809 }
810
811 public function handle_apps_revoke( WP_REST_Request $request ) {
812 $id = (int) $request->get_param( 'id' );
813 if ( $id <= 0 ) {
814 return new WP_REST_Response( [ 'error' => 'Invalid id.' ], 400 );
815 }
816 global $wpdb;
817 $wpdb->update( $this->table_tokens, [ 'revoked' => 1 ], [ 'id' => $id ] );
818 return new WP_REST_Response( [ 'revoked' => true ], 200 );
819 }
820 #endregion
821
822 #region Helpers
823 private function get_client( $client_id ) {
824 if ( !is_string( $client_id ) || $client_id === '' ) {
825 return null;
826 }
827 global $wpdb;
828 return $wpdb->get_row( $wpdb->prepare(
829 "SELECT * FROM {$this->table_clients} WHERE client_id = %s LIMIT 1",
830 $client_id
831 ) );
832 }
833
834 private function redirect_uri_registered( $client, $redirect_uri ) {
835 if ( !$client || !is_string( $redirect_uri ) || $redirect_uri === '' ) {
836 return false;
837 }
838 $registered = json_decode( $client->redirect_uris, true );
839 if ( !is_array( $registered ) ) {
840 return false;
841 }
842 foreach ( $registered as $uri ) {
843 if ( hash_equals( (string) $uri, $redirect_uri ) ) {
844 return true;
845 }
846 }
847 return false;
848 }
849
850 private function append_params( $url, $params ) {
851 $sep = strpos( $url, '?' ) === false ? '?' : '&';
852 return $url . $sep . http_build_query( $params );
853 }
854
855 private function random_token( $bytes = 32 ) {
856 return bin2hex( random_bytes( (int) $bytes ) );
857 }
858
859 private function oauth_error( $code, $description, $status = 400 ) {
860 $response = new WP_REST_Response( [
861 'error' => $code,
862 'error_description' => $description,
863 ], $status );
864 $response->header( 'Cache-Control', 'no-store' );
865 $response->header( 'Pragma', 'no-cache' );
866 return $response;
867 }
868
869 /**
870 * Add WWW-Authenticate header to 401 responses on the protected MCP route,
871 * pointing clients at the resource metadata document so they can discover
872 * the authorization server automatically.
873 */
874 public function add_www_authenticate_header( $response, $server, $request ) {
875 if ( !( $response instanceof WP_HTTP_Response ) ) {
876 return $response;
877 }
878 $route = $request instanceof WP_REST_Request ? $request->get_route() : '';
879 if ( $route !== '/' . $this->namespace . '/http' ) {
880 return $response;
881 }
882 $status = $response->get_status();
883 if ( $status !== 401 && $status !== 403 ) {
884 return $response;
885 }
886 $resource_metadata = rest_url( $this->namespace . '/.well-known/oauth-protected-resource' );
887 $response->header(
888 'WWW-Authenticate',
889 sprintf( 'Bearer realm="MCP", resource_metadata="%s"', $resource_metadata )
890 );
891 return $response;
892 }
893 #endregion
894
895 #region HTML rendering (consent + error pages)
896 private function render_consent_page( $client, $params, $user ) {
897 $nonce = wp_create_nonce( self::NONCE_ACTION );
898 $action_url = rest_url( $this->namespace . '/oauth/authorize' );
899 $site_name = get_bloginfo( 'name' );
900 $client_name = $client->client_name ?: 'Unnamed MCP Client';
901 $role_label = $this->describe_user_role( $user );
902
903 status_header( 200 );
904 nocache_headers();
905 header( 'Content-Type: text/html; charset=utf-8' );
906
907 $hidden_fields = [
908 'client_id' => $params['client_id'],
909 'redirect_uri' => $params['redirect_uri'],
910 'state' => $params['state'],
911 'scope' => $params['scope'],
912 'code_challenge' => $params['code_challenge'],
913 'code_challenge_method' => $params['code_challenge_method'],
914 '_mwai_nonce' => $nonce,
915 ];
916
917 echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">';
918 echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
919 echo '<title>' . esc_html( sprintf( 'Authorize %s', $client_name ) ) . '</title>';
920 echo $this->consent_styles();
921 echo '</head><body><main class="mwai-oauth-card">';
922
923 echo '<h1>Authorize this app</h1>';
924 echo '<p class="mwai-oauth-app"><strong>' . esc_html( $client_name ) . '</strong> wants to connect to <strong>' . esc_html( $site_name ) . '</strong>.</p>';
925
926 echo '<div class="mwai-oauth-meta">';
927 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>';
928 echo '<div><span class="mwai-oauth-label">Permissions</span><span class="mwai-oauth-value">' . esc_html( $role_label ) . '</span></div>';
929 echo '</div>';
930
931 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>';
932
933 echo '<form method="POST" action="' . esc_url( $action_url ) . '">';
934 foreach ( $hidden_fields as $name => $value ) {
935 echo '<input type="hidden" name="' . esc_attr( $name ) . '" value="' . esc_attr( $value ) . '">';
936 }
937 echo '<div class="mwai-oauth-buttons">';
938 echo '<button type="submit" name="action" value="approve" class="mwai-oauth-approve">Approve</button>';
939 echo '<button type="submit" name="action" value="deny" class="mwai-oauth-deny">Deny</button>';
940 echo '</div>';
941 echo '</form>';
942
943 echo '</main></body></html>';
944 }
945
946 private function render_error_page( $message ) {
947 status_header( 400 );
948 nocache_headers();
949 header( 'Content-Type: text/html; charset=utf-8' );
950 echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">';
951 echo '<title>Authorization error</title>';
952 echo $this->consent_styles();
953 echo '</head><body><main class="mwai-oauth-card">';
954 echo '<h1>Authorization error</h1>';
955 echo '<p class="mwai-oauth-note">' . esc_html( $message ) . '</p>';
956 echo '</main></body></html>';
957 }
958
959 private function describe_user_role( $user ) {
960 if ( !$user || empty( $user->roles ) ) {
961 return 'No role';
962 }
963 $role = $user->roles[0];
964 $names = [
965 'administrator' => 'Administrator (full access)',
966 'editor' => 'Editor',
967 'author' => 'Author',
968 'contributor' => 'Contributor',
969 'subscriber' => 'Subscriber',
970 ];
971 return $names[ $role ] ?? ucfirst( $role );
972 }
973
974 private function consent_styles() {
975 return '<style>
976 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; }
977 .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%; }
978 .mwai-oauth-card h1 { font-size: 22px; margin: 0 0 16px; font-weight: 600; }
979 .mwai-oauth-app { font-size: 15px; line-height: 1.5; margin: 0 0 24px; }
980 .mwai-oauth-meta { background: #f7f8fa; border-radius: 8px; padding: 14px 16px; margin-bottom: 20px; }
981 .mwai-oauth-meta > div { display: flex; justify-content: space-between; align-items: baseline; padding: 6px 0; font-size: 14px; }
982 .mwai-oauth-label { color: #6b7280; }
983 .mwai-oauth-value { color: #1d2330; font-weight: 500; text-align: right; }
984 .mwai-oauth-note { font-size: 13px; color: #6b7280; line-height: 1.5; margin: 0 0 24px; }
985 .mwai-oauth-buttons { display: flex; gap: 10px; }
986 .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; }
987 .mwai-oauth-approve { background: #2271b1; color: #fff; }
988 .mwai-oauth-approve:hover { background: #135e96; }
989 .mwai-oauth-deny { background: #fff; color: #1d2330; border-color: #d0d4da; }
990 .mwai-oauth-deny:hover { background: #f1f2f5; }
991 </style>';
992 }
993 #endregion
994 }
995