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

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

249 lines 7.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * The credentials an OAuth client is issued.
5 */
6
7 namespace Extendify\Mcp\OAuth;
8
9 defined('ABSPATH') || die('No direct access.');
10
11 use Extendify\Mcp\Connections;
12 use Extendify\Mcp\Grants;
13
14 /**
15 * A row is keyed by the token's HMAC and never holds the token, so a copy of
16 * the database opens nothing.
17 */
18 class Tokens
19 {
20 // phpcs:disable PSR12.Properties.ConstantVisibility.NotFound
21 const LENGTH = 32;
22
23 const CODE_TTL = 60;
24
25 const SPENT_TTL = 10 * MINUTE_IN_SECONDS;
26
27 const ACCESS_TTL = HOUR_IN_SECONDS;
28
29 const GRANT_TTL = 30 * DAY_IN_SECONDS;
30
31 const CODE_PREFIX = 'extendify_mcp_code_';
32
33 const ACCESS_PREFIX = 'extendify_mcp_access_';
34 // phpcs:enable PSR12.Properties.ConstantVisibility.NotFound
35
36 /**
37 * @param array $payload - What the authorization request settled on.
38 * @return string
39 */
40 public static function mintCode(array $payload)
41 {
42 $code = self::random();
43 \set_transient(self::CODE_PREFIX . self::hash($code), $payload, self::CODE_TTL);
44
45 return $code;
46 }
47
48 /**
49 * @param mixed $code - The code the client sent to the token endpoint.
50 * @return array|null
51 */
52 public static function redeemCode($code)
53 {
54 if (!is_string($code) || $code === '') {
55 return null;
56 }
57
58 $key = self::CODE_PREFIX . self::hash($code);
59 $payload = \get_transient($key);
60 if (!is_array($payload)) {
61 return null;
62 }
63
64 \delete_transient($key);
65 if (isset($payload['spentFor'])) {
66 // A second presentation means one of the two was not the client's (RFC 6749 §4.1.2).
67 Connections::revoke($payload['spentFor']['userId'], $payload['spentFor']['id']);
68 return null;
69 }
70
71 return $payload;
72 }
73
74 /**
75 * @param string $code - The code just exchanged.
76 * @param array $grant - The grant the exchange issued.
77 * @return void
78 */
79 public static function markSpent($code, array $grant)
80 {
81 \set_transient(self::CODE_PREFIX . self::hash($code), [
82 'spentFor' => ['userId' => $grant['userId'], 'id' => $grant['id']],
83 ], self::SPENT_TTL);
84 }
85
86 /**
87 * @param integer $userId - The user the assistant acts as.
88 * @param string $client - The client id the assistant authorized under.
89 * @param string $label - The name the client goes by.
90 * @param array $grants - What the user allowed.
91 * @param integer $lifetime - Seconds until the grant expires unrefreshed.
92 * @return string|null
93 */
94 public static function mintGrant($userId, $client, $label, array $grants, $lifetime = self::GRANT_TTL)
95 {
96 // Without this, any logged-in subscriber could authorize an assistant on the site.
97 if (!\user_can((int) $userId, 'manage_options')) {
98 return null;
99 }
100
101 return self::store((int) $userId, [
102 // A refresh moves the row to a new key; the id is what stays.
103 'id' => \wp_generate_uuid4(),
104 'client' => \sanitize_text_field($client),
105 'label' => mb_substr(\sanitize_text_field($label), 0, Connections::LABEL_LENGTH),
106 'grants' => Grants::sanitize($grants),
107 'created' => time(),
108 'lastUsed' => 0,
109 'expires' => time() + $lifetime,
110 'salt' => Connections::fingerprint(),
111 ]);
112 }
113
114 /**
115 * Access tokens minted under the old row die with it.
116 *
117 * @param array $grant - The grant a refresh token was just presented for.
118 * @return string - The refresh token that replaces the one presented.
119 */
120 public static function rotateGrant(array $grant)
121 {
122 $data = $grant['data'];
123 $data['expires'] = time() + self::GRANT_TTL;
124 $token = self::store($grant['userId'], $data);
125 \delete_user_meta($grant['userId'], $grant['metaKey']);
126
127 return $token;
128 }
129
130 /**
131 * @param mixed $token - The refresh token the client sent.
132 * @return array|null
133 */
134 public static function findGrant($token)
135 {
136 if (!is_string($token) || $token === '') {
137 return null;
138 }
139
140 $key = self::grantKey($token);
141 $wpdb = $GLOBALS['wpdb'];
142 $row = $wpdb->get_row($wpdb->prepare(
143 "SELECT user_id, meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s LIMIT 1",
144 $key
145 ));
146
147 return $row ? self::grant((int) $row->user_id, $key, (array) \maybe_unserialize($row->meta_value)) : null;
148 }
149
150 /**
151 * @param array $grant - The grant the token acts under.
152 * @return string
153 */
154 public static function mintAccess(array $grant)
155 {
156 $token = self::random();
157 \set_transient(self::ACCESS_PREFIX . self::hash($token), [
158 'userId' => $grant['userId'],
159 'metaKey' => $grant['metaKey'],
160 ], self::ACCESS_TTL);
161
162 return $token;
163 }
164
165 /**
166 * The grant is read here, so revoking one ends its access tokens with it.
167 *
168 * @param mixed $token - The token from the Authorization header.
169 * @return array|null
170 */
171 public static function findAccess($token)
172 {
173 if (!is_string($token) || $token === '') {
174 return null;
175 }
176
177 $held = \get_transient(self::ACCESS_PREFIX . self::hash($token));
178 if (!is_array($held)) {
179 return null;
180 }
181
182 $data = \get_user_meta($held['userId'], $held['metaKey'], true);
183
184 return is_array($data) ? self::grant($held['userId'], $held['metaKey'], $data) : null;
185 }
186
187 /**
188 * @param integer $userId - The user the grant belongs to.
189 * @param string $metaKey - The row the grant lives in.
190 * @param array $data - What the row holds.
191 * @return array|null
192 */
193 private static function grant($userId, $metaKey, array $data)
194 {
195 if (($data['expires'] ?? 0) < time()) {
196 return null;
197 }
198
199 return [
200 'userId' => $userId,
201 'metaKey' => $metaKey,
202 'id' => (string) ($data['id'] ?? ''),
203 'grants' => Grants::sanitize($data['grants'] ?? null),
204 'data' => $data,
205 ];
206 }
207
208 /**
209 * @param integer $userId - The user the grant belongs to.
210 * @param array $data - The row to write.
211 * @return string - The refresh token the row is keyed by.
212 */
213 private static function store($userId, array $data)
214 {
215 $token = self::random();
216 \update_user_meta($userId, self::grantKey($token), $data);
217
218 return $token;
219 }
220
221 /**
222 * @return string
223 */
224 private static function random()
225 {
226 return \wp_generate_password(self::LENGTH, false, false);
227 }
228
229 /**
230 * @param string $token - The token to key a row by.
231 * @return string
232 */
233 private static function hash($token)
234 {
235 return hash_hmac('sha256', $token, Connections::secret());
236 }
237
238 /**
239 * A refresh arrives with a token and no user, and only meta_key is indexed.
240 *
241 * @param string $token - The refresh token to key the row by.
242 * @return string
243 */
244 private static function grantKey($token)
245 {
246 return Connections::prefix() . self::hash($token);
247 }
248 }
249