Skip to content

Commit d2a93c7

Browse files
committed
feat: integrate OAuth token introspection and userinfo handling
1 parent 73d69b4 commit d2a93c7

File tree

8 files changed

+432
-93
lines changed

8 files changed

+432
-93
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Mcp\Server\Authentication\Contract;
6+
7+
/**
8+
* HTTP client interface for OAuth token introspection.
9+
*/
10+
interface TokenIntrospectionClientInterface
11+
{
12+
/**
13+
* Introspect a token using RFC 7662 endpoint.
14+
*
15+
* @return array<string, mixed>
16+
* @throws \RuntimeException if introspection fails
17+
*/
18+
public function introspectToken(string $token, string $introspectionUrl, ?array $headers = null): array;
19+
20+
/**
21+
* Get user info from OAuth userinfo endpoint.
22+
*
23+
* @return array<string, mixed>
24+
* @throws \RuntimeException if userinfo request fails
25+
*/
26+
public function getUserInfo(string $token, string $userinfoUrl): array;
27+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Mcp\Server\Authentication\Dto;
6+
7+
/**
8+
* Normalized user profile across OAuth providers.
9+
*/
10+
final readonly class UserProfile implements \JsonSerializable
11+
{
12+
/**
13+
* @param array<string, mixed> $extra Provider-specific additional data
14+
*/
15+
public function __construct(
16+
public string|int $sub,
17+
public ?string $preferredUsername = null,
18+
public ?string $name = null,
19+
public ?string $email = null,
20+
public ?bool $emailVerified = null,
21+
public ?string $givenName = null,
22+
public ?string $familyName = null,
23+
public ?string $picture = null,
24+
public array $extra = [],
25+
) {}
26+
27+
public function jsonSerialize(): array
28+
{
29+
return \array_filter([
30+
'sub' => $this->sub,
31+
'preferred_username' => $this->preferredUsername,
32+
'name' => $this->name,
33+
'email' => $this->email,
34+
'email_verified' => $this->emailVerified,
35+
'given_name' => $this->givenName,
36+
'family_name' => $this->familyName,
37+
'picture' => $this->picture,
38+
'extra' => $this->extra,
39+
], static fn($value) => $value !== null && $value !== [] && $value !== '');
40+
}
41+
}

src/Authentication/Handler/MetadataHandler.php

Lines changed: 9 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
namespace Mcp\Server\Authentication\Handler;
66

7+
use JsonException;
78
use Mcp\Server\Authentication\Dto\OAuthMetadata;
89
use Mcp\Server\Authentication\Dto\OAuthProtectedResourceMetadata;
9-
use Mcp\Server\Authentication\Router\AuthRouterOptions;
1010
use Psr\Http\Message\ResponseFactoryInterface;
1111
use Psr\Http\Message\ResponseInterface;
1212
use Psr\Http\Message\ServerRequestInterface;
@@ -24,36 +24,12 @@ public function __construct(
2424
private StreamFactoryInterface $streamFactory,
2525
) {}
2626

27-
public static function create(
28-
AuthRouterOptions $options,
29-
ResponseFactoryInterface $responseFactory,
30-
StreamFactoryInterface $streamFactory,
31-
): self {
32-
$oauthMetadata = self::createOAuthMetadata($options);
33-
34-
// Create protected resource metadata
35-
$protectedResourceMetadata = new OAuthProtectedResourceMetadata(
36-
resource: $options->baseUrl ?? $oauthMetadata->getIssuer(),
37-
authorizationServers: [$oauthMetadata->getIssuer()],
38-
jwksUri: null,
39-
scopesSupported: empty($options->scopesSupported) ? null : $options->scopesSupported,
40-
bearerMethodsSupported: null,
41-
resourceSigningAlgValuesSupported: null,
42-
resourceName: $options->resourceName,
43-
resourceDocumentation: $options->serviceDocumentationUrl,
44-
);
45-
46-
return new self(
47-
oauthMetadata: $oauthMetadata,
48-
protectedResourceMetadata: $protectedResourceMetadata,
49-
responseFactory: $responseFactory,
50-
streamFactory: $streamFactory,
51-
);
52-
}
53-
27+
/**
28+
* @throws JsonException
29+
*/
5430
public function handleOAuthMetadata(ServerRequestInterface $request): ResponseInterface
5531
{
56-
$json = \json_encode($this->oauthMetadata->jsonSerialize(), JSON_THROW_ON_ERROR);
32+
$json = \json_encode($this->oauthMetadata->jsonSerialize(), \JSON_THROW_ON_ERROR);
5733
$body = $this->streamFactory->createStream($json);
5834

5935
return $this->responseFactory
@@ -63,9 +39,12 @@ public function handleOAuthMetadata(ServerRequestInterface $request): ResponseIn
6339
->withBody($body);
6440
}
6541

42+
/**
43+
* @throws JsonException
44+
*/
6645
public function handleProtectedResourceMetadata(ServerRequestInterface $request): ResponseInterface
6746
{
68-
$json = \json_encode($this->protectedResourceMetadata->jsonSerialize(), JSON_THROW_ON_ERROR);
47+
$json = \json_encode($this->protectedResourceMetadata->jsonSerialize(), \JSON_THROW_ON_ERROR);
6948
$body = $this->streamFactory->createStream($json);
7049

7150
return $this->responseFactory
@@ -74,58 +53,4 @@ public function handleProtectedResourceMetadata(ServerRequestInterface $request)
7453
->withHeader('Cache-Control', 'public, max-age=3600')
7554
->withBody($body);
7655
}
77-
78-
private static function createOAuthMetadata(AuthRouterOptions $options): OAuthMetadata
79-
{
80-
self::checkIssuerUrl($options->issuerUrl);
81-
82-
$baseUrl = $options->baseUrl ?? $options->issuerUrl;
83-
84-
return new OAuthMetadata(
85-
issuer: $options->issuerUrl,
86-
authorizationEndpoint: self::buildUrl('/oauth2/authorize', $baseUrl),
87-
tokenEndpoint: self::buildUrl('/oauth2/token', $baseUrl),
88-
responseTypesSupported: ['code'],
89-
registrationEndpoint: self::buildUrl('/oauth2/register', $baseUrl),
90-
scopesSupported: empty($options->scopesSupported) ? null : $options->scopesSupported,
91-
responseModesSupported: null,
92-
grantTypesSupported: ['authorization_code', 'refresh_token'],
93-
tokenEndpointAuthMethodsSupported: ['client_secret_post'],
94-
tokenEndpointAuthSigningAlgValuesSupported: null,
95-
serviceDocumentation: $options->serviceDocumentationUrl,
96-
revocationEndpoint: self::buildUrl('/oauth2/revoke', $baseUrl),
97-
revocationEndpointAuthMethodsSupported: ['client_secret_post'],
98-
revocationEndpointAuthSigningAlgValuesSupported: null,
99-
introspectionEndpoint: null,
100-
introspectionEndpointAuthMethodsSupported: null,
101-
introspectionEndpointAuthSigningAlgValuesSupported: null,
102-
codeChallengeMethodsSupported: ['S256'],
103-
);
104-
}
105-
106-
private static function checkIssuerUrl(string $issuer): void
107-
{
108-
$parsed = \parse_url($issuer);
109-
110-
// Technically RFC 8414 does not permit a localhost HTTPS exemption, but this is necessary for testing
111-
if (
112-
$parsed['scheme'] !== 'https' &&
113-
!\in_array($parsed['host'] ?? '', ['localhost', '127.0.0.1'], true)
114-
) {
115-
throw new \InvalidArgumentException('Issuer URL must be HTTPS');
116-
}
117-
118-
if (isset($parsed['fragment'])) {
119-
throw new \InvalidArgumentException("Issuer URL must not have a fragment: {$issuer}");
120-
}
121-
122-
if (isset($parsed['query'])) {
123-
throw new \InvalidArgumentException("Issuer URL must not have a query string: {$issuer}");
124-
}
125-
}
126-
127-
private static function buildUrl(string $path, string $baseUrl): string
128-
{
129-
return \rtrim($baseUrl, '/') . $path;
130-
}
13156
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Mcp\Server\Authentication\Provider;
6+
7+
use Mcp\Server\Authentication\AuthInfo;
8+
use Mcp\Server\Authentication\Contract\OAuthTokenVerifierInterface;
9+
use Mcp\Server\Authentication\Contract\TokenIntrospectionClientInterface;
10+
use Mcp\Server\Authentication\DefaultAuthInfo;
11+
use Mcp\Server\Authentication\Dto\UserProfile;
12+
use Mcp\Server\Authentication\Error\InvalidTokenError;
13+
14+
/**
15+
* Generic OAuth token verifier supporting multiple providers.
16+
*/
17+
final readonly class GenericTokenVerifier implements OAuthTokenVerifierInterface
18+
{
19+
public function __construct(
20+
private TokenIntrospectionConfig $config,
21+
private TokenIntrospectionClientInterface $client,
22+
) {}
23+
24+
/**
25+
* @throws InvalidTokenError
26+
*/
27+
public function verifyAccessToken(string $token): AuthInfo
28+
{
29+
// Verify token and get user profile
30+
$tokenData = $this->verifyToken($token);
31+
$userProfile = $this->getUserProfile($token, $tokenData);
32+
33+
// Create AuthInfo
34+
return $this->createAuthInfo($token, $tokenData, $userProfile);
35+
}
36+
37+
/**
38+
* @return array<string, mixed>
39+
* @throws InvalidTokenError
40+
*/
41+
private function verifyToken(string $token): array
42+
{
43+
// Try introspection first if configured and enabled
44+
if ($this->config->useIntrospection && $this->config->introspectionUrl !== null) {
45+
try {
46+
return $this->client->introspectToken(
47+
$token,
48+
$this->config->introspectionUrl,
49+
$this->config->headers,
50+
);
51+
} catch (\Throwable $e) {
52+
// If introspection fails and we have userinfo URL, fall back to it
53+
if ($this->config->userinfoUrl !== null) {
54+
// For userinfo-only flow, we'll get token info from the user profile
55+
return [];
56+
}
57+
58+
throw $e;
59+
}
60+
}
61+
62+
// For providers without introspection, we'll validate through userinfo endpoint
63+
if ($this->config->userinfoUrl !== null) {
64+
// Token validation will happen when we call getUserInfo
65+
return [];
66+
}
67+
68+
throw new InvalidTokenError('No token verification endpoint configured');
69+
}
70+
71+
private function getUserProfile(string $token, array $tokenData): UserProfile
72+
{
73+
if ($this->config->userinfoUrl === null) {
74+
// Try to build user profile from introspection data
75+
return $this->buildUserProfileFromIntrospection($tokenData);
76+
}
77+
78+
// Get user info from userinfo endpoint
79+
$userInfo = $this->client->getUserInfo($token, $this->config->userinfoUrl);
80+
81+
return $this->buildUserProfileFromUserInfo($userInfo);
82+
}
83+
84+
private function buildUserProfileFromIntrospection(array $data): UserProfile
85+
{
86+
// Map introspection fields to standard profile fields
87+
$mappedData = $this->mapFields($data, $this->config->userFieldMapping);
88+
89+
return new UserProfile(
90+
sub: $mappedData['sub'] ?? $data['sub'] ?? 'unknown',
91+
preferredUsername: $mappedData['preferred_username'] ?? $data['username'] ?? null,
92+
name: $mappedData['name'] ?? $data['name'] ?? null,
93+
email: $mappedData['email'] ?? $data['email'] ?? null,
94+
emailVerified: $mappedData['email_verified'] ?? $data['email_verified'] ?? null,
95+
givenName: $mappedData['given_name'] ?? $data['given_name'] ?? null,
96+
familyName: $mappedData['family_name'] ?? $data['family_name'] ?? null,
97+
picture: $mappedData['picture'] ?? $data['picture'] ?? null,
98+
extra: $data,
99+
);
100+
}
101+
102+
private function buildUserProfileFromUserInfo(array $data): UserProfile
103+
{
104+
// Map provider fields to standard profile fields
105+
$mappedData = $this->mapFields($data, $this->config->userFieldMapping);
106+
107+
return new UserProfile(
108+
sub: $mappedData['sub'] ?? $data['id'] ?? (string)($data['user_id'] ?? 'unknown'),
109+
preferredUsername: $mappedData['preferred_username'] ?? $data['login'] ?? $data['username'] ?? null,
110+
name: $mappedData['name'] ?? $data['name'] ?? null,
111+
email: $mappedData['email'] ?? $data['email'] ?? null,
112+
emailVerified: $mappedData['email_verified'] ?? $data['email_verified'] ?? null,
113+
givenName: $mappedData['given_name'] ?? $data['given_name'] ?? null,
114+
familyName: $mappedData['family_name'] ?? $data['family_name'] ?? null,
115+
picture: $mappedData['picture'] ?? $data['avatar_url'] ?? $data['picture'] ?? null,
116+
extra: $data,
117+
);
118+
}
119+
120+
/**
121+
* @param array<string, mixed> $data
122+
* @param array<string, string> $mapping
123+
* @return array<string, mixed>
124+
*/
125+
private function mapFields(array $data, array $mapping): array
126+
{
127+
$mapped = [];
128+
129+
foreach ($mapping as $sourceField => $targetField) {
130+
if (isset($data[$sourceField])) {
131+
$mapped[$targetField] = $data[$sourceField];
132+
}
133+
}
134+
135+
return $mapped;
136+
}
137+
138+
private function createAuthInfo(string $token, array $tokenData, UserProfile $userProfile): AuthInfo
139+
{
140+
// Extract client ID
141+
$clientId = $tokenData['client_id'] ?? $tokenData['aud'] ?? $userProfile->sub;
142+
143+
// Extract scopes
144+
$scopes = [];
145+
if (isset($tokenData['scope'])) {
146+
$scopes = \is_array($tokenData['scope'])
147+
? $tokenData['scope']
148+
: \explode(' ', (string)$tokenData['scope']);
149+
} elseif (isset($tokenData['scopes'])) {
150+
$scopes = \is_array($tokenData['scopes']) ? $tokenData['scopes'] : [$tokenData['scopes']];
151+
}
152+
153+
// Extract expiration
154+
$expiresAt = $tokenData['exp'] ?? null;
155+
if ($expiresAt !== null && !\is_int($expiresAt)) {
156+
$expiresAt = (int)$expiresAt;
157+
}
158+
159+
// Extract resource/audience
160+
$resource = $tokenData['aud'] ?? $tokenData['resource'] ?? null;
161+
162+
// Combine all extra data
163+
$extra = \array_merge(
164+
['user_profile' => $userProfile->jsonSerialize()],
165+
$tokenData,
166+
);
167+
168+
return new DefaultAuthInfo(
169+
token: $token,
170+
clientId: (string)$clientId,
171+
scopes: $scopes,
172+
expiresAt: $expiresAt,
173+
resource: $resource,
174+
extra: $extra,
175+
);
176+
}
177+
}

src/Authentication/Provider/ProxyClientsStore.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
*/
1717
final readonly class ProxyClientsStore implements OAuthRegisteredClientsStoreInterface
1818
{
19+
/**
20+
* @param callable(string $clientId):OAuthClientInformation|null $getClient
21+
* @param non-empty-string|null $registrationUrl
22+
*/
1923
public function __construct(
2024
private \Closure $getClient,
2125
private ?string $registrationUrl,
@@ -46,7 +50,7 @@ public function registerClient(OAuthClientInformation $client): OAuthClientInfor
4650
throw new ServerError("Client registration failed: {$response->getStatusCode()}");
4751
}
4852

49-
$data = \json_decode((string) $response->getBody(), true);
53+
$data = \json_decode((string)$response->getBody(), true);
5054
if (\json_last_error() !== JSON_ERROR_NONE) {
5155
throw new ServerError('Invalid JSON response from registration endpoint');
5256
}

0 commit comments

Comments
 (0)