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

Added PKCE support #46

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,7 @@ _book
*.epub
*.mobi
*.pdf
.idea
.idea
.vscode
vendor
composer.lock
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ This plugin uses the OAuth 2 protocol to allow delegated authorization; that is,

This plugin only supports WordPress >= 4.8.

## Proof Key for Code Exchange
OAuth2 plugin supports PKCE as a protection against authorization code interception attack (relevant only for authorization code grant type). In order to use PKCE, on the initial authorization request, add two fields:
* _code_challenge_
* _code_challenge_method_ (optional).

Code verifier is a 43-128 length random string consisting of [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~". Code challenge is derived from the code verifier depending on the challenge method. Two types are supported, 's256' and 'plain'. Plain is just code_challenge = code_verifier. s256 method uses SHA256 to hash the code verifier and then do a base64 encoding of the resulting hash. e.g.

```code_verifier = 052edd3941bb8040ecac75d2359d7cd1abe2518911b
code_challenge = base64( sha256( code_verifier ) ) = MmNmZTJlNGZhYmNmYzQ3YTI4MmRhY2Q1NGEwZDUzZTFiZGFhNTNlODI4MGY3NjM0YWUwNjA1YjYzMmQwNDMxNQ==
code_challenge_method = s256
```

In the next step, when using the code received from the server to obtain an access token, code_verifier must be passed in as an additional field to the request, and it must be using the code_verifier value that was used to calculate the code_challenge in the initial request.

## CLI Commands

### PKCE

A custom WP CLI helper command to generate a random code verifier and a code challenge.

```wp oauth2 generate-code-challenge```

## Warning

Expand Down
43 changes: 23 additions & 20 deletions inc/admin/namespace.php
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ function validate_parameters( $params ) {
if ( ! empty( $params['callback'] ) ) {
$valid['callback'] = $params['callback'];
}
$valid['force-pkce'] = ( ! empty( $params['force-pkce'] ) ) ? 'true' : 'false';

return $valid;
}
Expand Down Expand Up @@ -201,29 +202,21 @@ function handle_edit_submit( Client $consumer = null ) {
return $messages;
}

$data = [
'name' => $params['name'],
'description' => $params['description'],
'meta' => [
'type' => $params['type'],
'callback' => $params['callback'],
'force-pkce' => isset( $params['force-pkce'] ) ? 'true' : 'false',
],
];

if ( empty( $consumer ) ) {
// Create the consumer
$data = [
'name' => $params['name'],
'description' => $params['description'],
'meta' => [
'type' => $params['type'],
'callback' => $params['callback'],
],
];

$consumer = $result = Client::create( $data );
} else {
// Update the existing consumer post
$data = [
'name' => $params['name'],
'description' => $params['description'],
'meta' => [
'type' => $params['type'],
'callback' => $params['callback'],
],
];

$result = $consumer->update( $data );
}

Expand Down Expand Up @@ -300,14 +293,15 @@ function render_edit_page() {
$data = [];

if ( empty( $consumer ) || ! empty( $form_data ) ) {
foreach ( [ 'name', 'description', 'callback', 'type' ] as $key ) {
foreach ( [ 'name', 'description', 'callback', 'type', 'force-pkce' ] as $key ) {
$data[ $key ] = empty( $form_data[ $key ] ) ? '' : $form_data[ $key ];
}
} else {
$data['name'] = $consumer->get_name();
$data['description'] = $consumer->get_description( true );
$data['type'] = $consumer->get_type();
$data['callback'] = $consumer->get_redirect_uris();
$data['force-pkce'] = $consumer->should_force_pkce() ? 'true' : 'false';

if ( is_array( $data['callback'] ) ) {
$data['callback'] = implode( ',', $data['callback'] );
Expand Down Expand Up @@ -404,7 +398,16 @@ function render_edit_page() {
</th>
<td>
<input type="text" class="regular-text" name="callback" id="oauth-callback" value="<?php echo esc_attr( $data['callback'] ) ?>"/>
<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>
<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">
<label for="force-pkce"><?php echo esc_html_x( 'Force PKCE?', 'field name', 'oauth2' ) ?></label>
</th>
<td>
<input type="checkbox" <?php checked( 'true', esc_attr( $data['force-pkce'] ) ) ?> class="regular-text" name="force-pkce" id="force-pkce" value="true"/>
<p class="description"><?php esc_html_e( 'Whether your OAuth2 server should force PKCE for authorisation requests. If PKCE is forced, code_challenge field must be present in the initial request when authorization code grant is used', 'oauth2' ) ?></p>
</td>
</tr>
</table>
Expand Down
16 changes: 14 additions & 2 deletions inc/class-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class Client {
const TYPE_KEY = '_oauth2_client_type';
const REDIRECT_URI_KEY = '_oauth2_redirect_uri';
const AUTH_CODE_KEY_PREFIX = '_oauth2_authcode_';
const FORCE_PKCE = '_oauth2_force-pkce';
const AUTH_CODE_LENGTH = 12;
const CLIENT_ID_LENGTH = 12;
const CLIENT_SECRET_LENGTH = 48;
Expand Down Expand Up @@ -124,6 +125,15 @@ public function get_redirect_uris() {
return (array) get_post_meta( $this->get_post_id(), static::REDIRECT_URI_KEY, true );
}

/**
* Whether the client requires PKCE
*
* @return Boolean
*/
public function should_force_pkce() {
return 'true' === get_post_meta( $this->get_post_id(), static::FORCE_PKCE, true );
}

/**
* Validate a callback URL.
*
Expand Down Expand Up @@ -229,8 +239,8 @@ public function check_redirect_uri( $uri ) {
*
* @return Authorization_Code|WP_Error
*/
public function generate_authorization_code( WP_User $user ) {
return Authorization_Code::create( $this, $user );
public function generate_authorization_code( WP_User $user, $data ) {
return Authorization_Code::create( $this, $user, $data );
}

/**
Expand Down Expand Up @@ -332,6 +342,7 @@ public static function create( $data ) {
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::FORCE_PKCE => $data['meta']['force-pkce'],
];

foreach ( $meta as $key => $value ) {
Expand Down Expand Up @@ -368,6 +379,7 @@ public function update( $data ) {
$meta = [
static::REDIRECT_URI_KEY => $data['meta']['callback'],
static::TYPE_KEY => $data['meta']['type'],
static::FORCE_PKCE => $data['meta']['force-pkce'],
];

foreach ( $meta as $key => $value ) {
Expand Down
7 changes: 6 additions & 1 deletion inc/endpoints/class-token.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ public function register_routes() {
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
],
'code_verifier' => [
'required' => false,
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
],
],
] );
}
Expand Down Expand Up @@ -71,7 +76,7 @@ public function exchange_token( WP_REST_Request $request ) {
return $auth_code;
}

$is_valid = $auth_code->validate();
$is_valid = $auth_code->validate( [ 'code_verifier' => $request['code_verifier'] ] );
if ( is_wp_error( $is_valid ) ) {
// Invalid request, but code itself exists, so we should delete
// (and silently ignore errors).
Expand Down
7 changes: 7 additions & 0 deletions inc/namespace.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use WP\OAuth2\Types\Type;
use WP_REST_Response;
use WP_CLI;

function bootstrap() {
// Core authentication hooks.
Expand All @@ -21,6 +22,12 @@ function bootstrap() {
// Admin-related.
add_action( 'init', __NAMESPACE__ . '\\rest_oauth2_load_authorize_page' );
add_action( 'admin_menu', __NAMESPACE__ . '\\Admin\\register' );

// WP-Cli
if ( class_exists( __NAMESPACE__ . '\\Utilities\\Command' ) ) {
WP_CLI::add_command( 'oauth2', __NAMESPACE__ . '\\Utilities\\Command' );
}

Admin\Profile\bootstrap();
}

Expand Down
56 changes: 53 additions & 3 deletions inc/tokens/class-authorization-code.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,48 @@ public function get_expiration() {
return (int) $value['expiration'];
}

/**
* Verify code_verifier for PKCE
*
* @param array $args Other request arguments to validate.
* @return Boolean|WP_error True if valid, error describing problem otherwise.
*/
protected function validate_code_verifier( $args ) {
$value = $this->get_value();

if ( empty( $value['code_challenge'] ) ) {
return true;
}

$code_verifier = $args['code_verifier'];
$is_valid = false;

switch ( strtolower( $value['code_challenge_method'] ) ) {
case 's256':
$encoded = base64_encode( hash( 'sha256', $code_verifier ) );
$is_valid = hash_equals( $encoded, $value['code_challenge'] );
break;
case 'plain':
$is_valid = hash_equals( $code_verifier, $value['code_challenge'] );
break;
default:
return new WP_Error(
'oauth2.tokens.authorization_code.validate_code_verifier.invalid_request',
__( 'Invalid challenge method.', 'oauth2' )
);
break;
}

if ( ! $is_valid ) {
return new WP_Error(
'oauth2.tokens.authorization_code.validate_code_verifier.invalid_grant',
__( 'Invalid code verifier.', 'oauth2' )
);
}

return true;
}

/**
* Validate the code for use.
*
Expand All @@ -129,6 +171,13 @@ public function validate( $args = [] ) {
);
}

$code_verifier = $this->validate_code_verifier( [
'code_verifier' => $args['code_verifier'],
] );
if ( is_wp_error( $code_verifier ) ) {
return $code_verifier;
}

return true;
}

Expand Down Expand Up @@ -180,16 +229,17 @@ public static function get_by_code( Client $client, $code ) {
*
* @param Client $client
* @param WP_User $user
* @param Array $data Containing data specific for this OAuth2 request, like redirect_uri and code_challenge
*
* @return Authorization_Code|WP_Error Authorization code instance, or error on failure.
*/
public static function create( Client $client, WP_User $user ) {
public static function create( Client $client, WP_User $user, $data ) {
Copy link
Member

Choose a reason for hiding this comment

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

$data should be documented here.

$code = wp_generate_password( static::KEY_LENGTH, false );
$meta_key = static::KEY_PREFIX . $code;
$data = [
$data = array_merge( [
'user' => (int) $user->ID,
'expiration' => time() + static::MAX_AGE,
];
], $data );
$result = add_post_meta( $client->get_post_id(), wp_slash( $meta_key ), wp_slash( $data ), true );
if ( ! $result ) {
return new WP_Error(
Expand Down
2 changes: 1 addition & 1 deletion inc/types/class-authorization-code.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ protected function handle_authorization_submission( $submit, Client $client, $da
case 'authorize':
// Generate authorization code and redirect back.
$user = wp_get_current_user();
$code = $client->generate_authorization_code( $user );
$code = $client->generate_authorization_code( $user, $data );
if ( is_wp_error( $code ) ) {
return $code;
}
Expand Down
Loading