Skip to content

Commit eba4d29

Browse files
committed
[ADD] Bypass for auth.mode=none in API JWT and scope middlewares, new tests for auth.mode behavior
1 parent f82bc31 commit eba4d29

4 files changed

Lines changed: 330 additions & 0 deletions

File tree

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"test:unit:redactor": "php tests/Unit/RedactorTest.php",
3232
"test:unit:security-policy": "php tests/Unit/SecurityPolicyTest.php",
3333
"test:unit:scope-policy": "php tests/Unit/ScopePolicyTest.php",
34+
"test:unit:api-auth-mode": "php tests/Unit/ApiAuthModeMiddlewareTest.php",
3435
"test:unit:server-registry": "php tests/Unit/ServerRegistryTest.php",
3536
"test:unit:model-field-leakage": "php tests/Unit/ModelFieldPolicyLeakageTest.php",
3637
"test:feature:dispatch-idempotency": "php tests/Feature/DispatchIdempotencyBehaviorTest.php",
@@ -69,6 +70,7 @@
6970
"@test:unit:redactor",
7071
"@test:unit:security-policy",
7172
"@test:unit:scope-policy",
73+
"@test:unit:api-auth-mode",
7274
"@test:unit:server-registry",
7375
"@test:unit:model-field-leakage",
7476
"@test:phase25",

