Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
303e2c4
Add PKCE helper class
roborourke Aug 31, 2026
f5249aa
Bind PKCE challenge to authorization codes
roborourke Aug 31, 2026
beb6992
Add per-client PKCE requirement
roborourke Aug 31, 2026
bf81aa4
Validate PKCE parameters at the authorisation endpoint
roborourke Aug 31, 2026
fdb4c39
Verify code_verifier at the token endpoint
roborourke Aug 31, 2026
ca1ec3c
Add PKCE settings to the client admin screen
roborourke Aug 31, 2026
f032204
Advertise supported PKCE methods in the REST index
roborourke Aug 31, 2026
078552b
Add WP-CLI command to generate PKCE pairs
roborourke Aug 31, 2026
f966cec
Document PKCE support
roborourke Aug 31, 2026
a94f613
Trigger CI checks
roborourke Sep 1, 2026
0c481d6
Fix CI failures after merging main
roborourke Sep 1, 2026
0257a45
Fix PKCE error redirects silently landing on wp-admin instead of the …
roborourke Sep 1, 2026
c827fba
Advertise PKCE methods in the RFC 8414 metadata document
roborourke Sep 17, 2026
0b19589
Apply review feedback on PKCE
roborourke Sep 17, 2026
e52fa65
Merge upstream main into roborourke/pkce-support
roborourke Sep 23, 2026
40696fe
Apply Copilot review feedback on PKCE error handling
roborourke Sep 23, 2026
6cd4069
Name the supported PKCE methods in the unsupported-method error
roborourke Sep 23, 2026
3d64684
Link PKCE tests to the RFC sections they cover, and fill gaps
roborourke Sep 23, 2026
7ea2796
Merge remote-tracking branch 'upstream/main' into roborourke/pkce-sup…
roborourke Oct 7, 2026
384e2f1
Map PKCE token errors through the RFC 6749 error format
roborourke Oct 7, 2026
f46c09e
Expect a 400 when a burned PKCE code is reused
roborourke Oct 7, 2026
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,6 +28,7 @@
<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>

<!-- Rules: PHP compatibility -->
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,54 @@ This plugin only supports WordPress >= 4.8.

Requires PHP 7.4 or higher.

## 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'] );

if ( isset( $params['token_ttl'] ) && '' !== $params['token_ttl'] ) {
$ttl = filter_var(
Expand Down Expand Up @@ -233,6 +234,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'],
'token_ttl' => $params['token_ttl'],
],
];
Expand All @@ -248,6 +250,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'],
'token_ttl' => $params['token_ttl'],
],
];
Expand Down Expand Up @@ -343,12 +346,21 @@ function render_edit_page() {
}
$data['client_credentials_enabled'] = ! empty( $form_data['client_credentials_enabled'] );
$data['token_ttl'] = isset( $form_data['token_ttl'] ) ? $form_data['token_ttl'] : '';

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();
$data['token_ttl'] = $consumer->get_token_ttl();

if ( is_array( $data['callback'] ) ) {
Expand Down Expand Up @@ -475,6 +487,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>
<tr>
<th scope="row">
<label for="oauth-token-ttl"><?php echo esc_html_x( 'Token TTL (seconds)', 'field name', 'oauth2' ); ?></label>
Expand Down
58 changes: 45 additions & 13 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 TOKEN_TTL_KEY = '_oauth2_client_token_ttl';
const AUTH_CODE_KEY_PREFIX = '_oauth2_authcode_';
const AUTH_CODE_LENGTH = 12;
Expand Down Expand Up @@ -143,6 +144,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 the token TTL for client credentials tokens.
*
Expand Down Expand Up @@ -265,11 +283,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 );
Comment thread
Copilot marked this conversation as resolved.
}

/**
Expand Down Expand Up @@ -377,6 +397,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' : '',
];

if ( isset( $data['meta']['token_ttl'] ) && '' !== $data['meta']['token_ttl'] ) {
Expand Down Expand Up @@ -414,20 +435,31 @@ 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',
static::TOKEN_TTL_KEY => 'token_ttl',
];
$boolean_fields = [ static::CLIENT_CREDENTIALS_ENABLED_KEY, static::PKCE_REQUIRED_KEY ];

if ( isset( $data['meta']['token_ttl'] ) && '' !== $data['meta']['token_ttl'] ) {
$meta[ static::TOKEN_TTL_KEY ] = (int) $data['meta']['token_ttl'];
} else {
$meta[ static::TOKEN_TTL_KEY ] = '';
}
foreach ( $fields as $meta_key => $data_key ) {
if ( ! array_key_exists( $data_key, $data['meta'] ) ) {
continue;
}

foreach ( $meta as $key => $value ) {
update_post_meta( $post_id, wp_slash( $key ), wp_slash( $value ) );
$value = $data['meta'][ $data_key ];
if ( in_array( $meta_key, $boolean_fields, true ) ) {
$value = ! empty( $value ) ? '1' : '';
} elseif ( static::TOKEN_TTL_KEY === $meta_key ) {
$value = ( null === $value || '' === $value ) ? '' : (int) $value;
}

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

$post = get_post( $post_id );
Expand Down
159 changes: 159 additions & 0 deletions inc/class-pkce.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?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.
*
* Only the built-in `S256` and `plain` methods can be enabled; any
* other value is dropped, since no challenge can be derived for it.
* Comparisons against these values are case-sensitive, per RFC 7636
* section 4.3.
*
* @param string[] $methods Supported `code_challenge_method` values.
*/
$methods = apply_filters( 'oauth2.pkce.supported_methods', [ static::METHOD_S256, static::METHOD_PLAIN ] );

return array_values( array_intersect( (array) $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
Loading