Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions inc/endpoints/class-token.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,32 @@
use WP_Http;
use WP\OAuth2;
use WP_REST_Request;
use WP_REST_Response;

/**
* Token endpoint handler.
*/
class Token {
const ROUTE = '/oauth2/access_token';

/**
* RFC 6749 section 5.2 error codes for the errors this endpoint returns.
*/
const OAUTH_ERRORS = [
'rest_missing_callback_param' => 'invalid_request',
'rest_invalid_param' => 'invalid_request',
'oauth2.endpoints.token.invalid_request' => 'invalid_request',
'oauth2.endpoints.token.exchange_token.invalid_client' => 'invalid_client',
'oauth2.endpoints.token.invalid_client' => 'invalid_client',
'oauth2.client.check_authorization_code.invalid_code' => 'invalid_grant',
'oauth2.tokens.authorization_code.validate.expired' => 'invalid_grant',
'oauth2.tokens.authorization_code.get_user.invalid_data' => 'invalid_grant',
];

public function register_hooks() {
add_filter( 'rest_request_after_callbacks', [ $this, 'format_error_response' ], 10, 3 );
Comment thread
Copilot marked this conversation as resolved.
}

public function register_routes() {
register_rest_route(
'oauth2',
Expand Down Expand Up @@ -49,6 +71,78 @@ public function register_routes() {
);
}

/**
* Add the RFC 6749 section 5.2 error fields to a token endpoint error.
*
* The WordPress `code`, `message` and `data` fields are kept alongside
* `error` and `error_description`.
*
* @param WP_REST_Response|WP_Error|mixed $response Result of the request.
* @param array $handler Route handler.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|mixed Formatted error response, or the original response.
*/
public function format_error_response( $response, $handler, $request ) {
if ( ! is_wp_error( $response ) || ! $request instanceof WP_REST_Request || static::ROUTE !== $request->get_route() ) {
return $response;
}

$error = $this->get_oauth_error( $response );
$error_data = $response->get_error_data();
$status = is_array( $error_data ) && isset( $error_data['status'] ) ? (int) $error_data['status'] : WP_Http::INTERNAL_SERVER_ERROR;

if ( 'server_error' === $error ) {
$status = WP_Http::INTERNAL_SERVER_ERROR;
} elseif ( ! ( 'invalid_client' === $error && WP_Http::UNAUTHORIZED === $status ) ) {
$status = WP_Http::BAD_REQUEST;
}

$formatted = rest_convert_error_to_response( $response );
$data = $formatted->get_data();

$data['error'] = $error;
$data['error_description'] = $response->get_error_message();

$formatted->set_data( $data );
$formatted->set_status( $status );

if ( WP_Http::UNAUTHORIZED === $status ) {
$formatted->header( 'WWW-Authenticate', 'Basic realm="oauth2"' );
}

return $formatted;
}

/**
* Get the RFC 6749 section 5.2 error code for an error.
*
* An `error` key in the error data wins over the built-in map. Unknown
* errors become `invalid_request` if they carry a 4xx status, or
* `server_error` otherwise.
*
* @param WP_Error $error Error returned by the endpoint.
* @return string OAuth error code.
*/
protected function get_oauth_error( WP_Error $error ) {
$data = $error->get_error_data();
if ( is_array( $data ) && ! empty( $data['error'] ) && is_string( $data['error'] ) ) {
return $data['error'];
}

$code = $error->get_error_code();
if ( 'rest_invalid_param' === $code && isset( $data['params']['grant_type'] ) ) {
return 'unsupported_grant_type';
}

if ( isset( static::OAUTH_ERRORS[ $code ] ) ) {
return static::OAUTH_ERRORS[ $code ];
}

$status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : WP_Http::INTERNAL_SERVER_ERROR;

return $status >= 400 && $status < 500 ? 'invalid_request' : 'server_error';
}

/**
* Validates the given grant type.
*
Expand Down
1 change: 1 addition & 0 deletions inc/endpoints/namespace.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
function register() {
$token_endpoint = new Token();
$token_endpoint->register_routes();
$token_endpoint->register_hooks();

// Register convenience URL.
register_rest_route(
Expand Down
172 changes: 172 additions & 0 deletions tests/test-token-endpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
use WP\OAuth2\Endpoints\Token;
use WP\OAuth2\Tokens\Access_Token;
use WP\OAuth2\Tokens\Authorization_Code;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

/**
Expand Down Expand Up @@ -415,4 +417,174 @@ public function test_validate_grant_type_rejects_unknown() {
$this->assertFalse( $handler->validate_grant_type( 'implicit' ) );
$this->assertFalse( $handler->validate_grant_type( '' ) );
}

// -------------------------------------------------------------------------
// RFC 6749 section 5.2 error responses
// -------------------------------------------------------------------------

/**
* Dispatch a token request and return the response.
*
* @param array $params Request parameters.
* @return WP_REST_Response
*/
protected function request_token( array $params ) {
$request = new WP_REST_Request( 'POST', '/oauth2/access_token' );
foreach ( $params as $key => $value ) {
$request->set_param( $key, $value );
}

return $this->server->dispatch( $request );
}

/**
* Assert a response is an RFC 6749 section 5.2 error that keeps the WordPress fields.
*
* @param WP_REST_Response $response Response to check.
* @param string $error Expected OAuth error code.
* @param int $status Expected HTTP status.
*/
protected function assertOAuthError( WP_REST_Response $response, $error, $status ) {
$data = $response->get_data();

$this->assertSame( $status, $response->get_status() );
$this->assertSame( $error, $data['error'] );
$this->assertSame( $data['message'], $data['error_description'] );
$this->assertNotEmpty( $data['code'] );
}

public function test_missing_parameter_is_invalid_request() {
$response = $this->request_token( [
'grant_type' => 'authorization_code',
'code' => 'somecode',
] );

$this->assertOAuthError( $response, 'invalid_request', 400 );
$this->assertSame( 'rest_missing_callback_param', $response->get_data()['code'] );
}

public function test_missing_grant_type_is_invalid_request() {
$this->assertOAuthError( $this->request_token( [] ), 'invalid_request', 400 );
}

public function test_unknown_grant_type_is_unsupported_grant_type() {
$response = $this->request_token( [ 'grant_type' => 'password' ] );

$this->assertOAuthError( $response, 'unsupported_grant_type', 400 );
}

public function test_unknown_client_is_invalid_client() {
$response = $this->request_token( [
'grant_type' => 'authorization_code',
'client_id' => 'nonexistent-client',
'code' => 'anycode',
] );

$this->assertOAuthError( $response, 'invalid_client', 400 );
}

public function test_unknown_code_is_invalid_grant() {
$response = $this->request_token( [
'grant_type' => 'authorization_code',
'client_id' => $this->client->get_id(),
'code' => 'invalid-code-xyz',
] );

$this->assertOAuthError( $response, 'invalid_grant', 400 );
}

public function test_expired_code_is_invalid_grant() {
$user = $this->factory->user->create_and_get();
$code = Authorization_Code::create( $this->client, $user );

$meta_key = Authorization_Code::KEY_PREFIX . $code->get_code();
$value = get_post_meta( $this->client->get_post_id(), $meta_key, true );
$value['expiration'] = time() - 1;
update_post_meta( $this->client->get_post_id(), $meta_key, $value );

$response = $this->request_token( [
'grant_type' => 'authorization_code',
'client_id' => $this->client->get_id(),
'code' => $code->get_code(),
] );

$this->assertOAuthError( $response, 'invalid_grant', 400 );
}

public function test_failed_client_authentication_is_invalid_client_with_challenge() {
$response = $this->request_token( [
'grant_type' => 'client_credentials',
'client_id' => 'nonexistent',
'client_secret' => 'wrong',
] );

$this->assertOAuthError( $response, 'invalid_client', 401 );
$this->assertSame( 'Basic realm="oauth2"', $response->get_headers()['WWW-Authenticate'] );
}

public function test_missing_client_credentials_is_invalid_request() {
$response = $this->request_token( [ 'grant_type' => 'client_credentials' ] );

$this->assertOAuthError( $response, 'invalid_request', 400 );
}

public function test_error_data_can_set_the_oauth_error() {
$handler = new Token();
$request = new WP_REST_Request( 'POST', '/oauth2/access_token' );
$response = $handler->format_error_response(
new WP_Error( 'custom', 'Scope not allowed.', [ 'error' => 'invalid_scope' ] ),
[],
$request
);

$this->assertOAuthError( $response, 'invalid_scope', 400 );
}

public function test_unknown_error_is_server_error() {
$handler = new Token();
$request = new WP_REST_Request( 'POST', '/oauth2/access_token' );
$response = $handler->format_error_response( new WP_Error( 'custom', 'Broken.' ), [], $request );

$this->assertOAuthError( $response, 'server_error', 500 );
}

public function test_unknown_client_error_is_invalid_request() {
$handler = new Token();
$request = new WP_REST_Request( 'POST', '/oauth2/access_token' );
$response = $handler->format_error_response( new WP_Error( 'custom', 'Bad.', [ 'status' => 401 ] ), [], $request );

$this->assertOAuthError( $response, 'invalid_request', 400 );
}

public function test_server_error_is_always_500() {
$handler = new Token();
$request = new WP_REST_Request( 'POST', '/oauth2/access_token' );
$response = $handler->format_error_response(
new WP_Error( 'custom', 'Broken.', [ 'status' => 400, 'error' => 'server_error' ] ),
[],
$request
);

$this->assertOAuthError( $response, 'server_error', 500 );
}

public function test_errors_on_other_routes_are_left_alone() {
$handler = new Token();
$error = new WP_Error( 'custom', 'Broken.' );

$this->assertSame( $error, $handler->format_error_response( $error, [], new WP_REST_Request( 'GET', '/wp/v2/posts' ) ) );
}

public function test_successful_response_has_no_error_fields() {
$user = $this->factory->user->create_and_get();
$code = Authorization_Code::create( $this->client, $user );
$response = $this->request_token( [
'grant_type' => 'authorization_code',
'client_id' => $this->client->get_id(),
'code' => $code->get_code(),
] );

$this->assertSame( 200, $response->get_status() );
$this->assertArrayNotHasKey( 'error', $response->get_data() );
}
}
4 changes: 2 additions & 2 deletions tests/test-www-authenticate.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ public function test_challenge_is_not_added_to_a_forbidden_response() {
* The token endpoint is the authorization server, not a resource it
* protects, so it must not point clients back at resource metadata.
*/
public function test_challenge_is_not_added_to_the_token_endpoint() {
public function test_bearer_challenge_is_not_added_to_the_token_endpoint() {
$request = new WP_REST_Request( 'POST', '/oauth2/access_token' );
$request->set_param( 'grant_type', 'client_credentials' );
$request->set_param( 'client_id', 'nonexistent' );
Expand All @@ -123,7 +123,7 @@ public function test_challenge_is_not_added_to_the_token_endpoint() {
$response = $this->dispatch( $request );

$this->assertEquals( 401, $response->get_status() );
$this->assertNull( $this->get_challenge( $response ) );
$this->assertEquals( 'Basic realm="oauth2"', $this->get_challenge( $response ) );
}

public function test_an_existing_challenge_is_not_overwritten() {
Expand Down
Loading