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-authorization-controller.php

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

423 lines 12.3 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 Authorization 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
14 use function Activitypub\get_client_ip;
15
16 /**
17 * Authorization_Controller class for handling the OAuth 2.0 authorization endpoint.
18 *
19 * Implements:
20 * - Authorization endpoint (GET/POST /oauth/authorize)
21 *
22 * @since 8.1.0
23 */
24 class Authorization_Controller extends \WP_REST_Controller {
25 /**
26 * The namespace of this controller's route.
27 *
28 * @var string
29 */
30 protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
31
32 /**
33 * The base of this controller's route.
34 *
35 * @var string
36 */
37 protected $rest_base = 'oauth';
38
39 /**
40 * Register routes.
41 */
42 public function register_routes() {
43 // Authorization endpoint - GET displays consent form, POST handles approval.
44 \register_rest_route(
45 $this->namespace,
46 '/' . $this->rest_base . '/authorize',
47 array(
48 array(
49 'methods' => \WP_REST_Server::READABLE,
50 'callback' => array( $this, 'authorize' ),
51 'permission_callback' => '__return_true',
52 'args' => array(
53 'response_type' => array(
54 'description' => 'OAuth response type (must be "code").',
55 'type' => 'string',
56 'required' => true,
57 'enum' => array( 'code' ),
58 ),
59 'client_id' => array(
60 'description' => 'The OAuth client identifier.',
61 'type' => 'string',
62 'required' => true,
63 ),
64 'redirect_uri' => array(
65 'description' => 'The URI to redirect to after authorization. Supports custom URI schemes for native apps.',
66 'type' => 'string',
67 'required' => true,
68 ),
69 'scope' => array(
70 'description' => 'Space-separated list of requested scopes.',
71 'type' => 'string',
72 ),
73 'state' => array(
74 'description' => 'Opaque value for CSRF protection.',
75 'type' => 'string',
76 ),
77 'code_challenge' => array(
78 'description' => 'PKCE code challenge (recommended).',
79 'type' => 'string',
80 ),
81 'code_challenge_method' => array(
82 'description' => 'PKCE code challenge method.',
83 'type' => 'string',
84 'enum' => array( 'S256' ),
85 'default' => 'S256',
86 ),
87 ),
88 ),
89 array(
90 'methods' => \WP_REST_Server::CREATABLE,
91 'callback' => array( $this, 'authorize_submit' ),
92 'permission_callback' => array( $this, 'authorize_submit_permissions_check' ),
93 'args' => array(
94 'response_type' => array(
95 'description' => 'OAuth response type (must be "code").',
96 'type' => 'string',
97 'required' => true,
98 'enum' => array( 'code' ),
99 ),
100 'client_id' => array(
101 'description' => 'The OAuth client identifier.',
102 'type' => 'string',
103 'required' => true,
104 ),
105 'redirect_uri' => array(
106 'description' => 'The URI to redirect to after authorization. Supports custom URI schemes for native apps.',
107 'type' => 'string',
108 'required' => true,
109 ),
110 'scope' => array(
111 'description' => 'Space-separated list of requested scopes.',
112 'type' => 'string',
113 ),
114 'state' => array(
115 'description' => 'Opaque value for CSRF protection.',
116 'type' => 'string',
117 ),
118 'code_challenge' => array(
119 'description' => 'PKCE code challenge (recommended).',
120 'type' => 'string',
121 ),
122 'code_challenge_method' => array(
123 'description' => 'PKCE code challenge method.',
124 'type' => 'string',
125 'enum' => array( 'S256' ),
126 'default' => 'S256',
127 ),
128 'approve' => array(
129 'description' => 'Whether the user approved the authorization.',
130 'type' => 'boolean',
131 'required' => true,
132 ),
133 '_wpnonce' => array(
134 'description' => 'WordPress nonce for CSRF protection.',
135 'type' => 'string',
136 'required' => true,
137 ),
138 ),
139 ),
140 )
141 );
142 }
143
144 /**
145 * Handle authorization request (GET /oauth/authorize).
146 *
147 * Validates request parameters and redirects to wp-admin consent page.
148 *
149 * @param \WP_REST_Request $request The request object.
150 * @return \WP_REST_Response|\WP_Error
151 */
152 public function authorize( \WP_REST_Request $request ) {
153 // Rate-limit authorization requests to prevent abuse (max 20 per minute per IP).
154 $ip = get_client_ip();
155 if ( '' === $ip ) {
156 return $this->rate_limit_response( \__( 'Too many authorization requests. Please try again later.', 'activitypub' ) );
157 }
158 $transient_key = 'ap_oauth_auth_' . \md5( $ip );
159 $count = (int) \get_transient( $transient_key );
160
161 if ( $count >= 20 ) {
162 return $this->rate_limit_response( \__( 'Too many authorization requests. Please try again later.', 'activitypub' ) );
163 }
164
165 \set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS );
166
167 $client_id = $request->get_param( 'client_id' );
168 $redirect_uri = $request->get_param( 'redirect_uri' );
169 $response_type = $request->get_param( 'response_type' );
170 $scope = $request->get_param( 'scope' );
171 $state = $request->get_param( 'state' );
172
173 // Validate client.
174 $client = Client::get( $client_id );
175 if ( \is_wp_error( $client ) ) {
176 return $this->error_page( $client );
177 }
178
179 // Validate redirect URI.
180 if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) {
181 return $this->error_page(
182 new \WP_Error(
183 'activitypub_invalid_redirect_uri',
184 \__( 'Invalid redirect URI for this client.', 'activitypub' ),
185 array( 'status' => 400 )
186 )
187 );
188 }
189
190 // Only support 'code' response type.
191 if ( 'code' !== $response_type ) {
192 return $this->redirect_with_error(
193 $redirect_uri,
194 'unsupported_response_type',
195 'Only authorization code flow is supported.',
196 $state
197 );
198 }
199
200 // Check for PKCE (recommended but optional for compatibility).
201 $code_challenge = $request->get_param( 'code_challenge' );
202
203 /*
204 * Redirect to wp-login.php with action=activitypub_authorize.
205 * This uses WordPress's login_form_{action} hook for proper cookie auth.
206 */
207 $login_url = \wp_login_url();
208 $login_url = \add_query_arg(
209 array(
210 'action' => 'activitypub_authorize',
211 'client_id' => $client_id,
212 'redirect_uri' => $redirect_uri,
213 'response_type' => $response_type,
214 'scope' => $scope,
215 'state' => $state,
216 'code_challenge' => $code_challenge,
217 'code_challenge_method' => $request->get_param( 'code_challenge_method' ) ?: 'S256',
218 ),
219 $login_url
220 );
221
222 return new \WP_REST_Response(
223 null,
224 302,
225 array( 'Location' => $login_url )
226 );
227 }
228
229 /**
230 * Handle authorization approval (POST /oauth/authorize).
231 *
232 * @param \WP_REST_Request $request The request object.
233 * @return \WP_REST_Response|\WP_Error
234 */
235 public function authorize_submit( \WP_REST_Request $request ) {
236 $client_id = $request->get_param( 'client_id' );
237 $redirect_uri = $request->get_param( 'redirect_uri' );
238 $scope = $request->get_param( 'scope' );
239 $state = $request->get_param( 'state' );
240 $code_challenge = $request->get_param( 'code_challenge' );
241 $code_challenge_method = $request->get_param( 'code_challenge_method' ) ?: 'S256';
242 $approve = $request->get_param( 'approve' );
243
244 // Re-validate client and redirect URI (form fields could be tampered with).
245 $client = Client::get( $client_id );
246 if ( \is_wp_error( $client ) ) {
247 return $this->error_page( $client );
248 }
249
250 if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) {
251 return $this->error_page(
252 new \WP_Error(
253 'activitypub_invalid_redirect_uri',
254 \__( 'Invalid redirect URI for this client.', 'activitypub' ),
255 array( 'status' => 400 )
256 )
257 );
258 }
259
260 // User denied authorization.
261 if ( ! $approve ) {
262 return $this->redirect_with_error(
263 $redirect_uri,
264 'access_denied',
265 'The user denied the authorization request.',
266 $state
267 );
268 }
269
270 // Create authorization code.
271 $scopes = Scope::validate( Scope::parse( $scope ) );
272 $code = Authorization_Code::create(
273 \get_current_user_id(),
274 $client_id,
275 $redirect_uri,
276 $scopes,
277 $code_challenge,
278 $code_challenge_method
279 );
280
281 if ( \is_wp_error( $code ) ) {
282 // A refused scope is an OAuth error the client can act on; the rest are internal failures.
283 $error = 'invalid_scope' === $code->get_error_code() ? 'invalid_scope' : 'server_error';
284
285 return $this->redirect_with_error(
286 $redirect_uri,
287 $error,
288 $code->get_error_message(),
289 $state
290 );
291 }
292
293 // Redirect back to client with code.
294 $redirect_url = \add_query_arg(
295 array(
296 'code' => $code,
297 'state' => $state,
298 ),
299 $redirect_uri
300 );
301
302 return new \WP_REST_Response(
303 null,
304 302,
305 array( 'Location' => $redirect_url )
306 );
307 }
308
309 /**
310 * Permission check for authorization submission.
311 *
312 * @param \WP_REST_Request $request The request object.
313 * @return bool|\WP_Error True if allowed, error otherwise.
314 */
315 public function authorize_submit_permissions_check( \WP_REST_Request $request ) {
316 if ( ! \is_user_logged_in() ) {
317 return new \WP_Error(
318 'activitypub_not_logged_in',
319 \__( 'You must be logged in to authorize applications.', 'activitypub' ),
320 array( 'status' => 401 )
321 );
322 }
323
324 // Verify nonce.
325 $nonce = $request->get_param( '_wpnonce' );
326 if ( ! \wp_verify_nonce( $nonce, 'activitypub_oauth_authorize' ) ) {
327 return new \WP_Error(
328 'activitypub_invalid_nonce',
329 \__( 'Invalid security token. Please try again.', 'activitypub' ),
330 array( 'status' => 403 )
331 );
332 }
333
334 return true;
335 }
336
337 /**
338 * Redirect to wp-login.php with a styled error message.
339 *
340 * These errors occur before a valid redirect URI is confirmed, so we
341 * cannot safely redirect back to the client. Instead, redirect to
342 * wp-login.php where the error is rendered using login_header/login_footer
343 * for a consistent, user-friendly appearance.
344 *
345 * The error message is stored in a short-lived transient (5 minutes)
346 * keyed by a random token. Only the opaque token is passed in the URL,
347 * preventing social-engineering attacks where an attacker crafts a URL
348 * with arbitrary error text displayed inside WordPress login chrome.
349 *
350 * @since 8.1.0
351 *
352 * @param \WP_Error $error The error to display.
353 * @return \WP_REST_Response Redirect response to wp-login.php.
354 */
355 private function error_page( $error ) {
356 $token = \wp_generate_password( 20, false );
357 \set_transient( 'ap_oauth_err_' . $token, $error->get_error_message(), 5 * MINUTE_IN_SECONDS );
358
359 $login_url = \add_query_arg(
360 array(
361 'action' => 'activitypub_authorize',
362 'auth_error' => $token,
363 ),
364 \wp_login_url()
365 );
366
367 return new \WP_REST_Response(
368 null,
369 302,
370 array( 'Location' => $login_url )
371 );
372 }
373
374 /**
375 * Redirect with an OAuth error.
376 *
377 * @param string $redirect_uri The redirect URI.
378 * @param string $error Error code.
379 * @param string $description Error description.
380 * @param string $state The state parameter.
381 * @return \WP_REST_Response
382 */
383 private function redirect_with_error( $redirect_uri, $error, $description, $state = null ) {
384 $params = array(
385 'error' => $error,
386 'error_description' => $description,
387 );
388
389 if ( $state ) {
390 $params['state'] = $state;
391 }
392
393 $redirect_url = \add_query_arg( $params, $redirect_uri );
394
395 return new \WP_REST_Response(
396 null,
397 302,
398 array( 'Location' => $redirect_url )
399 );
400 }
401
402 /**
403 * Build a 429 rate-limit response with a Retry-After header.
404 *
405 * @since 9.0.0
406 *
407 * @param string $message Translated human-readable error message.
408 * @return \WP_REST_Response
409 */
410 private function rate_limit_response( $message ) {
411 return new \WP_REST_Response(
412 array(
413 'code' => 'activitypub_rate_limit',
414 'message' => $message,
415 'data' => array( 'status' => 429 ),
416 ),
417 429,
418 // RFC 6585 ยง4: send Retry-After so clients can back off.
419 array( 'Retry-After' => (string) MINUTE_IN_SECONDS )
420 );
421 }
422 }
423