fix: output raw HTML from authorize endpoint, bypass REST JSON wrapper

This commit is contained in:
2026-05-08 21:45:30 +00:00
parent 3b2bcf0932
commit f5d82ac3dd
+75 -82
View File
@@ -4,47 +4,31 @@ namespace AskTamMCP\MCP;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/**
* OAuth 2.0 server for Claude.ai connector compatibility.
*
* Claude.ai uses authorization_code flow (not client_credentials):
* 1. Claude.ai redirects user to /oauth/authorize?client_id=...&redirect_uri=...&state=...
* 2. Plugin shows a simple approval page — user clicks Approve
* 3. Plugin redirects back to redirect_uri with ?code=...&state=...
* 4. Claude.ai POSTs to /oauth/token with the code to exchange for access_token
* 5. Plugin returns { access_token, token_type: "bearer" }
* 6. Claude.ai uses Bearer token on all SSE calls
*
* Claude.ai connector setup:
* URL: https://yoursite.com/wp-json/asktam-mcp/v1/sse
* OAuth Client ID: your mcp_xxx key
* Client Secret: your mcp_xxx key (same value)
*/
class OAuthServer { class OAuthServer {
public function registerRoutes(): void { public function registerRoutes(): void {
// Authorization endpoint — Claude.ai redirects user here // Authorization endpoint
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'],
'permission_callback' => '__return_true', 'permission_callback' => '__return_true',
]); ]);
// Token endpoint — Claude.ai POSTs here to exchange code for token // Token endpoint
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',
]); ]);
// RFC 8414 discovery — Claude.ai fetches this to find all endpoints // Metadata
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',
]); ]);
// Well-known discovery at site root (Claude.ai checks this) // Well-known
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',
@@ -55,13 +39,13 @@ class OAuthServer {
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Authorization endpoint — shows approval UI, issues code on POST // Authorization endpoint — must output raw HTML and die, bypassing REST wrapper
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
public function handleAuthorize(\WP_REST_Request $request): void { public function handleAuthorize(\WP_REST_Request $request): void {
$clientId = $request->get_param('client_id') ?? ''; $clientId = sanitize_text_field($request->get_param('client_id') ?? '');
$redirectUri = $request->get_param('redirect_uri') ?? ''; $redirectUri = esc_url_raw($request->get_param('redirect_uri') ?? '');
$state = $request->get_param('state') ?? ''; $state = sanitize_text_field($request->get_param('state') ?? '');
$approved = $request->get_param('_mcp_approve') ?? ''; $approved = sanitize_text_field($request->get_param('_mcp_approve') ?? '');
// Validate client_id // Validate client_id
global $wpdb; global $wpdb;
@@ -72,12 +56,19 @@ class OAuthServer {
); );
if (!$row) { if (!$row) {
wp_die('Invalid client_id. Check your API key in WP Admin → AskTam MCP.', 'MCP Auth Error', ['response' => 401]); // Send user back with error
if ($redirectUri) {
wp_redirect(add_query_arg(['error' => 'invalid_client', 'state' => $state], $redirectUri));
exit;
}
status_header(401);
header('Content-Type: text/plain');
echo 'Invalid client_id. Check your API key in WP Admin → AskTam MCP.';
exit;
} }
// User approved — issue code and redirect // POST: user clicked Approve
if ($approved === '1' && $_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST' && $approved === '1') {
// Generate a short-lived auth code (stored as transient, 5 min TTL)
$code = 'mcp_code_' . bin2hex(random_bytes(16)); $code = 'mcp_code_' . bin2hex(random_bytes(16));
set_transient('asktam_mcp_code_' . $code, [ set_transient('asktam_mcp_code_' . $code, [
'client_id' => $clientId, 'client_id' => $clientId,
@@ -85,61 +76,69 @@ class OAuthServer {
'scopes' => $row['scopes'], 'scopes' => $row['scopes'],
], 300); ], 300);
$redirectBack = add_query_arg(['code' => $code, 'state' => $state], $redirectUri); wp_redirect(add_query_arg(['code' => $code, 'state' => $state], $redirectUri));
wp_redirect($redirectBack);
exit; exit;
} }
// Show approval page // GET: render approval page — output raw HTML, bypass REST JSON wrapper
$siteName = get_bloginfo('name'); $siteName = get_bloginfo('name');
$keyName = esc_html($row['name']); $keyName = esc_html($row['name']);
$scopes = esc_html($row['scopes']); $scopes = array_map('trim', explode(',', $row['scopes']));
$actionUrl = rest_url('asktam-mcp/v1/oauth/authorize'); $actionUrl = rest_url('asktam-mcp/v1/oauth/authorize');
// Send correct headers BEFORE any output
status_header(200);
header('Content-Type: text/html; charset=UTF-8');
header('X-Robots-Tag: noindex');
// Remove REST API JSON headers that WP might have set
header_remove('Content-Type'); // remove any previously set
header('Content-Type: text/html; charset=UTF-8');
// Output and die — this prevents WP REST from wrapping in JSON
ob_end_clean();
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authorize — <?php echo esc_html($siteName); ?></title> <title>Authorize — <?php echo esc_html($siteName); ?></title>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { 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; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; 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; } .card { background: #fff; border-radius: 16px; box-shadow: 0 4px 32px rgba(0,0,0,.12); max-width: 420px; width: 100%; padding: 40px 36px; }
.logo { display: flex; align-items: center; gap: 10px; margin-bottom: 28px; } .logo { display: flex; align-items: center; gap: 12px; margin-bottom: 32px; }
.logo svg { flex-shrink: 0; } .logo strong { font-size: 17px; color: #1a1a1a; }
.logo strong { font-size: 18px; color: #1a1a1a; } h2 { font-size: 22px; color: #1a1a1a; margin-bottom: 10px; }
h2 { font-size: 20px; color: #1a1a1a; margin-bottom: 8px; } p { color: #666; font-size: 14px; line-height: 1.65; margin-bottom: 22px; }
p { color: #555; font-size: 14px; line-height: 1.6; margin-bottom: 20px; } .key-box { background: #f8f9ff; border: 1.5px solid #d0d9ff; border-radius: 10px; padding: 16px 18px; margin-bottom: 28px; }
.key-info { background: #f0f6fc; border: 1px solid #c0d8f0; border-radius: 8px; padding: 14px 16px; margin-bottom: 24px; } .key-box .lbl { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .6px; color: #888; margin-bottom: 5px; }
.key-info .label { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: #888; margin-bottom: 4px; } .key-box .val { font-size: 15px; font-weight: 700; color: #1a1a1a; margin-bottom: 10px; }
.key-info .value { font-size: 14px; font-weight: 600; color: #1a1a1a; } .scopes { display: flex; flex-wrap: wrap; gap: 6px; }
.scopes { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } .scope { background: #ede9fe; color: #5b21b6; border-radius: 5px; padding: 3px 10px; font-size: 12px; font-weight: 600; }
.scope { background: #ede9fe; color: #5b21b6; border-radius: 4px; padding: 2px 8px; font-size: 12px; font-weight: 500; } .btns { display: flex; gap: 10px; }
.actions { display: flex; gap: 10px; } .btn { flex: 1; padding: 13px; border-radius: 9px; border: none; font-size: 15px; font-weight: 700; cursor: pointer; transition: opacity .15s; }
.btn { flex: 1; padding: 11px; border-radius: 7px; border: none; font-size: 15px; font-weight: 600; cursor: pointer; } .btn:active { opacity: .8; }
.btn-approve { background: #2271b1; color: #fff; } .approve { background: #2271b1; color: #fff; }
.btn-approve:hover { background: #1a5a8a; } .deny { background: #f5f5f5; color: #444; border: 1.5px solid #ddd; }
.btn-deny { background: #f6f7f7; color: #333; border: 1px solid #ddd; } .note { margin-top: 22px; font-size: 12px; color: #aaa; text-align: center; line-height: 1.5; }
.btn-deny:hover { background: #eee; }
.footer { margin-top: 20px; font-size: 12px; color: #999; text-align: center; }
</style> </style>
</head> </head>
<body> <body>
<div class="card"> <div class="card">
<div class="logo"> <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> <svg width="36" height="36" viewBox="0 0 36 36" fill="none"><circle cx="18" cy="18" r="18" fill="#2271b1"/><path d="M11 26L18 11L25 26" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M13.5 21H22.5" stroke="white" stroke-width="2.5" stroke-linecap="round"/></svg>
<strong>AskTam MCP — <?php echo esc_html($siteName); ?></strong> <strong>AskTam MCP &mdash; <?php echo esc_html($siteName); ?></strong>
</div> </div>
<h2>Authorize Access</h2> <h2>Authorize Access</h2>
<p>An external AI client is requesting access to your WordPress site using the following API key:</p> <p>An AI client is requesting access to your WordPress site using this API key:</p>
<div class="key-info"> <div class="key-box">
<div class="label">API Key</div> <div class="lbl">API Key</div>
<div class="value"><?php echo $keyName; ?></div> <div class="val"><?php echo $keyName; ?></div>
<div class="scopes"> <div class="scopes">
<?php foreach (array_map('trim', explode(',', $row['scopes'])) as $s): ?> <?php foreach ($scopes as $s): ?>
<span class="scope"><?php echo esc_html($s); ?></span> <span class="scope"><?php echo esc_html($s); ?></span>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
@@ -150,22 +149,22 @@ class OAuthServer {
<input type="hidden" name="redirect_uri" value="<?php echo esc_attr($redirectUri); ?>"> <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="state" value="<?php echo esc_attr($state); ?>">
<input type="hidden" name="_mcp_approve" value="1"> <input type="hidden" name="_mcp_approve" value="1">
<div class="actions"> <div class="btns">
<button type="submit" class="btn btn-approve">Approve</button> <button type="submit" class="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> <button type="button" class="btn deny" onclick="window.location='<?php echo esc_js(add_query_arg(['error' => 'access_denied', 'state' => $state], $redirectUri)); ?>'">Deny</button>
</div> </div>
</form> </form>
<div class="footer">Only approve if you initiated this connection from Claude.ai or another trusted MCP client.</div> <div class="note">Only approve if you initiated this connection from Claude.ai or another trusted MCP client.</div>
</div> </div>
</body> </body>
</html> </html>
<?php <?php
exit; exit;
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Token endpoint — exchanges auth code or client_credentials for access token // Token endpoint
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
public function handleToken(\WP_REST_Request $request): \WP_REST_Response { public function handleToken(\WP_REST_Request $request): \WP_REST_Response {
$grantType = $request->get_param('grant_type') ?? ''; $grantType = $request->get_param('grant_type') ?? '';
@@ -173,7 +172,6 @@ class OAuthServer {
if ($grantType === 'authorization_code') { if ($grantType === 'authorization_code') {
return $this->handleAuthCodeExchange($request); return $this->handleAuthCodeExchange($request);
} }
if ($grantType === 'client_credentials') { if ($grantType === 'client_credentials') {
return $this->handleClientCredentials($request); return $this->handleClientCredentials($request);
} }
@@ -183,15 +181,11 @@ class OAuthServer {
private function handleAuthCodeExchange(\WP_REST_Request $request): \WP_REST_Response { private function handleAuthCodeExchange(\WP_REST_Request $request): \WP_REST_Response {
$code = $request->get_param('code') ?? ''; $code = $request->get_param('code') ?? '';
$redirectUri = $request->get_param('redirect_uri') ?? '';
[$clientId, $clientSecret] = $this->extractCredentials($request);
if (empty($code)) { if (empty($code)) {
return $this->oauthError('invalid_request', 'Missing code', 400); return $this->oauthError('invalid_request', 'Missing code', 400);
} }
// Retrieve and validate the auth code transient
$codeData = get_transient('asktam_mcp_code_' . $code); $codeData = get_transient('asktam_mcp_code_' . $code);
if (!$codeData) { if (!$codeData) {
return $this->oauthError('invalid_grant', 'Authorization code expired or invalid', 400); return $this->oauthError('invalid_grant', 'Authorization code expired or invalid', 400);
@@ -199,16 +193,15 @@ class OAuthServer {
delete_transient('asktam_mcp_code_' . $code); delete_transient('asktam_mcp_code_' . $code);
// Validate client_id matches [$clientId] = $this->extractCredentials($request);
if (!empty($clientId) && $clientId !== $codeData['client_id']) { if (!empty($clientId) && $clientId !== $codeData['client_id']) {
return $this->oauthError('invalid_client', 'client_id mismatch', 401); 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([ return new \WP_REST_Response([
'access_token' => $codeData['client_id'], 'access_token' => $codeData['client_id'],
'token_type' => 'bearer', 'token_type' => 'bearer',
'expires_in' => 3600 * 24 * 30, // 30 days 'expires_in' => 3600 * 24 * 30,
'scope' => $codeData['scopes'], 'scope' => $codeData['scopes'],
], 200); ], 200);
} }
@@ -240,7 +233,7 @@ class OAuthServer {
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// RFC 8414 metadata — Claude.ai discovers all endpoints from here // Metadata
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
public function handleMetadata(): \WP_REST_Response { public function handleMetadata(): \WP_REST_Response {
$base = rest_url('asktam-mcp/v1'); $base = rest_url('asktam-mcp/v1');