Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add scope adherence #76

Merged
merged 6 commits into from Feb 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
137 changes: 89 additions & 48 deletions includes/class-mastodon-api.php

Large diffs are not rendered by default.

21 changes: 4 additions & 17 deletions includes/class-mastodon-app.php
Expand Up @@ -118,23 +118,6 @@ public function check_redirect_uri( $redirect_uri ) {
return false;
}

public function check_scopes( $requested_scopes ) {
$allowed_scopes = explode( ' ', $this->get_scopes() );

foreach ( explode( ' ', $requested_scopes ) as $s ) {
if ( false !== strpos( $s, ':' ) ) {
list( $scope, $subscope ) = explode( ':', $s, 2 );
} else {
$scope = $s;
}
if ( ! in_array( $scope, $allowed_scopes, true ) ) {
return false;
}
}

return false;
}

public function delete_last_requests() {
return delete_metadata( 'term', $this->term->term_id, 'request' );
}
Expand Down Expand Up @@ -512,6 +495,10 @@ function ( $post_format ) {
return $args;
}

public function has_scope( $requested_scope ) {
return OAuth2\Scope_Util::checkSingleScope( $requested_scope, $this->get_scopes() );
}

/**
* Get an app via client_id.
*
Expand Down
1 change: 1 addition & 0 deletions includes/class-mastodon-oauth.php
Expand Up @@ -44,6 +44,7 @@ public function __construct() {
$this->server = new Server( new Oauth2\Authorization_Code_Storage(), $config );
$this->server->addStorage( new Oauth2\Mastodon_App_Storage(), 'client_credentials' );
$this->server->addStorage( new Oauth2\Access_Token_Storage(), 'access_token' );
$this->server->setScopeUtil( new Oauth2\Scope_Util() );

if ( '/oauth/token' === strtok( $_SERVER['REQUEST_URI'], '?' ) ) {
// Avoid interference with private site plugins.
Expand Down
10 changes: 2 additions & 8 deletions includes/oauth2/class-access-token-storage.php
Expand Up @@ -167,14 +167,8 @@ public function getAccessToken( $oauth_token ) {
$access_token = array(
'access_token' => $oauth_token,
);
foreach ( array(
'client_id' => 'client_id',
'user_id' => 'user_id',
'expires' => 'expires',
'redirect_uri' => 'redirect_uri',
'scope' => 'scope',
) as $key => $meta_key ) {
$access_token[ $key ] = get_term_meta( $term->term_id, $meta_key, true );
foreach ( array_keys( self::$access_token_data ) as $meta_key ) {
$access_token[ $meta_key ] = get_term_meta( $term->term_id, $meta_key, true );
}

$access_token['created_at'] = $access_token['expires'] - YEAR_IN_SECONDS * 2;
Expand Down
91 changes: 74 additions & 17 deletions includes/oauth2/class-authenticate-handler.php
Expand Up @@ -35,14 +35,21 @@ public function handle( Request $request, Response $response ) {
return $redirect_uri;
}

$scopes = $app->check_scopes( $_GET['scope'] );
if ( is_wp_error( $scopes ) ) {
return $scopes;
$scopes = array();
foreach ( explode( ' ', $_GET['scope'] ) as $scope ) {
if ( $app->has_scope( $scope ) ) {
$scopes[] = $scope;
}
}
if ( empty( $scopes ) ) {
$response->setError( 403, 'invalid_scopes', 'Invalid scope was requested.' );
return $response;
}

$data = array(
'user' => wp_get_current_user(),
'client_name' => $client_name,
'scopes' => implode( ' ', $scopes ),
'body_class_attr' => implode( ' ', array_diff( get_body_class(), array( 'error404' ) ) ),
'cancel_url' => $this->get_cancel_url( $request ),
'form_url' => home_url( '/oauth/authorize' ),
Expand Down Expand Up @@ -96,6 +103,27 @@ private function render_no_permission_screen( $data ) {
}

private function render_consent_screen( $data ) {
$scope_explanations = array(
'read' => __( 'Read information from your account, for example read your statuses.', 'enable-mastodon-apps' ),
'write' => __( 'Write information to your account, for example post a status on your behalf.', 'enable-mastodon-apps' ),
'follow' => __( 'Follow other accounts using your account.', 'enable-mastodon-apps' ),
'push' => __( 'Subscribe to push events for your account.', 'enable-mastodon-apps' ),
);

$requested_scopes = array();
foreach ( explode( ' ', $data['scopes'] ) as $scope ) {
$p = strpos( $scope, ':' );
if ( false === $p ) {
$requested_scopes[ $scope ] = array( 'all' => true );
} else {
$main_scope = substr( $scope, 0, $p );
if ( ! isset( $requested_scopes[ $main_scope ] ) ) {
$requested_scopes[ $main_scope ] = array();
}
$requested_scopes[ $main_scope ][ substr( $scope, $p + 1 ) ] = true;
}
}

?>
<div id="openid-connect-authenticate">
<div id="openid-connect-authenticate-form-container" class="login">
Expand All @@ -113,23 +141,52 @@ private function render_consent_screen( $data ) {
</h2>
<br/>
<p>
<label>
<?php
echo wp_kses(
sprintf(
<?php
echo wp_kses(
sprintf(
// translators: %1$s is the site name, %2$s is the username.
__( 'Do you want to log in to <em>%1$s</em> with your <em>%2$s</em> account?', 'enable-mastodon-apps' ),
$data['client_name'],
get_bloginfo( 'name' )
),
array(
'em' => array(),
)
);
?>
</label>
__( 'Do you want to log in to <strong>%1$s</strong> with your <strong>%2$s</strong> account?', 'enable-mastodon-apps' ),
$data['client_name'],
get_bloginfo( 'name' )
),
array(
'strong' => array(),
)
);
?>
</p>
<br/>
<p>
<?php
esc_html_e( 'Requested permissions:', 'enable-mastodon-apps' );
?>
</p>
<ul style="margin-left: 1em">

<?php
foreach ( $requested_scopes as $main_scope => $subscopes ) {
if ( ! isset( $scope_explanations[ $main_scope ] ) ) {
continue;
}
echo '<li style="margin-top: .5em" title="', esc_attr( $main_scope ), '">', esc_html( $scope_explanations[ $main_scope ] );
$all = isset( $subscopes['all'] ) && $subscopes['all'];
unset( $subscopes['all'] );
if ( ! empty( $subscopes ) && ! $all ) {
echo ' ', __( 'Only the following sub-permissions:', 'enable-mastodon-apps' );
echo '<ul style="margin-left: 1em">';
foreach ( $subscopes as $subscope => $true ) {
if ( ! isset( $scope_explanations[ $main_scope . ':' . $subscope ] ) ) {
$scope_explanations[ $main_scope . ':' . $subscope ] = ucwords( $subscope ) . ' (' . $main_scope . ':' . $subscope . ')';
}
echo '<li style="margin-top: .5em" title="', esc_attr( $main_scope . ':' . $subscope ), '">', esc_html( $scope_explanations[ $main_scope . ':' . $subscope ] ), '</li>';
}
echo '</ul>';
}
echo '</li>';
}
?>
</ul>
<br/>
<?php wp_nonce_field( 'wp_rest' ); /* The nonce will give the REST call the userdata. */ ?>
<?php foreach ( $data['form_fields'] as $key => $value ) : ?>
<input type="hidden" name="<?php echo esc_attr( $key ); ?>" value="<?php echo esc_attr( $value ); ?>"/>
Expand Down
36 changes: 36 additions & 0 deletions includes/oauth2/class-scope-util.php
@@ -0,0 +1,36 @@
<?php
/**
* OAuth2 Scope Util
*
* @package Friends
*/