src/Middleware/EnsureApiJwt.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ class EnsureApiJwt
1414
{
1515
public function handle(Request $request, Closure $next): Response
1616
{
17+
if ($this->authModeIsNone()) {
18+
return $next($request);
19+
}
20+
1721
if (!class_exists(JwtAuthMiddleware::class)) {
1822
return TransportError::response($request, 401, 'unauthenticated', 'Unauthenticated');
1923
}
@@ -50,6 +54,13 @@ public function handle(Request $request, Closure $next): Response
5054
return $response;
5155
}
5256

57+
private function authModeIsNone(): bool
58+
{
59+
$mode = strtolower(trim((string)config('cms.settings.eMCP.auth.mode', 'sapi_jwt')));
60+
61+
return $mode === 'none';
62+
}
63+
5364
private function isRawSapiErrorResponse(Response $response): bool
5465
{
5566
if (!method_exists($response, 'getContent')) {

src/Middleware/EnsureMcpScopes.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ public function __construct(
1818

1919
public function handle(Request $request, Closure $next)
2020
{
21+
if ($this->authModeIsNone()) {
22+
return $next($request);
23+
}
24+
2125
if (!(bool)config('cms.settings.eMCP.auth.require_scopes', true)) {
2226
return $next($request);
2327
}
@@ -65,4 +69,11 @@ private function hasJwtContext(Request $request): bool
6569

6670
return false;
6771
}
72+
73+
private function authModeIsNone(): bool
74+
{
75+
$mode = strtolower(trim((string)config('cms.settings.eMCP.auth.mode', 'sapi_jwt')));
76+
77+
return $mode === 'none';
78+
}
6879
}
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symfony\Component\HttpFoundation {
6+
class Response
7+
{
8+
public function __construct(
9+
protected string $content = '',
10+
protected int $statusCode = 200
11+
) {
12+
}
13+
14+
public function getStatusCode(): int
15+
{
16+
return $this->statusCode;
17+
}
18+
19+
public function getContent(): string
20+
{
21+
return $this->content;
22+
}
23+
}
24+
}
25+
26+
namespace Illuminate\Support {
27+
if (!class_exists(Str::class, false)) {
28+
final class Str
29+
{
30+
public static function contains(string $haystack, string $needle): bool
31+
{
32+
return str_contains($haystack, $needle);
33+
}
34+
35+
public static function is(string $pattern, string $value): bool
36+
{
37+
if ($pattern === '*') {
38+
return true;
39+
}
40+
41+
$quoted = preg_quote($pattern, '/');
42+
$regex = '/^' . str_replace('\\*', '.*', $quoted) . '$/u';
43+
44+
return (bool)preg_match($regex, $value);
45+
}
46+
47+
public static function uuid(): string
48+
{
49+
return '00000000-0000-4000-8000-000000000000';
50+
}
51+
}
52+
}
53+
}
54+
55+
namespace Illuminate\Http {
56+
use Symfony\Component\HttpFoundation\Response;
57+
58+
final class HeaderBag
59+
{
60+
/** @var array<string, string> */
61+
private array $items = [];
62+
63+
public function set(string $name, string $value): void
64+
{
65+
$this->items[strtolower($name)] = $value;
66+
}
67+
68+
public function get(string $name, mixed $default = null): mixed
69+
{
70+
return $this->items[strtolower($name)] ?? $default;
71+
}
72+
}
73+
74+
final class AttributeBag
75+
{
76+
/** @var array<string, mixed> */
77+
private array $items = [];
78+
79+
public function set(string $key, mixed $value): void
80+
{
81+
$this->items[$key] = $value;
82+
}
83+
84+
public function get(string $key, mixed $default = null): mixed
85+
{
86+
return $this->items[$key] ?? $default;
87+
}
88+
89+
public function has(string $key): bool
90+
{
91+
return array_key_exists($key, $this->items);
92+
}
93+
}
94+
95+
class Request
96+
{
97+
public HeaderBag $headers;
98+
public AttributeBag $attributes;
99+
100+
/**
101+
* @param array<string, mixed> $routeParameters
102+
*/
103+
public function __construct(
104+
private string $content = '',
105+
private array $routeParameters = []
106+
) {
107+
$this->headers = new HeaderBag();
108+
$this->attributes = new AttributeBag();
109+
}
110+
111+
public function getContent(): string
112+
{
113+
return $this->content;
114+
}
115+
116+
public function route(string $key, mixed $default = null): mixed
117+
{
118+
return $this->routeParameters[$key] ?? $default;
119+
}
120+
121+
public function ip(): string
122+
{
123+
return '127.0.0.1';
124+
}
125+
}
126+
127+
class JsonResponse extends Response
128+
{
129+
public HeaderBag $headers;
130+
131+
/**
132+
* @param array<string, mixed> $payload
133+
*/
134+
public function __construct(array $payload, int $status = 200)
135+
{
136+
parent::__construct((string)json_encode($payload, JSON_UNESCAPED_SLASHES), $status);
137+
$this->headers = new HeaderBag();
138+
}
139+
}
140+
}
141+
142+
namespace Seiger\sApi\Http\Middleware {
143+
use Closure;
144+
use Illuminate\Http\Request;
145+
use Symfony\Component\HttpFoundation\Response;
146+
147+
final class JwtAuthMiddleware
148+
{
149+
/** @var callable|null */
150+
public static $handler = null;
151+
152+
public function handle(Request $request, Closure $next): Response
153+
{
154+
if (is_callable(self::$handler)) {
155+
return (self::$handler)($request, $next);
156+
}
157+
158+
return $next($request);
159+
}
160+
}
161+
}
162+
163+
namespace {
164+
use EvolutionCMS\eMCP\Middleware\EnsureApiJwt;
165+
use EvolutionCMS\eMCP\Middleware\EnsureMcpScopes;
166+
use EvolutionCMS\eMCP\Services\ScopePolicy;
167+
use Illuminate\Http\JsonResponse;
168+
use Illuminate\Http\Request;
169+
use Seiger\sApi\Http\Middleware\JwtAuthMiddleware;
170+
use Symfony\Component\HttpFoundation\Response;
171+
172+
function assertTrue(bool $condition, string $message): void
173+
{
174+
if (!$condition) {
175+
throw new RuntimeException($message);
176+
}
177+
}
178+
179+
/**
180+
* @return array<string, mixed>
181+
*/
182+
function jsonDecodeObject(string $content): array
183+
{
184+
$decoded = json_decode($content, true);
185+
return is_array($decoded) ? $decoded : [];
186+
}
187+
188+
/** @var array<string, mixed> $configValues */
189+
$configValues = [
190+
'cms.settings.eMCP.auth.mode' => 'sapi_jwt',
191+
'cms.settings.eMCP.auth.require_scopes' => true,
192+
'cms.settings.eMCP.auth.scope_map' => [],
193+
'cms.settings.eMCP.trace.header' => 'X-Trace-Id',
194+
'cms.settings.eMCP.trace.generate_if_missing' => true,
195+
'mcp.servers' => [],
196+
];
197+
198+
if (!function_exists('config')) {
199+
function config(string $key, mixed $default = null): mixed
200+
{
201+
global $configValues;
202+
203+
return $configValues[$key] ?? $default;
204+
}
205+
}
206+
207+
if (!function_exists('response')) {
208+
function response(): object
209+
{
210+
return new class {
211+
/**
212+
* @param array<string, mixed> $payload
213+
*/
214+
public function json(array $payload, int $status = 200): JsonResponse
215+
{
216+
return new JsonResponse($payload, $status);
217+
}
218+
};
219+
}
220+
}
221+
222+
if (!function_exists('app')) {
223+
function app(): object
224+
{
225+
return new class {
226+
public function make(string $class): object
227+
{
228+
return new $class();
229+
}
230+
};
231+
}
232+
}
233+
234+
require_once __DIR__ . '/../../src/Support/TraceContext.php';
235+
require_once __DIR__ . '/../../src/Support/TransportError.php';
236+
require_once __DIR__ . '/../../src/Services/ScopePolicy.php';
237+
require_once __DIR__ . '/../../src/Middleware/EnsureApiJwt.php';
238+
require_once __DIR__ . '/../../src/Middleware/EnsureMcpScopes.php';
239+
240+
$apiJwt = new EnsureApiJwt();
241+
$scopePolicy = new ScopePolicy();
242+
$scopeMiddleware = new EnsureMcpScopes($scopePolicy);
243+
$next = static fn(Request $request): Response => new Response('pass', 209);
244+
245+
$configValues['cms.settings.eMCP.auth.mode'] = 'none';
246+
$noneModeResponse = $apiJwt->handle(new Request(), $next);
247+
assertTrue($noneModeResponse->getStatusCode() === 209, 'auth.mode=none must bypass EnsureApiJwt');
248+
249+
$configValues['cms.settings.eMCP.auth.mode'] = 'sapi_jwt';
250+
JwtAuthMiddleware::$handler = static function (Request $request, \Closure $next): Response {
251+
return new JsonResponse([
252+
'success' => false,
253+
'message' => 'Unauthorized.',
254+
'object' => (object)[],
255+
'code' => 401,
256+
], 401);
257+
};
258+
259+
$normalizedJwtError = $apiJwt->handle(new Request(), $next);
260+
assertTrue($normalizedJwtError->getStatusCode() === 401, 'EnsureApiJwt must normalize raw sApi 401');
261+
$normalizedJwtPayload = jsonDecodeObject($normalizedJwtError->getContent());
262+
assertTrue(
263+
(($normalizedJwtPayload['error']['code'] ?? '') === 'unauthenticated'),
264+
'EnsureApiJwt must return transport unauthenticated code'
265+
);
266+
267+
$configValues['cms.settings.eMCP.auth.mode'] = 'none';
268+
$configValues['cms.settings.eMCP.auth.require_scopes'] = true;
269+
$scopeBypassResponse = $scopeMiddleware->handle(
270+
new Request('{"jsonrpc":"2.0","method":"tools/call"}', ['server' => 'content']),
271+
$next
272+
);
273+
assertTrue($scopeBypassResponse->getStatusCode() === 209, 'auth.mode=none must bypass EnsureMcpScopes');
274+
275+
$configValues['cms.settings.eMCP.auth.mode'] = 'sapi_jwt';
276+
$configValues['cms.settings.eMCP.auth.require_scopes'] = true;
277+
$missingJwtResponse = $scopeMiddleware->handle(
278+
new Request('{"jsonrpc":"2.0","method":"tools/call"}', ['server' => 'content']),
279+
$next
280+
);
281+
assertTrue($missingJwtResponse->getStatusCode() === 401, 'EnsureMcpScopes must require JWT context');
282+
$missingJwtPayload = jsonDecodeObject($missingJwtResponse->getContent());
283+
assertTrue(
284+
(($missingJwtPayload['error']['code'] ?? '') === 'unauthenticated'),
285+
'EnsureMcpScopes must return unauthenticated on missing JWT context'
286+
);
287+
288+
$configValues['cms.settings.eMCP.auth.require_scopes'] = false;
289+
$scopesDisabledResponse = $scopeMiddleware->handle(
290+
new Request('{"jsonrpc":"2.0","method":"tools/call"}', ['server' => 'content']),
291+
$next
292+
);
293+
assertTrue($scopesDisabledResponse->getStatusCode() === 209, 'require_scopes=false must bypass EnsureMcpScopes');
294+
295+
$configValues['cms.settings.eMCP.auth.require_scopes'] = true;
296+
$deniedRequest = new Request('{"jsonrpc":"2.0","method":"tools/call"}', ['server' => 'content']);
297+
$deniedRequest->attributes->set('sapi.jwt.payload', ['sub' => '123']);
298+
$deniedRequest->attributes->set('sapi.jwt.scopes', ['mcp:read']);
299+
300+
$scopeDeniedResponse = $scopeMiddleware->handle($deniedRequest, $next);
301+
assertTrue($scopeDeniedResponse->getStatusCode() === 403, 'EnsureMcpScopes must deny missing required scope');
302+
$scopeDeniedPayload = jsonDecodeObject($scopeDeniedResponse->getContent());
303+
assertTrue((($scopeDeniedPayload['error']['code'] ?? '') === 'scope_denied'), 'Scope denial code must be scope_denied');
304+
305+
echo "API auth mode middleware checks passed.\n";
306+
}

0 commit comments

Comments
 (0)