feat(oauth): add Dynamic Client Registration (RFC 7591) so Claude.ai can connect
- /oauth/register creates a public (PKCE) client; client_id uses 'mcpc_' prefix so it is NOT accepted as a raw bearer by TokenManager::resolve() — only the human-approved JWT works - registered redirect_uris stored + enforced exactly at /authorize - registration_endpoint advertised in AS metadata - fixes Claude.ai reconnect loop (DCR was missing; /oauth/register was 404) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8t9GEyyLZbUQu26na1JQg
This commit is contained in:
@@ -6,10 +6,17 @@ defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* OAuth 2.0 server with:
|
||||
* - Dynamic Client Registration (RFC 7591) for MCP clients (Claude.ai)
|
||||
* - PKCE (S256) verification on token exchange
|
||||
* - redirect_uri allowlist per key
|
||||
* - redirect_uri allowlist per key + per registered client
|
||||
* - Short-lived JWT access tokens (not raw API keys)
|
||||
* - authorization_code + client_credentials grants
|
||||
*
|
||||
* Security note on DCR: registered client_ids use the 'mcpc_' prefix so they are
|
||||
* NOT accepted as raw bearer tokens by TokenManager::resolve() (which only accepts
|
||||
* 'mcp_'-prefixed raw keys). A registered client therefore cannot act as a credential
|
||||
* on its own — the only usable token is the short-lived JWT, which is issued only
|
||||
* after the site owner approves on the consent screen.
|
||||
*/
|
||||
class OAuthServer {
|
||||
|
||||
@@ -20,7 +27,15 @@ class OAuthServer {
|
||||
'127.0.0.1',
|
||||
];
|
||||
|
||||
private const CLIENTS_OPTION = 'asktam_mcp_oauth_clients';
|
||||
|
||||
public function registerRoutes(): void {
|
||||
register_rest_route('asktam-mcp/v1', '/oauth/register', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'handleRegister'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
register_rest_route('asktam-mcp/v1', '/oauth/authorize', [
|
||||
'methods' => ['GET', 'POST'],
|
||||
'callback' => [$this, 'handleAuthorize'],
|
||||
@@ -48,6 +63,70 @@ class OAuthServer {
|
||||
}, 20);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Dynamic Client Registration (RFC 7591)
|
||||
// -------------------------------------------------------------------------
|
||||
public function handleRegister(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$body = $request->get_json_params();
|
||||
if (!is_array($body) || empty($body)) {
|
||||
$body = $request->get_params();
|
||||
}
|
||||
|
||||
$redirectUris = $body['redirect_uris'] ?? [];
|
||||
if (is_string($redirectUris)) $redirectUris = [$redirectUris];
|
||||
if (empty($redirectUris) || !is_array($redirectUris)) {
|
||||
return $this->oauthError('invalid_redirect_uri', 'redirect_uris is required', 400);
|
||||
}
|
||||
|
||||
$clean = [];
|
||||
foreach ($redirectUris as $uri) {
|
||||
$uri = esc_url_raw((string) $uri);
|
||||
if (!$uri || !$this->isRedirectUriAllowed($uri)) {
|
||||
return $this->oauthError('invalid_redirect_uri', 'redirect_uri host not allowed: ' . $uri, 400);
|
||||
}
|
||||
$clean[] = $uri;
|
||||
}
|
||||
|
||||
$clientName = sanitize_text_field($body['client_name'] ?? 'MCP Client');
|
||||
if ($clientName === '') $clientName = 'MCP Client';
|
||||
|
||||
// DCR clients are public (PKCE); we do not issue a client_secret.
|
||||
$scopes = 'read,write,admin,woocommerce';
|
||||
$clientId = 'mcpc_' . bin2hex(random_bytes(24));
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'asktam_mcp_keys';
|
||||
$wpdb->insert($table, [
|
||||
'name' => 'oauth: ' . $clientName,
|
||||
'api_key' => $clientId,
|
||||
'scopes' => $scopes,
|
||||
'created_at' => current_time('mysql'),
|
||||
'is_active' => 1,
|
||||
]);
|
||||
|
||||
// Store the registered redirect_uris so /authorize can enforce an exact match.
|
||||
$reg = get_option(self::CLIENTS_OPTION, []);
|
||||
if (!is_array($reg)) $reg = [];
|
||||
$reg[$clientId] = [
|
||||
'client_name' => $clientName,
|
||||
'redirect_uris' => $clean,
|
||||
'created_at' => time(),
|
||||
];
|
||||
if (count($reg) > 50) $reg = array_slice($reg, -50, 50, true); // bound growth
|
||||
update_option(self::CLIENTS_OPTION, $reg, false);
|
||||
|
||||
return new \WP_REST_Response([
|
||||
'client_id' => $clientId,
|
||||
'client_id_issued_at' => time(),
|
||||
'redirect_uris' => $clean,
|
||||
'grant_types' => ['authorization_code'],
|
||||
'response_types' => ['code'],
|
||||
'token_endpoint_auth_method' => 'none',
|
||||
'client_name' => $clientName,
|
||||
'scope' => str_replace(',', ' ', $scopes),
|
||||
], 201);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Authorization endpoint
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -64,6 +143,16 @@ class OAuthServer {
|
||||
wp_die('Invalid redirect_uri: host not in allowlist.', 'MCP Security Error', ['response' => 400]);
|
||||
}
|
||||
|
||||
// 1b. If this client_id was issued via DCR, enforce its registered redirect_uris exactly.
|
||||
$reg = get_option(self::CLIENTS_OPTION, []);
|
||||
if (is_array($reg) && isset($reg[$clientId])) {
|
||||
$registered = $reg[$clientId]['redirect_uris'] ?? [];
|
||||
if (!empty($registered) && !in_array($redirectUri, $registered, true)) {
|
||||
$this->redirectError($redirectUri, 'invalid_request', $state, 'redirect_uri not registered for this client');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Validate client_id
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'asktam_mcp_keys';
|
||||
@@ -215,6 +304,7 @@ class OAuthServer {
|
||||
'issuer' => get_site_url(),
|
||||
'authorization_endpoint' => $base . '/oauth/authorize',
|
||||
'token_endpoint' => $base . '/oauth/token',
|
||||
'registration_endpoint' => $base . '/oauth/register',
|
||||
'token_endpoint_auth_methods_supported' => ['client_secret_post', 'client_secret_basic', 'none'],
|
||||
'grant_types_supported' => ['authorization_code', 'client_credentials'],
|
||||
'response_types_supported' => ['code'],
|
||||
|
||||
Reference in New Issue
Block a user