-
Notifications
You must be signed in to change notification settings - Fork 45
Require confidential clients to authenticate on the authorization_code grant #83
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
Open
roborourke
wants to merge
5
commits into
WP-API:main
Choose a base branch
from
humanmade:roborourke/Fix-get_type-private-auth-code-path-check
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
e1cfd57
Declare the client authentication contract on ClientInterface
roborourke ca0ba9b
Add Client::requires_secret() for confidential clients
roborourke 12bfb41
Authenticate confidential clients on the authorization_code grant
roborourke 5d60c35
Merge remote-tracking branch 'upstream/main' into roborourke/Fix-get_…
roborourke c766b49
Send a Basic challenge for every failed header authentication
roborourke File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ | |
| use WP_Http; | ||
| use WP\OAuth2; | ||
| use WP_REST_Request; | ||
| use WP_REST_Response; | ||
| /** | ||
| * Token endpoint handler. | ||
| */ | ||
|
|
@@ -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'] ) { | ||
|
|
@@ -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] ); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -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; | ||
| } | ||
| } | ||
|
|
@@ -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', | ||
|
|
@@ -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() ) { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
|
@@ -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; | ||
| } | ||
|
|
||
|
|
@@ -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 ); | ||
|
|
@@ -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) | ||
|
|
@@ -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; | ||
| } | ||
|
|
@@ -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 | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() ); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.