fix: answer /.well-known OAuth discovery without a canonical redirect; smoke checks the real path
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
|||||||
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
|
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
|
||||||
use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
|
use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
|
||||||
use LIT\MCP\Integrations\WordPress\WordPressIntegration;
|
use LIT\MCP\Integrations\WordPress\WordPressIntegration;
|
||||||
|
use LIT\MCP\Server\DiscoveryCanonical;
|
||||||
use LIT\MCP\Server\RequestContext;
|
use LIT\MCP\Server\RequestContext;
|
||||||
use LIT\MCP\Server\ServerRegistrar;
|
use LIT\MCP\Server\ServerRegistrar;
|
||||||
use LIT\MCP\Server\VisibilityFilter;
|
use LIT\MCP\Server\VisibilityFilter;
|
||||||
@@ -57,6 +58,7 @@ final class Plugin
|
|||||||
$access->register();
|
$access->register();
|
||||||
(new RequestContext())->register();
|
(new RequestContext())->register();
|
||||||
(new VisibilityFilter())->register();
|
(new VisibilityFilter())->register();
|
||||||
|
(new DiscoveryCanonical())->register();
|
||||||
|
|
||||||
$limits = new Limits();
|
$limits = new Limits();
|
||||||
$changeLog = new ChangeLog();
|
$changeLog = new ChangeLog();
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Server;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps WordPress' canonical redirect away from mcp-oauth's two discovery documents.
|
||||||
|
* With a pretty permalink structure ending in "/", WordPress 301-redirects
|
||||||
|
* /.well-known/oauth-protected-resource to a trailing-slash URL; the resource_metadata
|
||||||
|
* URL in the 401 header and RFC 8414/9728 clients use the exact path, so it must answer
|
||||||
|
* directly instead of relying on every client following redirects.
|
||||||
|
*/
|
||||||
|
final class DiscoveryCanonical
|
||||||
|
{
|
||||||
|
private const PATH = '#/\.well-known/oauth-(?:protected-resource|authorization-server)/?$#D';
|
||||||
|
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
add_filter('redirect_canonical', [$this, 'filter'], 10, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filter(mixed $redirect, mixed $requested): mixed
|
||||||
|
{
|
||||||
|
$path = is_string($requested) ? (string) wp_parse_url($requested, PHP_URL_PATH) : '';
|
||||||
|
|
||||||
|
return preg_match(self::PATH, $path) === 1 ? false : $redirect;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,14 +60,16 @@ curl -s --max-time 20 -H "Host: $HOST" -H 'X-Forwarded-Proto: https' \
|
|||||||
|| fail "authorization-server metadata missing CIMD flag"
|
|| fail "authorization-server metadata missing CIMD flag"
|
||||||
pass "RFC 8414 authorization-server metadata advertises CIMD"
|
pass "RFC 8414 authorization-server metadata advertises CIMD"
|
||||||
|
|
||||||
# ENVIRONMENT check (never fails): the real .well-known path, same Host/proto.
|
# The real .well-known path, same Host/proto, WITHOUT following redirects: clients use
|
||||||
WELLKNOWN=$(curl -s --max-time 20 -H "Host: $HOST" -H 'X-Forwarded-Proto: https' \
|
# the exact resource_metadata URL from the 401 header. Needs the web server to pass
|
||||||
'http://localhost/.well-known/oauth-protected-resource')
|
# /.well-known/ to WordPress (DDEV: .ddev/nginx/well-known.conf) and the plugin's
|
||||||
if echo "$WELLKNOWN" | grep -q '"resource"'; then
|
# DiscoveryCanonical (no trailing-slash 301).
|
||||||
pass "/.well-known/ reachable"
|
for DOC in oauth-protected-resource oauth-authorization-server; do
|
||||||
else
|
CODE=$(curl -s --max-time 20 -o /dev/null -w '%{http_code}' -H "Host: $HOST" -H 'X-Forwarded-Proto: https' \
|
||||||
echo "WARN - /.well-known/ blocked by web server config (claude.ai discovery will fail; allow .well-known in nginx)"
|
"http://localhost/.well-known/$DOC")
|
||||||
fi
|
[ "$CODE" = "200" ] || fail "/.well-known/$DOC answered $CODE (web server blocks it or WordPress redirects)"
|
||||||
|
done
|
||||||
|
pass "/.well-known/ discovery documents answer directly (200, no redirect)"
|
||||||
|
|
||||||
# 2. Unauthenticated request -> 401 with WWW-Authenticate
|
# 2. Unauthenticated request -> 401 with WWW-Authenticate
|
||||||
UNAUTH=$(curl -s --max-time 20 -i -X POST "$ENDPOINT" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}')
|
UNAUTH=$(curl -s --max-time 20 -i -X POST "$ENDPOINT" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}')
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Server;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Server\DiscoveryCanonical;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
final class DiscoveryCanonicalTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Functions\when('wp_parse_url')->alias('parse_url');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string}> */
|
||||||
|
public static function discoveryUrls(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'protected resource' => ['https://example.test/.well-known/oauth-protected-resource'],
|
||||||
|
'authorization server' => ['https://example.test/.well-known/oauth-authorization-server'],
|
||||||
|
'subdirectory install' => ['https://example.test/wp/.well-known/oauth-protected-resource'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider discoveryUrls */
|
||||||
|
public function testDiscoveryDocumentsAreNeverRedirected(string $requested): void
|
||||||
|
{
|
||||||
|
self::assertFalse((new DiscoveryCanonical())->filter($requested . '/', $requested));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOtherRedirectsStayUntouched(): void
|
||||||
|
{
|
||||||
|
$filter = new DiscoveryCanonical();
|
||||||
|
|
||||||
|
$page = 'https://example.test/seite';
|
||||||
|
$acme = 'https://example.test/.well-known/acme-challenge/x';
|
||||||
|
|
||||||
|
self::assertSame($page . '/', $filter->filter($page . '/', $page));
|
||||||
|
self::assertSame($acme . '/', $filter->filter($acme . '/', $acme));
|
||||||
|
self::assertFalse($filter->filter(false, 'https://example.test/.well-known/oauth-authorization-server'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user