Initial geladen: WP App Portal

This commit is contained in:
2026-04-10 11:32:42 +02:00
parent a772a0ad53
commit fdfd055748
5658 changed files with 1968631 additions and 0 deletions
@@ -0,0 +1,26 @@
<?php
namespace Royal_MCP\OAuth;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* PKCE (Proof Key for Code Exchange) utility.
*
* Implements S256 code challenge verification per OAuth 2.1 / RFC 7636.
*/
class PKCE {
/**
* Verify a PKCE code_verifier against a stored code_challenge.
*
* @param string $code_verifier The verifier sent by the client in the token request.
* @param string $code_challenge The challenge stored from the authorization request.
* @return bool True if the verifier matches the challenge.
*/
public static function verify( $code_verifier, $code_challenge ) {
$computed = rtrim( strtr( base64_encode( hash( 'sha256', $code_verifier, true ) ), '+/', '-_' ), '=' );
return hash_equals( $code_challenge, $computed );
}
}
@@ -0,0 +1,497 @@
<?php
namespace Royal_MCP\OAuth;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* OAuth 2.0 Authorization Server for MCP.
*
* Implements the OAuth 2.1 authorization code flow with PKCE
* per the MCP specification (2025-03-26).
*
* Endpoints (served at domain root via rewrite rules):
* - GET /.well-known/oauth-authorization-server → metadata()
* - POST /register → register()
* - GET /authorize → authorize_get()
* - POST /authorize → authorize_post()
* - POST /token → token()
*/
class Server {
/**
* Dispatch an OAuth request based on the query var value.
*
* @param string $action The royal_mcp_oauth query var (metadata|authorize|token|register).
*/
public function dispatch( $action ) {
$request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET';
// Set CORS headers for token and register endpoints (may be called cross-origin).
if ( in_array( $action, [ 'token', 'register', 'metadata', 'protected_resource' ], true ) ) {
header( 'Access-Control-Allow-Origin: *' );
header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
header( 'Access-Control-Allow-Headers: Content-Type, Authorization' );
if ( 'OPTIONS' === $request_method ) {
status_header( 204 );
exit;
}
}
switch ( $action ) {
case 'protected_resource':
$this->protected_resource_metadata();
break;
case 'metadata':
$this->metadata();
break;
case 'register':
$this->register( $request_method );
break;
case 'authorize':
if ( 'POST' === $request_method ) {
$this->authorize_post();
} else {
$this->authorize_get();
}
break;
case 'token':
$this->token( $request_method );
break;
default:
status_header( 404 );
exit;
}
}
/* ------------------------------------------------------------------
* GET /.well-known/oauth-protected-resource (RFC 9728)
* Tells the client which authorization server protects this resource.
* ----------------------------------------------------------------*/
private function protected_resource_metadata() {
$base = home_url();
$metadata = [
'resource' => $base . '/wp-json/royal-mcp/v1',
'authorization_servers' => [ $base ],
'bearer_methods_supported' => [ 'header' ],
'scopes_supported' => [ 'mcp:full' ],
];
$this->json_response( $metadata, 200, [ 'Cache-Control' => 'public, max-age=3600' ] );
}
/* ------------------------------------------------------------------
* GET /.well-known/oauth-authorization-server
* ----------------------------------------------------------------*/
private function metadata() {
$base = home_url();
$metadata = [
'issuer' => $base,
'authorization_endpoint' => $base . '/authorize',
'token_endpoint' => $base . '/token',
'registration_endpoint' => $base . '/register',
'response_types_supported' => [ 'code' ],
'grant_types_supported' => [ 'authorization_code', 'refresh_token' ],
'token_endpoint_auth_methods_supported' => [ 'none', 'client_secret_post' ],
'code_challenge_methods_supported' => [ 'S256' ],
'scopes_supported' => [ 'mcp:full' ],
'service_documentation' => 'https://royalplugins.com/support/royal-mcp/',
];
$this->json_response( $metadata, 200, [ 'Cache-Control' => 'public, max-age=3600' ] );
}
/* ------------------------------------------------------------------
* POST /register — Dynamic Client Registration (RFC 7591)
* ----------------------------------------------------------------*/
private function register( $request_method = 'GET' ) {
if ( 'POST' !== $request_method ) {
$this->json_error( 'invalid_request', 'POST method required.', 405 );
}
// Rate-limit registrations by IP.
$ip = $this->get_client_ip();
$transient_key = 'royal_mcp_reg_rate_' . md5( $ip );
$count = (int) get_transient( $transient_key );
if ( $count >= 10 ) {
$this->json_error( 'rate_limit', 'Too many registration attempts. Try again later.', 429 );
}
set_transient( $transient_key, $count + 1, 60 );
// Parse body.
$body = json_decode( file_get_contents( 'php://input' ), true );
if ( ! is_array( $body ) ) {
$this->json_error( 'invalid_request', 'Invalid JSON body.', 400 );
}
// Validate redirect_uris.
$redirect_uris = isset( $body['redirect_uris'] ) && is_array( $body['redirect_uris'] ) ? $body['redirect_uris'] : [];
foreach ( $redirect_uris as $uri ) {
if ( ! $this->is_valid_redirect_uri( $uri ) ) {
$this->json_error( 'invalid_redirect_uri', 'Redirect URIs must be localhost or HTTPS.', 400 );
}
}
$client = Token_Store::register_client( $body );
$this->json_response( $client, 201 );
}
/* ------------------------------------------------------------------
* GET /authorize — Show consent screen
* ----------------------------------------------------------------*/
private function authorize_get() {
// OAuth authorize endpoint — params come from external MCP client, no WP nonce possible.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$response_type = isset( $_GET['response_type'] ) ? sanitize_text_field( wp_unslash( $_GET['response_type'] ) ) : '';
$client_id = isset( $_GET['client_id'] ) ? sanitize_text_field( wp_unslash( $_GET['client_id'] ) ) : '';
$redirect_uri = isset( $_GET['redirect_uri'] ) ? sanitize_text_field( wp_unslash( $_GET['redirect_uri'] ) ) : '';
$code_challenge = isset( $_GET['code_challenge'] ) ? sanitize_text_field( wp_unslash( $_GET['code_challenge'] ) ) : '';
$code_challenge_method = isset( $_GET['code_challenge_method'] ) ? sanitize_text_field( wp_unslash( $_GET['code_challenge_method'] ) ) : '';
$state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : '';
$scope = isset( $_GET['scope'] ) ? sanitize_text_field( wp_unslash( $_GET['scope'] ) ) : 'mcp:full';
// Validate client FIRST — never redirect to unvalidated redirect_uri (OAuth 2.1 §4.1.2.1).
$client = Token_Store::get_client( $client_id );
if ( ! $client ) {
wp_die(
esc_html__( 'Unknown client_id. The application has not been registered.', 'royal-mcp' ),
esc_html__( 'Authorization Error', 'royal-mcp' ),
[ 'response' => 400 ]
);
}
// Validate redirect_uri BEFORE any redirects.
if ( empty( $redirect_uri ) || ! Token_Store::validate_redirect_uri( $redirect_uri, $client ) ) {
wp_die(
esc_html__( 'Invalid redirect_uri.', 'royal-mcp' ),
esc_html__( 'Authorization Error', 'royal-mcp' ),
[ 'response' => 400 ]
);
}
// Now safe to redirect errors to the validated redirect_uri.
if ( 'code' !== $response_type ) {
$this->authorize_error( $redirect_uri, $state, 'unsupported_response_type', 'Only response_type=code is supported.' );
}
// PKCE is required.
if ( empty( $code_challenge ) || 'S256' !== $code_challenge_method ) {
$this->authorize_error( $redirect_uri, $state, 'invalid_request', 'PKCE with code_challenge_method=S256 is required.' );
}
// Ensure user is logged into WordPress.
if ( ! is_user_logged_in() ) {
// Build the full authorize URL with all params to come back after login.
$authorize_url = add_query_arg(
[
'response_type' => $response_type,
'client_id' => $client_id,
'redirect_uri' => $redirect_uri,
'code_challenge' => $code_challenge,
'code_challenge_method' => $code_challenge_method,
'state' => $state,
'scope' => $scope,
],
home_url( '/authorize' )
);
wp_safe_redirect( wp_login_url( $authorize_url ) );
exit;
}
// User is logged in — render consent screen.
$current_user = wp_get_current_user();
$site_name = get_bloginfo( 'name' );
// Pass variables to the template.
$rmcp_oauth = [
'client_name' => $client['client_name'] ?? $client_id,
'client_id' => $client_id,
'redirect_uri' => $redirect_uri,
'code_challenge' => $code_challenge,
'code_challenge_method' => $code_challenge_method,
'state' => $state,
'scope' => $scope,
'user_display_name' => $current_user->display_name,
'site_name' => $site_name,
'nonce' => wp_create_nonce( 'royal_mcp_authorize' ),
];
// Load the consent template.
include ROYAL_MCP_PLUGIN_DIR . 'templates/admin/authorize.php';
exit;
// phpcs:enable WordPress.Security.NonceVerification.Recommended
}
/* ------------------------------------------------------------------
* POST /authorize — Process consent
* ----------------------------------------------------------------*/
private function authorize_post() {
// Verify nonce.
$nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'royal_mcp_authorize' ) ) {
wp_die(
esc_html__( 'Security check failed. Please try again.', 'royal-mcp' ),
esc_html__( 'Authorization Error', 'royal-mcp' ),
[ 'response' => 403 ]
);
}
$redirect_uri = isset( $_POST['redirect_uri'] ) ? sanitize_text_field( wp_unslash( $_POST['redirect_uri'] ) ) : '';
$client_id = isset( $_POST['client_id'] ) ? sanitize_text_field( wp_unslash( $_POST['client_id'] ) ) : '';
$code_challenge = isset( $_POST['code_challenge'] ) ? sanitize_text_field( wp_unslash( $_POST['code_challenge'] ) ) : '';
$code_challenge_method = isset( $_POST['code_challenge_method'] ) ? sanitize_text_field( wp_unslash( $_POST['code_challenge_method'] ) ) : '';
$state = isset( $_POST['state'] ) ? sanitize_text_field( wp_unslash( $_POST['state'] ) ) : '';
$scope = isset( $_POST['scope'] ) ? sanitize_text_field( wp_unslash( $_POST['scope'] ) ) : 'mcp:full';
$action = isset( $_POST['authorize_action'] ) ? sanitize_text_field( wp_unslash( $_POST['authorize_action'] ) ) : '';
// User denied.
if ( 'deny' === $action ) {
$this->authorize_error( $redirect_uri, $state, 'access_denied', 'The user denied the authorization request.' );
}
// Validate client still exists.
$client = Token_Store::get_client( $client_id );
if ( ! $client ) {
wp_die( esc_html__( 'Unknown client.', 'royal-mcp' ), '', [ 'response' => 400 ] );
}
// Validate redirect_uri again.
if ( empty( $redirect_uri ) || ! Token_Store::validate_redirect_uri( $redirect_uri, $client ) ) {
wp_die( esc_html__( 'Invalid redirect URI.', 'royal-mcp' ), '', [ 'response' => 400 ] );
}
// Must be logged in.
if ( ! is_user_logged_in() ) {
wp_die( esc_html__( 'Not authenticated.', 'royal-mcp' ), '', [ 'response' => 401 ] );
}
// Generate authorization code.
$code = bin2hex( random_bytes( 32 ) );
Token_Store::store_auth_code( $code, [
'user_id' => get_current_user_id(),
'client_id' => $client_id,
'redirect_uri' => $redirect_uri,
'code_challenge' => $code_challenge,
'code_challenge_method' => $code_challenge_method,
'scope' => $scope,
] );
// Redirect back to the client with the code.
$redirect = add_query_arg(
[
'code' => $code,
'state' => $state,
],
$redirect_uri
);
// phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- OAuth callback URI is external (e.g. claude.ai).
wp_redirect( $redirect );
exit;
}
/* ------------------------------------------------------------------
* POST /token — Token exchange
* ----------------------------------------------------------------*/
private function token( $request_method = 'GET' ) {
// OAuth token endpoint — external MCP clients cannot provide WP nonces.
// phpcs:disable WordPress.Security.NonceVerification.Missing
if ( 'POST' !== $request_method ) {
$this->json_error( 'invalid_request', 'POST method required.', 405 );
}
// Parse form-encoded body (standard OAuth).
$grant_type = isset( $_POST['grant_type'] ) ? sanitize_text_field( wp_unslash( $_POST['grant_type'] ) ) : '';
switch ( $grant_type ) {
case 'authorization_code':
$this->token_authorization_code();
break;
case 'refresh_token':
$this->token_refresh();
break;
default:
$this->json_error( 'unsupported_grant_type', 'Supported grant types: authorization_code, refresh_token.', 400 );
}
}
/**
* Exchange an authorization code for tokens.
*/
private function token_authorization_code() {
$code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : '';
$redirect_uri = isset( $_POST['redirect_uri'] ) ? sanitize_text_field( wp_unslash( $_POST['redirect_uri'] ) ) : '';
$client_id = isset( $_POST['client_id'] ) ? sanitize_text_field( wp_unslash( $_POST['client_id'] ) ) : '';
$code_verifier = isset( $_POST['code_verifier'] ) ? sanitize_text_field( wp_unslash( $_POST['code_verifier'] ) ) : '';
if ( empty( $code ) || empty( $client_id ) || empty( $code_verifier ) || empty( $redirect_uri ) ) {
$this->json_error( 'invalid_request', 'Missing required parameters: code, client_id, code_verifier, redirect_uri.', 400 );
}
// Consume the code (single-use).
$code_data = Token_Store::consume_auth_code( $code );
if ( ! $code_data ) {
$this->json_error( 'invalid_grant', 'Authorization code is invalid, expired, or already used.', 400 );
}
// Validate client_id.
if ( ! hash_equals( $code_data['client_id'], $client_id ) ) {
$this->json_error( 'invalid_grant', 'client_id mismatch.', 400 );
}
// Validate redirect_uri (must match exactly).
if ( $redirect_uri !== $code_data['redirect_uri'] ) {
$this->json_error( 'invalid_grant', 'redirect_uri mismatch.', 400 );
}
// Verify PKCE.
if ( ! PKCE::verify( $code_verifier, $code_data['code_challenge'] ) ) {
$this->json_error( 'invalid_grant', 'PKCE verification failed.', 400 );
}
// Authenticate confidential clients.
$client = Token_Store::get_client( $client_id );
if ( $client && 'client_secret_post' === ( $client['token_endpoint_auth_method'] ?? 'none' ) ) {
$client_secret = isset( $_POST['client_secret'] ) ? sanitize_text_field( wp_unslash( $_POST['client_secret'] ) ) : '';
if ( empty( $client_secret ) || ! hash_equals( $client['client_secret_hash'], hash( 'sha256', $client_secret ) ) ) {
$this->json_error( 'invalid_client', 'Client authentication failed.', 401 );
}
}
// Issue tokens.
$tokens = Token_Store::create_token_pair( $client_id, $code_data['user_id'], $code_data['scope'] ?? '' );
// Include resource indicator if client sent one (RFC 8707).
$resource = isset( $_POST['resource'] ) ? sanitize_text_field( wp_unslash( $_POST['resource'] ) ) : '';
if ( ! empty( $resource ) ) {
$tokens['resource'] = $resource;
}
$this->json_response( $tokens, 200, [ 'Cache-Control' => 'no-store', 'Pragma' => 'no-cache' ] );
}
/**
* Refresh an access token.
*/
private function token_refresh() {
$refresh_token = isset( $_POST['refresh_token'] ) ? sanitize_text_field( wp_unslash( $_POST['refresh_token'] ) ) : '';
$client_id = isset( $_POST['client_id'] ) ? sanitize_text_field( wp_unslash( $_POST['client_id'] ) ) : '';
if ( empty( $refresh_token ) || empty( $client_id ) ) {
$this->json_error( 'invalid_request', 'Missing required parameters: refresh_token, client_id.', 400 );
}
// Consume the refresh token (rotation — old one is revoked).
$token_data = Token_Store::consume_refresh_token( $refresh_token );
if ( ! $token_data ) {
$this->json_error( 'invalid_grant', 'Refresh token is invalid, expired, or revoked.', 400 );
}
// Validate client_id (timing-safe).
if ( ! hash_equals( $token_data['client_id'], $client_id ) ) {
$this->json_error( 'invalid_grant', 'client_id mismatch.', 400 );
}
// Issue new token pair.
$tokens = Token_Store::create_token_pair( $client_id, (int) $token_data['user_id'], $token_data['scope'] ?? '' );
$this->json_response( $tokens, 200, [ 'Cache-Control' => 'no-store', 'Pragma' => 'no-cache' ] );
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
/* ------------------------------------------------------------------
* Helpers
* ----------------------------------------------------------------*/
/**
* Send a JSON response and exit.
*/
private function json_response( $data, $status = 200, $extra_headers = [] ) {
status_header( $status );
header( 'Content-Type: application/json; charset=utf-8' );
foreach ( $extra_headers as $key => $value ) {
header( $key . ': ' . $value );
}
echo wp_json_encode( $data );
exit;
}
/**
* Send an OAuth error response and exit.
*/
private function json_error( $error, $description, $status = 400 ) {
$this->json_response(
[
'error' => $error,
'error_description' => $description,
],
$status
);
}
/**
* Redirect to the client with an error (authorize endpoint).
*/
private function authorize_error( $redirect_uri, $state, $error, $description ) {
if ( empty( $redirect_uri ) ) {
wp_die( esc_html( $description ), esc_html__( 'Authorization Error', 'royal-mcp' ), [ 'response' => 400 ] );
}
$redirect = add_query_arg(
[
'error' => $error,
'error_description' => $description,
'state' => $state,
],
$redirect_uri
);
// phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- OAuth error redirect to client's registered callback URI.
wp_redirect( $redirect );
exit;
}
/**
* Validate a redirect URI (must be localhost or HTTPS).
*/
private function is_valid_redirect_uri( $uri ) {
$parsed = wp_parse_url( $uri );
if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) {
return false;
}
$is_localhost = in_array( $parsed['host'], [ 'localhost', '127.0.0.1', '::1' ], true );
return $is_localhost || 'https' === $parsed['scheme'];
}
/**
* Get the client IP address.
*/
private function get_client_ip() {
if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
$ips = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) );
return trim( $ips[0] );
}
return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0';
}
}
@@ -0,0 +1,404 @@
<?php
namespace Royal_MCP\OAuth;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* OAuth Token Store.
*
* Handles CRUD for access/refresh tokens, authorization codes,
* and dynamically registered OAuth clients.
*/
class Token_Store {
/** Token lifetimes in seconds. */
const ACCESS_TOKEN_TTL = 3600; // 1 hour
const REFRESH_TOKEN_TTL = 2592000; // 30 days
const AUTH_CODE_TTL = 600; // 10 minutes
/* ------------------------------------------------------------------
* Table helpers
* ----------------------------------------------------------------*/
/**
* Get the tokens table name.
*/
public static function tokens_table() {
global $wpdb;
return $wpdb->prefix . 'royal_mcp_oauth_tokens';
}
/**
* Get the clients table name.
*/
public static function clients_table() {
global $wpdb;
return $wpdb->prefix . 'royal_mcp_oauth_clients';
}
/**
* Create both OAuth tables. Called from plugin activation.
*/
public static function create_tables() {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$tokens_table = self::tokens_table();
$clients_table = self::clients_table();
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
// dbDelta needs each CREATE TABLE as a separate call.
dbDelta( "CREATE TABLE IF NOT EXISTS $tokens_table (
id bigint(20) NOT NULL AUTO_INCREMENT,
token_hash varchar(64) NOT NULL,
token_type varchar(20) NOT NULL,
client_id varchar(255) NOT NULL,
user_id bigint(20) NOT NULL,
scope varchar(255) DEFAULT '',
expires_at datetime NOT NULL,
created_at datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
revoked tinyint(1) DEFAULT 0 NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY token_hash (token_hash),
KEY client_id (client_id),
KEY user_id (user_id),
KEY expires_at (expires_at)
) $charset_collate;" );
dbDelta( "CREATE TABLE IF NOT EXISTS $clients_table (
id bigint(20) NOT NULL AUTO_INCREMENT,
client_id varchar(255) NOT NULL,
client_secret_hash varchar(64) DEFAULT NULL,
client_name varchar(255) NOT NULL,
redirect_uris text NOT NULL,
grant_types varchar(255) DEFAULT 'authorization_code' NOT NULL,
token_endpoint_auth_method varchar(50) DEFAULT 'none' NOT NULL,
created_at datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY client_id (client_id)
) $charset_collate;" );
}
/**
* Drop OAuth tables. Called from uninstall.
*/
public static function drop_tables() {
global $wpdb;
$tokens_table = esc_sql( self::tokens_table() );
$clients_table = esc_sql( self::clients_table() );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( "DROP TABLE IF EXISTS `{$tokens_table}`" );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( "DROP TABLE IF EXISTS `{$clients_table}`" );
}
/* ------------------------------------------------------------------
* Authorization codes (stored as transients — short-lived)
* ----------------------------------------------------------------*/
/**
* Store an authorization code.
*
* @param string $code The raw authorization code.
* @param array $data Payload: user_id, client_id, redirect_uri, code_challenge, code_challenge_method, scope.
*/
public static function store_auth_code( $code, array $data ) {
$data['created_at'] = time();
set_transient( 'royal_mcp_authcode_' . hash( 'sha256', $code ), $data, self::AUTH_CODE_TTL );
}
/**
* Consume an authorization code (single-use).
*
* @param string $code The raw code presented by the client.
* @return array|false The stored data, or false if invalid/expired.
*/
public static function consume_auth_code( $code ) {
$key = 'royal_mcp_authcode_' . hash( 'sha256', $code );
$data = get_transient( $key );
// Immediately delete — codes are single-use.
delete_transient( $key );
return $data;
}
/* ------------------------------------------------------------------
* Access / Refresh tokens
* ----------------------------------------------------------------*/
/**
* Generate and store a token pair (access + refresh).
*
* @param string $client_id WordPress OAuth client ID.
* @param int $user_id WordPress user ID.
* @param string $scope Space-separated scopes.
* @return array [ 'access_token' => …, 'refresh_token' => …, 'expires_in' => … ]
*/
public static function create_token_pair( $client_id, $user_id, $scope = '' ) {
$access_token = bin2hex( random_bytes( 32 ) );
$refresh_token = bin2hex( random_bytes( 32 ) );
self::store_token( $access_token, 'access', $client_id, $user_id, $scope, self::ACCESS_TOKEN_TTL );
self::store_token( $refresh_token, 'refresh', $client_id, $user_id, $scope, self::REFRESH_TOKEN_TTL );
return [
'access_token' => $access_token,
'token_type' => 'Bearer',
'expires_in' => self::ACCESS_TOKEN_TTL,
'refresh_token' => $refresh_token,
'scope' => $scope,
];
}
/**
* Store a single token (hashed) in the database.
*/
private static function store_token( $raw_token, $type, $client_id, $user_id, $scope, $ttl ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->insert(
self::tokens_table(),
[
'token_hash' => hash( 'sha256', $raw_token ),
'token_type' => $type,
'client_id' => $client_id,
'user_id' => $user_id,
'scope' => $scope,
'expires_at' => gmdate( 'Y-m-d H:i:s', time() + $ttl ),
],
[ '%s', '%s', '%s', '%d', '%s', '%s' ]
);
}
/**
* Validate a Bearer token.
*
* @param string $raw_token The raw access token from the Authorization header.
* @return array|false Token row (with user_id, client_id, scope) or false.
*/
public static function validate_token( $raw_token ) {
global $wpdb;
$table = self::tokens_table();
$hash = hash( 'sha256', $raw_token );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from safe helper method.
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM `{$table}` WHERE token_hash = %s AND token_type = 'access' AND revoked = 0 AND expires_at > %s LIMIT 1",
$hash,
gmdate( 'Y-m-d H:i:s' )
),
ARRAY_A
);
return $row ? $row : false;
}
/**
* Validate and consume a refresh token (token rotation).
*
* @param string $raw_refresh_token The raw refresh token.
* @return array|false Token row or false.
*/
public static function consume_refresh_token( $raw_refresh_token ) {
global $wpdb;
$table = self::tokens_table();
$hash = hash( 'sha256', $raw_refresh_token );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from safe helper method.
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM `{$table}` WHERE token_hash = %s AND token_type = 'refresh' AND revoked = 0 AND expires_at > %s LIMIT 1",
$hash,
gmdate( 'Y-m-d H:i:s' )
),
ARRAY_A
);
if ( ! $row ) {
return false;
}
// Revoke the old refresh token (rotation).
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->update(
$table,
[ 'revoked' => 1 ],
[ 'id' => $row['id'] ],
[ '%d' ],
[ '%d' ]
);
return $row;
}
/**
* Revoke all tokens for a client+user combination.
*/
public static function revoke_tokens_for_user( $client_id, $user_id ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->update(
self::tokens_table(),
[ 'revoked' => 1 ],
[ 'client_id' => $client_id, 'user_id' => $user_id ],
[ '%d' ],
[ '%s', '%d' ]
);
}
/**
* Delete expired and revoked tokens. Called by scheduled cleanup.
*/
public static function cleanup_expired() {
global $wpdb;
$table = esc_sql( self::tokens_table() );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query(
$wpdb->prepare(
"DELETE FROM `{$table}` WHERE revoked = 1 OR expires_at < %s",
gmdate( 'Y-m-d H:i:s' )
)
);
}
/* ------------------------------------------------------------------
* Dynamic client registration
* ----------------------------------------------------------------*/
/**
* Register a new OAuth client.
*
* @param array $data Client registration data.
* @return array The stored client record (with generated client_id).
*/
public static function register_client( array $data ) {
global $wpdb;
$client_id = 'rmcp_' . bin2hex( random_bytes( 16 ) );
$client_secret = null;
$client_secret_hash = null;
$auth_method = isset( $data['token_endpoint_auth_method'] ) ? sanitize_text_field( $data['token_endpoint_auth_method'] ) : 'none';
if ( 'client_secret_post' === $auth_method ) {
$client_secret = bin2hex( random_bytes( 32 ) );
$client_secret_hash = hash( 'sha256', $client_secret );
}
$redirect_uris = isset( $data['redirect_uris'] ) && is_array( $data['redirect_uris'] )
? array_map( 'sanitize_url', $data['redirect_uris'] )
: [];
$client_name = isset( $data['client_name'] ) ? sanitize_text_field( $data['client_name'] ) : 'MCP Client';
$grant_types = isset( $data['grant_types'] ) && is_array( $data['grant_types'] )
? sanitize_text_field( implode( ' ', $data['grant_types'] ) )
: 'authorization_code';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->insert(
self::clients_table(),
[
'client_id' => $client_id,
'client_secret_hash' => $client_secret_hash,
'client_name' => $client_name,
'redirect_uris' => wp_json_encode( $redirect_uris ),
'grant_types' => $grant_types,
'token_endpoint_auth_method' => $auth_method,
],
[ '%s', '%s', '%s', '%s', '%s', '%s' ]
);
$result = [
'client_id' => $client_id,
'client_name' => $client_name,
'redirect_uris' => $redirect_uris,
'grant_types' => explode( ' ', $grant_types ),
'token_endpoint_auth_method' => $auth_method,
'response_types' => [ 'code' ],
'client_id_issued_at' => time(),
];
if ( $client_secret ) {
$result['client_secret'] = $client_secret;
}
return $result;
}
/**
* Look up a registered client by client_id.
*
* Checks the database first (dynamic clients), then falls back
* to the static client configured in plugin settings.
*
* @param string $client_id The client ID.
* @return array|false Client row or false.
*/
public static function get_client( $client_id ) {
global $wpdb;
$table = self::clients_table();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from safe helper method.
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM `{$table}` WHERE client_id = %s LIMIT 1", $client_id ),
ARRAY_A
);
if ( $row ) {
$row['redirect_uris'] = json_decode( $row['redirect_uris'], true ) ?: [];
return $row;
}
// Check static client from settings.
$settings = get_option( 'royal_mcp_settings', [] );
if ( ! empty( $settings['oauth_client_id'] ) && hash_equals( $settings['oauth_client_id'], $client_id ) ) {
return [
'client_id' => $settings['oauth_client_id'],
'client_secret_hash' => ! empty( $settings['oauth_client_secret'] ) ? hash( 'sha256', $settings['oauth_client_secret'] ) : null,
'client_name' => get_bloginfo( 'name' ) . ' (static)',
'redirect_uris' => [], // Static clients accept any localhost/HTTPS redirect.
'grant_types' => 'authorization_code',
'token_endpoint_auth_method' => ! empty( $settings['oauth_client_secret'] ) ? 'client_secret_post' : 'none',
'is_static' => true,
];
}
return false;
}
/**
* Validate a redirect URI against a client's registered URIs.
*
* @param string $redirect_uri The URI to validate.
* @param array $client The client record from get_client().
* @return bool True if allowed.
*/
public static function validate_redirect_uri( $redirect_uri, $client ) {
// Must be localhost (any port) or HTTPS.
$parsed = wp_parse_url( $redirect_uri );
if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) {
return false;
}
$is_localhost = in_array( $parsed['host'], [ 'localhost', '127.0.0.1', '::1' ], true );
if ( ! $is_localhost && 'https' !== $parsed['scheme'] ) {
return false;
}
// Static clients (from settings) accept any valid localhost/HTTPS URI.
if ( ! empty( $client['is_static'] ) ) {
return true;
}
// Dynamic clients: exact match required.
$registered = $client['redirect_uris'] ?? [];
if ( empty( $registered ) ) {
return true; // No URIs registered = accept any valid one (matches Claude Desktop behavior).
}
return in_array( $redirect_uri, $registered, true );
}
}
@@ -0,0 +1,2 @@
<?php
// Silence is golden.