feat: full authorization_code OAuth flow with approval UI
This commit is contained in:
+195
-71
@@ -5,75 +5,65 @@ namespace AskTamMCP\MCP;
|
|||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal OAuth 2.0 server for Claude.ai connector compatibility.
|
* OAuth 2.0 server for Claude.ai connector compatibility.
|
||||||
*
|
*
|
||||||
* Implements client_credentials grant only.
|
* Claude.ai uses authorization_code flow (not client_credentials):
|
||||||
*
|
* 1. Claude.ai redirects user to /oauth/authorize?client_id=...&redirect_uri=...&state=...
|
||||||
* Flow:
|
* 2. Plugin shows a simple approval page — user clicks Approve
|
||||||
* 1. Claude.ai calls POST /oauth/token with client_id + client_secret
|
* 3. Plugin redirects back to redirect_uri with ?code=...&state=...
|
||||||
* 2. Plugin validates against wp_asktam_mcp_keys (api_key = client_id = client_secret)
|
* 4. Claude.ai POSTs to /oauth/token with the code to exchange for access_token
|
||||||
* 3. Plugin returns { access_token, token_type: "bearer", expires_in }
|
* 5. Plugin returns { access_token, token_type: "bearer" }
|
||||||
* 4. Claude.ai uses Authorization: Bearer <access_token> on all SSE/message calls
|
* 6. Claude.ai uses Bearer token on all SSE calls
|
||||||
* 5. Auth::validate() already handles Bearer — no further changes needed
|
|
||||||
*
|
*
|
||||||
* Claude.ai connector setup:
|
* Claude.ai connector setup:
|
||||||
* URL: https://yoursite.com/wp-json/asktam-mcp/v1/sse
|
* URL: https://yoursite.com/wp-json/asktam-mcp/v1/sse
|
||||||
* OAuth Client ID: your mcp_xxx key
|
* OAuth Client ID: your mcp_xxx key
|
||||||
* Token field: your mcp_xxx key (used as client_secret)
|
* Client Secret: your mcp_xxx key (same value)
|
||||||
*/
|
*/
|
||||||
class OAuthServer {
|
class OAuthServer {
|
||||||
|
|
||||||
public function registerRoutes(): void {
|
public function registerRoutes(): void {
|
||||||
// Token endpoint — Claude.ai POSTs here to exchange client credentials for Bearer token
|
// Authorization endpoint — Claude.ai redirects user here
|
||||||
|
register_rest_route('asktam-mcp/v1', '/oauth/authorize', [
|
||||||
|
'methods' => ['GET', 'POST'],
|
||||||
|
'callback' => [$this, 'handleAuthorize'],
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Token endpoint — Claude.ai POSTs here to exchange code for token
|
||||||
register_rest_route('asktam-mcp/v1', '/oauth/token', [
|
register_rest_route('asktam-mcp/v1', '/oauth/token', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [$this, 'handleToken'],
|
'callback' => [$this, 'handleToken'],
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Authorization server metadata — Claude.ai fetches this to discover token endpoint
|
// RFC 8414 discovery — Claude.ai fetches this to find all endpoints
|
||||||
register_rest_route('asktam-mcp/v1', '/oauth/metadata', [
|
register_rest_route('asktam-mcp/v1', '/oauth/metadata', [
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => [$this, 'handleMetadata'],
|
'callback' => [$this, 'handleMetadata'],
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Also register at /.well-known/oauth-authorization-server for auto-discovery
|
// Well-known discovery at site root (Claude.ai checks this)
|
||||||
// (Claude.ai checks this path relative to the SSE URL's origin)
|
|
||||||
add_action('rest_api_init', function () {
|
add_action('rest_api_init', function () {
|
||||||
register_rest_route('', '/.well-known/oauth-authorization-server', [
|
register_rest_route('', '/.well-known/oauth-authorization-server', [
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => [$this, 'handleWellKnown'],
|
'callback' => [$this, 'handleMetadata'],
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
]);
|
]);
|
||||||
}, 20);
|
}, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// -------------------------------------------------------------------------
|
||||||
* POST /wp-json/asktam-mcp/v1/oauth/token
|
// Authorization endpoint — shows approval UI, issues code on POST
|
||||||
*
|
// -------------------------------------------------------------------------
|
||||||
* Accepts:
|
public function handleAuthorize(\WP_REST_Request $request): void {
|
||||||
* grant_type=client_credentials
|
$clientId = $request->get_param('client_id') ?? '';
|
||||||
* client_id=mcp_xxx
|
$redirectUri = $request->get_param('redirect_uri') ?? '';
|
||||||
* client_secret=mcp_xxx
|
$state = $request->get_param('state') ?? '';
|
||||||
*
|
$approved = $request->get_param('_mcp_approve') ?? '';
|
||||||
* 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)) {
|
// Validate client_id
|
||||||
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;
|
global $wpdb;
|
||||||
$table = $wpdb->prefix . 'asktam_mcp_keys';
|
$table = $wpdb->prefix . 'asktam_mcp_keys';
|
||||||
$row = $wpdb->get_row(
|
$row = $wpdb->get_row(
|
||||||
@@ -82,59 +72,194 @@ class OAuthServer {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
return $this->oauthError('invalid_client', 'Invalid or inactive client_id', 401);
|
wp_die('Invalid client_id. Check your API key in WP Admin → AskTam MCP.', 'MCP Auth Error', ['response' => 401]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate secret matches client_id (our simple model)
|
// User approved — issue code and redirect
|
||||||
if (!hash_equals($clientId, $clientSecret)) {
|
if ($approved === '1' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
return $this->oauthError('invalid_client', 'client_secret does not match client_id', 401);
|
// Generate a short-lived auth code (stored as transient, 5 min TTL)
|
||||||
|
$code = 'mcp_code_' . bin2hex(random_bytes(16));
|
||||||
|
set_transient('asktam_mcp_code_' . $code, [
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'redirect_uri' => $redirectUri,
|
||||||
|
'scopes' => $row['scopes'],
|
||||||
|
], 300);
|
||||||
|
|
||||||
|
$redirectBack = add_query_arg(['code' => $code, 'state' => $state], $redirectUri);
|
||||||
|
wp_redirect($redirectBack);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue access token — store as transient, TTL 1 hour
|
// Show approval page
|
||||||
// Token IS the api_key itself (stateless — Auth::validate handles it)
|
$siteName = get_bloginfo('name');
|
||||||
// This way we don't need a separate token store
|
$keyName = esc_html($row['name']);
|
||||||
$accessToken = $clientId;
|
$scopes = esc_html($row['scopes']);
|
||||||
$expiresIn = 3600;
|
$actionUrl = rest_url('asktam-mcp/v1/oauth/authorize');
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Authorize — <?php echo esc_html($siteName); ?></title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 20px; }
|
||||||
|
.card { background: #fff; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.10); max-width: 420px; width: 100%; padding: 36px 32px; }
|
||||||
|
.logo { display: flex; align-items: center; gap: 10px; margin-bottom: 28px; }
|
||||||
|
.logo svg { flex-shrink: 0; }
|
||||||
|
.logo strong { font-size: 18px; color: #1a1a1a; }
|
||||||
|
h2 { font-size: 20px; color: #1a1a1a; margin-bottom: 8px; }
|
||||||
|
p { color: #555; font-size: 14px; line-height: 1.6; margin-bottom: 20px; }
|
||||||
|
.key-info { background: #f0f6fc; border: 1px solid #c0d8f0; border-radius: 8px; padding: 14px 16px; margin-bottom: 24px; }
|
||||||
|
.key-info .label { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: #888; margin-bottom: 4px; }
|
||||||
|
.key-info .value { font-size: 14px; font-weight: 600; color: #1a1a1a; }
|
||||||
|
.scopes { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||||
|
.scope { background: #ede9fe; color: #5b21b6; border-radius: 4px; padding: 2px 8px; font-size: 12px; font-weight: 500; }
|
||||||
|
.actions { display: flex; gap: 10px; }
|
||||||
|
.btn { flex: 1; padding: 11px; border-radius: 7px; border: none; font-size: 15px; font-weight: 600; cursor: pointer; }
|
||||||
|
.btn-approve { background: #2271b1; color: #fff; }
|
||||||
|
.btn-approve:hover { background: #1a5a8a; }
|
||||||
|
.btn-deny { background: #f6f7f7; color: #333; border: 1px solid #ddd; }
|
||||||
|
.btn-deny:hover { background: #eee; }
|
||||||
|
.footer { margin-top: 20px; font-size: 12px; color: #999; text-align: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="logo">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none"><circle cx="16" cy="16" r="16" fill="#2271b1"/><path d="M10 22L16 10L22 22" stroke="white" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 18H20" stroke="white" stroke-width="2.2" stroke-linecap="round"/></svg>
|
||||||
|
<strong>AskTam MCP — <?php echo esc_html($siteName); ?></strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
// Update last_used
|
<h2>Authorize Access</h2>
|
||||||
$wpdb->update($table, ['last_used' => current_time('mysql')], ['api_key' => $clientId]);
|
<p>An external AI client is requesting access to your WordPress site using the following API key:</p>
|
||||||
|
|
||||||
|
<div class="key-info">
|
||||||
|
<div class="label">API Key</div>
|
||||||
|
<div class="value"><?php echo $keyName; ?></div>
|
||||||
|
<div class="scopes">
|
||||||
|
<?php foreach (array_map('trim', explode(',', $row['scopes'])) as $s): ?>
|
||||||
|
<span class="scope"><?php echo esc_html($s); ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="<?php echo esc_url($actionUrl); ?>">
|
||||||
|
<input type="hidden" name="client_id" value="<?php echo esc_attr($clientId); ?>">
|
||||||
|
<input type="hidden" name="redirect_uri" value="<?php echo esc_attr($redirectUri); ?>">
|
||||||
|
<input type="hidden" name="state" value="<?php echo esc_attr($state); ?>">
|
||||||
|
<input type="hidden" name="_mcp_approve" value="1">
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn btn-approve">Approve</button>
|
||||||
|
<button type="button" class="btn btn-deny" onclick="window.location='<?php echo esc_js(add_query_arg(['error' => 'access_denied', 'state' => $state], $redirectUri)); ?>'">Deny</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="footer">Only approve if you initiated this connection from Claude.ai or another trusted MCP client.</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Token endpoint — exchanges auth code or client_credentials for access token
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
public function handleToken(\WP_REST_Request $request): \WP_REST_Response {
|
||||||
|
$grantType = $request->get_param('grant_type') ?? '';
|
||||||
|
|
||||||
|
if ($grantType === 'authorization_code') {
|
||||||
|
return $this->handleAuthCodeExchange($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($grantType === 'client_credentials') {
|
||||||
|
return $this->handleClientCredentials($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->oauthError('unsupported_grant_type', 'Supported: authorization_code, client_credentials', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleAuthCodeExchange(\WP_REST_Request $request): \WP_REST_Response {
|
||||||
|
$code = $request->get_param('code') ?? '';
|
||||||
|
$redirectUri = $request->get_param('redirect_uri') ?? '';
|
||||||
|
|
||||||
|
[$clientId, $clientSecret] = $this->extractCredentials($request);
|
||||||
|
|
||||||
|
if (empty($code)) {
|
||||||
|
return $this->oauthError('invalid_request', 'Missing code', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve and validate the auth code transient
|
||||||
|
$codeData = get_transient('asktam_mcp_code_' . $code);
|
||||||
|
if (!$codeData) {
|
||||||
|
return $this->oauthError('invalid_grant', 'Authorization code expired or invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete_transient('asktam_mcp_code_' . $code);
|
||||||
|
|
||||||
|
// Validate client_id matches
|
||||||
|
if (!empty($clientId) && $clientId !== $codeData['client_id']) {
|
||||||
|
return $this->oauthError('invalid_client', 'client_id mismatch', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The access token IS the api_key — stateless, Auth::validate handles it
|
||||||
|
return new \WP_REST_Response([
|
||||||
|
'access_token' => $codeData['client_id'],
|
||||||
|
'token_type' => 'bearer',
|
||||||
|
'expires_in' => 3600 * 24 * 30, // 30 days
|
||||||
|
'scope' => $codeData['scopes'],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleClientCredentials(\WP_REST_Request $request): \WP_REST_Response {
|
||||||
|
[$clientId, $clientSecret] = $this->extractCredentials($request);
|
||||||
|
|
||||||
|
if (empty($clientId) || empty($clientSecret)) {
|
||||||
|
return $this->oauthError('invalid_client', 'Missing client_id or client_secret', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || !hash_equals($clientId, $clientSecret)) {
|
||||||
|
return $this->oauthError('invalid_client', 'Invalid credentials', 401);
|
||||||
|
}
|
||||||
|
|
||||||
return new \WP_REST_Response([
|
return new \WP_REST_Response([
|
||||||
'access_token' => $accessToken,
|
'access_token' => $clientId,
|
||||||
'token_type' => 'bearer',
|
'token_type' => 'bearer',
|
||||||
'expires_in' => $expiresIn,
|
'expires_in' => 3600,
|
||||||
'scope' => $row['scopes'],
|
'scope' => $row['scopes'],
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// -------------------------------------------------------------------------
|
||||||
* OAuth Authorization Server Metadata (RFC 8414)
|
// RFC 8414 metadata — Claude.ai discovers all endpoints from here
|
||||||
* Claude.ai uses this to discover the token endpoint.
|
// -------------------------------------------------------------------------
|
||||||
*/
|
|
||||||
public function handleMetadata(): \WP_REST_Response {
|
public function handleMetadata(): \WP_REST_Response {
|
||||||
$base = rest_url('asktam-mcp/v1');
|
$base = rest_url('asktam-mcp/v1');
|
||||||
return new \WP_REST_Response([
|
return new \WP_REST_Response([
|
||||||
'issuer' => get_site_url(),
|
'issuer' => get_site_url(),
|
||||||
|
'authorization_endpoint' => $base . '/oauth/authorize',
|
||||||
'token_endpoint' => $base . '/oauth/token',
|
'token_endpoint' => $base . '/oauth/token',
|
||||||
'token_endpoint_auth_methods_supported' => ['client_secret_post', 'client_secret_basic'],
|
'token_endpoint_auth_methods_supported' => ['client_secret_post', 'client_secret_basic', 'none'],
|
||||||
'grant_types_supported' => ['client_credentials'],
|
'grant_types_supported' => ['authorization_code', 'client_credentials'],
|
||||||
'response_types_supported' => ['token'],
|
'response_types_supported' => ['code'],
|
||||||
|
'code_challenge_methods_supported' => ['S256', 'plain'],
|
||||||
'scopes_supported' => ['read', 'write', 'admin', 'woocommerce'],
|
'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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
private function extractCredentials(\WP_REST_Request $request): array {
|
private function extractCredentials(\WP_REST_Request $request): array {
|
||||||
// Try body params first (client_secret_post)
|
|
||||||
$clientId = $request->get_param('client_id');
|
$clientId = $request->get_param('client_id');
|
||||||
$clientSecret = $request->get_param('client_secret');
|
$clientSecret = $request->get_param('client_secret');
|
||||||
|
|
||||||
@@ -142,7 +267,6 @@ class OAuthServer {
|
|||||||
return [$clientId, $clientSecret];
|
return [$clientId, $clientSecret];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try Basic auth (client_secret_basic)
|
|
||||||
$authHeader = $request->get_header('Authorization');
|
$authHeader = $request->get_header('Authorization');
|
||||||
if ($authHeader && str_starts_with($authHeader, 'Basic ')) {
|
if ($authHeader && str_starts_with($authHeader, 'Basic ')) {
|
||||||
$decoded = base64_decode(substr($authHeader, 6));
|
$decoded = base64_decode(substr($authHeader, 6));
|
||||||
@@ -152,7 +276,7 @@ class OAuthServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['', ''];
|
return [$clientId ?? '', ''];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function oauthError(string $error, string $description, int $status): \WP_REST_Response {
|
private function oauthError(string $error, string $description, int $status): \WP_REST_Response {
|
||||||
|
|||||||
Reference in New Issue
Block a user