Skip to content

Commit 79a9916

Browse files
author
Volodymyr Panivko
committed
OAuth Implementation based on middleware
1 parent 6ac6671 commit 79a9916

File tree

8 files changed

+1002
-87
lines changed

8 files changed

+1002
-87
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@
4747
"psr/simple-cache": "^2.0 || ^3.0",
4848
"symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0",
4949
"symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0",
50-
"symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0"
50+
"symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0",
51+
"ext-openssl": "*"
5152
},
5253
"autoload": {
5354
"psr-4": {

examples/server/oauth-keycloak/server.php

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
declare(strict_types=1);
1313

14-
require_once dirname(__DIR__, 3).'/vendor/autoload.php';
14+
require_once dirname(__DIR__).'/bootstrap.php';
1515

1616
use Http\Discovery\Psr17Factory;
1717
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
@@ -21,30 +21,20 @@
2121
use Mcp\Server\Transport\Middleware\JwtTokenValidator;
2222
use Mcp\Server\Transport\Middleware\ProtectedResourceMetadata;
2323
use Mcp\Server\Transport\StreamableHttpTransport;
24-
use Psr\Log\AbstractLogger;
2524

26-
// Configuration from environment
25+
// Configuration
2726
// External URL is what clients use and what appears in tokens
28-
$keycloakExternalUrl = getenv('KEYCLOAK_EXTERNAL_URL') ?: 'http://localhost:8180';
27+
$keycloakExternalUrl = 'http://localhost:8180';
2928
// Internal URL is how this server reaches Keycloak (Docker network)
30-
$keycloakInternalUrl = getenv('KEYCLOAK_INTERNAL_URL') ?: 'http://keycloak:8080';
31-
$keycloakRealm = getenv('KEYCLOAK_REALM') ?: 'mcp';
32-
$mcpAudience = getenv('MCP_AUDIENCE') ?: 'mcp-server';
29+
$keycloakInternalUrl = 'http://keycloak:8080';
30+
$keycloakRealm = 'mcp';
31+
$mcpAudience = 'mcp-server';
3332

3433
// Issuer is what appears in the token (external URL)
3534
$issuer = rtrim($keycloakExternalUrl, '/').'/realms/'.$keycloakRealm;
3635
// JWKS URI uses internal URL to reach Keycloak within Docker network
3736
$jwksUri = rtrim($keycloakInternalUrl, '/').'/realms/'.$keycloakRealm.'/protocol/openid-connect/certs';
3837

39-
// Create logger
40-
$logger = new class extends AbstractLogger {
41-
public function log($level, \Stringable|string $message, array $context = []): void
42-
{
43-
$logMessage = sprintf("[%s] %s\n", strtoupper($level), $message);
44-
error_log($logMessage);
45-
}
46-
};
47-
4838
// Create PSR-17 factory
4939
$psr17Factory = new Psr17Factory();
5040
$request = $psr17Factory->createServerRequestFromGlobals();
@@ -77,15 +67,15 @@ public function log($level, \Stringable|string $message, array $context = []): v
7767
// Build MCP server
7868
$server = Server::builder()
7969
->setServerInfo('OAuth Keycloak Example', '1.0.0')
80-
->setLogger($logger)
81-
->setSession(new FileSessionStore('/tmp/mcp-sessions'))
70+
->setLogger(logger())
71+
->setSession(new FileSessionStore(__DIR__.'/sessions'))
8272
->setDiscovery(__DIR__)
8373
->build();
8474

8575
// Create transport with authorization middleware
8676
$transport = new StreamableHttpTransport(
8777
$request,
88-
logger: $logger,
78+
logger: logger(),
8979
middlewares: [$authMiddleware],
9080
);
9181

examples/server/oauth-microsoft/server.php

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
declare(strict_types=1);
1313

14-
require_once dirname(__DIR__, 3).'/vendor/autoload.php';
14+
require_once dirname(__DIR__).'/bootstrap.php';
1515

1616
use Http\Discovery\Psr17Factory;
1717
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
@@ -23,7 +23,6 @@
2323
use Mcp\Server\Transport\Middleware\ProtectedResourceMetadata;
2424
use Mcp\Server\Transport\StreamableHttpTransport;
2525
use Psr\Log\AbstractLogger;
26-
use Psr\Log\LoggerInterface;
2726

2827
// Configuration from environment
2928
$tenantId = getenv('AZURE_TENANT_ID') ?: throw new RuntimeException('AZURE_TENANT_ID environment variable is required');
@@ -36,15 +35,6 @@
3635
$issuerV1 = "https://sts.windows.net/{$tenantId}/";
3736
$issuers = [$issuerV2, $issuerV1];
3837

39-
// Create logger
40-
$logger = new class extends AbstractLogger {
41-
public function log($level, \Stringable|string $message, array $context = []): void
42-
{
43-
$logMessage = sprintf("[%s] %s\n", strtoupper($level), $message);
44-
error_log($logMessage);
45-
}
46-
};
47-
4838
// Create PSR-17 factory
4939
$psr17Factory = new Psr17Factory();
5040
$request = $psr17Factory->createServerRequestFromGlobals();
@@ -90,16 +80,16 @@ public function log($level, \Stringable|string $message, array $context = []): v
9080
// Build MCP server
9181
$server = Server::builder()
9282
->setServerInfo('OAuth Microsoft Example', '1.0.0')
93-
->setLogger($logger)
94-
->setSession(new FileSessionStore('/tmp/mcp-sessions'))
83+
->setLogger(logger())
84+
->setSession(new FileSessionStore(__DIR__.'/sessions'))
9585
->setDiscovery(__DIR__)
9686
->build();
9787

9888
// Create transport with OAuth proxy and authorization middlewares
9989
// Middlewares are reversed internally, so put OAuth proxy FIRST to execute FIRST
10090
$transport = new StreamableHttpTransport(
10191
$request,
102-
logger: $logger,
92+
logger: logger(),
10393
middlewares: [$oauthProxyMiddleware, $authMiddleware],
10494
);
10595

src/Server/Transport/Middleware/AuthorizationMiddleware.php

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -70,18 +70,15 @@ public function __construct(
7070

7171
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
7272
{
73-
// Serve metadata at well-known paths
7473
if ($this->isMetadataRequest($request)) {
7574
return $this->createMetadataResponse();
7675
}
7776

78-
// Extract Authorization header
7977
$authorization = $request->getHeaderLine('Authorization');
8078
if ('' === $authorization) {
8179
return $this->buildErrorResponse($request, AuthorizationResult::unauthorized());
8280
}
8381

84-
// Parse Bearer token
8582
$accessToken = $this->parseBearerToken($authorization);
8683
if (null === $accessToken) {
8784
return $this->buildErrorResponse(
@@ -90,7 +87,6 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
9087
);
9188
}
9289

93-
// Validate the token
9490
$result = $this->validator->validate($request, $accessToken);
9591
if ($result->isAllowed()) {
9692
return $handler->handle($this->applyAttributes($request, $result->getAttributes()));
@@ -101,33 +97,19 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
10197

10298
private function createMetadataResponse(): ResponseInterface
10399
{
104-
$payload = $this->metadata->toJson();
105-
106100
return $this->responseFactory
107101
->createResponse(200)
108102
->withHeader('Content-Type', 'application/json')
109-
->withBody($this->streamFactory->createStream($payload));
103+
->withBody($this->streamFactory->createStream($this->metadata->toJson()));
110104
}
111105

112106
private function isMetadataRequest(ServerRequestInterface $request): bool
113107
{
114-
if (empty($this->metadataPaths)) {
115-
return false;
116-
}
117-
118-
if ('GET' !== $request->getMethod()) {
108+
if (empty($this->metadataPaths) || 'GET' !== $request->getMethod()) {
119109
return false;
120110
}
121111

122-
$path = $request->getUri()->getPath();
123-
124-
foreach ($this->metadataPaths as $metadataPath) {
125-
if ($path === $metadataPath) {
126-
return true;
127-
}
128-
}
129-
130-
return false;
112+
return in_array($request->getUri()->getPath(), $this->metadataPaths, true);
131113
}
132114

133115
private function buildErrorResponse(ServerRequestInterface $request, AuthorizationResult $result): ResponseInterface

src/Server/Transport/Middleware/AuthorizationResult.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
*
2323
* @author Volodymyr Panivko <sveneld300@gmail.com>
2424
*/
25-
class AuthorizationResult
25+
final class AuthorizationResult
2626
{
2727
/**
2828
* @param list<string>|null $scopes Scopes to include in WWW-Authenticate challenge

src/Server/Transport/Middleware/JwtTokenValidator.php

Lines changed: 19 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,14 @@
1111

1212
namespace Mcp\Server\Transport\Middleware;
1313

14+
use Firebase\JWT\BeforeValidException;
15+
use Firebase\JWT\ExpiredException;
1416
use Firebase\JWT\JWK;
1517
use Firebase\JWT\JWT;
18+
use Firebase\JWT\SignatureInvalidException;
1619
use Http\Discovery\Psr17FactoryDiscovery;
1720
use Http\Discovery\Psr18ClientDiscovery;
21+
use Mcp\Exception\RuntimeException;
1822
use Psr\Http\Client\ClientInterface;
1923
use Psr\Http\Message\RequestFactoryInterface;
2024
use Psr\Http\Message\ServerRequestInterface;
@@ -128,31 +132,16 @@ public function validate(ServerRequestInterface $request, string $accessToken):
128132
}
129133

130134
return AuthorizationResult::allow($attributes);
131-
} catch (\Firebase\JWT\ExpiredException $e) {
132-
return AuthorizationResult::unauthorized(
133-
'invalid_token',
134-
'Token has expired.'
135-
);
136-
} catch (\Firebase\JWT\SignatureInvalidException $e) {
137-
return AuthorizationResult::unauthorized(
138-
'invalid_token',
139-
'Token signature verification failed.'
140-
);
141-
} catch (\Firebase\JWT\BeforeValidException $e) {
142-
return AuthorizationResult::unauthorized(
143-
'invalid_token',
144-
'Token is not yet valid.'
145-
);
135+
} catch (ExpiredException) {
136+
return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.');
137+
} catch (SignatureInvalidException) {
138+
return AuthorizationResult::unauthorized('invalid_token', 'Token signature verification failed.');
139+
} catch (BeforeValidException) {
140+
return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.');
146141
} catch (\UnexpectedValueException|\DomainException $e) {
147-
return AuthorizationResult::unauthorized(
148-
'invalid_token',
149-
'Token validation failed: ' . $e->getMessage()
150-
);
151-
} catch (\Throwable $e) {
152-
return AuthorizationResult::unauthorized(
153-
'invalid_token',
154-
'Token validation error.'
155-
);
142+
return AuthorizationResult::unauthorized('invalid_token', 'Token validation failed: '.$e->getMessage());
143+
} catch (\Throwable) {
144+
return AuthorizationResult::unauthorized('invalid_token', 'Token validation error.');
156145
}
157146
}
158147

@@ -320,10 +309,9 @@ private function validateIssuer(array $claims): bool
320309
return false;
321310
}
322311

323-
$tokenIssuer = $claims['iss'];
324312
$expectedIssuers = \is_array($this->issuer) ? $this->issuer : [$this->issuer];
325313

326-
return \in_array($tokenIssuer, $expectedIssuers, true);
314+
return \in_array($claims['iss'], $expectedIssuers, true);
327315
}
328316

329317
/**
@@ -336,24 +324,24 @@ private function fetchJwks(string $jwksUri): array
336324

337325
$response = $this->httpClient->sendRequest($request);
338326

339-
if ($response->getStatusCode() >= 400) {
340-
throw new \RuntimeException(sprintf(
327+
if (200 !== $response->getStatusCode()) {
328+
throw new RuntimeException(sprintf(
341329
'Failed to fetch JWKS from %s: HTTP %d',
342330
$jwksUri,
343331
$response->getStatusCode()
344332
));
345333
}
346334

347-
$body = (string)$response->getBody();
335+
$body = (string) $response->getBody();
348336

349337
try {
350338
$data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR);
351339
} catch (\JsonException $e) {
352-
throw new \RuntimeException(sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e);
340+
throw new RuntimeException(sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e);
353341
}
354342

355343
if (!\is_array($data) || !isset($data['keys'])) {
356-
throw new \RuntimeException('Invalid JWKS format: missing "keys" array.');
344+
throw new RuntimeException('Invalid JWKS format: missing "keys" array.');
357345
}
358346

359347
/** @var array<string, mixed> $data */

0 commit comments

Comments
 (0)