PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 All 128 releases
extendify / app / Mcp / OAuth / Authorize.php

Authorize.php in Extendify 3.2.2, at app/Mcp/OAuth/Authorize.php

470 lines 16.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * The consent screen an OAuth client sends the user to.
5 */
6
7 namespace Extendify\Mcp\OAuth;
8
9 defined('ABSPATH') || die('No direct access.');
10
11 use Extendify\Mcp\Allowed;
12 use Extendify\Mcp\Availability;
13 use Extendify\Mcp\Grants;
14 use Extendify\Mcp\Profile;
15 use Extendify\PartnerData;
16
17 /**
18 * A hidden wp-admin page, so WordPress's own login does the authenticating.
19 *
20 * An invalid request is explained here and never redirected: the return
21 * address is the thing that could not be trusted.
22 */
23 class Authorize
24 {
25 // phpcs:disable PSR12.Properties.ConstantVisibility.NotFound
26 const NONCE = 'extendify_mcp_authorize';
27
28 const APPROVE = 'approve';
29
30 const STYLE = 'extendify-mcp-authorize';
31 // phpcs:enable PSR12.Properties.ConstantVisibility.NotFound
32
33 /**
34 * @return void
35 */
36 public static function register()
37 {
38 \add_action('admin_menu', [self::class, 'registerPage']);
39 \add_action('admin_init', [self::class, 'handleDecision']);
40 }
41
42 /**
43 * Open to every signed-in user, so a non-administrator is told who can
44 * approve instead of seeing WordPress's "not allowed" page.
45 *
46 * @return void
47 */
48 public static function registerPage()
49 {
50 $hook = \add_submenu_page(
51 '',
52 \__('Connect an AI assistant', 'extendify-local'),
53 \__('Connect an AI assistant', 'extendify-local'),
54 'read',
55 Metadata::AUTHORIZE_PAGE,
56 [self::class, 'renderPage']
57 );
58 if ($hook) {
59 \add_action('load-' . $hook, [self::class, 'clearPage']);
60 }
61 }
62
63 /**
64 * Access is granted here, so no plugin's notice, widget or script may sit beside Approve.
65 *
66 * @return void
67 */
68 public static function clearPage()
69 {
70 $hooks = [
71 'admin_notices',
72 'all_admin_notices',
73 'user_admin_notices',
74 'network_admin_notices',
75 'in_admin_footer',
76 'admin_footer',
77 ];
78 foreach ($hooks as $hook) {
79 \remove_all_actions($hook);
80 }
81
82 Profile::quietNotices();
83 \add_filter('print_scripts_array', function (array $handles) {
84 return self::coreOnly($handles, \wp_scripts());
85 });
86 \add_filter('print_styles_array', function (array $handles) {
87 return self::coreOnly($handles, \wp_styles());
88 });
89 }
90
91 /**
92 * Core registers its assets by site-relative path; a plugin's carry a URL.
93 *
94 * @param array $handles - The handles about to print.
95 * @param \WP_Dependencies $registry - The scripts or styles they are registered with.
96 * @return array
97 */
98 private static function coreOnly(array $handles, \WP_Dependencies $registry)
99 {
100 return array_values(array_filter($handles, function ($handle) use ($registry) {
101 if ($handle === self::STYLE) {
102 return true;
103 }
104
105 $src = $registry->registered[$handle]->src ?? '';
106
107 return is_string($src) && preg_match('#^/wp-(admin|includes)/#', $src) === 1;
108 }));
109 }
110
111 /**
112 * @return void
113 */
114 public static function handleDecision()
115 {
116 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
117 $page = \sanitize_key(\wp_unslash($_GET['page'] ?? ''));
118 // phpcs:ignore WordPress.Security.NonceVerification.Missing
119 if ($page !== Metadata::AUTHORIZE_PAGE || !isset($_POST['extendify_mcp_decision'])) {
120 return;
121 }
122
123 // Without the nonce, any page the administrator visits could post an approval as them.
124 \check_admin_referer(self::NONCE);
125 $decision = \sanitize_key(\wp_unslash($_POST['extendify_mcp_decision']));
126 $scope = \sanitize_key(\wp_unslash($_POST['extendify_mcp_scope'] ?? ''));
127 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validate() accepts nothing it has not checked.
128 $params = \wp_unslash($_GET);
129 $back = $decision === self::APPROVE ? self::approve($params, $scope) : self::deny($params);
130 if (!$back) {
131 return;
132 }
133
134 // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- The address is the client's own, checked against its document.
135 \wp_redirect($back);
136 exit;
137 }
138
139 /**
140 * @return void
141 */
142 public static function renderPage()
143 {
144 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
145 self::render(\wp_unslash($_GET));
146 }
147
148 /**
149 * @param array $params - The authorization request, as sent.
150 * @return void
151 */
152 public static function render(array $params)
153 {
154 \wp_register_style(self::STYLE, false, [], false);
155 \wp_enqueue_style(self::STYLE);
156 \wp_add_inline_style(self::STYLE, self::styles());
157
158 echo '<div class="wrap extendify-mcp-wrap extendify-mcp-authorize">';
159 Profile::renderBand(
160 /* translators: MCP is a protocol name; keep it in English. */
161 \__('MCP', 'extendify-local'),
162 \__('Connect an AI assistant', 'extendify-local')
163 );
164 echo '<div class="extendify-mcp-page"><div class="extendify-mcp-consent">';
165 self::renderBody($params);
166 echo '</div></div></div>';
167 }
168
169 /**
170 * @param array $params - The authorization request, as sent.
171 * @param string $scope - The access the user chose.
172 * @return string|null - Where to send the client, or null when the request does not stand.
173 */
174 public static function approve(array $params, $scope)
175 {
176 $request = self::allowed($params);
177 if (!$request) {
178 return null;
179 }
180
181 $write = $scope === Grants::WRITE && self::writeOffered($request);
182 $code = Tokens::mintCode([
183 'userId' => \get_current_user_id(),
184 'client' => $request['client']['id'],
185 'label' => $request['client']['name'],
186 'redirectUri' => $request['redirectUri'],
187 'codeChallenge' => $request['codeChallenge'],
188 'grants' => $write ? Grants::names() : [Grants::READ],
189 'offline' => $request['offline'],
190 'resource' => Metadata::resource(),
191 ]);
192
193 return self::back($request, ['code' => $code]);
194 }
195
196 /**
197 * @param array $params - The authorization request, as sent.
198 * @return string|null - Where to send the client, or null when the request does not stand.
199 */
200 public static function deny(array $params)
201 {
202 $request = self::allowed($params);
203
204 return $request ? self::back($request, ['error' => 'access_denied']) : null;
205 }
206
207 /**
208 * @param array $params - The authorization request, as sent.
209 * @return array|null
210 */
211 private static function allowed(array $params)
212 {
213 // Without this, any signed-in user could hand an assistant access to the site.
214 if (!Availability::live() || !\current_user_can('manage_options')) {
215 return null;
216 }
217
218 $request = self::validate($params);
219
220 return \is_wp_error($request) ? null : $request;
221 }
222
223 /**
224 * @param array $params - The authorization request, as sent.
225 * @return array|\WP_Error
226 */
227 private static function validate(array $params)
228 {
229 $sent = function ($key) use ($params) {
230 return is_string($params[$key] ?? null) ? $params[$key] : '';
231 };
232
233 if ($sent('response_type') !== 'code') {
234 return self::refused(\__('The request from the assistant is incomplete or malformed.', 'extendify-local'));
235 }
236
237 $client = Clients::find($sent('client_id'));
238 if (!$client) {
239 return self::refused(\__(
240 'The assistant could not be identified. Its identity document is missing or does not name it.',
241 'extendify-local'
242 ));
243 }
244
245 $redirect = Clients::redirect($client, $sent('redirect_uri'));
246 if ($redirect === null) {
247 return self::refused(\__(
248 'The assistant asked to be sent back to an address it did not register.',
249 'extendify-local'
250 ));
251 }
252
253 $challenge = $sent('code_challenge');
254 if ($sent('code_challenge_method') !== 'S256' || !preg_match('/^[A-Za-z0-9._~-]{43,128}$/', $challenge)) {
255 return self::refused(\__('The request is missing the code challenge that protects it.', 'extendify-local'));
256 }
257
258 if (!Metadata::isResource($sent('resource'))) {
259 return self::refused(\__('The request is for a different site or endpoint.', 'extendify-local'));
260 }
261
262 return [
263 'client' => $client,
264 'redirectUri' => $redirect,
265 'codeChallenge' => $challenge,
266 'state' => $sent('state'),
267 'grants' => Grants::fromScope($sent('scope')),
268 'offline' => in_array('offline_access', preg_split('/\s+/', trim($sent('scope'))), true),
269 ];
270 }
271
272 /**
273 * @param string $message - What to tell the user.
274 * @return \WP_Error
275 */
276 private static function refused($message)
277 {
278 return new \WP_Error('extendify_mcp_invalid_request', $message);
279 }
280
281 /**
282 * @param array $request - The validated request.
283 * @return boolean
284 */
285 private static function writeOffered(array $request)
286 {
287 return in_array(Grants::WRITE, $request['grants'], true) && Allowed::writable();
288 }
289
290 /**
291 * @param array $request - The validated request.
292 * @param array $answer - The parameters to send back.
293 * @return string
294 */
295 private static function back(array $request, array $answer)
296 {
297 $answer['iss'] = Metadata::issuer();
298 if ($request['state'] !== '') {
299 $answer['state'] = $request['state'];
300 }
301
302 // add_query_arg encodes nothing it is handed.
303 return \add_query_arg(array_map('rawurlencode', $answer), $request['redirectUri']);
304 }
305
306 /**
307 * @param array $params - The authorization request, as sent.
308 * @return void
309 */
310 private static function renderBody(array $params)
311 {
312 if (!Availability::live()) {
313 self::renderOff();
314 return;
315 }
316
317 if (!\current_user_can('manage_options')) {
318 printf('<p>%s</p>', \esc_html(sprintf(
319 /* translators: %s: the signed-in user's name. */
320 \__(
321 'Only an administrator can connect an AI assistant to this site. You are signed in as %s.',
322 'extendify-local'
323 ),
324 \wp_get_current_user()->display_name
325 )));
326 return;
327 }
328
329 $request = self::validate($params);
330 if (\is_wp_error($request)) {
331 printf(
332 '<div class="notice notice-error inline"><p>%1$s</p></div><p>%2$s</p>',
333 \esc_html($request->get_error_message()),
334 \esc_html__('Nothing was sent back to the assistant. Try connecting again from it.', 'extendify-local')
335 );
336 return;
337 }
338
339 self::renderConsent($request);
340 }
341
342 /**
343 * @return void
344 */
345 private static function renderOff()
346 {
347 if (!Availability::turnedOff()) {
348 echo '<p>'
349 . \esc_html__('This site does not offer connections to AI assistants.', 'extendify-local')
350 . '</p>';
351 return;
352 }
353
354 printf('<p>%s</p>', \esc_html__(
355 'Connections are turned off for the whole site, so no assistant can connect right now.',
356 'extendify-local'
357 ));
358
359 if (!\current_user_can('manage_options')) {
360 return;
361 }
362
363 printf(
364 '<p><a class="button" href="%1$s">%2$s</a></p>',
365 \esc_url(\admin_url('options-general.php?page=' . Profile::PAGE)),
366 \esc_html__('Open MCP settings', 'extendify-local')
367 );
368 }
369
370 /**
371 * @param array $request - The validated request.
372 * @return void
373 */
374 private static function renderConsent(array $request)
375 {
376 $user = \wp_get_current_user();
377 $client = $request['client'];
378
379 printf('<p class="extendify-mcp-ask">%s</p>', \wp_kses(sprintf(
380 /* translators: 1: the AI assistant's name. 2: the site's name. 3: the user's name. 4: their username.
381 If the grammar needs a case a name can't take, add a word like "site" or "user" before the name. */
382 \__('<strong>%1$s</strong> wants to work on %2$s as %3$s (%4$s).', 'extendify-local'),
383 \esc_html($client['name']),
384 \esc_html(\wp_specialchars_decode(\get_option('blogname'), ENT_QUOTES)),
385 \esc_html($user->display_name),
386 \esc_html($user->user_login)
387 ), ['strong' => []]));
388
389 echo '<form method="post">';
390 \wp_nonce_field(self::NONCE);
391 self::renderAccess($request);
392
393 printf('<p class="description">%s</p>', \esc_html(sprintf(
394 /* translators: %s: a website's host name, such as claude.ai. */
395 \__('You will be sent back to %s.', 'extendify-local'),
396 (string) \wp_parse_url($request['redirectUri'], PHP_URL_HOST)
397 )));
398
399 if (Clients::onThisComputerOnly($client)) {
400 printf(
401 '<div class="notice notice-warning inline"><p>%1$s %2$s</p></div>',
402 \esc_html__(
403 'This assistant runs on your own computer, and any program there could present itself as it.',
404 'extendify-local'
405 ),
406 \esc_html__('Approve only if you started this from a program you trust.', 'extendify-local')
407 );
408 }
409
410 printf(
411 '<p class="extendify-mcp-decide">'
412 . '<button type="submit" class="button button-primary" name="extendify_mcp_decision"'
413 . ' value="%1$s">%2$s</button>'
414 . '<button type="submit" class="button" name="extendify_mcp_decision" value="deny">%3$s</button>'
415 . '</p></form>',
416 \esc_attr(self::APPROVE),
417 \esc_html__('Approve', 'extendify-local'),
418 \esc_html__('Deny', 'extendify-local')
419 );
420 }
421
422 /**
423 * @param array $request - The validated request.
424 * @return void
425 */
426 private static function renderAccess(array $request)
427 {
428 if (!self::writeOffered($request)) {
429 printf('<p class="extendify-mcp-access">%s</p>', \esc_html(Grants::label([Grants::READ])));
430 return;
431 }
432
433 echo '<fieldset class="extendify-mcp-access"><legend class="screen-reader-text">'
434 . \esc_html__('Access', 'extendify-local') . '</legend>';
435 foreach ([Grants::READ => [Grants::READ], Grants::WRITE => Grants::names()] as $value => $grants) {
436 printf(
437 '<label><input type="radio" name="extendify_mcp_scope" value="%1$s"%2$s> %3$s</label>',
438 \esc_attr($value),
439 $value === Grants::READ ? ' checked' : '',
440 \esc_html(Grants::label($grants))
441 );
442 }
443
444 echo '</fieldset>';
445 }
446
447 /**
448 * Plugin styles are held off this page, so it sets the partner's colours itself.
449 *
450 * @return string
451 */
452 public static function styles()
453 {
454 $colors = [];
455 foreach (PartnerData::cssVariableMapping() as $variable => $value) {
456 $colors[] = $variable . ': ' . $value;
457 }
458
459 return \wp_strip_all_tags(':root { ' . implode('; ', $colors) . '; }') . '
460 ' . Profile::frameStyles('admin_page_' . Metadata::AUTHORIZE_PAGE) . '
461 .extendify-mcp-consent { max-width: 480px; margin: var(--wpds-dimension-gap-2xl, 24px) auto 0; padding: 24px;
462 background: var(--wpds-color-background-surface-neutral-strong, #fff);
463 border: 1px solid var(--wpds-color-stroke-surface-neutral, #dcdcde);
464 border-radius: var(--wpds-border-radius-lg, 8px); }
465 .extendify-mcp-consent > :first-child { margin-top: 0; }
466 .extendify-mcp-authorize .extendify-mcp-access label { display: block; margin: 6px 0; }
467 .extendify-mcp-authorize .extendify-mcp-decide { display: flex; gap: 8px; margin: 16px 0 0; }';
468 }
469 }
470