Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions inc/class-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,28 @@ public function get_secret() {
return get_post_meta( $this->get_post_id(), static::CLIENT_SECRET_KEY, true );
}

/**
* Check whether the client must authenticate with its secret.
*
* Clients declared as private are confidential clients in RFC 6749 terms,
* so they have to prove they hold the secret they were issued.
*
* @link https://tools.ietf.org/html/rfc6749#section-3.2.1
*
* @return bool True if the secret must be verified, false otherwise.
*/
public function requires_secret() {
$requires_secret = ( 'private' === $this->get_type() );

/**
* Filter whether a client must authenticate with its secret.
*
* @param bool $requires_secret Whether the secret must be verified.
* @param Client $client Client being checked.
*/
return (bool) apply_filters( 'oauth2.client.requires_secret', $requires_secret, $this );
}

/**
* Check if the provided secret matches the client's secret.
*
Expand Down
25 changes: 25 additions & 0 deletions inc/class-clientinterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,31 @@ public function get_type();
*/
public function get_secret();

/**
* Check whether the client must authenticate with its secret.
*
* @link https://tools.ietf.org/html/rfc6749#section-3.2.1
*
* @return bool True if the secret must be verified, false otherwise.
*/
public function requires_secret();
Comment on lines +50 to +57

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a deliberate breaking change. It will ship in a major version bump, so custom ClientInterface classes need to add these three methods.


/**
* Check if the provided secret matches the client's secret.
*
* @param string $secret Secret to check.
*
* @return bool True if the secret matches, false otherwise.
*/
public function check_secret( $secret );

/**
* Check whether the client_credentials grant is enabled for this client.
*
* @return bool True if enabled, false otherwise.
*/
public function is_client_credentials_enabled();

/**
* Get registered URI for the client.
*
Expand Down
29 changes: 29 additions & 0 deletions inc/class-personalclient.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,35 @@ public function get_secret() {
return '';
}

/**
* Check whether the client must authenticate with its secret.
*
* @return bool Always false: personal tokens have no secret to check.
*/
public function requires_secret() {
return false;
}

/**
* Check if the provided secret matches the client's secret.
*
* @param string $secret Secret to check.
*
* @return bool Always false: personal tokens cannot authenticate as a client.
*/
public function check_secret( $secret ) {
return false;
}

/**
* Check whether the client_credentials grant is enabled for this client.
*
* @return bool Always false: personal tokens are issued to a user, not a client.
*/
public function is_client_credentials_enabled() {
return false;
}

