PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.6.7
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.6.7
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 4 days ago mcp-rest.php 1 month ago mcp.conf 1 year ago mcp.php 3 days ago model-audit.php 1 week ago workspace-mock.html 1 week ago wpai-connectors.php 2 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
935 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 if ( $grant_type === 'authorization_code' ) {
493 return $this->handle_token_auth_code( $request );
494 }
495 if ( $grant_type === 'refresh_token' ) {
496 return $this->handle_token_refresh( $request );
497 }
498 return $this->oauth_error( 'unsupported_grant_type', 'Supported: authorization_code, refresh_token.', 400 );
499 }
500
501 private function handle_token_auth_code( WP_REST_Request $request ) {
502 $code = (string) ( $request->get_param( 'code' ) ?? '' );
503 $redirect_uri = (string) ( $request->get_param( 'redirect_uri' ) ?? '' );
504 $code_verifier = (string) ( $request->get_param( 'code_verifier' ) ?? '' );
505 $client_id = (string) ( $request->get_param( 'client_id' ) ?? '' );
506
507 if ( $code === '' || $redirect_uri === '' || $code_verifier === '' ) {
508 return $this->oauth_error( 'invalid_request', 'Missing code, redirect_uri, or code_verifier.', 400 );
509 }
510
511 $key = $this->auth_code_key( $code );
512 $code_data = get_transient( $key );
513 if ( !is_array( $code_data ) ) {
514 return $this->oauth_error( 'invalid_grant', 'Authorization code is invalid or expired.', 400 );
515 }
516 // Single-use: delete immediately to prevent replay.
517 delete_transient( $key );
518
519 if ( $code_data['redirect_uri'] !== $redirect_uri ) {
520 return $this->oauth_error( 'invalid_grant', 'redirect_uri mismatch.', 400 );
521 }
522
523 $client = $this->get_client( $code_data['client_id'] );
524 if ( !$client ) {
525 return $this->oauth_error( 'invalid_client', 'Client not found.', 401 );
526 }
527 if ( $client_id !== '' && $client_id !== $client->client_id ) {
528 return $this->oauth_error( 'invalid_client', 'client_id mismatch.', 401 );
529 }
530 if ( !$this->authenticate_client_if_required( $client, $request ) ) {
531 return $this->oauth_error( 'invalid_client', 'Client authentication failed.', 401 );
532 }
533
534 // Verify PKCE.
535 $expected_challenge = rtrim( strtr( base64_encode( hash( 'sha256', $code_verifier, true ) ), '+/', '-_' ), '=' );
536 if ( !hash_equals( (string) $code_data['code_challenge'], $expected_challenge ) ) {
537 return $this->oauth_error( 'invalid_grant', 'PKCE verification failed.', 400 );
538 }
539
540 return $this->issue_token_pair( $client->client_id, (int) $code_data['user_id'], (string) $code_data['scope'] );
541 }
542
543 private function handle_token_refresh( WP_REST_Request $request ) {
544 $refresh_token = (string) ( $request->get_param( 'refresh_token' ) ?? '' );
545 $client_id = (string) ( $request->get_param( 'client_id' ) ?? '' );
546 if ( $refresh_token === '' ) {
547 return $this->oauth_error( 'invalid_request', 'Missing refresh_token.', 400 );
548 }
549
550 global $wpdb;
551 $hash = hash( 'sha256', $refresh_token );
552 $row = $wpdb->get_row(
553 $wpdb->prepare(
554 "SELECT * FROM {$this->table_tokens} WHERE refresh_token_hash = %s AND revoked = 0 LIMIT 1",
555 $hash
556 )
557 );
558 if ( !$row ) {
559 return $this->oauth_error( 'invalid_grant', 'Refresh token is invalid or revoked.', 400 );
560 }
561 if ( $row->refresh_expires && strtotime( $row->refresh_expires . ' UTC' ) < time() ) {
562 return $this->oauth_error( 'invalid_grant', 'Refresh token expired.', 400 );
563 }
564
565 $client = $this->get_client( $row->client_id );
566 if ( !$client ) {
567 return $this->oauth_error( 'invalid_client', 'Client not found.', 401 );
568 }
569 if ( $client_id !== '' && $client_id !== $client->client_id ) {
570 return $this->oauth_error( 'invalid_client', 'client_id mismatch.', 401 );
571 }
572 if ( !$this->authenticate_client_if_required( $client, $request ) ) {
573 return $this->oauth_error( 'invalid_client', 'Client authentication failed.', 401 );
574 }
575
576 // Refresh-token rotation (OAuth 2.1 best practice): revoke the old grant and issue a new pair.
577 $wpdb->update( $this->table_tokens, [ 'revoked' => 1 ], [ 'id' => $row->id ] );
578
579 return $this->issue_token_pair( $row->client_id, (int) $row->user_id, (string) $row->scope );
580 }
581
582 private function issue_token_pair( $client_id, $user_id, $scope ) {
583 global $wpdb;
584 $access_token = $this->random_token( 48 );
585 $refresh_token = $this->random_token( 48 );
586 $now = time();
587
588 $wpdb->insert( $this->table_tokens, [
589 'client_id' => $client_id,
590 'user_id' => $user_id,
591 'access_token_hash' => hash( 'sha256', $access_token ),
592 'refresh_token_hash' => hash( 'sha256', $refresh_token ),
593 'access_expires' => gmdate( 'Y-m-d H:i:s', $now + self::ACCESS_TOKEN_TTL ),
594 'refresh_expires' => gmdate( 'Y-m-d H:i:s', $now + self::REFRESH_TOKEN_TTL ),
595 'scope' => $scope,
596 'created' => gmdate( 'Y-m-d H:i:s', $now ),
597 ] );
598
599 $response = new WP_REST_Response( [
600 'access_token' => $access_token,
601 'token_type' => 'Bearer',
602 'expires_in' => self::ACCESS_TOKEN_TTL,
603 'refresh_token' => $refresh_token,
604 'scope' => $scope,
605 ], 200 );
606 $response->header( 'Cache-Control', 'no-store' );
607 $response->header( 'Pragma', 'no-cache' );
608 return $response;
609 }
610
611 private function authenticate_client_if_required( $client, WP_REST_Request $request ) {
612 if ( $client->token_endpoint_auth_method === 'none' ) {
613 return true;
614 }
615 $provided_secret = '';
616 if ( $client->token_endpoint_auth_method === 'client_secret_basic' ) {
617 $auth = $request->get_header( 'authorization' );
618 if ( $auth && preg_match( '#^Basic\s+(.+)$#i', $auth, $m ) ) {
619 $decoded = base64_decode( $m[1], true );
620 if ( $decoded && strpos( $decoded, ':' ) !== false ) {
621 [ $cid, $secret ] = explode( ':', $decoded, 2 );
622 if ( $cid === $client->client_id ) {
623 $provided_secret = $secret;
624 }
625 }
626 }
627 }
628 else {
629 $provided_secret = (string) ( $request->get_param( 'client_secret' ) ?? '' );
630 }
631 if ( $provided_secret === '' || !$client->client_secret_hash ) {
632 return false;
633 }
634 return hash_equals( $client->client_secret_hash, hash( 'sha256', $provided_secret ) );
635 }
636 #endregion
637
638 #region Revocation
639 public function handle_revoke( WP_REST_Request $request ) {
640 $token = (string) ( $request->get_param( 'token' ) ?? '' );
641 if ( $token === '' ) {
642 // RFC 7009: return 200 even on unknown tokens to avoid information leakage.
643 return new WP_REST_Response( null, 200 );
644 }
645 global $wpdb;
646 $hash = hash( 'sha256', $token );
647 $wpdb->query( $wpdb->prepare(
648 "UPDATE {$this->table_tokens} SET revoked = 1 WHERE access_token_hash = %s OR refresh_token_hash = %s",
649 $hash,
650 $hash
651 ) );
652 return new WP_REST_Response( null, 200 );
653 }
654 #endregion
655
656 #region Capability gate
657 /**
658 * Whether a user is allowed to authorize an OAuth client and to use an OAuth
659 * access token against the MCP endpoint. Defaults to administrator only,
660 * matching the documented MCP access model. The filter exists so the planned
661 * multi-user MCP work can broaden this safely once per-token capability
662 * scoping lands; until then, allowing a non-admin here re-opens CVE-class
663 * privilege escalation through tools like wp_create_user.
664 *
665 * Test manage_options, not the 'administrator' role name. Passing a role name
666 * to user_can() only matches when that exact key sits in the user's
667 * capabilities meta, so admin-equivalent accounts (custom roles, caps granted
668 * individually, or a plugin filtering user_has_cap) were refused at the
669 * consent screen while every wp-admin settings page loaded fine for them.
670 * manage_options keeps the privilege-escalation fix intact: editors and below
671 * do not hold it, and multisite super admins pass via WP_User::has_cap().
672 */
673 public function user_can_authorize( $user_id ) {
674 $user_id = (int) $user_id;
675 $allowed = $user_id > 0 && user_can( $user_id, 'manage_options' );
676 return (bool) apply_filters( 'mwai_mcp_oauth_user_can_authorize', $allowed, $user_id );
677 }
678 #endregion
679
680 #region Token validation (called from MCP auth path)
681 /**
682 * Validate an access token for protected resource access.
683 * Returns [ 'user_id' => N, 'client_id' => '...', 'scope' => '...' ] on success, null on failure.
684 * Also touches last_used so the admin UI can show recent activity.
685 */
686 public function validate_token( $token ) {
687 if ( !is_string( $token ) || $token === '' ) {
688 return null;
689 }
690 global $wpdb;
691 $hash = hash( 'sha256', $token );
692 $row = $wpdb->get_row(
693 $wpdb->prepare(
694 "SELECT t.*, c.client_name FROM {$this->table_tokens} t
695 LEFT JOIN {$this->table_clients} c ON c.client_id = t.client_id
696 WHERE t.access_token_hash = %s AND t.revoked = 0 LIMIT 1",
697 $hash
698 )
699 );
700 if ( !$row ) {
701 return null;
702 }
703 if ( strtotime( $row->access_expires . ' UTC' ) < time() ) {
704 return null;
705 }
706 // Touch last_used (non-blocking, single UPDATE).
707 $wpdb->update(
708 $this->table_tokens,
709 [ 'last_used' => current_time( 'mysql', 1 ) ],
710 [ 'id' => $row->id ]
711 );
712 return [
713 'user_id' => (int) $row->user_id,
714 'client_id' => $row->client_id,
715 'client_name' => $row->client_name,
716 'scope' => $row->scope,
717 ];
718 }
719 #endregion
720
721 #region Admin: list / revoke grants
722 public function handle_apps_list() {
723 global $wpdb;
724 $rows = $wpdb->get_results(
725 "SELECT t.id, t.client_id, t.user_id, t.created, t.last_used, t.access_expires, t.refresh_expires, t.revoked,
726 c.client_name
727 FROM {$this->table_tokens} t
728 LEFT JOIN {$this->table_clients} c ON c.client_id = t.client_id
729 WHERE t.revoked = 0
730 ORDER BY t.created DESC"
731 );
732 $out = [];
733 foreach ( $rows as $r ) {
734 $user = get_userdata( (int) $r->user_id );
735 $out[] = [
736 'id' => (int) $r->id,
737 'client_id' => $r->client_id,
738 'client_name' => $r->client_name ?: 'Unknown app',
739 'user_id' => (int) $r->user_id,
740 'user_login' => $user ? $user->user_login : 'deleted',
741 'user_display' => $user ? $user->display_name : 'Deleted user',
742 'created' => $r->created,
743 'last_used' => $r->last_used,
744 'access_expires' => $r->access_expires,
745 'refresh_expires' => $r->refresh_expires,
746 ];
747 }
748 return new WP_REST_Response( [ 'apps' => $out ], 200 );
749 }
750
751 public function handle_apps_revoke( WP_REST_Request $request ) {
752 $id = (int) $request->get_param( 'id' );
753 if ( $id <= 0 ) {
754 return new WP_REST_Response( [ 'error' => 'Invalid id.' ], 400 );
755 }
756 global $wpdb;
757 $wpdb->update( $this->table_tokens, [ 'revoked' => 1 ], [ 'id' => $id ] );
758 return new WP_REST_Response( [ 'revoked' => true ], 200 );
759 }
760 #endregion
761
762 #region Helpers
763 private function get_client( $client_id ) {
764 if ( !is_string( $client_id ) || $client_id === '' ) {
765 return null;
766 }
767 global $wpdb;
768 return $wpdb->get_row( $wpdb->prepare(
769 "SELECT * FROM {$this->table_clients} WHERE client_id = %s LIMIT 1",
770 $client_id
771 ) );
772 }
773
774 private function redirect_uri_registered( $client, $redirect_uri ) {
775 if ( !$client || !is_string( $redirect_uri ) || $redirect_uri === '' ) {
776 return false;
777 }
778 $registered = json_decode( $client->redirect_uris, true );
779 if ( !is_array( $registered ) ) {
780 return false;
781 }
782 foreach ( $registered as $uri ) {
783 if ( hash_equals( (string) $uri, $redirect_uri ) ) {
784 return true;
785 }
786 }
787 return false;
788 }
789
790 private function append_params( $url, $params ) {
791 $sep = strpos( $url, '?' ) === false ? '?' : '&';
792 return $url . $sep . http_build_query( $params );
793 }
794
795 private function random_token( $bytes = 32 ) {
796 return bin2hex( random_bytes( (int) $bytes ) );
797 }
798
799 private function oauth_error( $code, $description, $status = 400 ) {
800 $response = new WP_REST_Response( [
801 'error' => $code,
802 'error_description' => $description,
803 ], $status );
804 $response->header( 'Cache-Control', 'no-store' );
805 $response->header( 'Pragma', 'no-cache' );
806 return $response;
807 }
808
809 /**
810 * Add WWW-Authenticate header to 401 responses on the protected MCP route,
811 * pointing clients at the resource metadata document so they can discover
812 * the authorization server automatically.
813 */
814 public function add_www_authenticate_header( $response, $server, $request ) {
815 if ( !( $response instanceof WP_HTTP_Response ) ) {
816 return $response;
817 }
818 $route = $request instanceof WP_REST_Request ? $request->get_route() : '';
819 if ( $route !== '/' . $this->namespace . '/http' ) {
820 return $response;
821 }
822 $status = $response->get_status();
823 if ( $status !== 401 && $status !== 403 ) {
824 return $response;
825 }
826 $resource_metadata = rest_url( $this->namespace . '/.well-known/oauth-protected-resource' );
827 $response->header(
828 'WWW-Authenticate',
829 sprintf( 'Bearer realm="MCP", resource_metadata="%s"', $resource_metadata )
830 );
831 return $response;
832 }
833 #endregion
834
835 #region HTML rendering (consent + error pages)
836 private function render_consent_page( $client, $params, $user ) {
837 $nonce = wp_create_nonce( self::NONCE_ACTION );
838 $action_url = rest_url( $this->namespace . '/oauth/authorize' );
839 $site_name = get_bloginfo( 'name' );
840 $client_name = $client->client_name ?: 'Unnamed MCP Client';
841 $role_label = $this->describe_user_role( $user );
842
843 status_header( 200 );
844 nocache_headers();
845 header( 'Content-Type: text/html; charset=utf-8' );
846
847 $hidden_fields = [
848 'client_id' => $params['client_id'],
849 'redirect_uri' => $params['redirect_uri'],
850 'state' => $params['state'],
851 'scope' => $params['scope'],
852 'code_challenge' => $params['code_challenge'],
853 'code_challenge_method' => $params['code_challenge_method'],
854 '_mwai_nonce' => $nonce,
855 ];
856
857 echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">';
858 echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
859 echo '<title>' . esc_html( sprintf( 'Authorize %s', $client_name ) ) . '</title>';
860 echo $this->consent_styles();
861 echo '</head><body><main class="mwai-oauth-card">';
862
863 echo '<h1>Authorize this app</h1>';
864 echo '<p class="mwai-oauth-app"><strong>' . esc_html( $client_name ) . '</strong> wants to connect to <strong>' . esc_html( $site_name ) . '</strong>.</p>';
865
866 echo '<div class="mwai-oauth-meta">';
867 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>';
868 echo '<div><span class="mwai-oauth-label">Permissions</span><span class="mwai-oauth-value">' . esc_html( $role_label ) . '</span></div>';
869 echo '</div>';
870
871 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>';
872
873 echo '<form method="POST" action="' . esc_url( $action_url ) . '">';
874 foreach ( $hidden_fields as $name => $value ) {
875 echo '<input type="hidden" name="' . esc_attr( $name ) . '" value="' . esc_attr( $value ) . '">';
876 }
877 echo '<div class="mwai-oauth-buttons">';
878 echo '<button type="submit" name="action" value="approve" class="mwai-oauth-approve">Approve</button>';
879 echo '<button type="submit" name="action" value="deny" class="mwai-oauth-deny">Deny</button>';
880 echo '</div>';
881 echo '</form>';
882
883 echo '</main></body></html>';
884 }
885
886 private function render_error_page( $message ) {
887 status_header( 400 );
888 nocache_headers();
889 header( 'Content-Type: text/html; charset=utf-8' );
890 echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">';
891 echo '<title>Authorization error</title>';
892 echo $this->consent_styles();
893 echo '</head><body><main class="mwai-oauth-card">';
894 echo '<h1>Authorization error</h1>';
895 echo '<p class="mwai-oauth-note">' . esc_html( $message ) . '</p>';
896 echo '</main></body></html>';
897 }
898
899 private function describe_user_role( $user ) {
900 if ( !$user || empty( $user->roles ) ) {
901 return 'No role';
902 }
903 $role = $user->roles[0];
904 $names = [
905 'administrator' => 'Administrator (full access)',
906 'editor' => 'Editor',
907 'author' => 'Author',
908 'contributor' => 'Contributor',
909 'subscriber' => 'Subscriber',
910 ];
911 return $names[ $role ] ?? ucfirst( $role );
912 }
913
914 private function consent_styles() {
915 return '<style>
916 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; }
917 .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%; }
918 .mwai-oauth-card h1 { font-size: 22px; margin: 0 0 16px; font-weight: 600; }
919 .mwai-oauth-app { font-size: 15px; line-height: 1.5; margin: 0 0 24px; }
920 .mwai-oauth-meta { background: #f7f8fa; border-radius: 8px; padding: 14px 16px; margin-bottom: 20px; }
921 .mwai-oauth-meta > div { display: flex; justify-content: space-between; align-items: baseline; padding: 6px 0; font-size: 14px; }
922 .mwai-oauth-label { color: #6b7280; }
923 .mwai-oauth-value { color: #1d2330; font-weight: 500; text-align: right; }
924 .mwai-oauth-note { font-size: 13px; color: #6b7280; line-height: 1.5; margin: 0 0 24px; }
925 .mwai-oauth-buttons { display: flex; gap: 10px; }
926 .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; }
927 .mwai-oauth-approve { background: #2271b1; color: #fff; }
928 .mwai-oauth-approve:hover { background: #135e96; }
929 .mwai-oauth-deny { background: #fff; color: #1d2330; border-color: #d0d4da; }
930 .mwai-oauth-deny:hover { background: #f1f2f5; }
931 </style>';
932 }
933 #endregion
934 }
935