Files
advanced-mcp-plugin/includes/MCP/OAuthServer.php
T

165 lines
6.5 KiB
PHP

<?php
namespace AskTamMCP\MCP;
defined('ABSPATH') || exit;
/**
* Minimal OAuth 2.0 server for Claude.ai connector compatibility.
*
* Implements client_credentials grant only.
*
* Flow:
* 1. Claude.ai calls POST /oauth/token with client_id + client_secret
* 2. Plugin validates against wp_asktam_mcp_keys (api_key = client_id = client_secret)
* 3. Plugin returns { access_token, token_type: "bearer", expires_in }
* 4. Claude.ai uses Authorization: Bearer <access_token> on all SSE/message calls
* 5. Auth::validate() already handles Bearer — no further changes needed
*
* Claude.ai connector setup:
* URL: https://yoursite.com/wp-json/asktam-mcp/v1/sse
* OAuth Client ID: your mcp_xxx key
* Token field: your mcp_xxx key (used as client_secret)
*/
class OAuthServer {
public function registerRoutes(): void {
// Token endpoint — Claude.ai POSTs here to exchange client credentials for Bearer token
register_rest_route('asktam-mcp/v1', '/oauth/token', [
'methods' => 'POST',
'callback' => [$this, 'handleToken'],
'permission_callback' => '__return_true',
]);
// Authorization server metadata — Claude.ai fetches this to discover token endpoint
register_rest_route('asktam-mcp/v1', '/oauth/metadata', [
'methods' => 'GET',
'callback' => [$this, 'handleMetadata'],
'permission_callback' => '__return_true',
]);
// Also register at /.well-known/oauth-authorization-server for auto-discovery
// (Claude.ai checks this path relative to the SSE URL's origin)
add_action('rest_api_init', function() {
register_rest_route('', '/.well-known/oauth-authorization-server', [
'methods' => 'GET',
'callback' => [$this, 'handleWellKnown'],
'permission_callback' => '__return_true',
]);
}, 20);
}
/**
* POST /wp-json/asktam-mcp/v1/oauth/token
*
* Accepts:
* grant_type=client_credentials
* client_id=mcp_xxx
* client_secret=mcp_xxx
*
* Also accepts Basic auth header: Authorization: Basic base64(client_id:client_secret)
*/
public function handleToken(\WP_REST_Request $request): \WP_REST_Response {
// Extract credentials — try body params first, then Basic auth header
[$clientId, $clientSecret] = $this->extractCredentials($request);
if (empty($clientId) || empty($clientSecret)) {
return $this->oauthError('invalid_client', 'Missing client_id or client_secret', 401);
}
$grantType = $request->get_param('grant_type') ?? 'client_credentials';
if ($grantType !== 'client_credentials') {
return $this->oauthError('unsupported_grant_type', 'Only client_credentials grant is supported', 400);
}
// Validate: client_id must match a valid active key
// client_secret must equal client_id (single-key model — the API key IS both)
global $wpdb;
$table = $wpdb->prefix . 'asktam_mcp_keys';
$row = $wpdb->get_row(
$wpdb->prepare("SELECT * FROM {$table} WHERE api_key = %s AND is_active = 1", $clientId),
ARRAY_A
);
if (!$row) {
return $this->oauthError('invalid_client', 'Invalid or inactive client_id', 401);
}
// Validate secret matches client_id (our simple model)
if (!hash_equals($clientId, $clientSecret)) {
return $this->oauthError('invalid_client', 'client_secret does not match client_id', 401);
}
// Issue access token — store as transient, TTL 1 hour
// Token IS the api_key itself (stateless — Auth::validate handles it)
// This way we don't need a separate token store
$accessToken = $clientId;
$expiresIn = 3600;
// Update last_used
$wpdb->update($table, ['last_used' => current_time('mysql')], ['api_key' => $clientId]);
return new \WP_REST_Response([
'access_token' => $accessToken,
'token_type' => 'bearer',
'expires_in' => $expiresIn,
'scope' => $row['scopes'],
], 200);
}
/**
* OAuth Authorization Server Metadata (RFC 8414)
* Claude.ai uses this to discover the token endpoint.
*/
public function handleMetadata(): \WP_REST_Response {
$base = rest_url('asktam-mcp/v1');
return new \WP_REST_Response([
'issuer' => get_site_url(),
'token_endpoint' => $base . '/oauth/token',
'token_endpoint_auth_methods_supported' => ['client_secret_post', 'client_secret_basic'],
'grant_types_supported' => ['client_credentials'],
'response_types_supported' => ['token'],
'scopes_supported' => ['read', 'write', 'admin', 'woocommerce'],
]);
}
/**
* /.well-known/oauth-authorization-server
* Standard discovery endpoint — Claude.ai checks this against the SSE URL origin.
*/
public function handleWellKnown(): \WP_REST_Response {
return $this->handleMetadata();
}
// -------------------------------------------------------------------------
private function extractCredentials(\WP_REST_Request $request): array {
// Try body params first (client_secret_post)
$clientId = $request->get_param('client_id');
$clientSecret = $request->get_param('client_secret');
if (!empty($clientId) && !empty($clientSecret)) {
return [$clientId, $clientSecret];
}
// Try Basic auth (client_secret_basic)
$authHeader = $request->get_header('Authorization');
if ($authHeader && str_starts_with($authHeader, 'Basic ')) {
$decoded = base64_decode(substr($authHeader, 6));
if ($decoded && str_contains($decoded, ':')) {
[$clientId, $clientSecret] = explode(':', $decoded, 2);
return [trim($clientId), trim($clientSecret)];
}
}
return ['', ''];
}
private function oauthError(string $error, string $description, int $status): \WP_REST_Response {
return new \WP_REST_Response([
'error' => $error,
'error_description' => $description,
], $status);
}
}