PHP_AI_MCP_SSO_REDIRECT_URIS=http://127.0.0.1:18787/api/auth/sso/zyt/callback * AI_MCP_TEST_MYSQL=1 AI_MCP_TEST_BASE_URL=http://127.0.0.1:8099 php server/tests/AiMcpSsoTest.php * 夹具 ID 段:账号 93201-93204、角色 301-302。 */ require dirname(__DIR__) . '/vendor/autoload.php'; use think\App; use think\cache\driver\File as FileCache; use think\facade\Db; function aiMcpSsoExpect(bool $condition, string $message): void { if (!$condition) { fwrite(STDERR, "FAIL: {$message}\n"); exit(1); } } $base = rtrim((string) getenv('AI_MCP_TEST_BASE_URL'), '/'); $secret = (string) getenv('PHP_AI_MCP_SSO_CLIENT_SECRET'); $redirect = trim(explode(',', (string) getenv('PHP_AI_MCP_SSO_REDIRECT_URIS'))[0]); $clientId = (string) getenv('PHP_AI_MCP_SSO_CLIENT_ID'); if (getenv('AI_MCP_TEST_MYSQL') !== '1' || $base === '' || strlen($secret) < 32 || $redirect === '' || $clientId === '') { echo "AiMcpSsoTest SKIP (set AI_MCP_TEST_MYSQL=1, AI_MCP_TEST_BASE_URL, PHP_DATABASE_* for a disposable *_test database and the PHP_AI_MCP_SSO_* settings the server runs with)\n"; exit(0); } $app = new App(dirname(__DIR__) . DIRECTORY_SEPARATOR); $app->initialize(); $database = (string) config('database.connections.' . config('database.default') . '.database'); aiMcpSsoExpect(str_ends_with($database, '_test'), "refusing to run on database '{$database}' (name must end with _test)"); aiMcpSsoExpect((int) Db::name('system_menu')->where('perms', 'ai.mcp/access')->count() === 1, 'run 2026_09_24_ai_mcp.sql on the test database first'); // ---------------------------------------------------------------- 夹具 $now = time(); $pwd = create_password('Test@123456', (string) config('project.unique_identification')); Db::name('system_role')->whereIn('id', [301, 302])->delete(); foreach ([301 => 'AI 查询', 302 => '无 AI 权限'] as $id => $name) { Db::name('system_role')->insert(['id' => $id, 'name' => $name, 'desc' => 'ai-mcp-sso-test', 'sort' => 0, 'data_scope' => 4, 'create_time' => $now, 'update_time' => $now]); } Db::name('system_role_menu')->whereIn('role_id', [301, 302])->delete(); Db::name('system_role_menu')->insert(['role_id' => 301, 'menu_id' => (int) Db::name('system_menu')->where('perms', 'ai.mcp/access')->value('id')]); // 账号 => [名称, 角色, is_paw, disable] $admins = [93201 => ['s_doc', '单点医生', 301, 1, 0], 93202 => ['s_noai', '无权限', 302, 1, 0], 93203 => ['s_new', '新账号', 301, 0, 0], 93204 => ['s_off', '已停用', 301, 1, 1]]; Db::name('admin')->whereIn('id', array_keys($admins))->delete(); Db::name('admin_role')->whereIn('admin_id', array_keys($admins))->delete(); Db::name('admin_session')->whereIn('admin_id', array_keys($admins))->delete(); Db::name('ai_grant')->whereIn('admin_id', array_keys($admins))->delete(); Db::name('ai_access_log')->whereIn('admin_id', array_keys($admins))->delete(); Db::name('ai_access_log')->whereIn('tool', ['sso.login', 'sso.approve', 'sso.token'])->where('admin_id', 0)->delete(); foreach ($admins as $id => [$account, $name, $role, $isPaw, $disable]) { Db::name('admin')->insert(['id' => $id, 'root' => 0, 'name' => $name, 'avatar' => '', 'account' => $account, 'password' => $pwd, 'multipoint_login' => 1, 'is_paw' => $isPaw, 'work_wechat_userid' => '', 'disable' => $disable, 'phone' => '1370000' . substr((string) $id, -4), 'create_time' => $now, 'update_time' => $now]); Db::name('admin_role')->insert(['admin_id' => $id, 'role_id' => $role]); } $pcToken = substr(md5('sso-test-pc-' . $now), 0, 32); $aiToken = substr(md5('sso-test-ai-' . $now), 0, 32); $oldToken = substr(md5('sso-test-old-' . $now), 0, 32); Db::name('admin_session')->insert(['admin_id' => 93201, 'terminal' => 1, 'token' => $pcToken, 'update_time' => $now, 'expire_time' => $now + 3600]); Db::name('admin_session')->insert(['admin_id' => 93201, 'terminal' => 8, 'token' => $aiToken, 'update_time' => $now, 'expire_time' => $now + 3600]); Db::name('admin_session')->insert(['admin_id' => 93203, 'terminal' => 1, 'token' => $oldToken, 'update_time' => $now - 7200, 'expire_time' => $now - 60]); // 服务端(mcp 应用自己的缓存目录)里的错误计数与限流桶,清掉让断言可重复 $cacheOptions = (array) config('cache.stores.file'); $cacheOptions['path'] = app()->getRootPath() . 'runtime' . DIRECTORY_SEPARATOR . 'mcp' . DIRECTORY_SEPARATOR . 'cache'; $serverCache = new FileCache(app(), $cacheOptions); foreach (array_column($admins, 0) as $account) { $serverCache->delete('ai_mcp_grant_fail_' . md5($account)); } foreach (['sso_ip_', 'sso_token_', 'grant_ip_'] as $bucket) { foreach (['127.0.0.1', '::1'] as $ip) { $serverCache->delete('ai_mcp_rl_' . $bucket . md5($ip) . '_' . intdiv(time(), 600)); } } \think\facade\Cache::clear(); // ---------------------------------------------------------------- HTTP 工具 function aiMcpSsoHttp(string $method, string $url, ?array $body, array $headers = []): array { $ch = curl_init($url); $lines = ['Content-Type: application/json']; foreach ($headers as $k => $v) { $lines[] = $k . ': ' . $v; } $responseHeaders = []; curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $lines, CURLOPT_TIMEOUT => 60, CURLOPT_HEADERFUNCTION => static function ($ch, string $line) use (&$responseHeaders): int { $parts = explode(':', $line, 2); if (count($parts) === 2) { $responseHeaders[strtolower(trim($parts[0]))][] = trim($parts[1]); } return strlen($line); }]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE)); } $raw = (string) curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); return [$status, json_decode($raw, true), $raw, $responseHeaders]; } $state = 'state-' . bin2hex(random_bytes(8)); $ctx = ['client_id' => $clientId, 'redirect_uri' => $redirect, 'state' => $state]; $page = static fn (array $query) => aiMcpSsoHttp('GET', $base . '/mcp/sso/authorize?' . http_build_query($query), null); $sso = static fn (string $action, array $body) => (array) (aiMcpSsoHttp('POST', $base . '/mcp/sso/' . $action, $ctx + $body)[1] ?? []); $codeOf = static function (array $body) use ($redirect, $state): string { $url = (string) ($body['data']['redirect'] ?? ''); aiMcpSsoExpect(str_starts_with($url, $redirect . '?'), 'redirects back to the registered address: ' . json_encode($body, JSON_UNESCAPED_UNICODE)); parse_str((string) parse_url($url, PHP_URL_QUERY), $query); aiMcpSsoExpect(($query['state'] ?? '') === $state && preg_match('/^[0-9a-f]{64}$/', (string) ($query['code'] ?? '')) === 1, 'with the state and a one-time code'); return (string) $query['code']; }; $exchange = static fn (string $code, array $override = []) => aiMcpSsoHttp('POST', $base . '/mcp/sso/token', $override + ['client_id' => $clientId, 'client_secret' => $secret, 'code' => $code, 'redirect_uri' => $redirect, 'client_instance' => 'sso-test', 'label' => '行知 · 登录']); // ---------------------------------------------------------------- 登录确认页 [$status, , $html, $headers] = $page($ctx); aiMcpSsoExpect($status === 200 && str_contains($html, '登录「行知」') && str_contains($html, '
alert'), 'state is embedded as JSON'); [$status, , $html] = $page(['redirect_uri' => 'https://evil.example/cb'] + $ctx); // 只看页面本身(调试模式下框架会在 HTML 后面追加带请求地址的 Trace 面板,生产环境关闭调试) $main = preg_match('#
.*?
#s', $html, $found) === 1 ? $found[0] : ''; aiMcpSsoExpect($status === 400 && $main !== '' && !str_contains($html, ' 'other'] + $ctx); aiMcpSsoExpect($status === 400 && !str_contains($html, ' ''] + $ctx); aiMcpSsoExpect($status === 400 && !str_contains($html, ' ['x']] + $ctx); aiMcpSsoExpect($status === 400, 'array parameters are rejected cleanly'); // ---------------------------------------------------------------- 账号密码确认:与绑定同一套门禁 $body = $sso('login', ['account' => 's_doc', 'password' => 'wrong-password']); aiMcpSsoExpect(($body['code'] ?? null) === 0 && ($body['data']['reason'] ?? '') === 'invalid_credentials', 'wrong password refused: ' . json_encode($body, JSON_UNESCAPED_UNICODE)); $body = $sso('login', ['account' => 's_noai', 'password' => 'Test@123456']); aiMcpSsoExpect(($body['data']['reason'] ?? '') === 'no_ai_permission', 'accounts without ai.mcp/access cannot log in to 行知'); $body = $sso('login', ['account' => 's_new', 'password' => 'Test@123456']); aiMcpSsoExpect(($body['data']['reason'] ?? '') === 'need_change_password' && str_contains((string) $body['msg'], '再登录行知'), 'initial passwords must be changed first: ' . json_encode($body, JSON_UNESCAPED_UNICODE)); $body = $sso('login', ['account' => 's_off', 'password' => 'Test@123456']); aiMcpSsoExpect(($body['data']['reason'] ?? '') === 'disabled', 'disabled accounts are refused'); $body = (array) (aiMcpSsoHttp('POST', $base . '/mcp/sso/login', ['client_id' => $clientId, 'redirect_uri' => 'https://evil.example/cb', 'state' => $state, 'account' => 's_doc', 'password' => 'Test@123456'])[1] ?? []); aiMcpSsoExpect(($body['data']['reason'] ?? '') === 'invalid_client', 'the page endpoints check the registered redirect too'); $body = (array) (aiMcpSsoHttp('POST', $base . '/mcp/sso/login', ['client_id' => $clientId, 'redirect_uri' => $redirect, 'account' => 's_doc', 'password' => 'Test@123456'])[1] ?? []); aiMcpSsoExpect(($body['data']['reason'] ?? '') === 'invalid_request', 'a confirmation needs the state'); $passwordCode = $codeOf($sso('login', ['account' => 's_doc', 'password' => 'Test@123456'])); // ---------------------------------------------------------------- 已登录后台:一键确认只认正常登录 $body = $sso('whois', ['session' => $pcToken]); aiMcpSsoExpect(($body['code'] ?? null) === 1 && ($body['data']['account'] ?? '') === 's_doc' && ($body['data']['name'] ?? '') === '单点医生', 'whois names the PC login: ' . json_encode($body, JSON_UNESCAPED_UNICODE)); aiMcpSsoExpect(($sso('whois', ['session' => $aiToken])['data']['reason'] ?? '') === 'session_invalid', 'the AI back-office browser session cannot confirm a login'); aiMcpSsoExpect(($sso('whois', ['session' => $oldToken])['data']['reason'] ?? '') === 'session_invalid', 'an expired session cannot confirm a login'); aiMcpSsoExpect(($sso('approve', ['session' => 'not-a-token'])['data']['reason'] ?? '') === 'session_invalid', 'garbage sessions are refused'); aiMcpSsoExpect(($sso('approve', ['session' => ['x']])['data']['reason'] ?? '') === 'session_invalid', 'array sessions are refused cleanly'); $sessionCode = $codeOf($sso('approve', ['session' => $pcToken])); // ---------------------------------------------------------------- 授权码换令牌 [$status, $body] = $exchange($passwordCode, ['client_secret' => str_repeat('x', 40)]); aiMcpSsoExpect($status === 401 && ($body['data']['reason'] ?? '') === 'invalid_client', 'a wrong client secret is refused: ' . json_encode($body, JSON_UNESCAPED_UNICODE)); [$status, $body] = $exchange($passwordCode, ['redirect_uri' => 'https://evil.example/cb']); aiMcpSsoExpect($status === 400 && ($body['data']['reason'] ?? '') === 'invalid_client', 'the redirect address must match the registration'); [$status, $body] = $exchange($passwordCode); aiMcpSsoExpect($status === 200 && ($body['code'] ?? null) === 1 && (int) ($body['data']['admin']['id'] ?? 0) === 93201 && str_starts_with((string) ($body['data']['token'] ?? ''), 'zyt_ai_'), 'the code from the password confirmation gives the account and a read-only token: ' . json_encode($body, JSON_UNESCAPED_UNICODE)); $firstGrant = (int) $body['data']['grant_id']; $firstToken = (string) $body['data']['token']; [$status, $body] = $exchange($passwordCode); aiMcpSsoExpect($status === 400 && ($body['data']['reason'] ?? '') === 'invalid_grant', 'a code works once'); [, $who] = aiMcpSsoHttp('GET', $base . '/mcp/auth/whoami', null, ['Authorization' => 'Bearer ' . $firstToken]); aiMcpSsoExpect(($who['code'] ?? null) === 1 && (int) ($who['data']['admin']['id'] ?? 0) === 93201, 'the token works like a binding grant'); [$status, $body] = $exchange($sessionCode); aiMcpSsoExpect($status === 200 && ($body['code'] ?? null) === 1, 'the code from the one-click confirmation works too'); $grantRow = Db::name('ai_grant')->where('id', (int) $body['data']['grant_id'])->find(); aiMcpSsoExpect($grantRow && $grantRow['client'] === $clientId && $grantRow['client_instance'] === 'sso-test' && (int) $grantRow['status'] === 1, 'grant stored for the 行知 instance'); aiMcpSsoExpect((int) Db::name('ai_grant')->where('id', $firstGrant)->value('status') === 2, 'logging in again replaces the instance\'s previous grant'); // 授权码签发后账号被停用:兑换时拒绝 $lateCode = $codeOf($sso('approve', ['session' => $pcToken])); Db::name('admin')->where('id', 93201)->update(['disable' => 1]); [$status, $body] = $exchange($lateCode); aiMcpSsoExpect(($body['data']['reason'] ?? '') === 'disabled', 'an account disabled after confirming cannot redeem: ' . json_encode($body, JSON_UNESCAPED_UNICODE)); Db::name('admin')->where('id', 93201)->update(['disable' => 0]); [$status] = aiMcpSsoHttp('GET', $base . '/mcp/sso/token', null); aiMcpSsoExpect($status === 405, 'the token endpoint is POST only'); // ---------------------------------------------------------------- 审计与原有绑定接口 $tools = Db::name('ai_access_log')->where('admin_id', 93201)->column('tool'); aiMcpSsoExpect(in_array('sso.approve', $tools, true) && in_array('sso.token', $tools, true), 'confirmations and exchanges are audited'); aiMcpSsoExpect(Db::name('ai_access_log')->where(['tool' => 'sso.login', 'status' => 'denied'])->count() >= 4, 'refused logins are audited'); aiMcpSsoExpect(Db::name('ai_access_log')->where(['tool' => 'sso.token', 'status' => 'denied'])->count() >= 3, 'refused exchanges are audited'); [, $body] = aiMcpSsoHttp('POST', $base . '/mcp/auth/grant', ['account' => 's_doc', 'password' => 'Test@123456', 'client' => 'xingzhi', 'client_instance' => 'grant-test']); aiMcpSsoExpect(($body['code'] ?? null) === 1 && str_starts_with((string) ($body['data']['token'] ?? ''), 'zyt_ai_'), 'the binding endpoint still issues grants: ' . json_encode($body, JSON_UNESCAPED_UNICODE)); [, $body] = aiMcpSsoHttp('POST', $base . '/mcp/auth/grant', ['account' => 's_new', 'password' => 'Test@123456', 'client' => 'xingzhi']); aiMcpSsoExpect(($body['data']['reason'] ?? '') === 'need_change_password' && str_contains((string) $body['msg'], '再绑定 AI 助手'), 'and keeps its own wording'); echo "AiMcpSsoTest OK\n";