PluginProbe
ActivityPub / 9.3.1
ActivityPub v9.3.1
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / rest / oauth / class-token-controller.php

class-token-controller.php in ActivityPub 9.3.1, at includes/rest/oauth/class-token-controller.php

451 lines 13.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OAuth 2.0 Token REST Controller.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\Rest\OAuth;
9
10 use Activitypub\OAuth\Authorization_Code;
11 use Activitypub\OAuth\Client;
12 use Activitypub\OAuth\Scope;
13 use Activitypub\OAuth\Server as OAuth_Server;
14 use Activitypub\OAuth\Token;
15
16 use function Activitypub\get_client_ip;
17
18 /**
19 * Token_Controller class for handling OAuth 2.0 token endpoints.
20 *
21 * Implements:
22 * - Token endpoint (POST /oauth/token)
23 * - Revocation endpoint (POST /oauth/revoke)
24 * - Token introspection endpoint (POST /oauth/introspect)
25 *
26 * @since 8.1.0
27 */
28 class Token_Controller extends \WP_REST_Controller {
29 /**
30 * The namespace of this controller's route.
31 *
32 * @var string
33 */
34 protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
35
36 /**
37 * The base of this controller's route.
38 *
39 * @var string
40 */
41 protected $rest_base = 'oauth';
42
43 /**
44 * Register routes.
45 */
46 public function register_routes() {
47 // Token endpoint.
48 \register_rest_route(
49 $this->namespace,
50 '/' . $this->rest_base . '/token',
51 array(
52 array(
53 'methods' => \WP_REST_Server::CREATABLE,
54 'callback' => array( $this, 'token' ),
55 'permission_callback' => '__return_true',
56 'args' => array(
57 'grant_type' => array(
58 'description' => 'The grant type.',
59 'type' => 'string',
60 'required' => true,
61 'enum' => array( 'authorization_code', 'refresh_token' ),
62 ),
63 'client_id' => array(
64 'description' => 'The OAuth client identifier.',
65 'type' => 'string',
66 ),
67 'client_secret' => array(
68 'description' => 'The OAuth client secret (for confidential clients).',
69 'type' => 'string',
70 ),
71 'code' => array(
72 'description' => 'The authorization code (for authorization_code grant).',
73 'type' => 'string',
74 ),
75 'redirect_uri' => array(
76 'description' => 'The redirect URI (must match original for authorization_code grant). Supports custom URI schemes for native apps.',
77 'type' => 'string',
78 ),
79 'code_verifier' => array(
80 'description' => 'PKCE code verifier.',
81 'type' => 'string',
82 ),
83 'refresh_token' => array(
84 'description' => 'The refresh token (for refresh_token grant).',
85 'type' => 'string',
86 ),
87 'scope' => array(
88 'description' => 'Space-separated list of requested scopes.',
89 'type' => 'string',
90 ),
91 ),
92 ),
93 )
94 );
95
96 // Revocation endpoint (RFC 7009 — requires authentication).
97 \register_rest_route(
98 $this->namespace,
99 '/' . $this->rest_base . '/revoke',
100 array(
101 array(
102 'methods' => \WP_REST_Server::CREATABLE,
103 'callback' => array( $this, 'revoke' ),
104 'permission_callback' => array( $this, 'revoke_permissions_check' ),
105 'args' => array(
106 'token' => array(
107 'description' => 'The token to revoke.',
108 'type' => 'string',
109 'required' => true,
110 ),
111 'token_type_hint' => array(
112 'description' => 'Hint about the token type.',
113 'type' => 'string',
114 'enum' => array( 'access_token', 'refresh_token' ),
115 ),
116 ),
117 ),
118 )
119 );
120
121 // Token introspection endpoint (RFC 7662).
122 \register_rest_route(
123 $this->namespace,
124 '/' . $this->rest_base . '/introspect',
125 array(
126 array(
127 'methods' => \WP_REST_Server::CREATABLE,
128 'callback' => array( $this, 'introspect' ),
129 'permission_callback' => array( $this, 'introspect_permissions_check' ),
130 'args' => array(
131 'token' => array(
132 'description' => 'The token to introspect.',
133 'type' => 'string',
134 'required' => true,
135 ),
136 'token_type_hint' => array(
137 'description' => 'Hint about the token type.',
138 'type' => 'string',
139 'enum' => array( 'access_token', 'refresh_token' ),
140 ),
141 ),
142 ),
143 )
144 );
145 }
146
147 /**
148 * Handle token request (POST /oauth/token).
149 *
150 * @param \WP_REST_Request $request The request object.
151 * @return \WP_REST_Response|\WP_Error
152 */
153 public function token( \WP_REST_Request $request ) {
154 // Rate-limit token requests to prevent brute-force attacks (max 20 per minute per IP).
155 $ip = get_client_ip();
156 if ( '' === $ip ) {
157 return $this->token_error( 'rate_limited', 'Too many token requests. Please try again later.', 429 );
158 }
159 $transient_key = 'ap_oauth_tok_' . \md5( $ip );
160 $count = (int) \get_transient( $transient_key );
161
162 if ( $count >= 20 ) {
163 return $this->token_error( 'rate_limited', 'Too many token requests. Please try again later.', 429 );
164 }
165
166 \set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS );
167
168 $grant_type = $request->get_param( 'grant_type' );
169
170 /*
171 * Extract client credentials from either:
172 * - client_secret_basic: HTTP Basic Auth header (RFC 6749 Section 2.3.1)
173 * - client_secret_post: POST body parameters
174 */
175 $client_id = null;
176 $client_secret = null;
177 $auth_header = $request->get_header( 'Authorization' );
178
179 if ( $auth_header && 0 === \strpos( $auth_header, 'Basic ' ) ) {
180 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Required by OAuth spec.
181 $decoded = \base64_decode( \substr( $auth_header, 6 ), true );
182 if ( $decoded && false !== \strpos( $decoded, ':' ) ) {
183 list( $client_id, $client_secret ) = \explode( ':', $decoded, 2 );
184 $client_id = \urldecode( $client_id );
185 $client_secret = \urldecode( $client_secret );
186 }
187 }
188
189 // Fall back to POST body parameters (client_secret_post).
190 if ( ! $client_id ) {
191 $client_id = $request->get_param( 'client_id' );
192 $client_secret = $request->get_param( 'client_secret' );
193 }
194
195 // Validate client.
196 $client = Client::get( $client_id );
197 if ( \is_wp_error( $client ) ) {
198 return $this->token_error( 'invalid_client', 'Unknown client.' );
199 }
200
201 // Validate client credentials if confidential.
202 if ( ! $client->is_public() ) {
203 if ( ! Client::validate( $client_id, $client_secret ) ) {
204 return $this->token_error( 'invalid_client', 'Invalid client credentials.' );
205 }
206 }
207
208 switch ( $grant_type ) {
209 case 'authorization_code':
210 return $this->handle_authorization_code_grant( $request, $client_id );
211
212 case 'refresh_token':
213 return $this->handle_refresh_token_grant( $request, $client_id );
214
215 default:
216 return $this->token_error( 'unsupported_grant_type', 'Grant type not supported.' );
217 }
218 }
219
220 /**
221 * Handle authorization code grant.
222 *
223 * @param \WP_REST_Request $request The request object.
224 * @param string $client_id The client ID.
225 * @return \WP_REST_Response|\WP_Error
226 */
227 private function handle_authorization_code_grant( \WP_REST_Request $request, $client_id ) {
228 $code = $request->get_param( 'code' );
229 $redirect_uri = $request->get_param( 'redirect_uri' );
230 $code_verifier = $request->get_param( 'code_verifier' );
231
232 if ( empty( $code ) ) {
233 return $this->token_error( 'invalid_request', 'Authorization code is required.' );
234 }
235
236 $result = Authorization_Code::exchange( $code, $client_id, $redirect_uri, $code_verifier );
237
238 if ( \is_wp_error( $result ) ) {
239 return $this->token_error( 'invalid_grant', $result->get_error_message() );
240 }
241
242 return $this->token_response( $result );
243 }
244
245 /**
246 * Handle refresh token grant.
247 *
248 * @param \WP_REST_Request $request The request object.
249 * @param string $client_id The client ID.
250 * @return \WP_REST_Response|\WP_Error
251 */
252 private function handle_refresh_token_grant( \WP_REST_Request $request, $client_id ) {
253 $refresh_token = $request->get_param( 'refresh_token' );
254
255 if ( empty( $refresh_token ) ) {
256 return $this->token_error( 'invalid_request', 'Refresh token is required.' );
257 }
258
259 $result = Token::refresh( $refresh_token, $client_id );
260
261 if ( \is_wp_error( $result ) ) {
262 return $this->token_error( 'invalid_grant', $result->get_error_message() );
263 }
264
265 return $this->token_response( $result );
266 }
267
268 /**
269 * Handle token revocation (POST /oauth/revoke).
270 *
271 * @param \WP_REST_Request $request The request object.
272 * @return \WP_REST_Response
273 */
274 public function revoke( \WP_REST_Request $request ) {
275 $token = $request->get_param( 'token' );
276
277 if ( \current_user_can( 'manage_options' ) ) {
278 // Site admins may revoke any token. Null-null disables the ownership check.
279 Token::revoke( $token );
280 } else {
281 /*
282 * RFC 7009 §2.1: the server must verify the token was issued to
283 * the requesting client. When the caller authenticated with a
284 * bearer token we know the calling client, so require a client
285 * match and ignore the user — otherwise a low-trust client
286 * could revoke tokens the user had granted to a different
287 * client. For pure cookie-authenticated callers there is no
288 * client context, so user match is the only available check.
289 */
290 $caller_token = OAuth_Server::get_current_token();
291 if ( $caller_token ) {
292 Token::revoke( $token, null, $caller_token->get_client_id() );
293 } else {
294 Token::revoke( $token, \get_current_user_id(), null );
295 }
296 }
297
298 // Per RFC 7009, always return 200 even if the token doesn't exist or was not owned.
299 return new \WP_REST_Response( null, 200 );
300 }
301
302 /**
303 * Handle token introspection (POST /oauth/introspect).
304 *
305 * Implements RFC 7662 Token Introspection.
306 *
307 * @param \WP_REST_Request $request The request object.
308 * @return \WP_REST_Response
309 */
310 public function introspect( \WP_REST_Request $request ) {
311 $token = $request->get_param( 'token' );
312
313 // Introspect the token.
314 $response = Token::introspect( $token );
315
316 /*
317 * Scope introspection for non-admins. An OAuth-authenticated caller may only
318 * introspect tokens issued to its own client; a cookie-authenticated caller may
319 * only introspect its own tokens. Without the cookie branch, any logged-in user
320 * could read metadata for any token string, because get_current_token() is null
321 * for cookie sessions and the same-client check was skipped entirely.
322 */
323 if ( $response['active'] && ! \current_user_can( 'manage_options' ) ) {
324 $current_token = OAuth_Server::get_current_token();
325
326 if ( $current_token ) {
327 if ( $current_token->get_client_id() !== $response['client_id'] ) {
328 $response = array( 'active' => false );
329 }
330 } elseif ( \get_current_user_id() !== (int) $response['sub'] ) {
331 // `sub` is stored as a string; cast before comparing with the integer user ID.
332 $response = array( 'active' => false );
333 }
334 }
335
336 return new \WP_REST_Response( $response, 200 );
337 }
338
339 /**
340 * Permission check for token revocation.
341 *
342 * Per RFC 7009, the revocation endpoint must be protected.
343 * Requires either a logged-in user or a valid Bearer token.
344 *
345 * @return bool|\WP_Error True if allowed, error otherwise.
346 */
347 public function revoke_permissions_check() {
348 if ( \is_user_logged_in() ) {
349 return true;
350 }
351
352 $token = OAuth_Server::get_bearer_token();
353
354 if ( $token ) {
355 $validated = Token::validate( $token );
356
357 if ( ! \is_wp_error( $validated ) ) {
358 \wp_set_current_user( $validated->get_user_id() );
359 return true;
360 }
361 }
362
363 return new \WP_Error(
364 'activitypub_unauthorized',
365 \__( 'Authentication required.', 'activitypub' ),
366 array( 'status' => 401 )
367 );
368 }
369
370 /**
371 * Permission check for token introspection.
372 *
373 * Per RFC 7662, the introspection endpoint must be protected.
374 *
375 * @return bool|\WP_Error True if allowed, error otherwise.
376 */
377 public function introspect_permissions_check() {
378 if ( \is_user_logged_in() ) {
379 return true;
380 }
381
382 // Support Bearer token auth for public OAuth clients.
383 $token = OAuth_Server::get_bearer_token();
384
385 if ( $token ) {
386 $validated = Token::validate( $token );
387
388 if ( ! \is_wp_error( $validated ) ) {
389 \wp_set_current_user( $validated->get_user_id() );
390 return true;
391 }
392 }
393
394 return new \WP_Error(
395 'activitypub_unauthorized',
396 \__( 'Authentication required.', 'activitypub' ),
397 array( 'status' => 401 )
398 );
399 }
400
401 /**
402 * Create a token error response.
403 *
404 * @param string $error Error code.
405 * @param string $error_description Error description.
406 * @param int $status Optional. HTTP status code. Defaults to 400 per RFC 6749 §5.2;
407 * callers should pass 429 for rate-limit responses (RFC 6585).
408 * @return \WP_REST_Response
409 */
410 private function token_error( $error, $error_description, $status = 400 ) {
411 $headers = array(
412 'Content-Type' => 'application/json',
413 // RFC 6749 §5.1 requires the same no-cache headers on error responses as on success responses.
414 'Cache-Control' => 'no-store',
415 'Pragma' => 'no-cache',
416 );
417
418 // RFC 6585 §4: send Retry-After with rate-limit responses so clients can back off.
419 if ( 429 === $status ) {
420 $headers['Retry-After'] = (string) MINUTE_IN_SECONDS;
421 }
422
423 return new \WP_REST_Response(
424 array(
425 'error' => $error,
426 'error_description' => $error_description,
427 ),
428 $status,
429 $headers
430 );
431 }
432
433 /**
434 * Create a token success response.
435 *
436 * @param array $token_data Token data.
437 * @return \WP_REST_Response
438 */
439 private function token_response( $token_data ) {
440 return new \WP_REST_Response(
441 $token_data,
442 200,
443 array(
444 'Content-Type' => 'application/json',
445 'Cache-Control' => 'no-store',
446 'Pragma' => 'no-cache',
447 )
448 );
449 }
450 }
451