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 / Clients.php

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

275 lines 7.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * The OAuth clients that may ask to be authorized.
5 */
6
7 namespace Extendify\Mcp\OAuth;
8
9 defined('ABSPATH') || die('No direct access.');
10
11 use Extendify\Mcp\Connections;
12
13 /**
14 * A client id is the https URL of its metadata document, which names the
15 * addresses the client may be sent back to, or an id this site handed out
16 * to a client that registered without one.
17 */
18 class Clients
19 {
20 // phpcs:disable PSR12.Properties.ConstantVisibility.NotFound
21 const CACHE_PREFIX = 'extendify_mcp_client_';
22
23 const OPTION = 'extendify_oauth_clients';
24
25 /**
26 * Registration is unauthenticated, so the store is bounded.
27 */
28 const CAP = 200;
29
30 const MAX_REDIRECTS = 10;
31
32 const MAX_REDIRECT_LENGTH = 2048;
33
34 const IDLE_TTL = 30 * DAY_IN_SECONDS;
35
36 const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
37 // phpcs:enable PSR12.Properties.ConstantVisibility.NotFound
38
39 /**
40 * @param mixed $clientId - The client_id the request named.
41 * @return array|null
42 */
43 public static function find($clientId)
44 {
45 if (!is_string($clientId) || $clientId === '') {
46 return null;
47 }
48
49 return self::isDocumentUrl($clientId) ? self::fromDocument($clientId) : self::registered($clientId);
50 }
51
52 /**
53 * @param string $name - The name the client gave, or '' for none.
54 * @param array $redirectUris - The addresses it may be sent back to, already checked.
55 * @return array - The client as find() returns it.
56 */
57 public static function register($name, array $redirectUris)
58 {
59 $now = time();
60 $clients = array_filter(self::all(), function ($client) use ($now) {
61 return max($client['created'], $client['lastUsed']) > $now - self::IDLE_TTL;
62 });
63 uasort($clients, function ($a, $b) {
64 return max($a['created'], $a['lastUsed']) <=> max($b['created'], $b['lastUsed']);
65 });
66 while (count($clients) >= self::CAP) {
67 array_shift($clients);
68 }
69
70 $id = \wp_generate_password(32, false, false);
71 $name = \sanitize_text_field($name) ?: \__('Unnamed assistant', 'extendify-local');
72 $clients[$id] = [
73 'name' => mb_substr($name, 0, Connections::LABEL_LENGTH),
74 'redirectUris' => array_values($redirectUris),
75 'created' => $now,
76 'lastUsed' => 0,
77 ];
78 \update_option(self::OPTION, $clients, false);
79
80 return ['id' => $id, 'name' => $clients[$id]['name'], 'redirectUris' => $clients[$id]['redirectUris']];
81 }
82
83 /**
84 * Plain http is refused except to the user's own computer: a code sent
85 * over it could be read on the way.
86 *
87 * @param string $uri - A redirect URI a client wants registered.
88 * @return boolean
89 */
90 public static function acceptableRedirect($uri)
91 {
92 if (strlen($uri) > self::MAX_REDIRECT_LENGTH) {
93 return false;
94 }
95
96 $parts = \wp_parse_url($uri);
97 if (!is_array($parts) || empty($parts['host']) || isset($parts['fragment'])) {
98 return false;
99 }
100
101 return ($parts['scheme'] ?? '') === 'https' || self::isLoopback($uri);
102 }
103
104 /**
105 * @param string $url - The client id, an https URL.
106 * @return array|null
107 */
108 private static function fromDocument($url)
109 {
110 $key = self::CACHE_PREFIX . md5($url);
111 $held = \get_transient($key);
112 if (is_array($held)) {
113 return $held;
114 }
115
116 $client = self::fetch($url);
117 if ($client) {
118 \set_transient($key, $client, HOUR_IN_SECONDS);
119 }
120
121 return $client;
122 }
123
124 /**
125 * @param string $id - The client id this site handed out.
126 * @return array|null
127 */
128 private static function registered($id)
129 {
130 $clients = self::all();
131 if (!isset($clients[$id])) {
132 return null;
133 }
134
135 $clients[$id]['lastUsed'] = time();
136 \update_option(self::OPTION, $clients, false);
137
138 return [
139 'id' => $id,
140 'name' => $clients[$id]['name'],
141 'redirectUris' => $clients[$id]['redirectUris'],
142 ];
143 }
144
145 /**
146 * @return array - Registered clients by id.
147 */
148 private static function all()
149 {
150 $clients = \get_option(self::OPTION, []);
151
152 return is_array($clients) ? $clients : [];
153 }
154
155 /**
156 * @param array $client - The client as found.
157 * @param string $uri - The redirect_uri the request named, or '' for none.
158 * @return string|null - The address to send the client back to.
159 */
160 public static function redirect(array $client, $uri)
161 {
162 if ($uri === '') {
163 return count($client['redirectUris']) === 1 ? $client['redirectUris'][0] : null;
164 }
165
166 foreach ($client['redirectUris'] as $registered) {
167 if ($registered === $uri) {
168 return $uri;
169 }
170
171 // A program on the user's computer binds a fresh port each run (RFC 8252 §7.3).
172 if (
173 self::isLoopback($registered) && self::isLoopback($uri)
174 && self::withoutPort($registered) === self::withoutPort($uri)
175 ) {
176 return $uri;
177 }
178 }
179
180 return null;
181 }
182
183 /**
184 * @param array $client - The client as found.
185 * @return boolean
186 */
187 public static function onThisComputerOnly(array $client)
188 {
189 foreach ($client['redirectUris'] as $uri) {
190 if (!self::isLoopback($uri)) {
191 return false;
192 }
193 }
194
195 return true;
196 }
197
198 /**
199 * @param string $uri - A redirect URI.
200 * @return boolean
201 */
202 private static function isLoopback($uri)
203 {
204 $parts = \wp_parse_url($uri);
205
206 return is_array($parts)
207 && ($parts['scheme'] ?? '') === 'http'
208 && in_array($parts['host'] ?? '', self::LOOPBACK_HOSTS, true);
209 }
210
211 /**
212 * @param string $uri - A loopback redirect URI.
213 * @return string
214 */
215 private static function withoutPort($uri)
216 {
217 return preg_replace('#^(http://(?:\[[^\]]+\]|[^/:]+)):\d+#', '$1', $uri);
218 }
219
220 /**
221 * @param string $url - The client id.
222 * @return boolean
223 */
224 private static function isDocumentUrl($url)
225 {
226 $parts = \wp_parse_url($url);
227
228 return is_array($parts)
229 && ($parts['scheme'] ?? '') === 'https'
230 && !empty($parts['host'])
231 && !isset($parts['fragment'])
232 && !isset($parts['user'])
233 && !isset($parts['pass']);
234 }
235
236 /**
237 * @param string $url - The client id.
238 * @return array|null
239 */
240 private static function fetch($url)
241 {
242 $response = \wp_safe_remote_get($url, [
243 'timeout' => 5,
244 // A redirected document could not claim the URL it was fetched from.
245 'redirection' => 0,
246 'limit_response_size' => 64 * KB_IN_BYTES,
247 'headers' => ['Accept' => 'application/json'],
248 ]);
249 if (\is_wp_error($response) || \wp_remote_retrieve_response_code($response) !== 200) {
250 return null;
251 }
252
253 $document = json_decode(\wp_remote_retrieve_body($response), true);
254 if (!is_array($document) || ($document['client_id'] ?? null) !== $url) {
255 return null;
256 }
257
258 $uris = is_array($document['redirect_uris'] ?? null) ? $document['redirect_uris'] : [];
259 $uris = array_values(array_filter($uris, function ($uri) {
260 return is_string($uri) && self::acceptableRedirect($uri);
261 }));
262 if (!$uris) {
263 return null;
264 }
265
266 $name = is_string($document['client_name'] ?? null) ? \sanitize_text_field($document['client_name']) : '';
267
268 return [
269 'id' => $url,
270 'name' => mb_substr($name ?: (string) \wp_parse_url($url, PHP_URL_HOST), 0, Connections::LABEL_LENGTH),
271 'redirectUris' => $uris,
272 ];
273 }
274 }
275