/**
* Get registered URI for the client.
*
Expand Down
131 changes: 96 additions & 35 deletions inc/endpoints/class-token.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use WP_Http;
use WP\OAuth2;
use WP_REST_Request;
use WP_REST_Response;
/**
* Token endpoint handler.
*/
Expand Down Expand Up @@ -65,7 +66,7 @@ public function validate_grant_type( $type ) {
*
* @param WP_REST_Request $request Request object.
*
* @return array|WP_Error Token data on success, or error on failure.
* @return array|WP_Error|WP_REST_Response Token data on success, or error on failure.
*/
public function exchange_token( WP_REST_Request $request ) {
if ( 'client_credentials' === $request['grant_type'] ) {
Expand All @@ -74,16 +75,19 @@ public function exchange_token( WP_REST_Request $request ) {

// RFC 6749 section 2.3.1: a client may authenticate with HTTP Basic
// instead of body parameters. Body parameters take precedence.
if ( $request->get_param( 'client_id' ) === null || $request->get_param( 'client_id' ) === '' ) {
$basic = $this->get_basic_auth_credentials( $request );
if ( is_wp_error( $basic ) ) {
return $basic;
}
if ( null !== $basic ) {
$basic = $this->get_basic_auth_credentials( $request );
if ( false === $basic ) {
return $this->client_authentication_failed( $request );
}
if ( null !== $basic ) {
if ( $this->is_param_empty( $request, 'client_id' ) ) {
$request->set_param( 'client_id', $basic[0] );
if ( $request->get_param( 'client_secret' ) === null || $request->get_param( 'client_secret' ) === '' ) {
$request->set_param( 'client_secret', $basic[1] );
}
}

// Only accept the header secret for the client it names, so a body
// client_id can still be paired with a Basic secret.
if ( $this->is_param_empty( $request, 'client_secret' ) && $basic[0] === $request->get_param( 'client_id' ) ) {
$request->set_param( 'client_secret', $basic[1] );
}
}

Expand All @@ -93,7 +97,7 @@ public function exchange_token( WP_REST_Request $request ) {
// shape matches what WP REST API would produce at the schema layer.
$missing = [];
foreach ( [ 'client_id', 'code' ] as $required_param ) {
if ( $request->get_param( $required_param ) === null || $request->get_param( $required_param ) === '' ) {
if ( $this->is_param_empty( $request, $required_param ) ) {
$missing[] = $required_param;
}
}
Expand All @@ -110,6 +114,9 @@ public function exchange_token( WP_REST_Request $request ) {
}

$client = OAuth2\get_client( $request['client_id'] );
if ( empty( $client ) && null !== $basic ) {
return $this->client_authentication_failed( $request );
}
if ( empty( $client ) ) {
return new WP_Error(
'oauth2.endpoints.token.exchange_token.invalid_client',
Expand All @@ -122,6 +129,15 @@ public function exchange_token( WP_REST_Request $request ) {
);
}

// RFC 6749 section 4.1.3: the server must authenticate the client when
// the client is confidential. Public clients have no secret to check.
if ( $client->requires_secret() ) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c766b49. An unknown client sent with a Basic header now gets the 401 challenge. Body-only requests keep the old 400 response.

$client_secret = (string) $request->get_param( 'client_secret' );
if ( '' === $client_secret || ! $client->check_secret( $client_secret ) ) {
return $this->client_authentication_failed( $request );
}
}

$auth_code = $client->get_authorization_code( $request['code'] );
if ( is_wp_error( $auth_code ) ) {
return $auth_code;
Expand Down Expand Up @@ -163,11 +179,11 @@ public function exchange_token( WP_REST_Request $request ) {
* Handle client credentials grant type.
*
* @param WP_REST_Request $request Request object.
* @return array|WP_Error Token data on success, or error on failure.
* @return array|WP_Error|WP_REST_Response Token data on success, or error on failure.
*/
private function handle_client_credentials( WP_REST_Request $request ) {
$credentials = $this->extract_client_credentials( $request );
if ( is_wp_error( $credentials ) ) {
if ( is_wp_error( $credentials ) || $credentials instanceof WP_REST_Response ) {
return $credentials;
}

Expand All @@ -182,11 +198,7 @@ private function handle_client_credentials( WP_REST_Request $request ) {
$grant_ok = $client && $client->is_client_credentials_enabled();

if ( ! $creds_ok || ! $grant_ok ) {
return new WP_Error(
'oauth2.endpoints.token.invalid_client',
__( 'Client authentication failed.', 'oauth2' ),
[ 'status' => WP_Http::UNAUTHORIZED ]
);
return $this->client_authentication_failed( $request );
}

$token = OAuth2\Tokens\Access_Token::create_for_client( $client );
Expand All @@ -211,7 +223,7 @@ private function handle_client_credentials( WP_REST_Request $request ) {
* Extract client credentials from Authorization header or request body.
*
* @param WP_REST_Request $request Request object.
* @return array|WP_Error Array with client_id and client_secret, or error.
* @return array|WP_Error|WP_REST_Response Array with client_id and client_secret, or error.
*/
private function extract_client_credentials( WP_REST_Request $request ) {
// Try from request body first (avoids conflict with proxy/HTTP basic auth headers)
Expand All @@ -224,6 +236,9 @@ private function extract_client_credentials( WP_REST_Request $request ) {

// Fall back to Basic authentication from Authorization header
$basic = $this->get_basic_auth_credentials( $request );
if ( false === $basic ) {
return $this->client_authentication_failed( $request );
}
if ( null !== $basic ) {
return $basic;
}
Expand All @@ -236,37 +251,83 @@ private function extract_client_credentials( WP_REST_Request $request ) {
}

/**
* Read client credentials from an HTTP Basic Authorization header.
* Check whether a request parameter is missing or empty.
*
* @param WP_REST_Request $request Request object.
* @return array|WP_Error|null Array with client_id and client_secret, error if the
* header is malformed, or null if there is no Basic header.
* @param string $param Parameter name.
*
* @return bool True if the parameter has no usable value.
*/
private function get_basic_auth_credentials( WP_REST_Request $request ) {
private function is_param_empty( WP_REST_Request $request, $param ) {
$value = $request->get_param( $param );

return null === $value || '' === $value;
}

/**
* Build the response for a failed client authentication.
*
* The reason is never given: telling "unknown client" apart from "wrong
* secret" would confirm a valid client ID and secret pair.
*
* @param WP_REST_Request $request Request object.
*
* @return WP_Error|WP_REST_Response Error, or a response carrying a Basic challenge.
*/
private function client_authentication_failed( WP_REST_Request $request ) {
$error = new WP_Error(
'oauth2.endpoints.token.invalid_client',
__( 'Client authentication failed.', 'oauth2' ),
[ 'status' => WP_Http::UNAUTHORIZED ]
);

if ( ! $this->has_basic_auth_header( $request ) ) {
return $error;
}

// RFC 6749 section 5.2: a client that authenticated with the
// Authorization header must get a matching challenge back.
$response = rest_convert_error_to_response( $error );
$response->header( 'WWW-Authenticate', 'Basic realm="OAuth2 token endpoint"' );

return $response;
}

/**
* Check whether the request carries an HTTP Basic Authorization header.
*
* @param WP_REST_Request $request Request object.
*
* @return bool True if a Basic header is present.
*/
private function has_basic_auth_header( WP_REST_Request $request ) {
$auth_header = $request->get_header( 'authorization' );

if ( empty( $auth_header ) || stripos( $auth_header, 'Basic ' ) !== 0 ) {
return ! empty( $auth_header ) && stripos( $auth_header, 'Basic ' ) === 0;
}

/**
* Read client credentials from an HTTP Basic Authorization header.
*
* @param WP_REST_Request $request Request object.
* @return array|false|null Array with client_id and client_secret, false if the
* header is malformed, or null if there is no Basic header.
*/
private function get_basic_auth_credentials( WP_REST_Request $request ) {
if ( ! $this->has_basic_auth_header( $request ) ) {
return null;
}

$encoded = substr( $auth_header, 6 );
$encoded = substr( $request->get_header( 'authorization' ), 6 );
$decoded = base64_decode( $encoded, true );

if ( false === $decoded ) {
return new WP_Error(
'oauth2.endpoints.token.invalid_request',
__( 'Invalid Authorization header.', 'oauth2' ),
[ 'status' => WP_Http::BAD_REQUEST ]
);
return false;
}

$parts = explode( ':', $decoded, 2 );
if ( count( $parts ) !== 2 ) {
return new WP_Error(
'oauth2.endpoints.token.invalid_request',
__( 'Invalid Authorization header format.', 'oauth2' ),
[ 'status' => WP_Http::BAD_REQUEST ]
);
return false;
}

// RFC 6749 section 2.3.1: both values are form-encoded before they go
Expand Down
27 changes: 27 additions & 0 deletions tests/test-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,33 @@ public function test_check_secret_false_for_wrong_secret() {
$this->assertFalse( $this->client->check_secret( 'wrongsecret' ) );
}

public function test_requires_secret_true_for_private_client() {
$client = $this->create_client( [ 'type' => 'private' ] );
$this->assertTrue( $client->requires_secret() );
}

public function test_requires_secret_false_for_public_client() {
$client = $this->create_client( [ 'type' => 'public' ] );
$this->assertFalse( $client->requires_secret() );
}

public function test_requires_secret_false_for_other_type() {
$this->assertFalse( $this->client->requires_secret() );
}

public function test_requires_secret_false_without_a_stored_type() {
delete_post_meta( $this->client->get_post_id(), Client::TYPE_KEY );
$this->assertFalse( $this->client->requires_secret() );
}

public function test_requires_secret_can_be_filtered() {
add_filter( 'oauth2.client.requires_secret', '__return_true' );
$requires = $this->client->requires_secret();
remove_filter( 'oauth2.client.requires_secret', '__return_true' );

$this->assertTrue( $requires );
}

public function test_update_changes_name() {
$updated = $this->client->update( [
'name' => 'Updated Name',
Expand Down
41 changes: 41 additions & 0 deletions tests/test-personalclient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php
/**
* Tests for the PersonalClient class.
*
* @package WP\OAuth2\Tests
*/

namespace WP\OAuth2\Tests;

require_once __DIR__ . '/class-test-case.php';

use WP\OAuth2\PersonalClient;

/**
* Test cases for the internal client backing personal access tokens.
*/
class Test_PersonalClient extends Test_Case {

/**
* @var PersonalClient
*/
protected $client;

public function set_up() {
parent::set_up();
$this->client = PersonalClient::get_instance();
}

public function test_requires_secret_is_false() {
$this->assertFalse( $this->client->requires_secret() );
}

public function test_check_secret_is_false_for_any_value() {
$this->assertFalse( $this->client->check_secret( '' ) );
$this->assertFalse( $this->client->check_secret( 'anything' ) );
}

public function test_client_credentials_grant_is_disabled() {
$this->assertFalse( $this->client->is_client_credentials_enabled() );
}
}
Loading
Loading