PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
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 trunk, at includes/oauth/class-server.php

553 lines 19.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 * Whether the request is permitted to act for a scope.
178 *
179 * Only OAuth callers are limited by scope. A cookie-authenticated session carries no
180 * token and is therefore not scope-limited; it is bounded by WordPress capabilities.
181 *
182 * Unlike {@see self::check_oauth_permission()}, this does not require the request to be
183 * OAuth-authenticated, so it can be combined with checks that also accept a WP session.
184 *
185 * @since 9.3.0
186 *
187 * @param string $scope The scope to require of an OAuth caller.
188 * @return bool True if the request may act for the scope.
189 */
190 public static function permits_scope( $scope ) {
191 return ! self::is_oauth_request() || self::has_scope( $scope );
192 }
193
194 /**
195 * Extract Bearer token from Authorization header.
196 *
197 * @return string|null The token string or null.
198 */
199 public static function get_bearer_token() {
200 $auth_header = self::get_authorization_header();
201
202 if ( ! $auth_header ) {
203 return null;
204 }
205
206 // Check for Bearer token.
207 if ( 0 !== \strpos( $auth_header, 'Bearer ' ) ) {
208 return null;
209 }
210
211 return \substr( $auth_header, 7 );
212 }
213
214 /**
215 * Get the Authorization header.
216 *
217 * @return string|null The authorization header value or null.
218 */
219 private static function get_authorization_header() {
220 /*
221 * Only wp_unslash() is used here โ€” sanitize_text_field() could
222 * corrupt opaque bearer tokens by stripping characters.
223 */
224
225 // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Opaque auth token, must not be altered.
226 if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
227 return \wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] );
228 }
229
230 if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
231 return \wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] );
232 }
233 // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
234
235 // Fallback: read from Apache's own header API (case-insensitive).
236 if ( ! \function_exists( 'apache_request_headers' ) ) {
237 return null;
238 }
239
240 $headers = \apache_request_headers();
241
242 foreach ( $headers as $key => $value ) {
243 if ( 'authorization' === \strtolower( $key ) ) {
244 return $value;
245 }
246 }
247
248 return null;
249 }
250
251 /**
252 * Verify PKCE code_verifier against code_challenge.
253 *
254 * @param string $code_verifier The PKCE code verifier.
255 * @param string $code_challenge The stored code challenge.
256 * @param string $method The challenge method (only S256 is supported).
257 * @return bool True if valid.
258 */
259 public static function verify_pkce( $code_verifier, $code_challenge, $method = 'S256' ) {
260 return Authorization_Code::verify_pkce( $code_verifier, $code_challenge, $method );
261 }
262
263 /**
264 * Generate a cryptographically secure random string.
265 *
266 * @param int $length The length of the string in bytes.
267 * @return string The random string as hex.
268 */
269 public static function generate_token( $length = 32 ) {
270 return Token::generate_token( $length );
271 }
272
273 /**
274 * Permission callback for OAuth-protected endpoints.
275 *
276 * @param \WP_REST_Request $request The REST request.
277 * @param string $scope Required scope (optional).
278 * @return bool|\WP_Error True if authorized, error otherwise.
279 */
280 public static function check_oauth_permission( $request, $scope = null ) {
281 /**
282 * Filter to override OAuth permission check.
283 *
284 * Useful for testing. Return true to bypass OAuth check, false to continue.
285 *
286 * @param bool|null $result The permission result. Null to continue normal check.
287 * @param \WP_REST_Request $request The REST request.
288 * @param string|null $scope Required scope.
289 */
290 $override = \apply_filters( 'activitypub_oauth_check_permission', null, $request, $scope );
291
292 if ( null !== $override ) {
293 return $override;
294 }
295
296 if ( ! self::is_oauth_request() ) {
297 return new \WP_Error(
298 'activitypub_oauth_required',
299 \__( 'OAuth authentication required.', 'activitypub' ),
300 array( 'status' => 401 )
301 );
302 }
303
304 if ( $scope && ! self::has_scope( $scope ) ) {
305 return new \WP_Error(
306 'activitypub_insufficient_scope',
307 /* translators: %s: The required scope */
308 \sprintf( \__( 'This action requires the "%s" scope.', 'activitypub' ), $scope ),
309 array( 'status' => 403 )
310 );
311 }
312
313 return true;
314 }
315
316 /**
317 * Run cleanup tasks for OAuth data.
318 */
319 public static function cleanup() {
320 // Clean up expired tokens.
321 Token::cleanup_expired();
322
323 // Clean up expired authorization codes.
324 Authorization_Code::cleanup();
325 }
326
327 /**
328 * Get OAuth server metadata for discovery.
329 *
330 * @return array OAuth server metadata.
331 */
332 public static function get_metadata() {
333 $base_url = \trailingslashit( \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE ) );
334
335 return array(
336 'issuer' => \home_url(),
337 'authorization_endpoint' => $base_url . 'oauth/authorize',
338 'token_endpoint' => $base_url . 'oauth/token',
339 'revocation_endpoint' => $base_url . 'oauth/revoke',
340 'introspection_endpoint' => $base_url . 'oauth/introspect',
341 'registration_endpoint' => $base_url . 'oauth/clients',
342 'scopes_supported' => Scope::supported(),
343 'response_types_supported' => array( 'code' ),
344 'response_modes_supported' => array( 'query' ),
345 'grant_types_supported' => array( 'authorization_code', 'refresh_token' ),
346 'token_endpoint_auth_methods_supported' => array( 'none', 'client_secret_post', 'client_secret_basic' ),
347 'introspection_endpoint_auth_methods_supported' => array( 'bearer' ),
348 'code_challenge_methods_supported' => array( 'S256' ),
349 'service_documentation' => 'https://github.com/swicg/activitypub-api',
350 'client_id_metadata_document_supported' => true,
351 );
352 }
353
354 /**
355 * Handle OAuth authorization consent page via wp-login.php.
356 *
357 * This is triggered by wp-login.php?action=activitypub_authorize
358 */
359 public static function login_form_authorize() {
360 // Require user to be logged in.
361 if ( ! \is_user_logged_in() ) {
362 \auth_redirect();
363 }
364
365 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
366
367 if ( 'GET' === $request_method ) {
368 self::render_authorize_form();
369 } elseif ( 'POST' === $request_method ) {
370 self::process_authorize_form();
371 }
372
373 exit;
374 }
375
376 /**
377 * Render the OAuth authorization consent form.
378 */
379 private static function render_authorize_form() {
380 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Initial form display, nonce checked on POST.
381
382 // Check for error token (redirected from REST authorization endpoint).
383 if ( isset( $_GET['auth_error'] ) ) {
384 $token = \sanitize_text_field( \wp_unslash( $_GET['auth_error'] ) );
385 $error_message = \get_transient( 'ap_oauth_err_' . $token ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
386 \delete_transient( 'ap_oauth_err_' . $token );
387
388 if ( ! $error_message ) {
389 $error_message = \__( 'An authorization error occurred. Please try again.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
390 }
391
392 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
393 return;
394 }
395
396 $authorize_params = array(
397 'client_id' => isset( $_GET['client_id'] ) ? \sanitize_text_field( \wp_unslash( $_GET['client_id'] ) ) : '',
398 '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().
399 'scope' => isset( $_GET['scope'] ) ? \sanitize_text_field( \wp_unslash( $_GET['scope'] ) ) : '',
400 'state' => isset( $_GET['state'] ) ? \wp_unslash( $_GET['state'] ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- OAuth state is opaque; must be round-tripped exactly.
401 'code_challenge' => isset( $_GET['code_challenge'] ) ? \sanitize_text_field( \wp_unslash( $_GET['code_challenge'] ) ) : '',
402 'code_challenge_method' => isset( $_GET['code_challenge_method'] ) ? \sanitize_text_field( \wp_unslash( $_GET['code_challenge_method'] ) ) : 'S256',
403 );
404 // phpcs:enable WordPress.Security.NonceVerification.Recommended
405
406 // Validate client.
407 $client = Client::get( $authorize_params['client_id'] );
408 if ( \is_wp_error( $client ) ) {
409 $error_message = $client->get_error_message(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
410 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
411 return;
412 }
413
414 // Validate redirect URI.
415 if ( ! $client->is_valid_redirect_uri( $authorize_params['redirect_uri'] ) ) {
416 $error_message = \__( 'Invalid redirect URI for this client.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
417 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
418 return;
419 }
420
421 // Use the canonical client ID (may differ from the raw input for discovered clients).
422 $authorize_params['client_id'] = $client->get_client_id();
423
424 // These variables are used in the template.
425 $current_user = \wp_get_current_user(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
426 $scopes = Scope::validate( Scope::parse( $authorize_params['scope'] ) ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
427
428 // Build form action URL.
429 // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
430 $form_url = \add_query_arg(
431 \array_merge( array( 'action' => 'activitypub_authorize' ), $authorize_params ),
432 \wp_login_url()
433 );
434
435 // Include the template.
436 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-authorize.php'; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- $authorize_params used in template.
437 }
438
439 /**
440 * Process the OAuth authorization consent form submission.
441 */
442 private static function process_authorize_form() {
443 // Verify nonce.
444 if ( ! isset( $_POST['_wpnonce'] ) || ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ) ), 'activitypub_oauth_authorize' ) ) {
445 $error_message = \__( 'Security check failed. Please try again.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
446 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
447 exit;
448 }
449
450 // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified above.
451 $client_id = isset( $_POST['client_id'] ) ? \sanitize_text_field( \wp_unslash( $_POST['client_id'] ) ) : '';
452 $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().
453 $scope = isset( $_POST['scope'] ) ? \sanitize_text_field( \wp_unslash( $_POST['scope'] ) ) : '';
454 $state = isset( $_POST['state'] ) ? \wp_unslash( $_POST['state'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- OAuth state is opaque; must be round-tripped exactly.
455 $code_challenge = isset( $_POST['code_challenge'] ) ? \sanitize_text_field( \wp_unslash( $_POST['code_challenge'] ) ) : '';
456 $code_challenge_method = isset( $_POST['code_challenge_method'] ) ? \sanitize_text_field( \wp_unslash( $_POST['code_challenge_method'] ) ) : 'S256';
457 $approve = isset( $_POST['approve'] );
458 // phpcs:enable WordPress.Security.NonceVerification.Missing
459
460 // Only S256 is supported; normalize empty/missing values and reject anything else.
461 if ( empty( $code_challenge_method ) ) {
462 $code_challenge_method = 'S256';
463 } elseif ( 'S256' !== $code_challenge_method ) {
464 $error_message = \__( 'Only S256 is supported as PKCE code challenge method.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
465 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
466 exit;
467 }
468
469 // Re-validate client and redirect URI (form fields could be tampered with).
470 $client = Client::get( $client_id );
471
472 if ( \is_wp_error( $client ) ) {
473 $error_message = $client->get_error_message(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
474 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
475 exit;
476 }
477
478 if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) {
479 $error_message = \__( 'Invalid redirect URI for this client.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
480 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
481 exit;
482 }
483
484 // User denied authorization.
485 if ( ! $approve ) {
486 self::redirect_to_client(
487 $redirect_uri,
488 array(
489 'error' => 'access_denied',
490 'error_description' => 'The user denied the authorization request.',
491 'state' => $state,
492 )
493 );
494 }
495
496 // Create authorization code.
497 $scopes = Scope::validate( Scope::parse( $scope ) );
498 $code = Authorization_Code::create(
499 \get_current_user_id(),
500 $client_id,
501 $redirect_uri,
502 $scopes,
503 $code_challenge,
504 $code_challenge_method
505 );
506
507 if ( \is_wp_error( $code ) ) {
508 /*
509 * A refused scope is something the client can act on, so it travels as the OAuth
510 * error RFC 6749 ยง4.1.2.1 names. Everything else here is an internal failure, and
511 * those carry an `activitypub_` code the client has no use for.
512 */
513 $error = 'invalid_scope' === $code->get_error_code() ? 'invalid_scope' : 'server_error';
514
515 self::redirect_to_client(
516 $redirect_uri,
517 array(
518 'error' => $error,
519 'error_description' => $code->get_error_message(),
520 'state' => $state,
521 )
522 );
523 }
524
525 self::redirect_to_client(
526 $redirect_uri,
527 array(
528 'code' => $code,
529 'state' => $state,
530 )
531 );
532 }
533
534 /**
535 * Redirect to an OAuth client's redirect URI with query parameters.
536 *
537 * Uses a manual Location header because wp_redirect() strips custom
538 * URI schemes used by native/mobile apps (RFC 8252 Section 7.1).
539 * The URI is pre-validated against the registered client's redirect_uris
540 * before this method is called.
541 *
542 * @param string $redirect_uri The client's redirect URI.
543 * @param array $params Query parameters to append.
544 */
545 private static function redirect_to_client( $redirect_uri, $params ) {
546 $url = Sanitize::redirect_uri( \add_query_arg( $params, $redirect_uri ) );
547
548 \nocache_headers();
549 \header( 'Location: ' . $url, true, 303 );
550 exit;
551 }
552 }
553