Skip to content
49 changes: 37 additions & 12 deletions inc/admin/namespace.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,13 @@ function validate_parameters( $params ) {
}
$valid['type'] = wp_kses_post( $params['type'] );

if ( empty( $params['callback'] ) ) {
$valid['client_credentials_enabled'] = ! empty( $params['client_credentials_enabled'] );

// Callback is required unless this client only uses client_credentials.
if ( empty( $params['callback'] ) && ! $valid['client_credentials_enabled'] ) {
return new WP_Error( 'rest_oauth2_missing_callback', esc_html__( 'Client callback is required and must be a valid URL.', 'oauth2' ) );
}
if ( ! empty( $params['callback'] ) ) {
$valid['callback'] = $params['callback'];
}
$valid['callback'] = empty( $params['callback'] ) ? '' : $params['callback'];

return $valid;
}
Expand Down Expand Up @@ -215,8 +216,9 @@ function handle_edit_submit( Client $consumer = null ) {
'name' => $params['name'],
'description' => $params['description'],
'meta' => [
'type' => $params['type'],
'callback' => $params['callback'],
'type' => $params['type'],
'callback' => $params['callback'],
'client_credentials_enabled' => $params['client_credentials_enabled'],
],
];

Expand All @@ -228,8 +230,9 @@ function handle_edit_submit( Client $consumer = null ) {
'name' => $params['name'],
'description' => $params['description'],
'meta' => [
'type' => $params['type'],
'callback' => $params['callback'],
'type' => $params['type'],
'callback' => $params['callback'],
'client_credentials_enabled' => $params['client_credentials_enabled'],
],
];

