Skip to content
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
1 change: 1 addition & 0 deletions .phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,6 @@
<exclude name="WordPress.DateTime.RestrictedFunctions.date_date" />
<exclude name="WordPress.NamingConventions.ValidHookName.UseUnderscores" />
<exclude name="WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode" />
<exclude name="WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode" />
</rule>
</ruleset>
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,54 @@ 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 (PKCE)

The plugin supports PKCE ([RFC 7636](https://tools.ietf.org/html/rfc7636)) for
the `authorization_code` grant, as protection against authorization code
interception. To use it, add two extra parameters to the initial
authorization request:

* `code_challenge` (required)
* `code_challenge_method` (optional, defaults to `plain`; use `S256`)

`S256` derives the challenge from a code verifier by SHA-256 hashing it, then
base64url-encoding the digest with no padding. For example:

```
code_verifier = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
code_challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
```

This is **not** the same as `base64_encode( hash( 'sha256', $verifier ) )` —
that encodes the hex digest with standard base64, which is a different, wrong
value that RFC 7636's own worked example above will catch.

When exchanging the code for a token, pass the original `code_verifier` as an
extra parameter to the token endpoint. The server derives the challenge from
it the same way and checks it matches the one supplied at authorization time.

A client can be marked to require PKCE from its edit screen under
**Users → Applications**. When required, only `S256` satisfies the
requirement — `plain` remains an accepted method generally, but does not
count as PKCE having been used, since it offers no protection against a
malicious app on the same device reading the authorization request.

Filters: `oauth2.pkce.supported_methods` (accepted `code_challenge_method`
values, default `S256` and `plain`), `oauth2.pkce.required_methods` (methods
that satisfy "PKCE required", default `S256` only), and `oauth2.pkce.required`
(override whether PKCE is required for a given client).

## CLI Commands

### PKCE

Generate a random code verifier and its matching code challenge, for manually
testing a PKCE flow:

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

## Contributors Welcome!

This plugin works and is in use in several production environments, but the user experience and documentation could be substantially improved. We welcome input and contributions to make this tool better!
Expand Down
32 changes: 32 additions & 0 deletions inc/admin/namespace.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ function validate_parameters( $params ) {
$valid['type'] = wp_kses_post( $params['type'] );

$valid['client_credentials_enabled'] = ! empty( $params['client_credentials_enabled'] );
$valid['pkce_required'] = ! empty( $params['pkce_required'] );

// Callback is required unless this client only uses client_credentials.
if ( empty( $params['callback'] ) && ! $valid['client_credentials_enabled'] ) {
Expand Down Expand Up @@ -219,6 +220,7 @@ function handle_edit_submit( Client $consumer = null ) {
'type' => $params['type'],
'callback' => $params['callback'],
'client_credentials_enabled' => $params['client_credentials_enabled'],
'pkce_required' => $params['pkce_required'],
],
];

Expand All @@ -233,6 +235,7 @@ function handle_edit_submit( Client $consumer = null ) {
'type' => $params['type'],
'callback' => $params['callback'],
'client_credentials_enabled' => $params['client_credentials_enabled'],
'pkce_required' => $params['pkce_required'],
],
];

Expand Down Expand Up @@ -326,12 +329,21 @@ function render_edit_page() {
$data[ $key ] = empty( $form_data[ $key ] ) ? '' : $form_data[ $key ];
}
$data['client_credentials_enabled'] = ! empty( $form_data['client_credentials_enabled'] );

if ( empty( $consumer ) && empty( $form_data ) ) {
// A genuinely fresh "Add Application" page, not a failed submission
// redisplay: default new clients to requiring PKCE.
$data['pkce_required'] = true;
} else {
$data['pkce_required'] = ! empty( $form_data['pkce_required'] );
}
} else {
$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();
$data['pkce_required'] = $consumer->is_pkce_required();

if ( is_array( $data['callback'] ) ) {
$data['callback'] = implode( ',', $data['callback'] );
Expand Down Expand Up @@ -457,6 +469,26 @@ function render_edit_page() {
</p>
</td>
</tr>
<tr>
<th scope="row">
<?php echo esc_html_x( 'Require PKCE', 'field name', 'oauth2' ); ?>
</th>
<td>
<label for="oauth-pkce-required">
<input
type="checkbox"
name="pkce_required"
id="oauth-pkce-required"
value="1"
<?php checked( ! empty( $data['pkce_required'] ) ); ?>
/>
<?php esc_html_e( 'Require this application to use PKCE.', 'oauth2' ); ?>
</label>
<p class="description">
<?php esc_html_e( 'Recommended for public clients such as single-page apps, desktop apps, and mobile apps, which cannot keep a client secret confidential.', 'oauth2' ); ?>
</p>
</td>
</tr>
</table>

<?php
Expand Down
51 changes: 43 additions & 8 deletions inc/class-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class Client implements ClientInterface {
const TYPE_KEY = '_oauth2_client_type';
const REDIRECT_URI_KEY = '_oauth2_redirect_uri';
const CLIENT_CREDENTIALS_ENABLED_KEY = '_oauth2_client_credentials_enabled';
const PKCE_REQUIRED_KEY = '_oauth2_pkce_required';
const AUTH_CODE_KEY_PREFIX = '_oauth2_authcode_';
const AUTH_CODE_LENGTH = 12;
const CLIENT_ID_LENGTH = 12;
Expand Down Expand Up @@ -142,6 +143,23 @@ public function is_client_credentials_enabled() {
return (bool) get_post_meta( $this->get_post_id(), static::CLIENT_CREDENTIALS_ENABLED_KEY, true );
}

/**
* Check whether this client requires PKCE for the authorization_code grant.
*
* @return bool True if PKCE is required, false otherwise.
*/
public function is_pkce_required() {
$required = (bool) get_post_meta( $this->get_post_id(), static::PKCE_REQUIRED_KEY, true );

/**
* Filter whether PKCE is required for a client.
*
* @param bool $required True if PKCE is required for this client.
* @param Client $client Client being checked.
*/
return apply_filters( 'oauth2.pkce.required', $required, $this );
}

/**
* Get registered URI for the client.
*
Expand Down Expand Up @@ -249,11 +267,13 @@ public function check_redirect_uri( $uri ) {

/**
* @param WP_User $user
* @param array $data Optional extra data for the code, e.g. PKCE `code_challenge`
* and `code_challenge_method`. See Authorization_Code::create().
*
* @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, array $data = [] ) {
return Authorization_Code::create( $this, $user, $data );
}

/**
Expand Down Expand Up @@ -361,6 +381,7 @@ public static function create( $data ) {
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' : '',
static::PKCE_REQUIRED_KEY => ! empty( $data['meta']['pkce_required'] ) ? '1' : '',
];

foreach ( $meta as $key => $value ) {
Expand Down Expand Up @@ -394,14 +415,28 @@ public function update( $data ) {
return $post_id;
}

$meta = [
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' : '',
// Map of meta key => data key. Built this way, rather than as a fixed
// array of values, so that a caller omitting a key leaves the existing
// meta value untouched instead of silently resetting it to empty/false.
$fields = [
static::REDIRECT_URI_KEY => 'callback',
static::TYPE_KEY => 'type',
static::CLIENT_CREDENTIALS_ENABLED_KEY => 'client_credentials_enabled',
static::PKCE_REQUIRED_KEY => 'pkce_required',
];
$boolean_fields = [ static::CLIENT_CREDENTIALS_ENABLED_KEY, static::PKCE_REQUIRED_KEY ];

foreach ( $meta as $key => $value ) {
update_post_meta( $post_id, wp_slash( $key ), wp_slash( $value ) );
foreach ( $fields as $meta_key => $data_key ) {
if ( ! array_key_exists( $data_key, $data['meta'] ) ) {
continue;
}

$value = $data['meta'][ $data_key ];
if ( in_array( $meta_key, $boolean_fields, true ) ) {
$value = ! empty( $value ) ? '1' : '';
}

update_post_meta( $post_id, wp_slash( $meta_key ), wp_slash( $value ) );
}

$post = get_post( $post_id );
Expand Down
155 changes: 155 additions & 0 deletions inc/class-pkce.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php
/**
*
* @package WordPress
* @subpackage JSON API
*/

namespace WP\OAuth2;

/**
* Proof Key for Code Exchange (PKCE) helpers, per RFC 7636.
*
* Kept as a single set of static methods so the transform is written once,
* rather than duplicated between the authorisation endpoint, the token
* endpoint, and the WP-CLI helper command.
*/
class PKCE {
const METHOD_S256 = 'S256';
const METHOD_PLAIN = 'plain';

const VERIFIER_MIN_LENGTH = 43;
const VERIFIER_MAX_LENGTH = 128;

/**
* Get the challenge methods this site supports.
*
* @return string[] Supported `code_challenge_method` values.
*/
public static function supported_methods() {
/**
* Filter the PKCE code challenge methods this site accepts.
*
* Comparisons against these values are case-sensitive, per RFC 7636
* section 4.3.
*
* @param string[] $methods Supported `code_challenge_method` values.
*/
return apply_filters( 'oauth2.pkce.supported_methods', [ static::METHOD_S256, static::METHOD_PLAIN ] );
}

/**
* Derive a code challenge from a verifier, for a given transform method.
*
* @param string $verifier Code verifier.
* @param string $method Challenge method, `S256` or `plain`. Case-sensitive.
*
* @return string|null Derived challenge, or null if the method is not recognised.
*/
public static function derive_challenge( $verifier, $method ) {
if ( ! is_string( $verifier ) ) {
return null;
}

switch ( $method ) {
case static::METHOD_S256:
return rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode

case static::METHOD_PLAIN:
return $verifier;

default:
return null;
}
}

/**
* Verify a code verifier against a stored code challenge.
*
* Fails closed: an unsupported method, or a non-string input, returns
* false rather than raising a warning or a TypeError from hash_equals().
*
* @param string $verifier Code verifier supplied at the token endpoint.
* @param string $challenge Code challenge stored at authorization time.
* @param string $method Challenge method the challenge was derived with.
*
* @return bool Whether the verifier produces the given challenge.
*/
public static function verify( $verifier, $challenge, $method ) {
if ( ! is_string( $verifier ) || ! is_string( $challenge ) ) {
return false;
}

$derived = static::derive_challenge( $verifier, $method );
if ( null === $derived ) {
return false;
}

return hash_equals( $challenge, $derived );
}

/**
* Check whether a string is a valid PKCE code verifier.
*
* Per RFC 7636 section 4.1: 43-128 characters from the unreserved URI
* character set [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~".
*
* @param mixed $verifier Value to check.
*
* @return bool
*/
public static function is_valid_verifier( $verifier ) {
if ( ! is_string( $verifier ) ) {
return false;
}

return (bool) preg_match( '/^[A-Za-z0-9\-._~]{' . static::VERIFIER_MIN_LENGTH . ',' . static::VERIFIER_MAX_LENGTH . '}\z/', $verifier );
}

/**
* Check whether a string is a valid code challenge for the given method.
*
* `S256` challenges are base64url (unpadded) of a 32-byte SHA-256 digest,
* which is always exactly 43 characters from a narrower alphabet than the
* verifier's. `plain` challenges are just verifiers, so the verifier's
* character set and length range apply directly.
*
* @param mixed $challenge Value to check.
* @param string $method Challenge method, `S256` or `plain`. Case-sensitive.
*
* @return bool
*/
public static function is_valid_challenge( $challenge, $method ) {
if ( ! is_string( $challenge ) ) {
return false;
}

if ( static::METHOD_S256 === $method ) {
return (bool) preg_match( '/^[A-Za-z0-9\-_]{43}\z/', $challenge );
}

if ( static::METHOD_PLAIN === $method ) {
return static::is_valid_verifier( $challenge );
}

return false;
}

/**
* Generate a random code verifier.
*
* @param int $length Desired length, 43-128. Default 64.
*
* @return string Randomly generated code verifier.
*/
public static function generate_verifier( $length = 64 ) {
$length = max( static::VERIFIER_MIN_LENGTH, min( static::VERIFIER_MAX_LENGTH, (int) $length ) );

// Base64url-encode more bytes than needed, then trim to length, so the
// result is uniformly distributed over the unreserved character set.
$bytes = random_bytes( $length );
$verifier = rtrim( strtr( base64_encode( $bytes ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode

return substr( $verifier, 0, $length );
}
}
Loading