From 5e753431407ad85bf7d8a39045ed204b0515d7c0 Mon Sep 17 00:00:00 2001 From: Abhishek Kaushik Date: Mon, 16 Feb 2026 14:06:50 +0530 Subject: [PATCH 1/2] Add client_credentials grant and client tokens --- inc/authentication/namespace.php | 31 +++++++- inc/class-client.php | 11 +++ inc/endpoints/class-token.php | 114 +++++++++++++++++++++++++++++- inc/tokens/class-access-token.php | 114 ++++++++++++++++++++++++++++-- inc/tokens/class-token.php | 13 ++-- 5 files changed, 265 insertions(+), 18 deletions(-) diff --git a/inc/authentication/namespace.php b/inc/authentication/namespace.php index 4185ec1..69542d5 100644 --- a/inc/authentication/namespace.php +++ b/inc/authentication/namespace.php @@ -105,8 +105,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 +129,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 +150,19 @@ function attempt_authentication( $user = null ) { return $user; } + // Check if this is a client credentials token (no user) + if ( $token->is_client_token() ) { + // 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..71a1459 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,11 @@ 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' ) { + return $this->handle_client_credentials( $request ); + } + $client = OAuth2\get_client( $request['client_id'] ); if ( empty( $client ) ) { return new WP_Error( @@ -112,4 +122,102 @@ 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 client + $client = OAuth2\get_client( $client_id ); + if ( empty( $client ) ) { + return new WP_Error( + 'oauth2.endpoints.token.invalid_client', + __( 'Client authentication failed.', 'oauth2' ), + [ 'status' => WP_Http::UNAUTHORIZED ] + ); + } + + // Verify client secret + if ( ! $client->check_secret( $client_secret ) ) { + return new WP_Error( + 'oauth2.endpoints.token.invalid_client', + __( 'Client authentication failed.', 'oauth2' ), + [ 'status' => WP_Http::UNAUTHORIZED ] + ); + } + + // 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/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; } /** From 46b44dc14536429491c953335edde836c907fe37 Mon Sep 17 00:00:00 2001 From: Abhishek Kaushik Date: Tue, 17 Feb 2026 20:15:10 +0530 Subject: [PATCH 2/2] Add OAuth2 client credentials settings --- inc/admin/settings/namespace.php | 352 +++++++++++++++++++++++++++++++ inc/authentication/namespace.php | 12 ++ inc/endpoints/class-token.php | 43 +++- inc/namespace.php | 1 + plugin.php | 1 + 5 files changed, 401 insertions(+), 8 deletions(-) create mode 100644 inc/admin/settings/namespace.php 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 69542d5..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; /** @@ -152,6 +153,17 @@ function attempt_authentication( $user = null ) { // 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, diff --git a/inc/endpoints/class-token.php b/inc/endpoints/class-token.php index 71a1459..0f9c96b 100644 --- a/inc/endpoints/class-token.php +++ b/inc/endpoints/class-token.php @@ -70,6 +70,13 @@ public function validate_grant_type( $type ) { 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 ); } @@ -138,18 +145,22 @@ private function handle_client_credentials( WP_REST_Request $request ) { list( $client_id, $client_secret ) = $credentials; - // Get the client - $client = OAuth2\get_client( $client_id ); - if ( empty( $client ) ) { + // 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.invalid_client', - __( 'Client authentication failed.', 'oauth2' ), - [ 'status' => WP_Http::UNAUTHORIZED ] + 'oauth2.endpoints.token.not_configured', + __( 'Client credentials are not configured.', 'oauth2' ), + [ 'status' => WP_Http::INTERNAL_SERVER_ERROR ] ); } - // Verify client secret - if ( ! $client->check_secret( $client_secret ) ) { + // 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' ), @@ -157,6 +168,22 @@ private function handle_client_credentials( WP_REST_Request $request ) { ); } + // 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 ) ) { 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/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();