PluginProbe
ActivityPub / 9.0.2
ActivityPub v9.0.2
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.0.2, at includes/oauth/class-server.php

455 lines 16.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 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 $validated = Token::validate( $token );
60
61 if ( \is_wp_error( $validated ) ) {
62 return $validated;
63 }
64
65 self::$current_token = $validated;
66 \wp_set_current_user( $validated->get_user_id() );
67
68 return true;
69 }
70
71 /**
72 * Get the current OAuth token from the request.
73 *
74 * @return Token|null The validated token or null.
75 */
76 public static function get_current_token() {
77 return self::$current_token;
78 }
79
80 /**
81 * Check if the current request is authenticated via OAuth.
82 *
83 * @return bool True if OAuth authenticated.
84 */
85 public static function is_oauth_request() {
86 return null !== self::$current_token;
87 }
88
89 /**
90 * Check if the current token has a specific scope.
91 *
92 * @param string $scope The scope to check.
93 * @return bool True if the current token has the scope.
94 */
95 public static function has_scope( $scope ) {
96 if ( ! self::$current_token ) {
97 return false;
98 }
99
100 return self::$current_token->has_scope( $scope );
101 }
102
103 /**
104 * Extract Bearer token from Authorization header.
105 *
106 * @return string|null The token string or null.
107 */
108 public static function get_bearer_token() {
109 $auth_header = self::get_authorization_header();
110
111 if ( ! $auth_header ) {
112 return null;
113 }
114
115 // Check for Bearer token.
116 if ( 0 !== strpos( $auth_header, 'Bearer ' ) ) {
117 return null;
118 }
119
120 return substr( $auth_header, 7 );
121 }
122
123 /**
124 * Get the Authorization header.
125 *
126 * @return string|null The authorization header value or null.
127 */
128 private static function get_authorization_header() {
129 /*
130 * Only wp_unslash() is used here — sanitize_text_field() could
131 * corrupt opaque bearer tokens by stripping characters.
132 */
133
134 // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Opaque auth token, must not be altered.
135 if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
136 return \wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] );
137 }
138
139 if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
140 return \wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] );
141 }
142 // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
143
144 // Fallback: read from Apache's own header API (case-insensitive).
145 if ( ! function_exists( 'apache_request_headers' ) ) {
146 return null;
147 }
148
149 $headers = apache_request_headers();
150
151 foreach ( $headers as $key => $value ) {
152 if ( 'authorization' === strtolower( $key ) ) {
153 return $value;
154 }
155 }
156
157 return null;
158 }
159
160 /**
161 * Verify PKCE code_verifier against code_challenge.
162 *
163 * @param string $code_verifier The PKCE code verifier.
164 * @param string $code_challenge The stored code challenge.
165 * @param string $method The challenge method (only S256 is supported).
166 * @return bool True if valid.
167 */
168 public static function verify_pkce( $code_verifier, $code_challenge, $method = 'S256' ) {
169 return Authorization_Code::verify_pkce( $code_verifier, $code_challenge, $method );
170 }
171
172 /**
173 * Generate a cryptographically secure random string.
174 *
175 * @param int $length The length of the string in bytes.
176 * @return string The random string as hex.
177 */
178 public static function generate_token( $length = 32 ) {
179 return Token::generate_token( $length );
180 }
181
182 /**
183 * Permission callback for OAuth-protected endpoints.
184 *
185 * @param \WP_REST_Request $request The REST request.
186 * @param string $scope Required scope (optional).
187 * @return bool|\WP_Error True if authorized, error otherwise.
188 */
189 public static function check_oauth_permission( $request, $scope = null ) {
190 /**
191 * Filter to override OAuth permission check.
192 *
193 * Useful for testing. Return true to bypass OAuth check, false to continue.
194 *
195 * @param bool|null $result The permission result. Null to continue normal check.
196 * @param \WP_REST_Request $request The REST request.
197 * @param string|null $scope Required scope.
198 */
199 $override = \apply_filters( 'activitypub_oauth_check_permission', null, $request, $scope );
200
201 if ( null !== $override ) {
202 return $override;
203 }
204
205 if ( ! self::is_oauth_request() ) {
206 return new \WP_Error(
207 'activitypub_oauth_required',
208 \__( 'OAuth authentication required.', 'activitypub' ),
209 array( 'status' => 401 )
210 );
211 }
212
213 if ( $scope && ! self::has_scope( $scope ) ) {
214 return new \WP_Error(
215 'activitypub_insufficient_scope',
216 /* translators: %s: The required scope */
217 sprintf( \__( 'This action requires the "%s" scope.', 'activitypub' ), $scope ),
218 array( 'status' => 403 )
219 );
220 }
221
222 return true;
223 }
224
225 /**
226 * Run cleanup tasks for OAuth data.
227 */
228 public static function cleanup() {
229 // Clean up expired tokens.
230 Token::cleanup_expired();
231
232 // Clean up expired authorization codes.
233 Authorization_Code::cleanup();
234 }
235
236 /**
237 * Get OAuth server metadata for discovery.
238 *
239 * @return array OAuth server metadata.
240 */
241 public static function get_metadata() {
242 $base_url = \trailingslashit( \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE ) );
243
244 return array(
245 'issuer' => \home_url(),
246 'authorization_endpoint' => $base_url . 'oauth/authorize',
247 'token_endpoint' => $base_url . 'oauth/token',
248 'revocation_endpoint' => $base_url . 'oauth/revoke',
249 'introspection_endpoint' => $base_url . 'oauth/introspect',
250 'registration_endpoint' => $base_url . 'oauth/clients',
251 'scopes_supported' => Scope::supported(),
252 'response_types_supported' => array( 'code' ),
253 'response_modes_supported' => array( 'query' ),
254 'grant_types_supported' => array( 'authorization_code', 'refresh_token' ),
255 'token_endpoint_auth_methods_supported' => array( 'none', 'client_secret_post', 'client_secret_basic' ),
256 'introspection_endpoint_auth_methods_supported' => array( 'bearer' ),
257 'code_challenge_methods_supported' => array( 'S256' ),
258 'service_documentation' => 'https://github.com/swicg/activitypub-api',
259 'client_id_metadata_document_supported' => true,
260 );
261 }
262
263 /**
264 * Handle OAuth authorization consent page via wp-login.php.
265 *
266 * This is triggered by wp-login.php?action=activitypub_authorize
267 */
268 public static function login_form_authorize() {
269 // Require user to be logged in.
270 if ( ! \is_user_logged_in() ) {
271 \auth_redirect();
272 }
273
274 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
275
276 if ( 'GET' === $request_method ) {
277 self::render_authorize_form();
278 } elseif ( 'POST' === $request_method ) {
279 self::process_authorize_form();
280 }
281
282 exit;
283 }
284
285 /**
286 * Render the OAuth authorization consent form.
287 */
288 private static function render_authorize_form() {
289 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Initial form display, nonce checked on POST.
290
291 // Check for error token (redirected from REST authorization endpoint).
292 if ( isset( $_GET['auth_error'] ) ) {
293 $token = \sanitize_text_field( \wp_unslash( $_GET['auth_error'] ) );
294 $error_message = \get_transient( 'ap_oauth_err_' . $token ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
295 \delete_transient( 'ap_oauth_err_' . $token );
296
297 if ( ! $error_message ) {
298 $error_message = \__( 'An authorization error occurred. Please try again.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
299 }
300
301 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
302 return;
303 }
304
305 $authorize_params = array(
306 'client_id' => isset( $_GET['client_id'] ) ? \sanitize_text_field( \wp_unslash( $_GET['client_id'] ) ) : '',
307 '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().
308 'scope' => isset( $_GET['scope'] ) ? \sanitize_text_field( \wp_unslash( $_GET['scope'] ) ) : '',
309 'state' => isset( $_GET['state'] ) ? \wp_unslash( $_GET['state'] ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- OAuth state is opaque; must be round-tripped exactly.
310 'code_challenge' => isset( $_GET['code_challenge'] ) ? \sanitize_text_field( \wp_unslash( $_GET['code_challenge'] ) ) : '',
311 'code_challenge_method' => isset( $_GET['code_challenge_method'] ) ? \sanitize_text_field( \wp_unslash( $_GET['code_challenge_method'] ) ) : 'S256',
312 );
313 // phpcs:enable WordPress.Security.NonceVerification.Recommended
314
315 // Validate client.
316 $client = Client::get( $authorize_params['client_id'] );
317 if ( \is_wp_error( $client ) ) {
318 $error_message = $client->get_error_message(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
319 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
320 return;
321 }
322
323 // Validate redirect URI.
324 if ( ! $client->is_valid_redirect_uri( $authorize_params['redirect_uri'] ) ) {
325 $error_message = \__( 'Invalid redirect URI for this client.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
326 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
327 return;
328 }
329
330 // Use the canonical client ID (may differ from the raw input for discovered clients).
331 $authorize_params['client_id'] = $client->get_client_id();
332
333 // These variables are used in the template.
334 $current_user = \wp_get_current_user(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
335 $scopes = Scope::validate( Scope::parse( $authorize_params['scope'] ) ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
336
337 // Build form action URL.
338 // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
339 $form_url = \add_query_arg(
340 array_merge( array( 'action' => 'activitypub_authorize' ), $authorize_params ),
341 \wp_login_url()
342 );
343
344 // Include the template.
345 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-authorize.php'; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- $authorize_params used in template.
346 }
347
348 /**
349 * Process the OAuth authorization consent form submission.
350 */
351 private static function process_authorize_form() {
352 // Verify nonce.
353 if ( ! isset( $_POST['_wpnonce'] ) || ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ) ), 'activitypub_oauth_authorize' ) ) {
354 $error_message = \__( 'Security check failed. Please try again.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
355 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
356 exit;
357 }
358
359 // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified above.
360 $client_id = isset( $_POST['client_id'] ) ? \sanitize_text_field( \wp_unslash( $_POST['client_id'] ) ) : '';
361 $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().
362 $scope = isset( $_POST['scope'] ) ? \sanitize_text_field( \wp_unslash( $_POST['scope'] ) ) : '';
363 $state = isset( $_POST['state'] ) ? \wp_unslash( $_POST['state'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- OAuth state is opaque; must be round-tripped exactly.
364 $code_challenge = isset( $_POST['code_challenge'] ) ? \sanitize_text_field( \wp_unslash( $_POST['code_challenge'] ) ) : '';
365 $code_challenge_method = isset( $_POST['code_challenge_method'] ) ? \sanitize_text_field( \wp_unslash( $_POST['code_challenge_method'] ) ) : 'S256';
366 $approve = isset( $_POST['approve'] );
367 // phpcs:enable WordPress.Security.NonceVerification.Missing
368
369 // Only S256 is supported; normalize empty/missing values and reject anything else.
370 if ( empty( $code_challenge_method ) ) {
371 $code_challenge_method = 'S256';
372 } elseif ( 'S256' !== $code_challenge_method ) {
373 $error_message = \__( 'Only S256 is supported as PKCE code challenge method.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
374 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
375 exit;
376 }
377
378 // Re-validate client and redirect URI (form fields could be tampered with).
379 $client = Client::get( $client_id );
380
381 if ( \is_wp_error( $client ) ) {
382 $error_message = $client->get_error_message(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
383 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
384 exit;
385 }
386
387 if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) {
388 $error_message = \__( 'Invalid redirect URI for this client.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
389 include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
390 exit;
391 }
392
393 // User denied authorization.
394 if ( ! $approve ) {
395 self::redirect_to_client(
396 $redirect_uri,
397 array(
398 'error' => 'access_denied',
399 'error_description' => 'The user denied the authorization request.',
400 'state' => $state,
401 )
402 );
403 }
404
405 // Create authorization code.
406 $scopes = Scope::validate( Scope::parse( $scope ) );
407 $code = Authorization_Code::create(
408 \get_current_user_id(),
409 $client_id,
410 $redirect_uri,
411 $scopes,
412 $code_challenge,
413 $code_challenge_method
414 );
415
416 if ( \is_wp_error( $code ) ) {
417 self::redirect_to_client(
418 $redirect_uri,
419 array(
420 'error' => 'server_error',
421 'error_description' => $code->get_error_message(),
422 'state' => $state,
423 )
424 );
425 }
426
427 self::redirect_to_client(
428 $redirect_uri,
429 array(
430 'code' => $code,
431 'state' => $state,
432 )
433 );
434 }
435
436 /**
437 * Redirect to an OAuth client's redirect URI with query parameters.
438 *
439 * Uses a manual Location header because wp_redirect() strips custom
440 * URI schemes used by native/mobile apps (RFC 8252 Section 7.1).
441 * The URI is pre-validated against the registered client's redirect_uris
442 * before this method is called.
443 *
444 * @param string $redirect_uri The client's redirect URI.
445 * @param array $params Query parameters to append.
446 */
447 private static function redirect_to_client( $redirect_uri, $params ) {
448 $url = Sanitize::redirect_uri( \add_query_arg( $params, $redirect_uri ) );
449
450 \nocache_headers();
451 header( 'Location: ' . $url, true, 303 );
452 exit;
453 }
454 }
455