Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,13 @@ jobs:

- name: Run PHPCS
run: vendor/bin/phpcs

e2e:
name: E2E (Playground)
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v7

- name: Run PKCE e2e checks
run: bash tests/e2e/run.sh
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 );
}

/**
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
Loading
Loading