[Server] Oauth2 based on middleware (#221)

* Add Middleware handlers to StreamableHttpTransport

* OAuth Implementation based on middleware

* OAuth Implementation based on middleware

* Add Middleware handlers to StreamableHttpTransport

* OAuth Implementation based on middleware

* OAuth Implementation based on middleware

Signed-off-by: Volodymyr Panivko <sveneld300@gmail.com>

* Address PR review feedback for OAuth middleware

* Apply php-cs-fixer to satisfy QA

* Use Mcp exception classes in OAuth middleware

* Refactor OAuth HTTP stack and add Microsoft example policies

* Fix QA PHPStan findings in OAuth examples and auth middleware

* Require OIDC discovery in JWKS provider and refine OAuth proxy behavior

* Remove local development docker artifacts from VCS

* Revert .gitignore changes for local docker files

* Fix body retrieval in OAuthRequestMetaMiddleware for proper string conversion

Signed-off-by: Volodymyr Panivko <sveneld300@gmail.com>

* Address OAuth PR review feedback and align example configs

* Fix QA style issues and add authorization docs

* Fix PHPStan typing issues in OAuth middleware

* Remove duplicate Middleware namespace, consolidate under Http\Middleware

Remove old Mcp\Server\Transport\Middleware\ classes that were duplicated
in Mcp\Server\Transport\Http\Middleware\ and Http\OAuth\. Update docs
to reference the correct namespaces.

* Address PR review: replace Throwable catch with specific exceptions, add JSON_THROW_ON_ERROR

- JwtTokenValidator: replace catch(\Throwable) with explicit
  \InvalidArgumentException|\UnexpectedValueException|\DomainException,
  letting RuntimeException from JwksProvider propagate as infrastructure errors
- OAuthProxyMiddleware: add \JSON_THROW_ON_ERROR to json_encode for metadata response
- Update tests to expect RuntimeException for JWKS infrastructure failures

---------

Signed-off-by: Volodymyr Panivko <sveneld300@gmail.com>
Co-authored-by: Volodymyr Panivko <volodymyr.panivko@togethernetworks.com>
This commit is contained in:
Volodymyr Panivko 2026-03-10 17:10:07 +01:00 • committed by GitHub
commit e3f4b88768
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -0,0 +1,273 @@
<?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;
/**
* Proxies OAuth authorization requests to an upstream authorization server.
*
* This middleware implements the OAuth proxy pattern for MCP servers that
* delegate authorization to third-party OAuth providers (Microsoft, Keycloak, etc.).
*
* It handles:
* - /authorize: Redirects to the upstream authorization server
* - /token: Proxies token requests to the upstream token endpoint
* - /.well-known/oauth-authorization-server: Serves authorization server metadata
*
* @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;
$this->requestFactory = $requestFactory;
$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->getRequestFactory()
->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->getHttpClient()->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));
}
private function getHttpClient(): ClientInterface
{
return $this->httpClient ??= Psr18ClientDiscovery::find();
}
private function getRequestFactory(): RequestFactoryInterface
{
return $this->requestFactory ??= Psr17FactoryDiscovery::findRequestFactory();
}
}