Initial geladen: WP App Portal
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user