Expand Down Expand Up @@ -322,11 +325,13 @@ function render_edit_page() {
foreach ( [ 'name', 'description', 'callback', 'type' ] as $key ) {
$data[ $key ] = empty( $form_data[ $key ] ) ? '' : $form_data[ $key ];
}
$data['client_credentials_enabled'] = ! empty( $form_data['client_credentials_enabled'] );
} else {
$data['name'] = $consumer->get_name();
$data['description'] = $consumer->get_description( true );
$data['type'] = $consumer->get_type();
$data['callback'] = $consumer->get_redirect_uris();
$data['name'] = $consumer->get_name();
$data['description'] = $consumer->get_description( true );
$data['type'] = $consumer->get_type();
$data['callback'] = $consumer->get_redirect_uris();
$data['client_credentials_enabled'] = $consumer->is_client_credentials_enabled();

if ( is_array( $data['callback'] ) ) {
$data['callback'] = implode( ',', $data['callback'] );
Expand Down Expand Up @@ -432,6 +437,26 @@ function render_edit_page() {
<p class="description"><?php esc_html_e( "Your application's callback URI or a list of comma separated URIs. The callback passed with the request token must match the scheme, host, port, and path of this URL.", 'oauth2' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<?php echo esc_html_x( 'Client Credentials Grant', 'field name', 'oauth2' ); ?>
</th>
<td>
<label for="oauth-client-credentials-enabled">
<input
type="checkbox"
name="client_credentials_enabled"
id="oauth-client-credentials-enabled"
value="1"
<?php checked( ! empty( $data['client_credentials_enabled'] ) ); ?>
/>
<?php esc_html_e( 'Allow this application to obtain tokens using the client_credentials grant.', 'oauth2' ); ?>
</label>
<p class="description">
<?php esc_html_e( 'When enabled, this application can authenticate without a user using its client ID and secret. Use for machine-to-machine integrations only. Tokens issued this way are not associated with a WordPress user.', 'oauth2' ); ?>
</p>
</td>
</tr>
</table>

<?php
Expand Down
29 changes: 27 additions & 2 deletions inc/authentication/namespace.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ function get_token_from_request() {
function attempt_authentication( $user = null ) {
// Lock against infinite loops when querying the token itself.
static $is_querying_token = false;
global $oauth2_error;
$oauth2_error = null;
global $oauth2_error, $oauth2_client_credentials;
$oauth2_error = null;
$oauth2_client_credentials = null;

if ( ! empty( $user ) || $is_querying_token ) {
return $user;
Expand Down Expand Up @@ -136,6 +137,30 @@ function attempt_authentication( $user = null ) {
return $user;
}

// Check if this is a client credentials token (no user)
if ( $token->is_client_token() ) {
if ( ! $client->is_client_credentials_enabled() ) {
$oauth2_error = new WP_Error(
'oauth2.authentication.client_credentials_disabled',
__( 'Client credentials authentication is not enabled for this client.', 'oauth2' ),
[
'status' => \WP_Http::FORBIDDEN,
]
);
return $user;
}

// Set global variable for client credentials authentication
$oauth2_client_credentials = [
'authenticated' => true,
'client_id' => $client->get_id(),
'client' => $client,
'token' => $token,
];
// Return 0 to indicate no user but authentication is valid
return 0;
}

// Token found, authenticate as the user.
return $token->get_user_id();
}
Expand Down
51 changes: 37 additions & 14 deletions inc/class-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,16 @@
use WP_User;

class Client implements ClientInterface {
const POST_TYPE = 'oauth2_client';
const CLIENT_SECRET_KEY = '_oauth2_client_secret';
const TYPE_KEY = '_oauth2_client_type';
const REDIRECT_URI_KEY = '_oauth2_redirect_uri';
const AUTH_CODE_KEY_PREFIX = '_oauth2_authcode_';
const AUTH_CODE_LENGTH = 12;
const CLIENT_ID_LENGTH = 12;
const CLIENT_SECRET_LENGTH = 48;
const AUTH_CODE_AGE = 600; // 10 * MINUTE_IN_SECONDS
const POST_TYPE = 'oauth2_client';
const CLIENT_SECRET_KEY = '_oauth2_client_secret';
const TYPE_KEY = '_oauth2_client_type';
const REDIRECT_URI_KEY = '_oauth2_redirect_uri';
const CLIENT_CREDENTIALS_ENABLED_KEY = '_oauth2_client_credentials_enabled';
const AUTH_CODE_KEY_PREFIX = '_oauth2_authcode_';
const AUTH_CODE_LENGTH = 12;
const CLIENT_ID_LENGTH = 12;
const CLIENT_SECRET_LENGTH = 48;
const AUTH_CODE_AGE = 600; // 10 * MINUTE_IN_SECONDS

/**
* @var WP_Post
Expand Down Expand Up @@ -121,6 +122,26 @@ public function get_secret() {
return get_post_meta( $this->get_post_id(), static::CLIENT_SECRET_KEY, true );
}

/**
* 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 ) {
return hash_equals( $this->get_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() {
return (bool) get_post_meta( $this->get_post_id(), static::CLIENT_CREDENTIALS_ENABLED_KEY, true );
}

/**
* Get registered URI for the client.
*
Expand Down Expand Up @@ -336,9 +357,10 @@ public static function create( $data ) {

// Generate ID and secret.
$meta = [
static::REDIRECT_URI_KEY => $data['meta']['callback'],
static::TYPE_KEY => $data['meta']['type'],
static::CLIENT_SECRET_KEY => wp_generate_password( static::CLIENT_SECRET_LENGTH, false ),
static::REDIRECT_URI_KEY => $data['meta']['callback'],
static::TYPE_KEY => $data['meta']['type'],
static::CLIENT_SECRET_KEY => wp_generate_password( static::CLIENT_SECRET_LENGTH, false ),
static::CLIENT_CREDENTIALS_ENABLED_KEY => ! empty( $data['meta']['client_credentials_enabled'] ) ? '1' : '',
];

foreach ( $meta as $key => $value ) {
Expand Down Expand Up @@ -373,8 +395,9 @@ public function update( $data ) {
}

$meta = [
static::REDIRECT_URI_KEY => $data['meta']['callback'],
static::TYPE_KEY => $data['meta']['type'],
static::REDIRECT_URI_KEY => $data['meta']['callback'],
static::TYPE_KEY => $data['meta']['type'],
static::CLIENT_CREDENTIALS_ENABLED_KEY => ! empty( $data['meta']['client_credentials_enabled'] ) ? '1' : '',
];

foreach ( $meta as $key => $value ) {
Expand Down
128 changes: 124 additions & 4 deletions inc/endpoints/class-token.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
use WP_Http;
use WP\OAuth2;
use WP_REST_Request;

/**
* Token endpoint handler.
*/
Expand All @@ -30,12 +29,17 @@ public function register_routes() {
'validate_callback' => [ $this, 'validate_grant_type' ],
],
'client_id' => [
'required' => true,
'required' => false,
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
],
'code' => [
'required' => true,
'required' => false,
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
],
'client_secret' => [
'required' => false,
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
],
Expand All @@ -52,7 +56,7 @@ public function register_routes() {
* @return bool Whether or not the grant type is valid.
*/
public function validate_grant_type( $type ) {
return 'authorization_code' === $type;
return in_array( $type, [ 'authorization_code', 'client_credentials' ], true );
}

/**
Expand All @@ -63,6 +67,32 @@ public function validate_grant_type( $type ) {
* @return array|WP_Error Token data on success, or error on failure.
*/
public function exchange_token( WP_REST_Request $request ) {
if ( $request['grant_type'] === 'client_credentials' ) {
return $this->handle_client_credentials( $request );
}

// The authorization_code grant requires `client_id` and `code`.
// These are declared optional at the schema level so they don't
// apply to client_credentials, so validate presence here. The error
// 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 ) === '' ) {
$missing[] = $required_param;
}
}
if ( ! empty( $missing ) ) {
return new WP_Error(
'rest_missing_callback_param',
/* translators: %s: comma-separated list of missing parameter names. */
sprintf( __( 'Missing parameter(s): %s', 'oauth2' ), implode( ', ', $missing ) ),
[
'status' => WP_Http::BAD_REQUEST,
'params' => $missing,
]
);
}

$client = OAuth2\get_client( $request['client_id'] );
if ( empty( $client ) ) {
return new WP_Error(
Expand Down Expand Up @@ -112,4 +142,94 @@ public function exchange_token( WP_REST_Request $request ) {
];
return $data;
}

/**
* Handle client credentials grant type.
*
* @param WP_REST_Request $request Request object.
* @return array|WP_Error 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 ) ) {
return $credentials;
}

list( $client_id, $client_secret ) = $credentials;

// Collapse "client not found", "wrong secret", and "grant disabled"
// into a single failure path. Distinguishing them in the response
// would let an attacker confirm a valid client_id/secret pair even
// when the grant happens to be disabled.
$client = OAuth2\get_client( $client_id );
$creds_ok = $client && $client->check_secret( $client_secret );
$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 ]
);
}

$token = OAuth2\Tokens\Access_Token::create_for_client( $client );
if ( is_wp_error( $token ) ) {
return $token;
}

return [
'access_token' => $token->get_key(),
'token_type' => 'bearer',
];
}

/**
* 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.
*/
private function extract_client_credentials( WP_REST_Request $request ) {
// Try from request body first (avoids conflict with proxy/HTTP basic auth headers)
$client_id = $request->get_param( 'client_id' );
$client_secret = $request->get_param( 'client_secret' );

if ( ! empty( $client_id ) && ! empty( $client_secret ) ) {
return [ $client_id, $client_secret ];
}

// Fall back to Basic authentication from Authorization header
$auth_header = $request->get_header( 'authorization' );

if ( ! empty( $auth_header ) && stripos( $auth_header, 'Basic ' ) === 0 ) {
$encoded = substr( $auth_header, 6 );
$decoded = base64_decode( $encoded, true );

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

$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 [ trim( $parts[0] ), trim( $parts[1] ) ];
}

return new WP_Error(
'oauth2.endpoints.token.invalid_request',
__( 'Client credentials not provided.', 'oauth2' ),
[ 'status' => WP_Http::BAD_REQUEST ]
);
}
}
Loading