namespace Enable_Mastodon_Apps\OAuth2;

/**
* This class overrides the scope checking to allow for fine grained scopes.
*/
class Scope_Util extends \OAuth2\Scope {
public static function checkSingleScope( $required_scope, $available_scope ) {
$required_main_scope = strtok( $required_scope, ':' );
foreach ( explode( ' ', $available_scope ) as $scope ) {
if ( $scope === $required_scope ) {
return true;
}

if ( $scope === $required_main_scope ) {
return true;
}
}

return false;
}
public function checkScope( $required_scope, $available_scope ) {
foreach ( explode( ' ', $required_scope ) as $scope ) {
if ( ! self::checkSingleScope( $scope, $available_scope ) ) {
return false;
}
}
return true;
}
}
13 changes: 13 additions & 0 deletions tests/class-mastodon-api-testcase.php
Expand Up @@ -16,6 +16,7 @@ class Mastodon_API_TestCase extends \WP_UnitTestCase {
protected $token;
protected $post;
protected $friend_post;
protected $private_post;
protected $administrator;
protected $friend;
protected $app;
Expand Down Expand Up @@ -49,6 +50,18 @@ public function set_up() {
)
);
set_post_format( $this->post, 'status' );

$this->private_post = wp_insert_post(
array(
'post_author' => $this->administrator,
'post_content' => 'Private post',
'post_title' => '',
'post_status' => 'private',
'post_type' => 'post',
'post_date' => '2023-01-03 00:00:00',
)
);
set_post_format( $this->post, 'status' );
$args = array(
'supports' => array( 'title', 'editor', 'author', 'revisions', 'thumbnail', 'excerpt', 'comments', 'post-formats' ),
'taxonomies' => array( 'post_tag', 'post_format' ),
Expand Down
52 changes: 52 additions & 0 deletions tests/test-mastodon-app.php
@@ -0,0 +1,52 @@
<?php
/**
* Class Test_Apps_Endpoint
*
* @package Enable_Mastodon_Apps
*/

namespace Enable_Mastodon_Apps;

/**
* A testcase for the apps endpoint.
*
* @package
*/
class MastodonApp_Test extends \WP_UnitTestCase {
public function test_create_app() {
$app = Mastodon_App::save( 'test', array( Mastodon_OAuth::OOB_REDIRECT_URI ), 'read', '' );
$this->assertInstanceOf( Mastodon_App::class, $app );
}

public function test_create_app_with_empty_scope() {
$this->expectException( \Exception::class );
$app = Mastodon_App::save( 'test', array( Mastodon_OAuth::OOB_REDIRECT_URI ), '', '' );
}

/**
* Scopes to test
*
* @param string $app_scopes The application scopes.
* @param string $scope_to_test The scope to test.
* @param bool $has_scope Indicates if the test should assume the scope to be existent.
* @dataProvider scopes
*/
public function test_scope_given( $app_scopes, $scope_to_test, $has_scope ) {
$app = Mastodon_App::save( 'test', array( Mastodon_OAuth::OOB_REDIRECT_URI ), $app_scopes, '' );
$this->assertEquals( $has_scope, $app->has_scope( $scope_to_test ) );
}

public function scopes() {
return array(
array( 'read', 'read', true ),
array( 'read', 'read:accounts', true ),
array( 'read:accounts', 'read:accounts', true ),
array( 'read:accounts', 'read', false ),
array( 'write', 'read', false ),
array( 'read', 'write', false ),
array( 'read write', 'write', true ),
array( 'read write push', 'write', true ),
array( 'read', 'write:accounts', false ),
);
}
}
12 changes: 12 additions & 0 deletions tests/test-statuses-endpoint.php
Expand Up @@ -47,6 +47,18 @@ public function test_statuses_id() {
$this->assertIsInt( $data['favourites_count'] );
}

public function test_statuses_private_id() {
global $wp_rest_server;

$request = new \WP_REST_Request( 'GET', '/' . Mastodon_API::PREFIX . '/api/v1/statuses/' . $this->private_post );
$response = $wp_rest_server->dispatch( $request );
$this->assertEquals( 401, $response->get_status() );

$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $this->token;
$response = $wp_rest_server->dispatch( $request );
$this->assertEquals( 200, $response->get_status() );
}

public function test_statuses_delete() {
global $wp_rest_server;
$request = new \WP_REST_Request( 'DELETE', '/' . Mastodon_API::PREFIX . '/api/v1/statuses/' . $this->post );
Expand Down