Record ADR 0001: the MCP server is an OAuth 2.1 Resource Server that MAY delegate to an upstream authorization server, and will NOT issue tokens or act as an Identity Provider. Gives a single citable ruling to decline the recurring "native authorization server" PRs (e.g. #373). Also remove the surface signal that invites them: reframe the OAuthProxyMiddleware class docblock to lead with its delegation intent and state explicit non-goals (never mints/signs/stores/rotates tokens), and add a "Scope" section to docs/authorization.md describing the three OAuth roles and what the SDK does not do. No behavior change — docblock, docs, and ADR only.
268 lines
10 KiB
PHP
268 lines
10 KiB
PHP
<?php
|
|
|
|
/*
|
|
* This file is part of the official PHP MCP SDK.
|
|
*
|
|
* A collaboration between Symfony and the PHP Foundation.
|
|
*
|
|
* For the full copyright and license information, please view the LICENSE
|
|
* file that was distributed with this source code.
|
|
*/
|
|
|
|
namespace Mcp\Server\Transport\Http\Middleware;
|
|
|
|
use Http\Discovery\Psr17FactoryDiscovery;
|
|
use Http\Discovery\Psr18ClientDiscovery;
|
|
use Mcp\Exception\RuntimeException;
|
|
use Mcp\Server\Transport\Http\OAuth\OidcDiscoveryInterface;
|
|
use Psr\Http\Client\ClientExceptionInterface;
|
|
use Psr\Http\Client\ClientInterface;
|
|
use Psr\Http\Message\RequestFactoryInterface;
|
|
use Psr\Http\Message\ResponseFactoryInterface;
|
|
use Psr\Http\Message\ResponseInterface;
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
use Psr\Http\Message\StreamFactoryInterface;
|
|
use Psr\Http\Server\MiddlewareInterface;
|
|
use Psr\Http\Server\RequestHandlerInterface;
|
|
|
|
/**
|
|
* Delegates OAuth flows to an upstream authorization server so you do NOT have to build one.
|
|
*
|
|
* This middleware exists precisely so the MCP server does not become an authorization server:
|
|
* it forwards the OAuth endpoints to your existing upstream Identity Provider (Microsoft Entra
|
|
* ID, Keycloak, Auth0, Okta, league/oauth2-server, etc.) instead of issuing tokens itself.
|
|
*
|
|
* It delegates:
|
|
* - /authorize: Redirects the user agent to the upstream authorization endpoint
|
|
* - /token: Proxies the token request to the upstream token endpoint and returns its response
|
|
* - /.well-known/oauth-authorization-server: Serves metadata pointing at this delegating proxy
|
|
*
|
|
* Non-goals: this middleware never issues, mints, signs, stores, or rotates tokens, and the SDK
|
|
* is not an OAuth authorization server / Identity Provider. See
|
|
* adr/0001-oauth-authorization-server-out-of-scope.md.
|
|
*
|
|
* @author Volodymyr Panivko <sveneld300@gmail.com>
|
|
*/
|
|
final class OAuthProxyMiddleware implements MiddlewareInterface
|
|
{
|
|
private const CLIENT_SECRET_BASIC = 'client_secret_basic';
|
|
private const CLIENT_SECRET_POST = 'client_secret_post';
|
|
|
|
private ClientInterface $httpClient;
|
|
private RequestFactoryInterface $requestFactory;
|
|
private ResponseFactoryInterface $responseFactory;
|
|
private StreamFactoryInterface $streamFactory;
|
|
|
|
/**
|
|
* @param string $upstreamIssuer The issuer URL of the upstream OAuth provider
|
|
* @param string $localBaseUrl The base URL of this MCP server (e.g., http://localhost:8000)
|
|
* @param string|null $clientSecret Optional client secret for confidential clients
|
|
* @param OidcDiscoveryInterface $discovery OIDC discovery provider for upstream metadata
|
|
*/
|
|
public function __construct(
|
|
private readonly string $upstreamIssuer,
|
|
private readonly string $localBaseUrl,
|
|
private readonly OidcDiscoveryInterface $discovery,
|
|
private readonly ?string $clientSecret = null,
|
|
?ClientInterface $httpClient = null,
|
|
?RequestFactoryInterface $requestFactory = null,
|
|
?ResponseFactoryInterface $responseFactory = null,
|
|
?StreamFactoryInterface $streamFactory = null,
|
|
) {
|
|
$this->httpClient = $httpClient ?? Psr18ClientDiscovery::find();
|
|
$this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory();
|
|
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
|
|
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
|
|
}
|
|
|
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
|
{
|
|
$path = $request->getUri()->getPath();
|
|
|
|
if ('GET' === $request->getMethod() && '/.well-known/oauth-authorization-server' === $path) {
|
|
return $this->createAuthServerMetadataResponse();
|
|
}
|
|
|
|
if ('GET' === $request->getMethod() && '/authorize' === $path) {
|
|
return $this->handleAuthorize($request);
|
|
}
|
|
|
|
if ('POST' === $request->getMethod() && '/token' === $path) {
|
|
return $this->handleToken($request);
|
|
}
|
|
|
|
return $handler->handle($request);
|
|
}
|
|
|
|
private function handleAuthorize(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
try {
|
|
$authorizationEndpoint = $this->discovery->getAuthorizationEndpoint($this->upstreamIssuer);
|
|
} catch (RuntimeException) {
|
|
return $this->createErrorResponse(500, 'Upstream authorization endpoint not found');
|
|
}
|
|
|
|
$rawQueryString = $request->getUri()->getQuery();
|
|
$upstreamUrl = $authorizationEndpoint;
|
|
if ('' !== $rawQueryString) {
|
|
$upstreamUrl .= '?'.$rawQueryString;
|
|
}
|
|
|
|
return $this->responseFactory
|
|
->createResponse(302)
|
|
->withHeader('Location', $upstreamUrl)
|
|
->withHeader('Cache-Control', 'no-store');
|
|
}
|
|
|
|
private function handleToken(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
try {
|
|
$tokenEndpoint = $this->discovery->getTokenEndpoint($this->upstreamIssuer);
|
|
} catch (RuntimeException) {
|
|
return $this->createErrorResponse(500, 'Upstream token endpoint not found');
|
|
}
|
|
|
|
$body = $request->getBody()->__toString();
|
|
parse_str($body, $params);
|
|
|
|
$upstreamAuthorization = trim($request->getHeaderLine('Authorization'));
|
|
if ('' === $upstreamAuthorization) {
|
|
$upstreamAuthorization = null;
|
|
}
|
|
|
|
if (null !== $this->clientSecret && !isset($params['client_secret']) && null === $upstreamAuthorization) {
|
|
$authMethod = $this->resolveTokenEndpointAuthMethod();
|
|
|
|
if (self::CLIENT_SECRET_BASIC === $authMethod) {
|
|
$clientId = $params['client_id'] ?? null;
|
|
|
|
if (\is_string($clientId) && '' !== trim($clientId)) {
|
|
$upstreamAuthorization = 'Basic '.base64_encode(trim($clientId).':'.$this->clientSecret);
|
|
} else {
|
|
$params['client_secret'] = $this->clientSecret;
|
|
}
|
|
} else {
|
|
$params['client_secret'] = $this->clientSecret;
|
|
}
|
|
}
|
|
|
|
$body = http_build_query($params);
|
|
|
|
$upstreamRequest = $this->requestFactory
|
|
->createRequest('POST', $tokenEndpoint)
|
|
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
|
|
->withBody($this->streamFactory->createStream($body));
|
|
|
|
if (null !== $upstreamAuthorization) {
|
|
$upstreamRequest = $upstreamRequest->withHeader('Authorization', $upstreamAuthorization);
|
|
}
|
|
|
|
try {
|
|
$upstreamResponse = $this->httpClient->sendRequest($upstreamRequest);
|
|
$responseBody = $upstreamResponse->getBody()->__toString();
|
|
|
|
return $this->responseFactory
|
|
->createResponse($upstreamResponse->getStatusCode())
|
|
->withHeader('Content-Type', $upstreamResponse->getHeaderLine('Content-Type'))
|
|
->withHeader('Cache-Control', 'no-store')
|
|
->withBody($this->streamFactory->createStream($responseBody));
|
|
} catch (ClientExceptionInterface $e) {
|
|
return $this->createErrorResponse(502, 'Failed to contact upstream token endpoint: '.$e->getMessage());
|
|
}
|
|
}
|
|
|
|
private function createAuthServerMetadataResponse(): ResponseInterface
|
|
{
|
|
try {
|
|
$upstreamMetadata = $this->discovery->discover($this->upstreamIssuer);
|
|
} catch (RuntimeException) {
|
|
return $this->createErrorResponse(500, 'Failed to discover upstream server metadata');
|
|
}
|
|
|
|
$localBaseUrl = rtrim($this->localBaseUrl, '/');
|
|
$localMetadata = [
|
|
'issuer' => $localBaseUrl,
|
|
'authorization_endpoint' => $localBaseUrl.'/authorize',
|
|
'token_endpoint' => $localBaseUrl.'/token',
|
|
'response_types_supported' => $upstreamMetadata['response_types_supported'] ?? ['code'],
|
|
'grant_types_supported' => $upstreamMetadata['grant_types_supported'] ?? ['authorization_code', 'refresh_token'],
|
|
'code_challenge_methods_supported' => $upstreamMetadata['code_challenge_methods_supported'] ?? ['S256'],
|
|
];
|
|
|
|
$copyFields = [
|
|
'scopes_supported',
|
|
'token_endpoint_auth_methods_supported',
|
|
'jwks_uri',
|
|
];
|
|
|
|
foreach ($copyFields as $field) {
|
|
if (isset($upstreamMetadata[$field])) {
|
|
$localMetadata[$field] = $upstreamMetadata[$field];
|
|
}
|
|
}
|
|
|
|
return $this->responseFactory
|
|
->createResponse(200)
|
|
->withHeader('Content-Type', 'application/json')
|
|
->withHeader('Cache-Control', 'max-age=3600')
|
|
->withBody($this->streamFactory->createStream(json_encode($localMetadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES)));
|
|
}
|
|
|
|
private function createErrorResponse(int $status, string $message): ResponseInterface
|
|
{
|
|
$body = json_encode(['error' => 'server_error', 'error_description' => $message]);
|
|
|
|
return $this->responseFactory
|
|
->createResponse($status)
|
|
->withHeader('Content-Type', 'application/json')
|
|
->withBody($this->streamFactory->createStream($body));
|
|
}
|
|
|
|
private function resolveTokenEndpointAuthMethod(): string
|
|
{
|
|
$supportedMethods = $this->getTokenEndpointAuthMethods();
|
|
|
|
if (\in_array(self::CLIENT_SECRET_BASIC, $supportedMethods, true)) {
|
|
return self::CLIENT_SECRET_BASIC;
|
|
}
|
|
|
|
if (\in_array(self::CLIENT_SECRET_POST, $supportedMethods, true)) {
|
|
return self::CLIENT_SECRET_POST;
|
|
}
|
|
|
|
return self::CLIENT_SECRET_POST;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function getTokenEndpointAuthMethods(): array
|
|
{
|
|
try {
|
|
$metadata = $this->discovery->discover($this->upstreamIssuer);
|
|
} catch (RuntimeException) {
|
|
return [];
|
|
}
|
|
|
|
$methods = $metadata['token_endpoint_auth_methods_supported'] ?? null;
|
|
if (!\is_array($methods)) {
|
|
return [];
|
|
}
|
|
|
|
$normalized = [];
|
|
foreach ($methods as $method) {
|
|
if (!\is_string($method)) {
|
|
continue;
|
|
}
|
|
|
|
$method = trim($method);
|
|
if ('' === $method) {
|
|
continue;
|
|
}
|
|
|
|
$normalized[] = $method;
|
|
}
|
|
|
|
return array_values(array_unique($normalized));
|
|
}
|
|
}
|