165 lines
6.5 KiB
PHP
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);
|
|
}
|
|
}
|