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

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

214 lines 6.8 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 token endpoint.
5 */
6
7 namespace Extendify\Mcp\OAuth;
8
9 defined('ABSPATH') || die('No direct access.');
10
11 use Extendify\Config;
12 use Extendify\Mcp\Availability;
13 use Extendify\Mcp\Grants;
14 use Extendify\PartnerData;
15
16 /**
17 * Exchanges a code, or a refresh token, for an access token (RFC 6749 ยง4.1.3
18 * and ยง6). The client posts a form and reads JSON.
19 *
20 * A refresh rotates: the token the client sent is gone in the same request,
21 * so a copied refresh token dies as soon as the real one is used.
22 */
23 class TokenEndpoint
24 {
25 /**
26 * @return void
27 */
28 public static function register()
29 {
30 \add_action('rest_api_init', [self::class, 'registerRoute']);
31 }
32
33 /**
34 * @return void
35 */
36 public static function registerRoute()
37 {
38 \register_rest_route(Config::$slug . '/' . Config::$apiVersion, '/oauth/token', [
39 'methods' => 'POST',
40 'callback' => [self::class, 'handle'],
41 // The code or refresh token in the body is the credential.
42 'permission_callback' => '__return_true',
43 'show_in_index' => false,
44 ]);
45 }
46
47 /**
48 * @param \WP_REST_Request $request - The incoming request.
49 * @return \WP_REST_Response
50 */
51 public static function handle(\WP_REST_Request $request)
52 {
53 $sent = function ($key) use ($request) {
54 $value = $request->get_param($key);
55
56 return is_string($value) ? $value : '';
57 };
58
59 switch ($sent('grant_type')) {
60 case 'authorization_code':
61 $answer = self::fromCode($sent);
62 break;
63 case 'refresh_token':
64 $answer = self::fromRefresh($sent);
65 break;
66 default:
67 $answer = self::refused(
68 'unsupported_grant_type',
69 'Only authorization_code and refresh_token are offered.'
70 );
71 }
72
73 $response = new \WP_REST_Response($answer, isset($answer['error']) ? 400 : 200);
74 $response->header('Cache-Control', 'no-store');
75 $response->header('Pragma', 'no-cache');
76
77 return $response;
78 }
79
80 /**
81 * @param callable $sent - Reads a form field, or '' for none.
82 * @return array
83 */
84 private static function fromCode(callable $sent)
85 {
86 $required = ['code', 'code_verifier', 'client_id', 'redirect_uri'];
87 if (in_array('', array_map($sent, $required), true)) {
88 return self::refused('invalid_request', 'code, code_verifier, client_id and redirect_uri are required.');
89 }
90
91 $target = self::wrongTarget($sent('resource'));
92 if ($target) {
93 return $target;
94 }
95
96 $code = Availability::live() ? Tokens::redeemCode($sent('code')) : null;
97 if (!$code) {
98 return self::refused('invalid_grant', 'The code is unknown, spent or expired.');
99 }
100
101 if ($code['client'] !== $sent('client_id') || $sent('redirect_uri') !== $code['redirectUri']) {
102 return self::refused('invalid_grant', 'The code was issued to another client or return address.');
103 }
104
105 if (!self::verifies($sent('code_verifier'), $code['codeChallenge'])) {
106 return self::refused('invalid_grant', 'The code verifier does not match the challenge.');
107 }
108
109 $lifetime = $code['offline'] ? Tokens::GRANT_TTL : Tokens::ACCESS_TTL;
110 $refresh = Tokens::mintGrant($code['userId'], $code['client'], $code['label'], $code['grants'], $lifetime);
111 if (!$refresh) {
112 return self::refused('invalid_grant', 'The user who approved can no longer manage this site.');
113 }
114
115 $grant = Tokens::findGrant($refresh);
116 Tokens::markSpent($sent('code'), $grant);
117
118 return self::issued($grant, $code['offline'] ? $refresh : null);
119 }
120
121 /**
122 * @param callable $sent - Reads a form field, or '' for none.
123 * @return array
124 */
125 private static function fromRefresh(callable $sent)
126 {
127 if ($sent('refresh_token') === '' || $sent('client_id') === '') {
128 return self::refused('invalid_request', 'refresh_token and client_id are required.');
129 }
130
131 $target = self::wrongTarget($sent('resource'));
132 if ($target) {
133 return $target;
134 }
135
136 $grant = Tokens::findGrant($sent('refresh_token'));
137 if ($grant) {
138 PartnerData::refreshIfStale();
139 }
140
141 if (!$grant || !Availability::live() || $grant['data']['client'] !== $sent('client_id')) {
142 return self::refused('invalid_grant', 'The refresh token is unknown, expired or revoked.');
143 }
144
145 if ($sent('scope') !== '' && array_diff(Grants::fromScope($sent('scope')), $grant['grants'])) {
146 return self::refused('invalid_scope', 'The scope asks for more than was approved.');
147 }
148
149 $refresh = Tokens::rotateGrant($grant);
150
151 return self::issued(Tokens::findGrant($refresh), $refresh);
152 }
153
154 /**
155 * @param array $grant - The grant the tokens act under.
156 * @param string|null $refresh - The refresh token to hand out, if any.
157 * @return array
158 */
159 private static function issued(array $grant, $refresh)
160 {
161 $scope = $grant['grants'];
162 if ($refresh !== null) {
163 $scope[] = 'offline_access';
164 }
165
166 $body = [
167 'access_token' => Tokens::mintAccess($grant),
168 'token_type' => 'Bearer',
169 'expires_in' => Tokens::ACCESS_TTL,
170 'scope' => implode(' ', $scope),
171 ];
172 if ($refresh !== null) {
173 $body['refresh_token'] = $refresh;
174 }
175
176 return $body;
177 }
178
179 /**
180 * @param string $verifier - The code_verifier the client sent.
181 * @param string $challenge - The S256 challenge the authorization request carried.
182 * @return boolean
183 */
184 private static function verifies($verifier, $challenge)
185 {
186 $hashed = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
187
188 return hash_equals((string) $challenge, $hashed);
189 }
190
191 /**
192 * @param string $resource - The resource the client named, or '' for none.
193 * @return array|null - The refusal, or null when the request stands.
194 */
195 private static function wrongTarget($resource)
196 {
197 if ($resource === '' || Metadata::isResource($resource)) {
198 return null;
199 }
200
201 return self::refused('invalid_target', 'The token is for a different site or endpoint.');
202 }
203
204 /**
205 * @param string $error - An RFC 6749 ยง5.2 error code.
206 * @param string $description - What went wrong, for the client's log.
207 * @return array
208 */
209 private static function refused($error, $description)
210 {
211 return ['error' => $error, 'error_description' => $description];
212 }
213 }
214