Skip to content

Commit 96cb93d

Browse files
Merge pull request #9 from magebitcom/bugfix/prevent-store-redirect-well-known
fix(oauth): prevent store-code redirect for .well-known paths
2 parents a14ac9e + d95bdd3 commit 96cb93d

11 files changed

Lines changed: 354 additions & 13 deletions

File tree

Controller/Index/Index.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ public function execute(): ResponseInterface
8686
try {
8787
$context = $this->authenticator->authenticate($this->header('Authorization'));
8888
} catch (UnauthorizedException $e) {
89-
$metadataUrl = $this->urlBuilder->buildUrl('/.well-known/oauth-protected-resource');
89+
$metadataUrl = $this->urlBuilder->getProtectedResourceWellKnownUrl();
9090
$this->response->setHeader(
9191
'WWW-Authenticate',
9292
sprintf('Bearer realm="Magento MCP", resource_metadata="%s"', $metadataUrl),

Controller/OAuth/AuthorizationServerMetadata.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ public function execute(): ResponseInterface
5858

5959
$payload = [
6060
'issuer' => $issuer,
61-
'authorization_endpoint' => $issuer . '/mcp/oauth/authorize',
62-
'token_endpoint' => $issuer . '/mcp/oauth/token',
61+
'authorization_endpoint' => $this->urlBuilder->buildUrl('/mcp/oauth/authorize'),
62+
'token_endpoint' => $this->urlBuilder->buildUrl('/mcp/oauth/token'),
6363
'response_types_supported' => ['code'],
6464
'grant_types_supported' => ['authorization_code', 'refresh_token'],
6565
'code_challenge_methods_supported' => ['S256'],
@@ -69,7 +69,7 @@ public function execute(): ResponseInterface
6969
// S256 is the only advertised challenge method.
7070
'pkce_required' => true,
7171
'require_pushed_authorization_requests' => false,
72-
'service_documentation' => $issuer . '/mcp',
72+
'service_documentation' => $this->urlBuilder->getResourceUrl(),
7373
];
7474

7575
$body = json_encode($payload, JSON_UNESCAPED_SLASHES);

Controller/OAuth/ProtectedResourceMetadata.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,11 @@ public function execute(): ResponseInterface
5454
return $this->corsResponder->emitPreflight($this->response, self::ALLOWED_METHODS);
5555
}
5656

57-
$baseUrl = $this->urlBuilder->getBaseUrl();
58-
$resourceUrl = $baseUrl . '/mcp';
57+
$resourceUrl = $this->urlBuilder->getResourceUrl();
5958

6059
$payload = [
6160
'resource' => $resourceUrl,
62-
'authorization_servers' => [$baseUrl],
61+
'authorization_servers' => [$this->urlBuilder->getBaseUrl()],
6362
'bearer_methods_supported' => ['header'],
6463
'scopes_supported' => Scope::allValues(),
6564
'resource_documentation' => $resourceUrl,

Controller/Router/WellKnownRouter.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
class WellKnownRouter implements RouterInterface
2828
{
2929
private const PATH_AUTHORIZATION_SERVER = '.well-known/oauth-authorization-server';
30+
private const PATH_AUTHORIZATION_SERVER_PREFIX = '.well-known/oauth-authorization-server/';
3031
private const PATH_PROTECTED_RESOURCE = '.well-known/oauth-protected-resource';
3132
private const PATH_PROTECTED_RESOURCE_PREFIX = '.well-known/oauth-protected-resource/';
3233

@@ -85,7 +86,9 @@ public function match(RequestInterface $request): ?ActionInterface
8586
*/
8687
private function resolveAction(string $identifier): ?string
8788
{
88-
if ($identifier === self::PATH_AUTHORIZATION_SERVER) {
89+
if ($identifier === self::PATH_AUTHORIZATION_SERVER
90+
|| str_starts_with($identifier, self::PATH_AUTHORIZATION_SERVER_PREFIX)
91+
) {
8992
return self::ACTION_AUTHORIZATION_SERVER;
9093
}
9194
if ($identifier === self::PATH_PROTECTED_RESOURCE

Model/Url/PublicUrlBuilder.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,87 @@ public function buildUrl(string $path): string
4444
$path = '/' . ltrim($path, '/');
4545
return $this->getBaseUrl() . $path;
4646
}
47+
48+
/**
49+
* Scheme + host + optional port of the base URL, with no path component.
50+
* RFC 8414 §3 / RFC 9728 §3.1 insert `.well-known` directly after this.
51+
*
52+
* @return string
53+
*/
54+
public function getAuthority(): string
55+
{
56+
$base = $this->getBaseUrl();
57+
$parts = parse_url($base);
58+
if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) {
59+
return $base;
60+
}
61+
62+
$authority = $parts['scheme'] . '://' . $parts['host'];
63+
if (isset($parts['port'])) {
64+
$authority .= ':' . $parts['port'];
65+
}
66+
67+
return $authority;
68+
}
69+
70+
/**
71+
* Path component of the base URL, trimmed of slashes. Empty for a root
72+
* install, e.g. `lv` for https://example.com/lv/.
73+
*
74+
* @return string
75+
*/
76+
public function getBasePath(): string
77+
{
78+
$parts = parse_url($this->getBaseUrl());
79+
$path = is_array($parts) && isset($parts['path']) ? $parts['path'] : '';
80+
81+
return trim($path, '/');
82+
}
83+
84+
/**
85+
* The RFC 9728 resource identifier — what the MCP client targets.
86+
*
87+
* @return string
88+
*/
89+
public function getResourceUrl(): string
90+
{
91+
return $this->buildUrl('/mcp');
92+
}
93+
94+
/**
95+
* Build a spec-compliant well-known URL by inserting `.well-known/<name>`
96+
* between the authority and the resource/issuer path.
97+
*
98+
* @param string $name
99+
* @param string $resourcePath
100+
* @return string
101+
*/
102+
public function getWellKnownUrl(string $name, string $resourcePath = ''): string
103+
{
104+
$url = $this->getAuthority() . '/.well-known/' . trim($name, '/');
105+
$suffix = trim($resourcePath, '/');
106+
if ($suffix !== '') {
107+
$url .= '/' . $suffix;
108+
}
109+
110+
return $url;
111+
}
112+
113+
/**
114+
* @return string
115+
*/
116+
public function getProtectedResourceWellKnownUrl(): string
117+
{
118+
$path = trim($this->getBasePath() . '/mcp', '/');
119+
120+
return $this->getWellKnownUrl('oauth-protected-resource', $path);
121+
}
122+
123+
/**
124+
* @return string
125+
*/
126+
public function getAuthServerWellKnownUrl(): string
127+
{
128+
return $this->getWellKnownUrl('oauth-authorization-server', $this->getBasePath());
129+
}
47130
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
/**
3+
* @author Magebit <info@magebit.com>
4+
* @copyright Copyright (c) Magebit, Ltd. (https://magebit.com)
5+
* @license MIT
6+
*/
7+
declare(strict_types=1);
8+
9+
namespace Magebit\Mcp\Plugin\Store;
10+
11+
use Magento\Framework\App\Request\Http as HttpRequest;
12+
use Magento\Store\Model\BaseUrlChecker;
13+
14+
/**
15+
* Exempts the OAuth/MCP `.well-known` discovery paths from Magento's
16+
* redirect-to-base check. RFC 8414 §3 / RFC 9728 §3.1 require these documents
17+
* at the authority root, but on stores whose base URL carries a path (store
18+
* code) the base-URL check would otherwise 302 them to the store-coded path
19+
* before the WellKnownRouter runs. This treats the paths as already canonical;
20+
* it never adds or rewrites the store code.
21+
*/
22+
class WellKnownRedirectExemption
23+
{
24+
/**
25+
* @var string[]
26+
*/
27+
private const EXEMPT_PREFIXES = [
28+
'.well-known/oauth-protected-resource',
29+
'.well-known/oauth-authorization-server',
30+
];
31+
32+
/**
33+
* @param BaseUrlChecker $subject
34+
* @param bool $result
35+
* @param array<string, mixed> $uri
36+
* @param HttpRequest $request
37+
* @return bool
38+
*/
39+
public function afterExecute(
40+
BaseUrlChecker $subject,
41+
bool $result,
42+
array $uri,
43+
HttpRequest $request
44+
): bool {
45+
if ($result) {
46+
return true;
47+
}
48+
49+
$path = trim((string) $request->getPathInfo(), '/');
50+
foreach (self::EXEMPT_PREFIXES as $prefix) {
51+
if ($path === $prefix || str_starts_with($path, $prefix . '/')) {
52+
return true;
53+
}
54+
}
55+
56+
return $result;
57+
}
58+
}

Test/Api/AuthTest.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ public function testMissingAuthorizationHeaderReturns401(): void
3232
'resource_metadata="',
3333
$response['headers']['www-authenticate']
3434
);
35+
// RFC 9728 §3.1: .well-known is inserted directly after the authority,
36+
// with the resource path appended after it — never behind a store-code path.
3537
self::assertStringContainsString(
36-
'/mcp/oauth/protected-resource-metadata',
38+
'/.well-known/oauth-protected-resource/mcp',
3739
$response['headers']['www-authenticate']
3840
);
3941
}

Test/Unit/Controller/Router/WellKnownRouterTest.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,21 @@ public function testMatchesAuthorizationServerMetadata(): void
5252
self::assertSame($action, $this->router->match($this->request('/.well-known/oauth-authorization-server')));
5353
}
5454

55+
public function testMatchesAuthorizationServerMetadataWithIssuerSuffix(): void
56+
{
57+
// RFC 8414 §3 — the issuer path is appended to the .well-known prefix.
58+
$action = $this->createMock(ActionInterface::class);
59+
$this->routeConfig->method('getModulesByFrontName')->with('mcp')->willReturn(['Magebit_Mcp']);
60+
$this->actionList->method('get')
61+
->with('Magebit_Mcp', '', 'oauth', 'authorizationservermetadata')
62+
->willReturn(AuthorizationServerMetadata::class);
63+
$this->actionFactory->method('create')
64+
->with(AuthorizationServerMetadata::class)
65+
->willReturn($action);
66+
67+
self::assertSame($action, $this->router->match($this->request('/.well-known/oauth-authorization-server/lv')));
68+
}
69+
5570
public function testMatchesProtectedResourceMetadata(): void
5671
{
5772
$action = $this->createMock(ActionInterface::class);

Test/Unit/Model/Url/PublicUrlBuilderTest.php

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,7 @@ public function testReturnsStoreBaseUrl(): void
3939

4040
public function testBuildUrlConcatenatesPathOntoBaseUrl(): void
4141
{
42-
$store = $this->createMock(Store::class);
43-
$store->method('getBaseUrl')->with(UrlInterface::URL_TYPE_WEB)
44-
->willReturn('https://example.com/');
45-
$this->storeManager->method('getStore')->willReturn($store);
42+
$this->withBaseUrl('https://example.com/');
4643

4744
self::assertSame(
4845
'https://example.com/.well-known/oauth-protected-resource',
@@ -53,4 +50,100 @@ public function testBuildUrlConcatenatesPathOntoBaseUrl(): void
5350
$this->builder->buildUrl('foo')
5451
);
5552
}
53+
54+
public function testGetAuthorityStripsPathComponent(): void
55+
{
56+
$this->withBaseUrl('https://example.com/lv/');
57+
self::assertSame('https://example.com', $this->builder->getAuthority());
58+
}
59+
60+
public function testGetAuthorityPreservesPort(): void
61+
{
62+
$this->withBaseUrl('https://example.com:8443/lv/');
63+
self::assertSame('https://example.com:8443', $this->builder->getAuthority());
64+
}
65+
66+
public function testGetAuthorityFallsBackOnMalformedBaseUrl(): void
67+
{
68+
// parse_url can't yield scheme+host — never emit an empty authority.
69+
$this->withBaseUrl('not a url');
70+
self::assertSame('not a url', $this->builder->getAuthority());
71+
}
72+
73+
/**
74+
* @dataProvider basePathProvider
75+
* @param string $baseUrl
76+
* @param string $expected
77+
* @return void
78+
*/
79+
public function testGetBasePath(string $baseUrl, string $expected): void
80+
{
81+
$this->withBaseUrl($baseUrl);
82+
self::assertSame($expected, $this->builder->getBasePath());
83+
}
84+
85+
/**
86+
* @return array<string, array{string, string}>
87+
*/
88+
public static function basePathProvider(): array
89+
{
90+
return [
91+
'root' => ['https://example.com/', ''],
92+
'store code' => ['https://example.com/lv/', 'lv'],
93+
'subfolder' => ['https://example.com/shop/', 'shop'],
94+
];
95+
}
96+
97+
public function testGetResourceUrlKeepsBasePath(): void
98+
{
99+
$this->withBaseUrl('https://example.com/lv/');
100+
self::assertSame('https://example.com/lv/mcp', $this->builder->getResourceUrl());
101+
}
102+
103+
public function testProtectedResourceWellKnownUrlInsertsAfterAuthority(): void
104+
{
105+
$this->withBaseUrl('https://example.com/lv/');
106+
self::assertSame(
107+
'https://example.com/.well-known/oauth-protected-resource/lv/mcp',
108+
$this->builder->getProtectedResourceWellKnownUrl()
109+
);
110+
}
111+
112+
public function testProtectedResourceWellKnownUrlOnCleanBaseUrl(): void
113+
{
114+
$this->withBaseUrl('https://example.com/');
115+
self::assertSame(
116+
'https://example.com/.well-known/oauth-protected-resource/mcp',
117+
$this->builder->getProtectedResourceWellKnownUrl()
118+
);
119+
}
120+
121+
public function testAuthServerWellKnownUrlAppendsBasePath(): void
122+
{
123+
$this->withBaseUrl('https://example.com/lv/');
124+
self::assertSame(
125+
'https://example.com/.well-known/oauth-authorization-server/lv',
126+
$this->builder->getAuthServerWellKnownUrl()
127+
);
128+
}
129+
130+
public function testAuthServerWellKnownUrlOnCleanBaseUrl(): void
131+
{
132+
$this->withBaseUrl('https://example.com/');
133+
self::assertSame(
134+
'https://example.com/.well-known/oauth-authorization-server',
135+
$this->builder->getAuthServerWellKnownUrl()
136+
);
137+
}
138+
139+
/**
140+
* @param string $baseUrl
141+
* @return void
142+
*/
143+
private function withBaseUrl(string $baseUrl): void
144+
{
145+
$store = $this->createMock(Store::class);
146+
$store->method('getBaseUrl')->with(UrlInterface::URL_TYPE_WEB)->willReturn($baseUrl);
147+
$this->storeManager->method('getStore')->willReturn($store);
148+
}
56149
}

0 commit comments

Comments
 (0)