diff --git a/.gitignore b/.gitignore
index 7b3dea9..d7d46e9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,4 +14,7 @@ _book
*.epub
*.mobi
*.pdf
-.idea
\ No newline at end of file
+.idea
+.vscode
+vendor
+composer.lock
diff --git a/README.md b/README.md
index 92000b9..7f51a46 100755
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/inc/admin/namespace.php b/inc/admin/namespace.php
index 10fc0a8..669aeec 100644
--- a/inc/admin/namespace.php
+++ b/inc/admin/namespace.php
@@ -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;
}
@@ -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 );
}
@@ -300,7 +293,7 @@ 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 {
@@ -308,6 +301,7 @@ function render_edit_page() {
$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'] );
@@ -404,7 +398,16 @@ function render_edit_page() {
-
+
+ |
+
+
+
+
+ |
+
+ class="regular-text" name="force-pkce" id="force-pkce" value="true"/>
+
|
diff --git a/inc/class-client.php b/inc/class-client.php
index ddba759..abe2361 100644
--- a/inc/class-client.php
+++ b/inc/class-client.php
@@ -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;
@@ -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.
*
@@ -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 );
}
/**
@@ -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 ) {
@@ -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 ) {
diff --git a/inc/endpoints/class-token.php b/inc/endpoints/class-token.php
index 6b24a82..0db2265 100644
--- a/inc/endpoints/class-token.php
+++ b/inc/endpoints/class-token.php
@@ -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',
+ ],
],
] );
}
@@ -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).
diff --git a/inc/namespace.php b/inc/namespace.php
index b46b129..d335ae6 100644
--- a/inc/namespace.php
+++ b/inc/namespace.php
@@ -4,6 +4,7 @@
use WP\OAuth2\Types\Type;
use WP_REST_Response;
+use WP_CLI;
function bootstrap() {
// Core authentication hooks.
@@ -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();
}
diff --git a/inc/tokens/class-authorization-code.php b/inc/tokens/class-authorization-code.php
index e5f212e..2cd1d56 100644
--- a/inc/tokens/class-authorization-code.php
+++ b/inc/tokens/class-authorization-code.php
@@ -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.
*
@@ -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;
}
@@ -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 ) {
$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(
diff --git a/inc/types/class-authorization-code.php b/inc/types/class-authorization-code.php
index cff7e86..1166ae0 100644
--- a/inc/types/class-authorization-code.php
+++ b/inc/types/class-authorization-code.php
@@ -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;
}
diff --git a/inc/types/class-base.php b/inc/types/class-base.php
index cde81a1..15c8eaf 100644
--- a/inc/types/class-base.php
+++ b/inc/types/class-base.php
@@ -25,7 +25,6 @@ abstract protected function handle_authorization_submission( $submit, Client $cl
* Handle authorisation page.
*/
public function handle_authorisation() {
-
if ( empty( $_GET['client_id'] ) ) {
return new WP_Error(
'oauth2.types.authorization_code.handle_authorisation.missing_client_id',
@@ -34,10 +33,10 @@ public function handle_authorisation() {
}
// Gather parameters.
- $client_id = wp_unslash( $_GET['client_id'] );
- $redirect_uri = isset( $_GET['redirect_uri'] ) ? wp_unslash( $_GET['redirect_uri'] ) : null;
- $scope = isset( $_GET['scope'] ) ? wp_unslash( $_GET['scope'] ) : null;
- $state = isset( $_GET['state'] ) ? wp_unslash( $_GET['state'] ) : null;
+ $client_id = wp_unslash( $_GET['client_id'] );
+ $redirect_uri = isset( $_GET['redirect_uri'] ) ? wp_unslash( $_GET['redirect_uri'] ) : null;
+ $scope = isset( $_GET['scope'] ) ? wp_unslash( $_GET['scope'] ) : null;
+ $state = isset( $_GET['state'] ) ? wp_unslash( $_GET['state'] ) : null;
$client = Client::get_by_id( $client_id );
if ( empty( $client ) ) {
@@ -51,6 +50,16 @@ public function handle_authorisation() {
);
}
+ if ( $client->should_force_pkce() || isset( $_GET['code_challenge'] ) ) {
+ $pkce_data = $this->handle_pkce( wp_unslash( $_GET ) );
+ if ( is_wp_error( $pkce_data ) ) {
+ return $pkce_data;
+ }
+
+ $code_challenge = $pkce_data['code_challenge'];
+ $code_challenge_method = $pkce_data['code_challenge_method'];
+ }
+
// Validate the redirection URI.
$redirect_uri = $this->validate_redirect_uri( $client, $redirect_uri );
if ( is_wp_error( $redirect_uri ) ) {
@@ -70,7 +79,7 @@ public function handle_authorisation() {
// Check nonce.
$nonce_action = $this->get_nonce_action( $client );
- if ( ! wp_verify_nonce( wp_unslash( $_POST['_wpnonce'] ), $none_action ) ) {
+ if ( ! wp_verify_nonce( wp_unslash( $_POST['_wpnonce'] ), $nonce_action ) ) {
return new WP_Error(
'oauth2.types.authorization_code.handle_authorisation.invalid_nonce',
__( 'Invalid nonce.', 'oauth2' )
@@ -93,7 +102,7 @@ public function handle_authorisation() {
$submit = wp_unslash( $_POST['wp-submit'] );
- $data = compact( 'redirect_uri', 'scope', 'state' );
+ $data = array_merge( compact( 'redirect_uri', 'scope', 'state' ), isset( $pkce_data ) ? $pkce_data : [] );
return $this->handle_authorization_submission( $submit, $client, $data );
}
@@ -152,4 +161,63 @@ protected function render_form( Client $client, WP_Error $errors = null ) {
protected function get_nonce_action( Client $client ) {
return sprintf( 'oauth2_authorize:%s', $client->get_id() );
}
+
+ /**
+ * Get and validate PKCE parameters from a request.
+ *
+ * @param Array $args Array with code_challenge (required) and code_challenge_method (optional)
+ *
+ * @return string[] code_challenge and code_challenge_method
+ */
+ protected function handle_pkce( $args ) {
+ $code_challenge = isset( $args['code_challenge'] ) ? $args['code_challenge'] : null;
+ $code_challenge_method = isset( $args['code_challenge_method'] ) ? $args['code_challenge_method'] : null;
+
+ if ( empty( $code_challenge ) ) {
+ return new WP_Error(
+ 'oauth2.types.authorization_code.handle_authorisation.code_challenge_empty',
+ __( 'Code challenge cannot be empty', 'oauth2' ), $client_id,
+ [
+ 'status' => WP_Http::BAD_REQUEST,
+ 'client_id' => $client_id,
+ ]
+ );
+ }
+
+ if ( strlen( $code_challenge ) < 43 || strlen( $code_challenge ) > 128 ) {
+ return new WP_Error(
+ 'oauth2.types.authorization_code.handle_authorisation.code_challenge_length',
+ __( 'Code challenge should be 43 or more characters in length and less or equal to 128.', 'oauth2' ), $client_id,
+ [
+ 'status' => WP_Http::BAD_REQUEST,
+ 'client_id' => $client_id,
+ ]
+ );
+ }
+
+ if ( 0 === preg_match( '/^[a-zA-Z 0-9\.\-\_\~]*$/', $code_challenge ) ) {
+ return new WP_Error(
+ 'oauth2.types.authorization_code.handle_authorisation.code_challenge',
+ __( 'Should only containz A-Z, a-z, 0-9, ., -, _, ~', 'oauth2' ), $client_id,
+ [
+ 'status' => WP_Http::BAD_REQUEST,
+ 'client_id' => $client_id,
+ ]
+ );
+ }
+
+ $code_challenge_method = empty( $code_challenge_method ) ? 'plain' : $code_challenge_method;
+ if ( ! in_array( strtolower( $code_challenge_method ), [ 'plain', 's256' ], true ) ) {
+ return new WP_Error(
+ 'oauth2.types.authorization_code.handle_pkce.wrong_challenge_method',
+ __( 'Challenge method must be S256 or plain', 'oauth2' ), $client_id,
+ [
+ 'status' => WP_Http::BAD_REQUEST,
+ 'client_id' => $client_id,
+ ]
+ );
+ }
+
+ return [ 'code_challenge' => $code_challenge, 'code_challenge_method' => $code_challenge_method ];
+ }
}
diff --git a/inc/utilities/class-command.php b/inc/utilities/class-command.php
new file mode 100644
index 0000000..4d5a7fe
--- /dev/null
+++ b/inc/utilities/class-command.php
@@ -0,0 +1,68 @@
+]
+ * : The string to be hashed.
+ *
+ *
+ * [--length=]
+ * : The length of the random seed string.
+ * ---
+ * default: 64
+ * ---
+ *
+ * ## EXAMPLES
+ *
+ * wp oauth2 generate-code-challenge --length=64
+ *
+ * @alias generate-code-challenge
+ */
+ function generate_code_challenge( $args, $assoc_args ) {
+ $length = empty( $assoc_args['length'] ) ? 64 : intval( $assoc_args['length'] );
+
+ if ( $length < 43 || $length > 128 ) {
+ WP_CLI::error( 'Length should be >= 43 and <= 128.' );
+ }
+
+ if ( ! empty( $args[0] ) ) {
+ $random_seed = $args[0];
+
+ if ( strlen( $random_seed ) < 43 || strlen( $random_seed ) > 128 ) {
+ WP_CLI::error( 'Length of the provided random seed should be >= 43 and <= 128.' );
+ }
+
+ WP_CLI::warning( "Using provided string {$random_seed} as a random seed. It is recommended to use this command without parameters, 64 characters long random key will be generated automatically." );
+ } else {
+ $is_strong_crypto = true;
+ $random_seed = \bin2hex( \openssl_random_pseudo_bytes( $length / 2 + $length % 2, $is_strong_crypto ) );
+ $random_seed = \substr( $random_seed, 0, $length );
+
+ if ( ! $is_strong_crypto ) {
+ WP_CLI::error( 'openssl_random_pseudo_bytes failed to generate a cryptographically strong random string.' );
+ }
+ }
+
+ $code_challenge = base64_encode( hash( 'sha256', $random_seed ) );
+
+ $items = [
+ [
+ 'code_verifier' => $random_seed,
+ 'code_challenge = base64( sha256( code_verifier ) )' => $code_challenge,
+ ],
+ ];
+
+ Utils\format_items( 'table', $items, [ 'code_verifier', 'code_challenge = base64( sha256( code_verifier ) )' ] );
+ }
+}
diff --git a/plugin.php b/plugin.php
index af7ced8..d47ce0d 100644
--- a/plugin.php
+++ b/plugin.php
@@ -28,4 +28,8 @@
require __DIR__ . '/inc/admin/namespace.php';
require __DIR__ . '/inc/admin/profile/namespace.php';
+if ( defined( 'WP_CLI' ) && WP_CLI ) {
+ require __DIR__ . '/inc/utilities/class-command.php';
+}
+
bootstrap();