diff --git a/inc/admin/settings/namespace.php b/inc/admin/settings/namespace.php
new file mode 100644
index 0000000..cdf56c1
--- /dev/null
+++ b/inc/admin/settings/namespace.php
@@ -0,0 +1,352 @@
+ 'array',
+ 'sanitize_callback' => __NAMESPACE__ . '\\sanitize_settings',
+ 'default' => [
+ 'client_credentials_enabled' => false,
+ 'client_id' => '',
+ 'client_secret' => '',
+ ],
+ ] );
+
+ add_settings_section(
+ 'oauth2_client_credentials',
+ __( 'Client Credentials Grant', 'oauth2' ),
+ __NAMESPACE__ . '\\render_section_description',
+ PAGE_SLUG
+ );
+
+ add_settings_field(
+ 'client_credentials_enabled',
+ __( 'Enable Client Credentials', 'oauth2' ),
+ __NAMESPACE__ . '\\render_enabled_field',
+ PAGE_SLUG,
+ 'oauth2_client_credentials'
+ );
+
+ add_settings_field(
+ 'client_credentials_info',
+ __( 'Client Credentials', 'oauth2' ),
+ __NAMESPACE__ . '\\render_credentials_field',
+ PAGE_SLUG,
+ 'oauth2_client_credentials'
+ );
+}
+
+/**
+ * Sanitize settings input.
+ *
+ * Auto-generates client_id and client_secret when enabling for the first time.
+ *
+ * @param array $input Raw input.
+ * @return array Sanitized settings.
+ */
+function sanitize_settings( $input ) {
+ $existing = get_option( 'oauth2_settings', [] );
+ $sanitized = [];
+
+ $sanitized['client_credentials_enabled'] = ! empty( $input['client_credentials_enabled'] );
+
+ // Preserve existing credentials and storage client post ID.
+ $sanitized['client_id'] = ! empty( $existing['client_id'] ) ? $existing['client_id'] : '';
+ $sanitized['client_secret'] = ! empty( $existing['client_secret'] ) ? $existing['client_secret'] : '';
+ $sanitized['storage_client_post_id'] = ! empty( $existing['storage_client_post_id'] ) ? $existing['storage_client_post_id'] : 0;
+
+ if ( $sanitized['client_credentials_enabled'] && empty( $sanitized['client_id'] ) ) {
+ $client_id = wp_generate_password( Client::CLIENT_ID_LENGTH, false );
+ $client_secret = wp_generate_password( Client::CLIENT_SECRET_LENGTH, false );
+
+ $post_id = create_storage_client( $client_id, $client_secret );
+ if ( $post_id ) {
+ $sanitized['client_id'] = $client_id;
+ $sanitized['client_secret'] = $client_secret;
+ $sanitized['storage_client_post_id'] = $post_id;
+ }
+ }
+
+ return $sanitized;
+}
+
+/**
+ * Render the section description.
+ */
+function render_section_description() {
+ echo '
';
+ esc_html_e(
+ 'Configure the Client Credentials grant type for machine-to-machine authentication. When enabled, applications can authenticate using a client ID and secret without user interaction.',
+ 'oauth2'
+ );
+ echo '
';
+}
+
+/**
+ * Render the enable/disable checkbox field.
+ */
+function render_enabled_field() {
+ $options = get_option( 'oauth2_settings', [] );
+ $enabled = ! empty( $options['client_credentials_enabled'] );
+ ?>
+
+
+ />
+
+
+
+
+
+ ';
+ esc_html_e( 'Enable client credentials above and save to auto-generate credentials.', 'oauth2' );
+ echo '';
+ return;
+ }
+
+ $regenerate_url = wp_nonce_url(
+ admin_url( 'admin-post.php?action=oauth2_regenerate_credentials' ),
+ 'oauth2_regenerate_credentials'
+ );
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+ add_filter( 'oauth2.client_credentials.client_id', fn() => getenv( 'OAUTH2_CLIENT_ID' ) );
+add_filter( 'oauth2.client_credentials.client_secret', fn() => getenv( 'OAUTH2_CLIENT_SECRET' ) );
+
+
+ Client::POST_TYPE,
+ 'post_title' => __( 'Client Credentials (System)', 'oauth2' ),
+ 'post_content' => __( 'Auto-generated client for client credentials grant. Managed via Settings > OAuth2.', 'oauth2' ),
+ 'post_name' => $client_id,
+ 'post_status' => 'publish',
+ ], true );
+
+ if ( is_wp_error( $post_id ) ) {
+ return false;
+ }
+
+ update_post_meta( $post_id, Client::CLIENT_SECRET_KEY, $client_secret );
+ update_post_meta( $post_id, Client::TYPE_KEY, 'private' );
+ update_post_meta( $post_id, '_oauth2_is_system_client', true );
+
+ return $post_id;
+}
+
+/**
+ * Get the storage Client instance for token creation.
+ *
+ * Used by the token endpoint when credentials are overridden via filters
+ * and don't match the Client post slug directly.
+ *
+ * @return \WP\OAuth2\Client|null Client instance or null if not configured.
+ */
+function get_storage_client() {
+ $options = get_option( 'oauth2_settings', [] );
+ $post_id = ! empty( $options['storage_client_post_id'] ) ? (int) $options['storage_client_post_id'] : 0;
+
+ if ( ! $post_id ) {
+ return null;
+ }
+
+ return Client::get_by_post_id( $post_id );
+}
diff --git a/inc/authentication/namespace.php b/inc/authentication/namespace.php
index 4185ec1..89adf24 100644
--- a/inc/authentication/namespace.php
+++ b/inc/authentication/namespace.php
@@ -9,6 +9,7 @@
use WP_Error;
use WP_User;
+use WP\OAuth2\Admin\Settings;
use WP\OAuth2\Tokens;
/**
@@ -105,8 +106,9 @@ function get_token_from_request() {
function attempt_authentication( $user = null ) {
// Lock against infinite loops when querying the token itself.
static $is_querying_token = false;
- global $oauth2_error;
- $oauth2_error = null;
+ global $oauth2_error, $oauth2_client_credentials;
+ $oauth2_error = null;
+ $oauth2_client_credentials = null;
if ( ! empty( $user ) || $is_querying_token ) {
return $user;
@@ -128,6 +130,19 @@ function attempt_authentication( $user = null ) {
return $user;
}
+ // Check if the token has expired.
+ if ( $token->is_expired() ) {
+ $is_querying_token = false;
+ $oauth2_error = new WP_Error(
+ 'oauth2.authentication.token_expired',
+ __( 'Access token has expired.', 'oauth2' ),
+ [
+ 'status' => \WP_Http::UNAUTHORIZED,
+ ]
+ );
+ return $user;
+ }
+
$client = $token->get_client();
$is_querying_token = false;
@@ -136,6 +151,30 @@ function attempt_authentication( $user = null ) {
return $user;
}
+ // Check if this is a client credentials token (no user)
+ if ( $token->is_client_token() ) {
+ if ( ! Settings\is_client_credentials_enabled() ) {
+ $oauth2_error = new WP_Error(
+ 'oauth2.authentication.client_credentials_disabled',
+ __( 'Client credentials authentication is not enabled.', 'oauth2' ),
+ [
+ 'status' => \WP_Http::FORBIDDEN,
+ ]
+ );
+ return $user;
+ }
+
+ // Set global variable for client credentials authentication
+ $oauth2_client_credentials = [
+ 'authenticated' => true,
+ 'client_id' => $client->get_id(),
+ 'client' => $client,
+ 'token' => $token,
+ ];
+ // Return 0 to indicate no user but authentication is valid
+ return 0;
+ }
+
// Token found, authenticate as the user.
return $token->get_user_id();
}
diff --git a/inc/class-client.php b/inc/class-client.php
index 155343b..31f0118 100644
--- a/inc/class-client.php
+++ b/inc/class-client.php
@@ -121,6 +121,17 @@ public function get_secret() {
return get_post_meta( $this->get_post_id(), static::CLIENT_SECRET_KEY, true );
}
+ /**
+ * Check if the provided secret matches the client's secret.
+ *
+ * @param string $secret Secret to check.
+ *
+ * @return bool True if the secret matches, false otherwise.
+ */
+ public function check_secret( $secret ) {
+ return hash_equals( $this->get_secret(), $secret );
+ }
+
/**
* Get registered URI for the client.
*
diff --git a/inc/endpoints/class-token.php b/inc/endpoints/class-token.php
index 07fcd07..0f9c96b 100644
--- a/inc/endpoints/class-token.php
+++ b/inc/endpoints/class-token.php
@@ -30,12 +30,17 @@ public function register_routes() {
'validate_callback' => [ $this, 'validate_grant_type' ],
],
'client_id' => [
- 'required' => true,
+ 'required' => false,
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
],
'code' => [
- 'required' => true,
+ 'required' => false,
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ],
+ 'client_secret' => [
+ 'required' => false,
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
],
@@ -52,7 +57,7 @@ public function register_routes() {
* @return bool Whether or not the grant type is valid.
*/
public function validate_grant_type( $type ) {
- return 'authorization_code' === $type;
+ return in_array( $type, [ 'authorization_code', 'client_credentials' ], true );
}
/**
@@ -63,6 +68,18 @@ public function validate_grant_type( $type ) {
* @return array|WP_Error Token data on success, or error on failure.
*/
public function exchange_token( WP_REST_Request $request ) {
+ // Handle client_credentials grant type
+ if ( $request['grant_type'] === 'client_credentials' ) {
+ if ( ! OAuth2\Admin\Settings\is_client_credentials_enabled() ) {
+ return new WP_Error(
+ 'oauth2.endpoints.token.client_credentials_disabled',
+ __( 'The client_credentials grant type is not enabled.', 'oauth2' ),
+ [ 'status' => WP_Http::BAD_REQUEST ]
+ );
+ }
+ return $this->handle_client_credentials( $request );
+ }
+
$client = OAuth2\get_client( $request['client_id'] );
if ( empty( $client ) ) {
return new WP_Error(
@@ -112,4 +129,122 @@ public function exchange_token( WP_REST_Request $request ) {
];
return $data;
}
+
+ /**
+ * Handle client credentials grant type.
+ *
+ * @param WP_REST_Request $request Request object.
+ * @return array|WP_Error Token data on success, or error on failure.
+ */
+ private function handle_client_credentials( WP_REST_Request $request ) {
+ // Extract client credentials from Authorization header or request body
+ $credentials = $this->extract_client_credentials( $request );
+ if ( is_wp_error( $credentials ) ) {
+ return $credentials;
+ }
+
+ list( $client_id, $client_secret ) = $credentials;
+
+ // Get the expected credentials from settings/filters.
+ $expected_id = OAuth2\Admin\Settings\get_client_id();
+ $expected_secret = OAuth2\Admin\Settings\get_client_secret();
+
+ if ( empty( $expected_id ) || empty( $expected_secret ) ) {
+ return new WP_Error(
+ 'oauth2.endpoints.token.not_configured',
+ __( 'Client credentials are not configured.', 'oauth2' ),
+ [ 'status' => WP_Http::INTERNAL_SERVER_ERROR ]
+ );
+ }
+
+ // Validate client ID and secret using timing-safe comparison.
+ if ( ! hash_equals( $expected_id, $client_id )
+ || ! hash_equals( $expected_secret, $client_secret )
+ ) {
+ return new WP_Error(
+ 'oauth2.endpoints.token.invalid_client',
+ __( 'Client authentication failed.', 'oauth2' ),
+ [ 'status' => WP_Http::UNAUTHORIZED ]
+ );
+ }
+
+ // Look up a Client post for token storage.
+ // First try matching by client_id slug, then fall back to the settings storage client.
+ $client = OAuth2\get_client( $client_id );
+
+ if ( empty( $client ) ) {
+ $client = OAuth2\Admin\Settings\get_storage_client();
+ }
+
+ if ( empty( $client ) || is_wp_error( $client ) ) {
+ return new WP_Error(
+ 'oauth2.endpoints.token.storage_not_configured',
+ __( 'Client credentials storage is not configured.', 'oauth2' ),
+ [ 'status' => WP_Http::INTERNAL_SERVER_ERROR ]
+ );
+ }
+
+ // Create access token for the client (no user)
+ $token = OAuth2\Tokens\Access_Token::create_for_client( $client );
+ if ( is_wp_error( $token ) ) {
+ return $token;
+ }
+
+ $ttl = apply_filters( 'oauth2.client_token_ttl', OAuth2\Tokens\Access_Token::DEFAULT_TTL );
+
+ return [
+ 'access_token' => $token->get_key(),
+ 'token_type' => 'bearer',
+ 'expires_in' => $ttl,
+ ];
+ }
+
+ /**
+ * Extract client credentials from Authorization header or request body.
+ *
+ * @param WP_REST_Request $request Request object.
+ * @return array|WP_Error Array with client_id and client_secret, or error.
+ */
+ private function extract_client_credentials( WP_REST_Request $request ) {
+ $auth_header = $request->get_header( 'authorization' );
+
+ // Try Basic authentication from Authorization header
+ if ( ! empty( $auth_header ) && stripos( $auth_header, 'Basic ' ) === 0 ) {
+ $encoded = substr( $auth_header, 6 );
+ $decoded = base64_decode( $encoded, true );
+
+ if ( $decoded === false ) {
+ return new WP_Error(
+ 'oauth2.endpoints.token.invalid_request',
+ __( 'Invalid Authorization header.', 'oauth2' ),
+ [ 'status' => WP_Http::BAD_REQUEST ]
+ );
+ }
+
+ $parts = explode( ':', $decoded, 2 );
+ if ( count( $parts ) !== 2 ) {
+ return new WP_Error(
+ 'oauth2.endpoints.token.invalid_request',
+ __( 'Invalid Authorization header format.', 'oauth2' ),
+ [ 'status' => WP_Http::BAD_REQUEST ]
+ );
+ }
+
+ return [ trim( $parts[0] ), trim( $parts[1] ) ];
+ }
+
+ // Try from request body
+ $client_id = $request->get_param( 'client_id' );
+ $client_secret = $request->get_param( 'client_secret' );
+
+ if ( empty( $client_id ) || empty( $client_secret ) ) {
+ return new WP_Error(
+ 'oauth2.endpoints.token.invalid_request',
+ __( 'Client credentials not provided.', 'oauth2' ),
+ [ 'status' => WP_Http::BAD_REQUEST ]
+ );
+ }
+
+ return [ $client_id, $client_secret ];
+ }
}
diff --git a/inc/namespace.php b/inc/namespace.php
index ea681f1..c406c42 100644
--- a/inc/namespace.php
+++ b/inc/namespace.php
@@ -27,6 +27,7 @@ function bootstrap() {
add_action( 'init', __NAMESPACE__ . '\\rest_oauth2_load_authorize_page' );
add_action( 'admin_menu', __NAMESPACE__ . '\\Admin\\register' );
Admin\Profile\bootstrap();
+ Admin\Settings\bootstrap();
}
/**
diff --git a/inc/tokens/class-access-token.php b/inc/tokens/class-access-token.php
index 3b6b250..0ad5134 100644
--- a/inc/tokens/class-access-token.php
+++ b/inc/tokens/class-access-token.php
@@ -14,8 +14,10 @@
use WP_User_Query;
class Access_Token extends Token {
- const META_PREFIX = '_oauth2_access_';
- const KEY_LENGTH = 12;
+ const META_PREFIX = '_oauth2_access_';
+ const CLIENT_META_PREFIX = '_oauth2_client_token_';
+ const KEY_LENGTH = 12;
+ const DEFAULT_TTL = 86400; // 24 hours in seconds
/**
* @return string Meta prefix.
@@ -108,6 +110,7 @@ public function revoke() {
* @return static|null Token if ID is found, null otherwise.
*/
public static function get_by_id( $id ) {
+ // First try to find as a user token
$key = static::META_PREFIX . $id;
$args = [
'number' => 1,
@@ -125,17 +128,51 @@ public static function get_by_id( $id ) {
$query = new WP_User_Query( $args );
$results = $query->get_results();
- if ( empty( $results ) ) {
+ if ( ! empty( $results ) ) {
+ $user = $results[0];
+ $value = get_user_meta( $user->ID, wp_slash( $key ), false );
+ if ( ! empty( $value ) ) {
+ return new static( $user, $id, $value[0] );
+ }
+ }
+
+ // If not found as user token, try as client token
+ return static::get_client_token_by_id( $id );
+ }
+
+ /**
+ * Get a client token by ID.
+ *
+ * @param string $id Token ID.
+ *
+ * @return static|null Token if ID is found, null otherwise.
+ */
+ private static function get_client_token_by_id( $id ) {
+ $key = static::CLIENT_META_PREFIX . $id;
+ $args = [
+ 'post_type' => OAuth2\Client::POST_TYPE,
+ 'post_status' => 'any',
+ 'posts_per_page' => 1,
+ 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ [
+ 'key' => $key,
+ 'compare' => 'EXISTS',
+ ],
+ ],
+ ];
+
+ $query = new \WP_Query( $args );
+ if ( empty( $query->posts ) ) {
return null;
}
- $user = $results[0];
- $value = get_user_meta( $user->ID, wp_slash( $key ), false );
+ $post = $query->posts[0];
+ $value = get_post_meta( $post->ID, $key, true );
if ( empty( $value ) ) {
return null;
}
- return new static( $user, $id, $value[0] );
+ return new static( null, $id, $value );
}
/**
@@ -194,12 +231,75 @@ public static function create( ClientInterface $client, WP_User $user, $meta = [
return new static( $user, $key, $data );
}
+ /**
+ * Creates a new token for a client (no user association).
+ *
+ * @param ClientInterface $client
+ * @param array $meta
+ *
+ * @return Access_Token|WP_Error Token instance, or error on failure.
+ */
+ public static function create_for_client( ClientInterface $client, $meta = [] ) {
+ $ttl = apply_filters( 'oauth2.client_token_ttl', static::DEFAULT_TTL );
+ $data = [
+ 'client' => $client->get_id(),
+ 'created' => time(),
+ 'expires' => time() + $ttl,
+ 'meta' => $meta,
+ ];
+ $key = wp_generate_password( static::KEY_LENGTH, false );
+ $meta_key = static::CLIENT_META_PREFIX . $key;
+
+ // Store token as post meta on the client post
+ $result = add_post_meta( $client->get_post_id(), wp_slash( $meta_key ), wp_slash( $data ), true );
+ if ( ! $result ) {
+ return new WP_Error(
+ 'oauth2.tokens.access_token.create_for_client.could_not_create',
+ __( 'Unable to create client token.', 'oauth2' )
+ );
+ }
+
+ // Return token with null user
+ return new static( null, $key, $data );
+ }
+
+ /**
+ * Check if this is a client-only token (no user association).
+ *
+ * @return bool True if this is a client token, false otherwise.
+ */
+ public function is_client_token() {
+ return $this->user === null;
+ }
+
+ /**
+ * Check if the token has expired.
+ *
+ * @return bool True if the token has expired, false otherwise.
+ */
+ public function is_expired() {
+ if ( ! isset( $this->value['expires'] ) ) {
+ return false; // Tokens without expiration don't expire (backward compat)
+ }
+
+ return time() >= $this->value['expires'];
+ }
+
+ /**
+ * Get expiration timestamp.
+ *
+ * @return int|null Expiration timestamp, or null if no expiration.
+ */
+ public function get_expiration_time() {
+ return $this->value['expires'] ?? null;
+ }
+
/**
* Check if the token is valid.
*
* @return bool True if the token is valid, false otherwise.
*/
public function is_valid() {
- return true;
+ return ! $this->is_expired();
}
}
diff --git a/inc/tokens/class-token.php b/inc/tokens/class-token.php
index e150f86..994e847 100644
--- a/inc/tokens/class-token.php
+++ b/inc/tokens/class-token.php
@@ -13,7 +13,7 @@ abstract class Token {
/**
* User the token belongs to.
*
- * @var WP_User
+ * @var WP_User|null
*/
protected $user;
@@ -28,10 +28,11 @@ abstract class Token {
protected $value;
/**
- * @param WP_User $key
- * @param mixed $value
+ * @param WP_User|null $user
+ * @param string $key
+ * @param mixed $value
*/
- protected function __construct( WP_User $user, $key, $value ) {
+ protected function __construct( $user, $key, $value ) {
$this->user = $user;
$this->key = $key;
$this->value = $value;
@@ -40,10 +41,10 @@ protected function __construct( WP_User $user, $key, $value ) {
/**
* Get the ID for the user that the token represents.
*
- * @return int
+ * @return int|null User ID if token has a user, null otherwise.
*/
public function get_user_id() {
- return $this->user->ID;
+ return $this->user ? $this->user->ID : null;
}
/**
diff --git a/plugin.php b/plugin.php
index 9b8ae34..b031c0f 100644
--- a/plugin.php
+++ b/plugin.php
@@ -50,5 +50,6 @@
require __DIR__ . '/inc/admin/namespace.php';
require __DIR__ . '/inc/admin/profile/namespace.php';
require __DIR__ . '/inc/admin/profile/personaltokens/namespace.php';
+require __DIR__ . '/inc/admin/settings/namespace.php';
bootstrap();