PluginProbe
ActivityPub / 9.2.0
ActivityPub v9.2.0
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 / oauth / class-server.php

class-server.php in ActivityPub 9.2.0, at includes/oauth/class-server.php

528 lines 18.9 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 Server for ActivityPub C2S.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\OAuth;
9
10 use Activitypub\Sanitize;
11
12 /**
13 * Server class for OAuth 2.0 authentication and PKCE verification.
14 *
15 * Integrates with WordPress REST API authentication system.
16 */
17 class Server {
18 /**
19 * The current validated token for this request.
20 *
21 * @var Token|null
22 */
23 private static $current_token = null;
24
25 /**
26 * Initialize the OAuth server.
27 */
28 public static function init() {
29 // Hook into REST authentication - priority 20 to run after default auth.
30 \add_filter( 'rest_authentication_errors', array( self::class, 'authenticate_oauth' ), 20 );
31
32 // Schedule cleanup cron.
33 if ( ! \wp_next_scheduled( 'activitypub_oauth_cleanup' ) ) {
34 \wp_schedule_event( \time(), 'daily', 'activitypub_oauth_cleanup' );
35 }
36 \add_action( 'activitypub_oauth_cleanup', array( self::class, 'cleanup' ) );
37 }
38
39 /**
40 * Authenticate OAuth Bearer token for REST API requests.
41 *
42 * @param \WP_Error|null|bool $result Authentication result from previous filters.
43 * @return \WP_Error|null|bool Authentication result.
44 */
45 public static function authenticate_oauth( $result ) {
46 /*
47 * Reset OAuth state at the start of each authentication to prevent
48 * leaking state between multiple REST dispatches in the same process.
49 */
50 self::$current_token = null;
51
52 $token = self::get_bearer_token();
53
54 if ( ! $token ) {
55 // No Bearer token — respect errors from earlier auth filters.
56 return $result;
57 }
58
59 /*
60 * Only honor OAuth bearer tokens on the plugin's own REST API.
61 *
62 * These are scoped ActivityPub C2S grants. Honoring them on core routes
63 * (e.g. POST /wp/v2/posts) would establish a full-capability session that
64 * ignores the token's granted scope, since scope is only enforced where
65 * the plugin opts in via check_oauth_permission() (CWE-863). Every C2S
66 * endpoint lives under ACTIVITYPUB_REST_NAMESPACE, so restricting here
67 * does not affect legitimate clients. Direct callers (outbox permalinks,
68 * SSE query-param auth) invoke this method outside REST dispatch and are
69 * trusted to have scoped the context themselves.
70 */
71 if ( ! self::is_authenticatable_request() ) {
72 return $result;
73 }
74
75 $validated = Token::validate( $token );
76
77 if ( \is_wp_error( $validated ) ) {
78 return $validated;
79 }
80
81 self::$current_token = $validated;
82 \wp_set_current_user( $validated->get_user_id() );
83
84 return true;
85 }
86
87 /**
88 * Whether the current request may be authenticated with an OAuth bearer token.
89 *
90 * Bearer tokens are scoped ActivityPub C2S grants and must only authenticate
91 * the plugin's own REST API. During REST dispatch we therefore require the
92 * requested route to live under {@see ACTIVITYPUB_REST_NAMESPACE}; a token
93 * presented to a core route such as `/wp/v2/posts` is ignored so its granted
94 * scope cannot be bypassed. Outside REST dispatch (e.g. outbox permalinks
95 * that authenticate explicitly) there is no route to scope against, so the
96 * request is allowed through to the caller's own checks.
97 *
98 * @since 9.1.0
99 *
100 * @return bool True if the request may be OAuth-authenticated.
101 */
102 private static function is_authenticatable_request() {
103 global $wp;
104
105 $route = isset( $wp->query_vars['rest_route'] ) ? (string) $wp->query_vars['rest_route'] : '';
106
107 if ( '' === $route ) {
108 // No REST route in context: only trust non-REST (direct/permalink) callers.
109 return ! \wp_is_serving_rest_request();
110 }
111
112 $route = '/' . \ltrim( $route, '/' );
113 $namespace = '/' . \trim( ACTIVITYPUB_REST_NAMESPACE, '/' );
114
115 return $route === $namespace || 0 === \strpos( $route, $namespace . '/' );
116 }
117
118 /**
119 * Reject requests that authenticated with an OAuth bearer token.
120 *
121 * OAuth bearer tokens are scoped ActivityPub C2S grants and must not reach
122 * the plugin's admin and management endpoints, which authorize by WordPress
123 * capability rather than by OAuth scope. Without this guard a token consented
124 * to for a narrow scope (e.g. read) could drive privileged, capability-gated
125 * actions such as moderation. Cookie-authenticated admin-UI requests do not
126 * establish an OAuth session and are unaffected.
127 *
128 * @since 9.1.0
129 *
130 * @return \WP_Error|null WP_Error when the request is OAuth-authenticated, null otherwise.
131 */
132 public static function deny_if_oauth() {
133 if ( ! self::is_oauth_request() ) {
134 return null;
135 }
136
137 return new \WP_Error(
138 'activitypub_oauth_not_allowed',
139 \__( 'OAuth authentication is not allowed for this endpoint.', 'activitypub' ),
140 array( 'status' => 403 )
141 );
142 }
143
144 /**
145 * Get the current OAuth token from the request.
146 *
147 * @return Token|null The validated token or null.
148 */
149 public static function get_current_token() {
150 return self::$current_token;
151 }
152
153 /**
154 * Check if the current request is authenticated via OAuth.
155 *
156 * @return bool True if OAuth authenticated.
157 */
158 public static function is_oauth_request() {
159 return null !== self::$current_token;
160 }
161
162 /**
163 * Check if the current token has a specific scope.
164 *
165 * @param string $scope The scope to check.
166 * @return bool True if the current token has the scope.
167 */
168 public static function has_scope( $scope ) {
169 if ( ! self::$current_token ) {
170 return false;
171 }
172
173 return self::$current_token->has_scope( $scope );
174 }
175
176 /**
177 * Extract Bearer token from Authorization header.
178 *
179 * @return string|null The token string or null.
180 */
181 public static function get_bearer_token() {
182 $auth_header = self::get_authorization_header();
183
184 if ( ! $auth_header ) {
185 return null;
186 }
187
188 // Check for Bearer token.
189 if ( 0 !== \strpos( $auth_header, 'Bearer ' ) ) {
190 return null;
191 }
192
193 return \substr( $auth_header, 7 );
194 }
195
196 /**
197 * Get the Authorization header.
198 *
199 * @return string|null The authorization header value or null.
200 */
201 private static function get_authorization_header() {
202 /*
203 * Only wp_unslash() is used here — sanitize_text_field() could
204 * corrupt opaque bearer tokens by stripping characters.
205 */
206
207 // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Opaque auth token, must not be altered.
208 if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
209 return \wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] );
210 }
211
212 if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
213 return \wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] );
214 }
215 // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
216
217 // Fallback: read from Apache's own header API (case-insensitive).
218 if ( ! \function_exists( 'apache_request_headers' ) ) {
219 return null;
220 }
221
222 $headers = \apache_request_headers();
223
224 foreach ( $headers as $key => $value ) {
225 if ( 'authorization' === \strtolower( $key ) ) {
226 return $value;
227 }
228 }
229
230 return null;
231 }
232
233 /**
234 * Verify PKCE code_verifier against code_challenge.
235 *
236 * @param string $code_verifier The PKCE code verifier.
237 * @param string $code_challenge The stored code challenge.
238 * @param string $method The challenge method (only S256 is supported).
239 * @return bool True if valid.
240 */
241 public static function verify_pkce( $code_verifier, $code_challenge, $method = 'S256' ) {
242 return Authorization_Code::verify_pkce( $code_verifier, $code_challenge, $method );
243 }
244
245 /**
246 * Generate a cryptographically secure random string.
247 *
248 * @param int $length The length of the string in bytes.
249 * @return string The random string as hex.
250 */
251 public static function generate_token( $length = 32 ) {
252 return Token::generate_token( $length );
253 }
254
255 /**
256 * Permission callback for OAuth-protected endpoints.
257 *
258 * @param \WP_REST_Request $request The REST request.
259 * @param string $scope Required scope (optional).
260 * @return bool|\WP_Error True if authorized, error otherwise.
261 */
262 public static function check_oauth_permission( $request, $scope = null ) {
263 /**
264 * Filter to override OAuth permission check.
265 *
266 * Useful for testing. Return true to bypass OAuth check, false to continue.
267 *
268 * @param bool|null $result The permission result. Null to continue normal check.
269 * @param \WP_REST_Request $request The REST request.
270 * @param string|null $scope Required scope.
271 */
272 $override = \apply_filters( 'activitypub_oauth_check_permission', null, $request, $scope );
273
274 if ( null !== $override ) {
275 return $override;
276 }
277
278 if ( ! self::is_oauth_request() ) {
279 return new \WP_Error(
280 'activitypub_oauth_required',
281 \__( 'OAuth authentication required.', 'activitypub' ),
282 array( 'status' => 401 )
283 );
284 }
285
286 if ( $scope && ! self::has_scope( $scope ) ) {
287 return new \WP_Error(
288 'activitypub_insufficient_scope',
289 /* translators: %s: The required scope */
290 \sprintf( \__( 'This action requires the "%s" scope.', 'activitypub' ), $scope ),
291 array( 'status' => 403 )
292 );
293 }
294
295 return true;
296 }
297
298 /**
299 * Run cleanup tasks for OAuth data.
300 */
301 public static function cleanup() {
302 // Clean up expired tokens.
303 Token::cleanup_expired();
304
305 // Clean up expired authorization codes.
306 Authorization_Code::cleanup();
307 }
308
309 /**
310 * Get OAuth server metadata for discovery.
311 *
312 * @return array OAuth server metadata.
313 */
314 public static function get_metadata() {
315 $base_url = \trailingslashit( \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE ) );
316
317 return array(
318 'issuer' => \home_url(),
319 'authorization_endpoint' => $base_url . 'oauth/authorize',
320 'token_endpoint' => $base_url . 'oauth/token',
321 'revocation_endpoint' => $base_url . 'oauth/revoke',
322 'introspection_endpoint' => $base_url . 'oauth/introspect',
323 'registration_endpoint' => $base_url . 'oauth/clients',
324 'scopes_supported' => Scope::supported(),
325 'response_types_supported' => array( 'code' ),
326 'response_modes_supported' => array( 'query' ),
327 'grant_types_supported' => array( 'authorization_code', 'refresh_token' ),
328 'token_endpoint_auth_methods_supported' => array( 'none', 'client_secret_post', 'client_secret_basic' ),
329 'introspection_endpoint_auth_methods_supported' => array( 'bearer' ),
330 'code_challenge_methods_supported' => array( 'S256' ),
331 'service_documentation' => 'https://github.com/swicg/activitypub-api',
332 'client_id_metadata_document_supported' => true,
333 );
334 }
335
336 /**
337 * Handle OAuth authorization consent page via wp-login.php.
338 *
339 * This is triggered by wp-login.php?action=activitypub_authorize
340 */
341 public static function login_form_authorize() {
342 // Require user to be logged in.
343 if ( ! \is_user_logged_in() ) {
344 \auth_redirect();
345 }
346
347 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
348
349 if ( 'GET' === $request_method ) {
350 self::render_authorize_form();
351 } elseif ( 'POST' === $request_method ) {
352 self::process_authorize_form();
353 }
354
355 exit;
356 }
357
358 /**
359 * Render the OAuth authorization consent form.
360 */
361 private static function render_authorize_form() {
362 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Initial form display, nonce checked on POST.
363
364 // Check for error token (redirected from REST authorization endpoint).
365 if ( isset( $_GET['auth_error'] ) ) {
366 $token = \sanitize_text_field( \wp_unslash( $_GET['auth_error'] ) );
367 $error_message = \get_transient( 'ap_oauth_err_' . $token ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
368 \delete_transient( 'ap_oauth_err_' . $token );
369
370 if ( ! $error_message ) {
371 $error_message = \__( 'An authorization error occurred. Please try again.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
372 }
373
374 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
375 return;
376 }
377
378 $authorize_params = array(
379 'client_id' => isset( $_GET['client_id'] ) ? \sanitize_text_field( \wp_unslash( $_GET['client_id'] ) ) : '',
380 'redirect_uri' => isset( $_GET['redirect_uri'] ) ? Sanitize::redirect_uri( \wp_unslash( $_GET['redirect_uri'] ) ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized via Sanitize::redirect_uri().
381 'scope' => isset( $_GET['scope'] ) ? \sanitize_text_field( \wp_unslash( $_GET['scope'] ) ) : '',
382 'state' => isset( $_GET['state'] ) ? \wp_unslash( $_GET['state'] ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- OAuth state is opaque; must be round-tripped exactly.
383 'code_challenge' => isset( $_GET['code_challenge'] ) ? \sanitize_text_field( \wp_unslash( $_GET['code_challenge'] ) ) : '',
384 'code_challenge_method' => isset( $_GET['code_challenge_method'] ) ? \sanitize_text_field( \wp_unslash( $_GET['code_challenge_method'] ) ) : 'S256',
385 );
386 // phpcs:enable WordPress.Security.NonceVerification.Recommended
387
388 // Validate client.
389 $client = Client::get( $authorize_params['client_id'] );
390 if ( \is_wp_error( $client ) ) {
391 $error_message = $client->get_error_message(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
392 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
393 return;
394 }
395
396 // Validate redirect URI.
397 if ( ! $client->is_valid_redirect_uri( $authorize_params['redirect_uri'] ) ) {
398 $error_message = \__( 'Invalid redirect URI for this client.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
399 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
400 return;
401 }
402
403 // Use the canonical client ID (may differ from the raw input for discovered clients).
404 $authorize_params['client_id'] = $client->get_client_id();
405
406 // These variables are used in the template.
407 $current_user = \wp_get_current_user(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
408 $scopes = Scope::validate( Scope::parse( $authorize_params['scope'] ) ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
409
410 // Build form action URL.
411 // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
412 $form_url = \add_query_arg(
413 \array_merge( array( 'action' => 'activitypub_authorize' ), $authorize_params ),
414 \wp_login_url()
415 );
416
417 // Include the template.
418 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-authorize.php'; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- $authorize_params used in template.
419 }
420
421 /**
422 * Process the OAuth authorization consent form submission.
423 */
424 private static function process_authorize_form() {
425 // Verify nonce.
426 if ( ! isset( $_POST['_wpnonce'] ) || ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ) ), 'activitypub_oauth_authorize' ) ) {
427 $error_message = \__( 'Security check failed. Please try again.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
428 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
429 exit;
430 }
431
432 // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified above.
433 $client_id = isset( $_POST['client_id'] ) ? \sanitize_text_field( \wp_unslash( $_POST['client_id'] ) ) : '';
434 $redirect_uri = isset( $_POST['redirect_uri'] ) ? Sanitize::redirect_uri( \wp_unslash( $_POST['redirect_uri'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized via Sanitize::redirect_uri().
435 $scope = isset( $_POST['scope'] ) ? \sanitize_text_field( \wp_unslash( $_POST['scope'] ) ) : '';
436 $state = isset( $_POST['state'] ) ? \wp_unslash( $_POST['state'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- OAuth state is opaque; must be round-tripped exactly.
437 $code_challenge = isset( $_POST['code_challenge'] ) ? \sanitize_text_field( \wp_unslash( $_POST['code_challenge'] ) ) : '';
438 $code_challenge_method = isset( $_POST['code_challenge_method'] ) ? \sanitize_text_field( \wp_unslash( $_POST['code_challenge_method'] ) ) : 'S256';
439 $approve = isset( $_POST['approve'] );
440 // phpcs:enable WordPress.Security.NonceVerification.Missing
441
442 // Only S256 is supported; normalize empty/missing values and reject anything else.
443 if ( empty( $code_challenge_method ) ) {
444 $code_challenge_method = 'S256';
445 } elseif ( 'S256' !== $code_challenge_method ) {
446 $error_message = \__( 'Only S256 is supported as PKCE code challenge method.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
447 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
448 exit;
449 }
450
451 // Re-validate client and redirect URI (form fields could be tampered with).
452 $client = Client::get( $client_id );
453
454 if ( \is_wp_error( $client ) ) {
455 $error_message = $client->get_error_message(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
456 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
457 exit;
458 }
459
460 if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) {
461 $error_message = \__( 'Invalid redirect URI for this client.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
462 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
463 exit;
464 }
465
466 // User denied authorization.
467 if ( ! $approve ) {
468 self::redirect_to_client(
469 $redirect_uri,
470 array(
471 'error' => 'access_denied',
472 'error_description' => 'The user denied the authorization request.',
473 'state' => $state,
474 )
475 );
476 }
477
478 // Create authorization code.
479 $scopes = Scope::validate( Scope::parse( $scope ) );
480 $code = Authorization_Code::create(
481 \get_current_user_id(),
482 $client_id,
483 $redirect_uri,
484 $scopes,
485 $code_challenge,
486 $code_challenge_method
487 );
488
489 if ( \is_wp_error( $code ) ) {
490 self::redirect_to_client(
491 $redirect_uri,
492 array(
493 'error' => 'server_error',
494 'error_description' => $code->get_error_message(),
495 'state' => $state,
496 )
497 );
498 }
499
500 self::redirect_to_client(
501 $redirect_uri,
502 array(
503 'code' => $code,
504 'state' => $state,
505 )
506 );
507 }
508
509 /**
510 * Redirect to an OAuth client's redirect URI with query parameters.
511 *
512 * Uses a manual Location header because wp_redirect() strips custom
513 * URI schemes used by native/mobile apps (RFC 8252 Section 7.1).
514 * The URI is pre-validated against the registered client's redirect_uris
515 * before this method is called.
516 *
517 * @param string $redirect_uri The client's redirect URI.
518 * @param array $params Query parameters to append.
519 */
520 private static function redirect_to_client( $redirect_uri, $params ) {
521 $url = Sanitize::redirect_uri( \add_query_arg( $params, $redirect_uri ) );
522
523 \nocache_headers();
524 \header( 'Location: ' . $url, true, 303 );
525 exit;
526 }
527 }
528