PluginProbe
ActivityPub / 9.0.1
ActivityPub v9.0.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.0.1, at includes/rest/oauth/class-authorization-controller.php

420 lines 12.1 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 return $this->redirect_with_error(
283 $redirect_uri,
284 'server_error',
285 $code->get_error_message(),
286 $state
287 );
288 }
289
290 // Redirect back to client with code.
291 $redirect_url = \add_query_arg(
292 array(
293 'code' => $code,
294 'state' => $state,
295 ),
296 $redirect_uri
297 );
298
299 return new \WP_REST_Response(
300 null,
301 302,
302 array( 'Location' => $redirect_url )
303 );
304 }
305
306 /**
307 * Permission check for authorization submission.
308 *
309 * @param \WP_REST_Request $request The request object.
310 * @return bool|\WP_Error True if allowed, error otherwise.
311 */
312 public function authorize_submit_permissions_check( \WP_REST_Request $request ) {
313 if ( ! \is_user_logged_in() ) {
314 return new \WP_Error(
315 'activitypub_not_logged_in',
316 \__( 'You must be logged in to authorize applications.', 'activitypub' ),
317 array( 'status' => 401 )
318 );
319 }
320
321 // Verify nonce.
322 $nonce = $request->get_param( '_wpnonce' );
323 if ( ! \wp_verify_nonce( $nonce, 'activitypub_oauth_authorize' ) ) {
324 return new \WP_Error(
325 'activitypub_invalid_nonce',
326 \__( 'Invalid security token. Please try again.', 'activitypub' ),
327 array( 'status' => 403 )
328 );
329 }
330
331 return true;
332 }
333
334 /**
335 * Redirect to wp-login.php with a styled error message.
336 *
337 * These errors occur before a valid redirect URI is confirmed, so we
338 * cannot safely redirect back to the client. Instead, redirect to
339 * wp-login.php where the error is rendered using login_header/login_footer
340 * for a consistent, user-friendly appearance.
341 *
342 * The error message is stored in a short-lived transient (5 minutes)
343 * keyed by a random token. Only the opaque token is passed in the URL,
344 * preventing social-engineering attacks where an attacker crafts a URL
345 * with arbitrary error text displayed inside WordPress login chrome.
346 *
347 * @since 8.1.0
348 *
349 * @param \WP_Error $error The error to display.
350 * @return \WP_REST_Response Redirect response to wp-login.php.
351 */
352 private function error_page( $error ) {
353 $token = \wp_generate_password( 20, false );
354 \set_transient( 'ap_oauth_err_' . $token, $error->get_error_message(), 5 * MINUTE_IN_SECONDS );
355
356 $login_url = \add_query_arg(
357 array(
358 'action' => 'activitypub_authorize',
359 'auth_error' => $token,
360 ),
361 \wp_login_url()
362 );
363
364 return new \WP_REST_Response(
365 null,
366 302,
367 array( 'Location' => $login_url )
368 );
369 }
370
371 /**
372 * Redirect with an OAuth error.
373 *
374 * @param string $redirect_uri The redirect URI.
375 * @param string $error Error code.
376 * @param string $description Error description.
377 * @param string $state The state parameter.
378 * @return \WP_REST_Response
379 */
380 private function redirect_with_error( $redirect_uri, $error, $description, $state = null ) {
381 $params = array(
382 'error' => $error,
383 'error_description' => $description,
384 );
385
386 if ( $state ) {
387 $params['state'] = $state;
388 }
389
390 $redirect_url = \add_query_arg( $params, $redirect_uri );
391
392 return new \WP_REST_Response(
393 null,
394 302,
395 array( 'Location' => $redirect_url )
396 );
397 }
398
399 /**
400 * Build a 429 rate-limit response with a Retry-After header.
401 *
402 * @since 9.0.0
403 *
404 * @param string $message Translated human-readable error message.
405 * @return \WP_REST_Response
406 */
407 private function rate_limit_response( $message ) {
408 return new \WP_REST_Response(
409 array(
410 'code' => 'activitypub_rate_limit',
411 'message' => $message,
412 'data' => array( 'status' => 429 ),
413 ),
414 429,
415 // RFC 6585 ยง4: send Retry-After so clients can back off.
416 array( 'Retry-After' => (string) MINUTE_IN_SECONDS )
417 );
418 }
419 }
420