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:
2026-10-16 21:43:32 +00:00
parent 9a0f3bb03a
commit c63762487c
+91 -1
View File
@@ -6,10 +6,17 @@ defined('ABSPATH') || exit;
/** /**
* OAuth 2.0 server with: * OAuth 2.0 server with:
* - Dynamic Client Registration (RFC 7591) for MCP clients (Claude.ai)
* - PKCE (S256) verification on token exchange * - 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) * - Short-lived JWT access tokens (not raw API keys)
* - authorization_code + client_credentials grants * - 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 { class OAuthServer {
@@ -20,7 +27,15 @@ class OAuthServer {
'127.0.0.1', '127.0.0.1',
]; ];
private const CLIENTS_OPTION = 'asktam_mcp_oauth_clients';
public function registerRoutes(): void { 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', [ register_rest_route('asktam-mcp/v1', '/oauth/authorize', [
'methods' => ['GET', 'POST'], 'methods' => ['GET', 'POST'],
'callback' => [$this, 'handleAuthorize'], 'callback' => [$this, 'handleAuthorize'],
@@ -48,6 +63,70 @@ class OAuthServer {
}, 20); }, 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 // Authorization endpoint
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -64,6 +143,16 @@ class OAuthServer {
wp_die('Invalid redirect_uri: host not in allowlist.', 'MCP Security Error', ['response' => 400]); 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 // 2. Validate client_id
global $wpdb; global $wpdb;
$table = $wpdb->prefix . 'asktam_mcp_keys'; $table = $wpdb->prefix . 'asktam_mcp_keys';
@@ -215,6 +304,7 @@ class OAuthServer {
'issuer' => get_site_url(), 'issuer' => get_site_url(),
'authorization_endpoint' => $base . '/oauth/authorize', 'authorization_endpoint' => $base . '/oauth/authorize',
'token_endpoint' => $base . '/oauth/token', 'token_endpoint' => $base . '/oauth/token',
'registration_endpoint' => $base . '/oauth/register',
'token_endpoint_auth_methods_supported' => ['client_secret_post', 'client_secret_basic', 'none'], 'token_endpoint_auth_methods_supported' => ['client_secret_post', 'client_secret_basic', 'none'],
'grant_types_supported' => ['authorization_code', 'client_credentials'], 'grant_types_supported' => ['authorization_code', 'client_credentials'],
'response_types_supported' => ['code'], 'response_types_supported' => ['code'],