diff --git a/composer.json b/composer.json index b883603b..bdab0ce8 100644 --- a/composer.json +++ b/composer.json @@ -77,6 +77,7 @@ "Mcp\\Example\\Server\\EnvVariables\\": "examples/server/env-variables/", "Mcp\\Example\\Server\\ExplicitRegistration\\": "examples/server/explicit-registration/", "Mcp\\Example\\Server\\McpApps\\": "examples/server/mcp-apps/", + "Mcp\\Example\\Server\\OAuthAuthorizationServer\\": "examples/server/oauth-authorization-server/", "Mcp\\Example\\Server\\OAuthKeycloak\\": "examples/server/oauth-keycloak/", "Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/", "Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/", diff --git a/examples/server/oauth-authorization-server/.gitignore b/examples/server/oauth-authorization-server/.gitignore new file mode 100644 index 00000000..a8190efd --- /dev/null +++ b/examples/server/oauth-authorization-server/.gitignore @@ -0,0 +1,3 @@ +keys/ +sessions/ +cache/ diff --git a/examples/server/oauth-authorization-server/DemoResourceOwnerResolver.php b/examples/server/oauth-authorization-server/DemoResourceOwnerResolver.php new file mode 100644 index 00000000..733c81be --- /dev/null +++ b/examples/server/oauth-authorization-server/DemoResourceOwnerResolver.php @@ -0,0 +1,63 @@ +responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + } + + public function resolve(ServerRequestInterface $request): ?ResourceOwner + { + $user = $request->getCookieParams()[self::COOKIE] ?? null; + if (!\is_string($user) || '' === $user) { + return null; + } + + return new ResourceOwner($user, ['name' => 'Demo User']); + } + + public function onUnauthenticated(ServerRequestInterface $request): ResponseInterface + { + // A real implementation renders/redirects to a login form. Here we + // auto-login the demo user and resume the authorization request. + return $this->responseFactory + ->createResponse(302) + ->withHeader('Location', (string) $request->getUri()) + ->withHeader('Set-Cookie', self::COOKIE.'='.rawurlencode($this->demoUserId).'; Path=/; HttpOnly; SameSite=Lax') + ->withHeader('Cache-Control', 'no-store'); + } +} diff --git a/examples/server/oauth-authorization-server/McpElements.php b/examples/server/oauth-authorization-server/McpElements.php new file mode 100644 index 00000000..67da8da1 --- /dev/null +++ b/examples/server/oauth-authorization-server/McpElements.php @@ -0,0 +1,45 @@ + + */ + #[McpTool( + name: 'whoami', + description: 'Return the authenticated subject and scopes from the access token.' + )] + public function whoami(RequestContext $context): array + { + $meta = $context->getRequest()->getMeta() ?? []; + $oauth = isset($meta['oauth']) && \is_array($meta['oauth']) ? $meta['oauth'] : []; + $claims = isset($oauth['oauth.claims']) && \is_array($oauth['oauth.claims']) ? $oauth['oauth.claims'] : []; + $scopes = isset($oauth['oauth.scopes']) && \is_array($oauth['oauth.scopes']) ? $oauth['oauth.scopes'] : []; + + return [ + 'authenticated' => true, + 'subject' => $oauth['oauth.subject'] ?? ($claims['sub'] ?? null), + 'client_id' => $oauth['oauth.client_id'] ?? ($claims['client_id'] ?? null), + 'scopes' => $scopes, + 'issuer' => $claims['iss'] ?? null, + ]; + } +} diff --git a/examples/server/oauth-authorization-server/README.md b/examples/server/oauth-authorization-server/README.md new file mode 100644 index 00000000..db628246 --- /dev/null +++ b/examples/server/oauth-authorization-server/README.md @@ -0,0 +1,70 @@ +# OAuth Authorization Server Example + +A self-contained MCP server that is **its own** OAuth 2.1 authorization server: it +registers clients, authorizes users, issues its own RS256 JWT access tokens, and +validates those same tokens to protect the MCP endpoint — with no external +identity provider and no `league/oauth2-server`. + +It demonstrates the SDK's native authorization-server layer: + +- `GET /.well-known/oauth-authorization-server` — RFC 8414 metadata (enriched with `registration_endpoint`) +- `GET /.well-known/oauth-protected-resource` — RFC 9728 metadata +- `GET /.well-known/jwks.json` — public signing key (JWK Set) +- `POST /register` — RFC 7591 Dynamic Client Registration +- `GET /authorize` — authorization code grant with mandatory PKCE (S256) +- `POST /token` — `authorization_code` and `refresh_token` grants +- `POST /` — the protected MCP JSON-RPC endpoint (requires `Authorization: Bearer `) + +> The signing key is generated into `keys/private.pem` on first run, storage is +> in-memory, and login auto-approves a fixed demo user (`DemoResourceOwnerResolver`). +> Replace all three for production: a managed key, the PSR-16 (or your own) stores, +> and a real login/consent backed by your user system. + +## Run + +```bash +php -S localhost:8000 examples/server/oauth-authorization-server/server.php +``` + +## Walkthrough (the Claude.ai flow) + +```bash +BASE=http://localhost:8000 + +# 1. Dynamic client registration (public client + PKCE) +curl -s -X POST $BASE/register -H 'Content-Type: application/json' -d '{ + "redirect_uris": ["http://localhost:9999/callback"], + "token_endpoint_auth_method": "none", + "client_name": "Demo Client" +}' +# => { "client_id": "...", "redirect_uris": [...], ... } + +# 2. Authorize (PKCE). Use a known verifier/challenge pair: +# verifier = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk +# challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM +curl -s -i -c cookies.txt -b cookies.txt \ + "$BASE/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=http://localhost:9999/callback&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&scope=mcp:tools&state=xyz" +# => 302 Location: http://localhost:9999/callback?code=CODE&state=xyz + +# 3. Exchange the code for tokens +curl -s -X POST $BASE/token \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d "grant_type=authorization_code&code=CODE&redirect_uri=http://localhost:9999/callback&client_id=CLIENT_ID&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" +# => { "access_token": "", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "..." } + +# 4. Call the protected MCP endpoint +curl -s -X POST $BASE/ \ + -H "Authorization: Bearer " \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"whoami","arguments":{}}}' +``` + +A request without a valid token returns `401` with a `WWW-Authenticate` header +pointing at `/.well-known/oauth-protected-resource`. + +## Middleware order + +The OAuth endpoints are composed before the bearer-protection middleware so they +stay public; only the MCP JSON-RPC path requires a token. `ClientRegistrationMiddleware` +sits outermost of the authorization-server metadata middleware so it can inject +`registration_endpoint` into the served document. diff --git a/examples/server/oauth-authorization-server/server.php b/examples/server/oauth-authorization-server/server.php new file mode 100644 index 00000000..506b1a71 --- /dev/null +++ b/examples/server/oauth-authorization-server/server.php @@ -0,0 +1,115 @@ + 2048, 'private_key_type' => \OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($resource, $pem); + file_put_contents($keyPath, $pem); +} +$signingKey = RsaSigningKey::fromFile($keyPath); + +// --- Storage (PSR-16 filesystem cache so state survives across requests under +// `php -S`; swap for your own ClientRepository/stores in production) --- +$cache = new Psr16Cache(new FilesystemAdapter('mcp_oauth', 0, __DIR__.'/cache')); +$clients = new CacheClientRepository($cache); +$codes = new CacheAuthorizationCodeStore($cache); +$refreshTokens = new CacheRefreshTokenStore($cache); + +// --- Authorization server engine --- +$accessTokenIssuer = new JwtAccessTokenIssuer($signingKey, $baseUrl); +$codeIssuer = new NativeAuthorizationCodeIssuer($codes); +$granter = new NativeTokenGranter($clients, $codes, $refreshTokens, $accessTokenIssuer, resource: $baseUrl); + +// --- Resource server: validate our own self-issued tokens, no network needed --- +$tokenValidator = new JwtTokenValidator( + issuer: $baseUrl, + audience: $baseUrl, + jwksProvider: new StaticJwksProvider($signingKey), +); + +$authServerMetadata = new AuthorizationServerMetadata( + issuer: $baseUrl, + scopesSupported: $scopes, +); + +$protectedResourceMetadata = new ProtectedResourceMetadata( + authorizationServers: [$baseUrl], + scopesSupported: $scopes, + resource: $baseUrl, + resourceName: 'OAuth Authorization Server Example', +); + +$server = Server::builder() + ->setServerInfo('OAuth Authorization Server Example', '1.0.0') + ->setLogger(logger()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setDiscovery(__DIR__) + ->build(); + +$transport = new StreamableHttpTransport( + (new Psr17Factory())->createServerRequestFromGlobals(), + logger: logger(), + middleware: [ + ...StreamableHttpTransport::defaultMiddleware(), + // ClientRegistration must be OUTER of the metadata middleware so it can + // enrich the served document with registration_endpoint. + new ClientRegistrationMiddleware(new StoredClientRegistrar($clients, $scopes), $baseUrl), + new AuthorizationServerMetadataMiddleware($authServerMetadata), + new JwksMiddleware($signingKey), + new ProtectedResourceMetadataMiddleware($protectedResourceMetadata), + new AuthorizationEndpointMiddleware($clients, $codeIssuer, new DemoResourceOwnerResolver(), new AutoApproveConsent(), $scopes), + new TokenEndpointMiddleware($granter), + new AuthorizationMiddleware($tokenValidator, $protectedResourceMetadata), + new OAuthRequestMetaMiddleware(), + ], +); + +$response = $server->run($transport); + +(new SapiEmitter())->emit($response); diff --git a/src/Exception/OAuthException.php b/src/Exception/OAuthException.php new file mode 100644 index 00000000..847cc2fe --- /dev/null +++ b/src/Exception/OAuthException.php @@ -0,0 +1,79 @@ + $this->error, + 'error_description' => $this->getMessage(), + ]; + } +} diff --git a/src/Server/Transport/Http/Middleware/AuthorizationEndpointMiddleware.php b/src/Server/Transport/Http/Middleware/AuthorizationEndpointMiddleware.php new file mode 100644 index 00000000..39ea695d --- /dev/null +++ b/src/Server/Transport/Http/Middleware/AuthorizationEndpointMiddleware.php @@ -0,0 +1,221 @@ + */ + private array $supportedScopes; + + /** + * @param list $supportedScopes Allowed scopes (empty = accept any the client allows) + */ + public function __construct( + private readonly ClientRepositoryInterface $clients, + private readonly AuthorizationCodeIssuerInterface $codeIssuer, + private readonly ResourceOwnerResolverInterface $resourceOwner, + private readonly ConsentInterface $consent, + array $supportedScopes = [], + private readonly string $path = '/authorize', + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + $this->supportedScopes = array_values($supportedScopes); + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($this->path !== $request->getUri()->getPath() || !\in_array($request->getMethod(), ['GET', 'POST'], true)) { + return $handler->handle($request); + } + + $params = $this->collectParams($request); + + // 1. Client + redirect URI: errors here MUST NOT redirect (open-redirect prevention). + $clientId = $this->stringParam($params, 'client_id'); + if (null === $clientId || null === ($client = $this->clients->find($clientId))) { + return $this->directError(400, 'invalid_client', 'Unknown or missing client_id.'); + } + + $redirectUri = $this->stringParam($params, 'redirect_uri'); + if (null === $redirectUri || !$client->hasRedirectUri($redirectUri)) { + return $this->directError(400, 'invalid_request', 'Missing or unregistered redirect_uri.'); + } + + $state = $this->stringParam($params, 'state'); + + // 2. response_type + if ('code' !== $this->stringParam($params, 'response_type')) { + return $this->redirectError($redirectUri, 'unsupported_response_type', 'Only response_type=code is supported.', $state); + } + + // 3. PKCE (S256 required) + $codeChallenge = $this->stringParam($params, 'code_challenge'); + $codeChallengeMethod = $this->stringParam($params, 'code_challenge_method'); + if (null === $codeChallenge || Pkce::METHOD_S256 !== $codeChallengeMethod) { + return $this->redirectError($redirectUri, 'invalid_request', 'PKCE with code_challenge_method=S256 is required.', $state); + } + + // 4. Scopes + $scopes = $this->resolveScopes($params, $client->scopes); + foreach ($scopes as $scope) { + $allowedByServer = [] === $this->supportedScopes || \in_array($scope, $this->supportedScopes, true); + if (!$allowedByServer || !$client->allowsScope($scope)) { + return $this->redirectError($redirectUri, 'invalid_scope', \sprintf('The scope "%s" is not allowed.', $scope), $state); + } + } + + // 5. Resource owner (host authenticates the user) + $owner = $this->resourceOwner->resolve($request); + if (null === $owner) { + return $this->resourceOwner->onUnauthenticated($request); + } + + // 6. Consent + $decision = $this->consent->decide($client, $scopes, $owner, $request); + if (null !== $decision->response) { + return $decision->response; + } + if (!$decision->approved) { + return $this->redirectError($redirectUri, 'access_denied', 'The resource owner denied the request.', $state); + } + $scopes = $decision->approvedScopes ?? $scopes; + + // 7. Issue the authorization code + $resource = $this->stringParam($params, 'resource'); + $code = $this->codeIssuer->issueCode($client, $owner, $redirectUri, $scopes, $codeChallenge, Pkce::METHOD_S256, $resource); + + $location = $this->appendQuery($redirectUri, array_filter([ + 'code' => $code, + 'state' => $state, + ], static fn (?string $v): bool => null !== $v)); + + return $this->responseFactory + ->createResponse(302) + ->withHeader('Location', $location) + ->withHeader('Cache-Control', 'no-store'); + } + + /** + * @return array + */ + private function collectParams(ServerRequestInterface $request): array + { + $params = $request->getQueryParams(); + + if ('POST' === $request->getMethod() + && str_starts_with(strtolower($request->getHeaderLine('Content-Type')), 'application/x-www-form-urlencoded')) { + parse_str($request->getBody()->__toString(), $body); + $params = array_merge($params, $body); + } + + return $params; + } + + /** + * @param array $params + * @param list $clientScopes + * + * @return list + */ + private function resolveScopes(array $params, array $clientScopes): array + { + $scope = $this->stringParam($params, 'scope'); + if (null === $scope) { + return [] !== $clientScopes ? $clientScopes : $this->supportedScopes; + } + + return array_values(array_filter(explode(' ', $scope), static fn (string $s): bool => '' !== $s)); + } + + /** + * @param array $params + */ + private function stringParam(array $params, string $name): ?string + { + $value = $params[$name] ?? null; + if (!\is_string($value) || '' === $value) { + return null; + } + + return $value; + } + + /** + * @param array $params + */ + private function appendQuery(string $uri, array $params): string + { + if ([] === $params) { + return $uri; + } + + $separator = str_contains($uri, '?') ? '&' : '?'; + + return $uri.$separator.http_build_query($params); + } + + private function redirectError(string $redirectUri, string $error, string $description, ?string $state): ResponseInterface + { + $location = $this->appendQuery($redirectUri, array_filter([ + 'error' => $error, + 'error_description' => $description, + 'state' => $state, + ], static fn (?string $v): bool => null !== $v)); + + return $this->responseFactory + ->createResponse(302) + ->withHeader('Location', $location) + ->withHeader('Cache-Control', 'no-store'); + } + + private function directError(int $status, string $error, string $description): ResponseInterface + { + return $this->responseFactory + ->createResponse($status) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Cache-Control', 'no-store') + ->withBody($this->streamFactory->createStream(json_encode([ + 'error' => $error, + 'error_description' => $description, + ], \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES))); + } +} diff --git a/src/Server/Transport/Http/Middleware/AuthorizationServerMetadataMiddleware.php b/src/Server/Transport/Http/Middleware/AuthorizationServerMetadataMiddleware.php new file mode 100644 index 00000000..c83dce05 --- /dev/null +++ b/src/Server/Transport/Http/Middleware/AuthorizationServerMetadataMiddleware.php @@ -0,0 +1,58 @@ +responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ('GET' !== $request->getMethod() || $this->metadata->getMetadataPath() !== $request->getUri()->getPath()) { + return $handler->handle($request); + } + + return $this->responseFactory + ->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Cache-Control', 'max-age=3600') + ->withBody($this->streamFactory->createStream(json_encode($this->metadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES))); + } +} diff --git a/src/Server/Transport/Http/Middleware/JwksMiddleware.php b/src/Server/Transport/Http/Middleware/JwksMiddleware.php new file mode 100644 index 00000000..da3566e2 --- /dev/null +++ b/src/Server/Transport/Http/Middleware/JwksMiddleware.php @@ -0,0 +1,68 @@ + */ + private array $signingKeys; + + /** + * @param SigningKeyInterface|iterable $signingKeys One key, or a set to support rotation + */ + public function __construct( + SigningKeyInterface|iterable $signingKeys, + private readonly string $path = self::DEFAULT_PATH, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + $this->signingKeys = $signingKeys instanceof SigningKeyInterface + ? [$signingKeys] + : array_values([...$signingKeys]); + + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ('GET' !== $request->getMethod() || $this->path !== $request->getUri()->getPath()) { + return $handler->handle($request); + } + + $keys = array_map(static fn (SigningKeyInterface $key): array => $key->getPublicJwk(), $this->signingKeys); + + return $this->responseFactory + ->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Cache-Control', 'max-age=3600') + ->withBody($this->streamFactory->createStream(json_encode(['keys' => $keys], \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES))); + } +} diff --git a/src/Server/Transport/Http/Middleware/TokenEndpointMiddleware.php b/src/Server/Transport/Http/Middleware/TokenEndpointMiddleware.php new file mode 100644 index 00000000..1c311c48 --- /dev/null +++ b/src/Server/Transport/Http/Middleware/TokenEndpointMiddleware.php @@ -0,0 +1,108 @@ +responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ('POST' !== $request->getMethod() || $this->path !== $request->getUri()->getPath()) { + return $handler->handle($request); + } + + parse_str($request->getBody()->__toString(), $params); + $params = $this->applyBasicAuth($request, $params); + + $grantType = $params['grant_type'] ?? ''; + if (!\is_string($grantType)) { + $grantType = ''; + } + + try { + $tokenResponse = $this->granter->grant($grantType, $params); + } catch (OAuthException $e) { + return $this->json($e->httpStatus, $e->toArray()); + } + + return $this->json(200, $tokenResponse->toArray()); + } + + /** + * Normalizes HTTP Basic client credentials into the params array + * (client_secret_basic), without overriding body-provided values. + * + * @param array $params + * + * @return array + */ + private function applyBasicAuth(ServerRequestInterface $request, array $params): array + { + $authorization = $request->getHeaderLine('Authorization'); + if (!preg_match('/^Basic\s+(.+)$/i', $authorization, $matches)) { + return $params; + } + + $decoded = base64_decode(trim($matches[1]), true); + if (false === $decoded || !str_contains($decoded, ':')) { + return $params; + } + + [$clientId, $clientSecret] = explode(':', $decoded, 2); + $params['client_id'] ??= rawurldecode($clientId); + $params['client_secret'] ??= rawurldecode($clientSecret); + + return $params; + } + + /** + * @param array $data + */ + private function json(int $status, array $data): ResponseInterface + { + return $this->responseFactory + ->createResponse($status) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Cache-Control', 'no-store') + ->withHeader('Pragma', 'no-cache') + ->withBody($this->streamFactory->createStream(json_encode($data, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES))); + } +} diff --git a/src/Server/Transport/Http/OAuth/AccessTokenIssuerInterface.php b/src/Server/Transport/Http/OAuth/AccessTokenIssuerInterface.php new file mode 100644 index 00000000..3267be3c --- /dev/null +++ b/src/Server/Transport/Http/OAuth/AccessTokenIssuerInterface.php @@ -0,0 +1,36 @@ + $scopes + * @param array $claims Additional claims to embed (e.g. from the resource owner) + * + * @return array{token: string, tokenId: string} The encoded access token and its unique id (jti) + */ + public function issue( + string $subject, + string $audience, + array $scopes, + string $clientId, + int $ttlSeconds, + array $claims = [], + ): array; +} diff --git a/src/Server/Transport/Http/OAuth/AccessTokenStoreInterface.php b/src/Server/Transport/Http/OAuth/AccessTokenStoreInterface.php new file mode 100644 index 00000000..8011e9ad --- /dev/null +++ b/src/Server/Transport/Http/OAuth/AccessTokenStoreInterface.php @@ -0,0 +1,29 @@ + $scopes + * @param array $userClaims + */ + public function __construct( + public readonly string $clientId, + public readonly string $redirectUri, + public readonly array $scopes, + public readonly string $codeChallenge, + public readonly string $codeChallengeMethod, + public readonly string $userId, + public readonly array $userClaims, + public readonly ?string $resource, + public readonly \DateTimeImmutable $expiresAt, + ) { + } + + public function isExpired(\DateTimeImmutable $now): bool + { + return $now >= $this->expiresAt; + } +} diff --git a/src/Server/Transport/Http/OAuth/AuthorizationCodeIssuerInterface.php b/src/Server/Transport/Http/OAuth/AuthorizationCodeIssuerInterface.php new file mode 100644 index 00000000..aec2379a --- /dev/null +++ b/src/Server/Transport/Http/OAuth/AuthorizationCodeIssuerInterface.php @@ -0,0 +1,38 @@ + $scopes + * + * @return string The authorization code to return to the client + */ + public function issueCode( + Client $client, + ResourceOwner $resourceOwner, + string $redirectUri, + array $scopes, + string $codeChallenge, + string $codeChallengeMethod, + ?string $resource = null, + ): string; +} diff --git a/src/Server/Transport/Http/OAuth/AuthorizationCodeStoreInterface.php b/src/Server/Transport/Http/OAuth/AuthorizationCodeStoreInterface.php new file mode 100644 index 00000000..594504f0 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/AuthorizationCodeStoreInterface.php @@ -0,0 +1,28 @@ + */ + private array $scopesSupported; + + /** @var list */ + private array $responseTypesSupported; + + /** @var list */ + private array $grantTypesSupported; + + /** @var list */ + private array $codeChallengeMethodsSupported; + + /** @var list */ + private array $tokenEndpointAuthMethodsSupported; + + /** @var array */ + private array $extra; + + /** + * @param list $scopesSupported + * @param list $responseTypesSupported + * @param list $grantTypesSupported + * @param list $codeChallengeMethodsSupported + * @param list $tokenEndpointAuthMethodsSupported + * @param array $extra + */ + public function __construct( + string $issuer, + ?string $authorizationEndpoint = null, + ?string $tokenEndpoint = null, + ?string $jwksUri = null, + ?string $registrationEndpoint = null, + array $scopesSupported = ['mcp:tools', 'mcp:resources'], + array $responseTypesSupported = ['code'], + array $grantTypesSupported = ['authorization_code', 'refresh_token'], + array $codeChallengeMethodsSupported = ['S256'], + array $tokenEndpointAuthMethodsSupported = ['client_secret_basic', 'client_secret_post', 'none'], + array $extra = [], + private readonly string $metadataPath = self::DEFAULT_METADATA_PATH, + ) { + $issuer = rtrim(trim($issuer), '/'); + if ('' === $issuer) { + throw new InvalidArgumentException('Authorization server metadata requires a non-empty issuer.'); + } + + $this->issuer = $issuer; + $this->authorizationEndpoint = $authorizationEndpoint ?? $issuer.'/authorize'; + $this->tokenEndpoint = $tokenEndpoint ?? $issuer.'/token'; + $this->jwksUri = $jwksUri ?? $issuer.'/.well-known/jwks.json'; + $this->registrationEndpoint = $registrationEndpoint; + $this->scopesSupported = array_values($scopesSupported); + $this->responseTypesSupported = array_values($responseTypesSupported); + $this->grantTypesSupported = array_values($grantTypesSupported); + $this->codeChallengeMethodsSupported = array_values($codeChallengeMethodsSupported); + $this->tokenEndpointAuthMethodsSupported = array_values($tokenEndpointAuthMethodsSupported); + $this->extra = $extra; + } + + public function getIssuer(): string + { + return $this->issuer; + } + + public function getMetadataPath(): string + { + return $this->metadataPath; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = [ + 'issuer' => $this->issuer, + 'authorization_endpoint' => $this->authorizationEndpoint, + 'token_endpoint' => $this->tokenEndpoint, + 'jwks_uri' => $this->jwksUri, + 'scopes_supported' => $this->scopesSupported, + 'response_types_supported' => $this->responseTypesSupported, + 'grant_types_supported' => $this->grantTypesSupported, + 'code_challenge_methods_supported' => $this->codeChallengeMethodsSupported, + 'token_endpoint_auth_methods_supported' => $this->tokenEndpointAuthMethodsSupported, + ]; + + if (null !== $this->registrationEndpoint) { + $data['registration_endpoint'] = $this->registrationEndpoint; + } + + return array_merge($this->extra, $data); + } +} diff --git a/src/Server/Transport/Http/OAuth/AutoApproveConsent.php b/src/Server/Transport/Http/OAuth/AutoApproveConsent.php new file mode 100644 index 00000000..ad1df3ef --- /dev/null +++ b/src/Server/Transport/Http/OAuth/AutoApproveConsent.php @@ -0,0 +1,30 @@ +expiresAt->getTimestamp() - time(); + if ($ttl <= 0) { + return; + } + + $this->cache->set($this->prefix.$code, $authorizationCode, $ttl); + } + + public function consume(string $code): ?AuthorizationCode + { + $key = $this->prefix.$code; + $stored = $this->cache->get($key); + $this->cache->delete($key); + + return $stored instanceof AuthorizationCode ? $stored : null; + } +} diff --git a/src/Server/Transport/Http/OAuth/CacheClientRepository.php b/src/Server/Transport/Http/OAuth/CacheClientRepository.php new file mode 100644 index 00000000..6c7b4f2d --- /dev/null +++ b/src/Server/Transport/Http/OAuth/CacheClientRepository.php @@ -0,0 +1,40 @@ +cache->get($this->prefix.$clientId); + + return $stored instanceof Client ? $stored : null; + } + + public function save(Client $client): void + { + $this->cache->set($this->prefix.$client->clientId, $client); + } +} diff --git a/src/Server/Transport/Http/OAuth/CacheRefreshTokenStore.php b/src/Server/Transport/Http/OAuth/CacheRefreshTokenStore.php new file mode 100644 index 00000000..b3bd0bb9 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/CacheRefreshTokenStore.php @@ -0,0 +1,46 @@ +expiresAt->getTimestamp() - time(); + if ($ttl <= 0) { + return; + } + + $this->cache->set($this->prefix.$token, $refreshToken, $ttl); + } + + public function consume(string $token): ?RefreshToken + { + $key = $this->prefix.$token; + $stored = $this->cache->get($key); + $this->cache->delete($key); + + return $stored instanceof RefreshToken ? $stored : null; + } +} diff --git a/src/Server/Transport/Http/OAuth/Client.php b/src/Server/Transport/Http/OAuth/Client.php new file mode 100644 index 00000000..6fda3d37 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/Client.php @@ -0,0 +1,74 @@ + */ + public readonly array $redirectUris; + + /** @var list */ + public readonly array $grantTypes; + + /** @var list */ + public readonly array $scopes; + + /** + * @param list $redirectUris + * @param list $grantTypes + * @param list $scopes + */ + public function __construct( + public readonly string $clientId, + public readonly ?string $clientSecret = null, + array $redirectUris = [], + array $grantTypes = ['authorization_code', 'refresh_token'], + array $scopes = [], + public readonly string $tokenEndpointAuthMethod = self::AUTH_METHOD_CLIENT_SECRET_BASIC, + public readonly ?string $clientName = null, + ) { + $this->redirectUris = array_values($redirectUris); + $this->grantTypes = array_values($grantTypes); + $this->scopes = array_values($scopes); + } + + public function isPublic(): bool + { + return self::AUTH_METHOD_NONE === $this->tokenEndpointAuthMethod || null === $this->clientSecret; + } + + public function hasRedirectUri(string $redirectUri): bool + { + return \in_array($redirectUri, $this->redirectUris, true); + } + + public function supportsGrant(string $grantType): bool + { + return \in_array($grantType, $this->grantTypes, true); + } + + public function allowsScope(string $scope): bool + { + return [] === $this->scopes || \in_array($scope, $this->scopes, true); + } +} diff --git a/src/Server/Transport/Http/OAuth/ClientRepositoryInterface.php b/src/Server/Transport/Http/OAuth/ClientRepositoryInterface.php new file mode 100644 index 00000000..d863f151 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/ClientRepositoryInterface.php @@ -0,0 +1,24 @@ +|null $approvedScopes + */ + private function __construct( + public readonly bool $approved, + public readonly ?array $approvedScopes, + public readonly ?ResponseInterface $response, + ) { + } + + /** + * @param list|null $approvedScopes Null keeps the requested scopes + */ + public static function approve(?array $approvedScopes = null): self + { + return new self(true, $approvedScopes, null); + } + + public static function deny(): self + { + return new self(false, null, null); + } + + /** + * Short-circuit the authorization request with a custom response (e.g. the + * consent form to display before a decision can be made). + */ + public static function respondWith(ResponseInterface $response): self + { + return new self(false, null, $response); + } +} diff --git a/src/Server/Transport/Http/OAuth/ConsentInterface.php b/src/Server/Transport/Http/OAuth/ConsentInterface.php new file mode 100644 index 00000000..bdb5f8f1 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/ConsentInterface.php @@ -0,0 +1,35 @@ + $scopes + */ + public function decide( + Client $client, + array $scopes, + ResourceOwner $resourceOwner, + ServerRequestInterface $request, + ): ConsentDecision; +} diff --git a/src/Server/Transport/Http/OAuth/InMemoryAccessTokenStore.php b/src/Server/Transport/Http/OAuth/InMemoryAccessTokenStore.php new file mode 100644 index 00000000..9388a749 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/InMemoryAccessTokenStore.php @@ -0,0 +1,37 @@ + token id => revoked */ + private array $tokens = []; + + public function record(string $tokenId, string $clientId, string $userId, \DateTimeImmutable $expiresAt): void + { + $this->tokens[$tokenId] = false; + } + + public function isRevoked(string $tokenId): bool + { + return $this->tokens[$tokenId] ?? false; + } + + public function revoke(string $tokenId): void + { + $this->tokens[$tokenId] = true; + } +} diff --git a/src/Server/Transport/Http/OAuth/InMemoryAuthorizationCodeStore.php b/src/Server/Transport/Http/OAuth/InMemoryAuthorizationCodeStore.php new file mode 100644 index 00000000..b5d7b26b --- /dev/null +++ b/src/Server/Transport/Http/OAuth/InMemoryAuthorizationCodeStore.php @@ -0,0 +1,35 @@ + */ + private array $codes = []; + + public function store(string $code, AuthorizationCode $authorizationCode): void + { + $this->codes[$code] = $authorizationCode; + } + + public function consume(string $code): ?AuthorizationCode + { + $stored = $this->codes[$code] ?? null; + unset($this->codes[$code]); + + return $stored; + } +} diff --git a/src/Server/Transport/Http/OAuth/InMemoryClientRepository.php b/src/Server/Transport/Http/OAuth/InMemoryClientRepository.php new file mode 100644 index 00000000..47e5553d --- /dev/null +++ b/src/Server/Transport/Http/OAuth/InMemoryClientRepository.php @@ -0,0 +1,42 @@ + */ + private array $clients = []; + + /** + * @param list $clients + */ + public function __construct(array $clients = []) + { + foreach ($clients as $client) { + $this->save($client); + } + } + + public function find(string $clientId): ?Client + { + return $this->clients[$clientId] ?? null; + } + + public function save(Client $client): void + { + $this->clients[$client->clientId] = $client; + } +} diff --git a/src/Server/Transport/Http/OAuth/InMemoryRefreshTokenStore.php b/src/Server/Transport/Http/OAuth/InMemoryRefreshTokenStore.php new file mode 100644 index 00000000..8e651c6b --- /dev/null +++ b/src/Server/Transport/Http/OAuth/InMemoryRefreshTokenStore.php @@ -0,0 +1,35 @@ + */ + private array $tokens = []; + + public function store(string $token, RefreshToken $refreshToken): void + { + $this->tokens[$token] = $refreshToken; + } + + public function consume(string $token): ?RefreshToken + { + $stored = $this->tokens[$token] ?? null; + unset($this->tokens[$token]); + + return $stored; + } +} diff --git a/src/Server/Transport/Http/OAuth/JwtAccessTokenIssuer.php b/src/Server/Transport/Http/OAuth/JwtAccessTokenIssuer.php new file mode 100644 index 00000000..9f634987 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/JwtAccessTokenIssuer.php @@ -0,0 +1,76 @@ +clock?->now() ?? new \DateTimeImmutable())->getTimestamp(); + $tokenId = bin2hex(random_bytes(16)); + + $payload = $claims; + $payload['iss'] = $this->issuer; + $payload['sub'] = $subject; + $payload['aud'] = $audience; + $payload['client_id'] = $clientId; + $payload['jti'] = $tokenId; + $payload['iat'] = $now; + $payload['exp'] = $now + $ttlSeconds; + $payload[$this->scopeClaim] = $this->scopesAsArray ? array_values($scopes) : implode(' ', $scopes); + + if ($this->includeNotBefore) { + $payload['nbf'] = $now; + } + + $token = JWT::encode($payload, $this->signingKey->getPrivateKeyPem(), self::ALGORITHM, $this->signingKey->getKeyId()); + + return ['token' => $token, 'tokenId' => $tokenId]; + } +} diff --git a/src/Server/Transport/Http/OAuth/NativeAuthorizationCodeIssuer.php b/src/Server/Transport/Http/OAuth/NativeAuthorizationCodeIssuer.php new file mode 100644 index 00000000..7d66010c --- /dev/null +++ b/src/Server/Transport/Http/OAuth/NativeAuthorizationCodeIssuer.php @@ -0,0 +1,55 @@ +clock?->now() ?? new \DateTimeImmutable(); + $code = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + + $this->codes->store($code, new AuthorizationCode( + clientId: $client->clientId, + redirectUri: $redirectUri, + scopes: array_values($scopes), + codeChallenge: $codeChallenge, + codeChallengeMethod: $codeChallengeMethod, + userId: $resourceOwner->id, + userClaims: $resourceOwner->claims, + resource: $resource, + expiresAt: $now->modify(\sprintf('+%d seconds', $this->codeTtl)), + )); + + return $code; + } +} diff --git a/src/Server/Transport/Http/OAuth/NativeTokenGranter.php b/src/Server/Transport/Http/OAuth/NativeTokenGranter.php new file mode 100644 index 00000000..36a77e58 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/NativeTokenGranter.php @@ -0,0 +1,221 @@ + $this->grantAuthorizationCode($params), + 'refresh_token' => $this->grantRefreshToken($params), + '' => throw OAuthException::invalidRequest('Missing "grant_type" parameter.'), + default => throw OAuthException::unsupportedGrantType(\sprintf('Unsupported grant type "%s".', $grantType)), + }; + } + + /** + * @param array $params + */ + private function grantAuthorizationCode(array $params): TokenResponse + { + $client = $this->authenticateClient($params); + + if (!$client->supportsGrant('authorization_code')) { + throw OAuthException::unauthorizedClient('The client is not authorized to use the authorization code grant.'); + } + + $code = $this->requireParam($params, 'code'); + $redirectUri = $this->requireParam($params, 'redirect_uri'); + $codeVerifier = $this->requireParam($params, 'code_verifier'); + + $authorizationCode = $this->codes->consume($code); + if (null === $authorizationCode) { + throw OAuthException::invalidGrant('The authorization code is invalid or has already been used.'); + } + + if ($authorizationCode->isExpired($this->now())) { + throw OAuthException::invalidGrant('The authorization code has expired.'); + } + + if (!hash_equals($authorizationCode->clientId, $client->clientId)) { + throw OAuthException::invalidGrant('The authorization code was issued to another client.'); + } + + if (!hash_equals($authorizationCode->redirectUri, $redirectUri)) { + throw OAuthException::invalidGrant('The redirect URI does not match the authorization request.'); + } + + if (!Pkce::verify($codeVerifier, $authorizationCode->codeChallenge)) { + throw OAuthException::invalidGrant('The PKCE code verifier is invalid.'); + } + + return $this->issueTokens( + $client, + $authorizationCode->userId, + $authorizationCode->scopes, + $authorizationCode->userClaims, + $authorizationCode->resource, + ); + } + + /** + * @param array $params + */ + private function grantRefreshToken(array $params): TokenResponse + { + $client = $this->authenticateClient($params); + + if (!$client->supportsGrant('refresh_token')) { + throw OAuthException::unauthorizedClient('The client is not authorized to use the refresh token grant.'); + } + + $token = $this->requireParam($params, 'refresh_token'); + + $refreshToken = $this->refreshTokens->consume($token); + if (null === $refreshToken) { + throw OAuthException::invalidGrant('The refresh token is invalid or has already been used.'); + } + + if ($refreshToken->isExpired($this->now())) { + throw OAuthException::invalidGrant('The refresh token has expired.'); + } + + if (!hash_equals($refreshToken->clientId, $client->clientId)) { + throw OAuthException::invalidGrant('The refresh token was issued to another client.'); + } + + $scopes = $this->resolveRefreshScopes($params, $refreshToken->scopes); + + return $this->issueTokens( + $client, + $refreshToken->userId, + $scopes, + $refreshToken->userClaims, + $refreshToken->resource, + ); + } + + /** + * @param list $scopes + * @param array $userClaims + */ + private function issueTokens(Client $client, string $userId, array $scopes, array $userClaims, ?string $resource): TokenResponse + { + $audience = $resource ?? $this->resource ?? $client->clientId; + + $accessToken = $this->issuer->issue($userId, $audience, $scopes, $client->clientId, $this->accessTokenTtl, $userClaims); + + $refreshTokenValue = null; + if (null !== $this->refreshTokenTtl && $client->supportsGrant('refresh_token')) { + $refreshTokenValue = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + + $this->refreshTokens->store($refreshTokenValue, new RefreshToken( + clientId: $client->clientId, + userId: $userId, + scopes: $scopes, + userClaims: $userClaims, + resource: $resource, + expiresAt: $this->now()->modify(\sprintf('+%d seconds', $this->refreshTokenTtl)), + )); + } + + return new TokenResponse($accessToken['token'], $this->accessTokenTtl, $scopes, $refreshTokenValue); + } + + /** + * @param array $params + */ + private function authenticateClient(array $params): Client + { + $clientId = $params['client_id'] ?? null; + if (!\is_string($clientId) || '' === $clientId) { + throw OAuthException::invalidClient('Client authentication failed: missing client_id.'); + } + + $client = $this->clients->find($clientId); + if (null === $client) { + throw OAuthException::invalidClient('Client authentication failed: unknown client.'); + } + + if (!$client->isPublic()) { + $secret = $params['client_secret'] ?? null; + if (!\is_string($secret) || '' === $secret || !hash_equals((string) $client->clientSecret, $secret)) { + throw OAuthException::invalidClient('Client authentication failed: invalid client secret.'); + } + } + + return $client; + } + + /** + * @param array $params + * @param list $grantedScopes + * + * @return list + */ + private function resolveRefreshScopes(array $params, array $grantedScopes): array + { + $requested = $params['scope'] ?? null; + if (!\is_string($requested) || '' === trim($requested)) { + return $grantedScopes; + } + + $requestedScopes = array_values(array_filter(explode(' ', $requested), static fn (string $s): bool => '' !== $s)); + + foreach ($requestedScopes as $scope) { + if (!\in_array($scope, $grantedScopes, true)) { + throw OAuthException::invalidScope(\sprintf('The requested scope "%s" exceeds the originally granted scope.', $scope)); + } + } + + return $requestedScopes; + } + + /** + * @param array $params + */ + private function requireParam(array $params, string $name): string + { + $value = $params[$name] ?? null; + if (!\is_string($value) || '' === $value) { + throw OAuthException::invalidRequest(\sprintf('Missing or invalid "%s" parameter.', $name)); + } + + return $value; + } + + private function now(): \DateTimeImmutable + { + return $this->clock?->now() ?? new \DateTimeImmutable(); + } +} diff --git a/src/Server/Transport/Http/OAuth/Pkce.php b/src/Server/Transport/Http/OAuth/Pkce.php new file mode 100644 index 00000000..5ff37c44 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/Pkce.php @@ -0,0 +1,39 @@ + $scopes + * @param array $userClaims + */ + public function __construct( + public readonly string $clientId, + public readonly string $userId, + public readonly array $scopes, + public readonly array $userClaims, + public readonly ?string $resource, + public readonly \DateTimeImmutable $expiresAt, + ) { + } + + public function isExpired(\DateTimeImmutable $now): bool + { + return $now >= $this->expiresAt; + } +} diff --git a/src/Server/Transport/Http/OAuth/RefreshTokenStoreInterface.php b/src/Server/Transport/Http/OAuth/RefreshTokenStoreInterface.php new file mode 100644 index 00000000..c0c9577f --- /dev/null +++ b/src/Server/Transport/Http/OAuth/RefreshTokenStoreInterface.php @@ -0,0 +1,26 @@ + $claims + */ + public function __construct( + public readonly string $id, + public readonly array $claims = [], + ) { + } +} diff --git a/src/Server/Transport/Http/OAuth/ResourceOwnerResolverInterface.php b/src/Server/Transport/Http/OAuth/ResourceOwnerResolverInterface.php new file mode 100644 index 00000000..0eec969e --- /dev/null +++ b/src/Server/Transport/Http/OAuth/ResourceOwnerResolverInterface.php @@ -0,0 +1,34 @@ +|null */ + private ?array $publicJwk = null; + + public function __construct( + private readonly string $privateKeyPem, + ?string $keyId = null, + private readonly ?string $passphrase = null, + ) { + if (!\function_exists('openssl_pkey_get_private')) { + throw new RuntimeException('The RsaSigningKey requires the openssl extension (ext-openssl).'); + } + + if ('' === trim($privateKeyPem)) { + throw new InvalidArgumentException('The private key PEM must not be empty.'); + } + + $this->keyId = $keyId ?? $this->deriveKeyId(); + } + + public static function fromFile(string $pemPath, ?string $keyId = null, ?string $passphrase = null): self + { + $contents = @file_get_contents($pemPath); + if (false === $contents) { + throw new InvalidArgumentException(\sprintf('Unable to read private key from "%s".', $pemPath)); + } + + return new self($contents, $keyId, $passphrase); + } + + public function getKeyId(): string + { + return $this->keyId; + } + + public function getPrivateKeyPem(): string + { + return $this->privateKeyPem; + } + + public function getPublicJwk(): array + { + if (null !== $this->publicJwk) { + return $this->publicJwk; + } + + $details = $this->keyDetails(); + + if (\OPENSSL_KEYTYPE_RSA !== ($details['type'] ?? null) || !isset($details['rsa']['n'], $details['rsa']['e'])) { + throw new RuntimeException('The signing key must be an RSA key.'); + } + + return $this->publicJwk = [ + 'kty' => 'RSA', + 'use' => 'sig', + 'alg' => self::ALGORITHM, + 'kid' => $this->keyId, + 'n' => self::base64UrlEncode($details['rsa']['n']), + 'e' => self::base64UrlEncode($details['rsa']['e']), + ]; + } + + /** + * @return array{type?: int, rsa?: array{n?: string, e?: string}} + */ + private function keyDetails(): array + { + $key = openssl_pkey_get_private($this->privateKeyPem, $this->passphrase ?? ''); + if (false === $key) { + throw new RuntimeException('Unable to parse the private key: '.openssl_error_string()); + } + + $details = openssl_pkey_get_details($key); + if (false === $details) { + throw new RuntimeException('Unable to read public key details: '.openssl_error_string()); + } + + /* @var array{type?: int, rsa?: array{n?: string, e?: string}} $details */ + return $details; + } + + private function deriveKeyId(): string + { + $details = $this->keyDetails(); + $modulus = $details['rsa']['n'] ?? ''; + + return substr(hash('sha256', $modulus), 0, 16); + } + + private static function base64UrlEncode(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } +} diff --git a/src/Server/Transport/Http/OAuth/SigningKeyInterface.php b/src/Server/Transport/Http/OAuth/SigningKeyInterface.php new file mode 100644 index 00000000..b3785781 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/SigningKeyInterface.php @@ -0,0 +1,36 @@ + + */ + public function getPublicJwk(): array; +} diff --git a/src/Server/Transport/Http/OAuth/StaticJwksProvider.php b/src/Server/Transport/Http/OAuth/StaticJwksProvider.php new file mode 100644 index 00000000..baea1498 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/StaticJwksProvider.php @@ -0,0 +1,44 @@ +> */ + private array $keys; + + /** + * @param SigningKeyInterface|iterable $signingKeys + */ + public function __construct(SigningKeyInterface|iterable $signingKeys) + { + $keys = $signingKeys instanceof SigningKeyInterface ? [$signingKeys] : $signingKeys; + + $this->keys = array_map( + static fn (SigningKeyInterface $key): array => $key->getPublicJwk(), + array_values([...$keys]), + ); + } + + public function getJwks(string $issuer, ?string $jwksUri = null): array + { + return ['keys' => $this->keys]; + } +} diff --git a/src/Server/Transport/Http/OAuth/StoredClientRegistrar.php b/src/Server/Transport/Http/OAuth/StoredClientRegistrar.php new file mode 100644 index 00000000..49d3de31 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/StoredClientRegistrar.php @@ -0,0 +1,177 @@ + */ + private array $defaultScopes; + + /** @var list */ + private array $allowedRedirectUriSchemes; + + /** + * @param list $defaultScopes + * @param list $allowedRedirectUriSchemes Schemes allowed in addition to http on loopback hosts + */ + public function __construct( + private readonly ClientRepositoryInterface $clients, + array $defaultScopes = [], + array $allowedRedirectUriSchemes = ['https'], + ) { + $this->defaultScopes = array_values($defaultScopes); + $this->allowedRedirectUriSchemes = array_values($allowedRedirectUriSchemes); + } + + public function register(array $registrationRequest): array + { + $redirectUris = $this->validateRedirectUris($registrationRequest['redirect_uris'] ?? null); + $authMethod = $this->resolveAuthMethod($registrationRequest['token_endpoint_auth_method'] ?? null); + $grantTypes = $this->resolveGrantTypes($registrationRequest['grant_types'] ?? null); + $scopes = $this->resolveScopes($registrationRequest['scope'] ?? null); + $clientName = isset($registrationRequest['client_name']) && \is_string($registrationRequest['client_name']) + ? $registrationRequest['client_name'] + : null; + + $clientId = bin2hex(random_bytes(16)); + $isPublic = Client::AUTH_METHOD_NONE === $authMethod; + $clientSecret = $isPublic ? null : bin2hex(random_bytes(32)); + + $this->clients->save(new Client( + clientId: $clientId, + clientSecret: $clientSecret, + redirectUris: $redirectUris, + grantTypes: $grantTypes, + scopes: $scopes, + tokenEndpointAuthMethod: $authMethod, + clientName: $clientName, + )); + + $response = [ + 'client_id' => $clientId, + 'client_id_issued_at' => time(), + 'redirect_uris' => $redirectUris, + 'grant_types' => $grantTypes, + 'response_types' => ['code'], + 'token_endpoint_auth_method' => $authMethod, + 'scope' => implode(' ', $scopes), + ]; + + if (null !== $clientSecret) { + $response['client_secret'] = $clientSecret; + $response['client_secret_expires_at'] = 0; + } + + if (null !== $clientName) { + $response['client_name'] = $clientName; + } + + return $response; + } + + /** + * @return list + */ + private function validateRedirectUris(mixed $redirectUris): array + { + if (!\is_array($redirectUris) || [] === $redirectUris) { + throw new ClientRegistrationException('At least one redirect URI is required.', 'invalid_redirect_uri'); + } + + $normalized = []; + foreach ($redirectUris as $uri) { + if (!\is_string($uri) || !$this->isAllowedRedirectUri($uri)) { + throw new ClientRegistrationException(\sprintf('Invalid or disallowed redirect URI: "%s".', \is_string($uri) ? $uri : \gettype($uri)), 'invalid_redirect_uri'); + } + + $normalized[] = $uri; + } + + return array_values(array_unique($normalized)); + } + + private function isAllowedRedirectUri(string $uri): bool + { + $parts = parse_url($uri); + if (false === $parts || !isset($parts['scheme'])) { + return false; + } + + $scheme = strtolower($parts['scheme']); + $host = strtolower($parts['host'] ?? ''); + + if ('http' === $scheme && \in_array($host, ['localhost', '127.0.0.1', '::1'], true)) { + return true; + } + + return \in_array($scheme, $this->allowedRedirectUriSchemes, true); + } + + private function resolveAuthMethod(mixed $method): string + { + if (Client::AUTH_METHOD_NONE === $method) { + return Client::AUTH_METHOD_NONE; + } + + if (Client::AUTH_METHOD_CLIENT_SECRET_POST === $method) { + return Client::AUTH_METHOD_CLIENT_SECRET_POST; + } + + return Client::AUTH_METHOD_CLIENT_SECRET_BASIC; + } + + /** + * @return list + */ + private function resolveGrantTypes(mixed $grantTypes): array + { + $requested = \is_array($grantTypes) + ? array_values(array_filter($grantTypes, 'is_string')) + : ['authorization_code']; + + if (!\in_array('authorization_code', $requested, true)) { + $requested[] = 'authorization_code'; + } + + if (!\in_array('refresh_token', $requested, true)) { + $requested[] = 'refresh_token'; + } + + return array_values(array_unique($requested)); + } + + /** + * @return list + */ + private function resolveScopes(mixed $scope): array + { + if (!\is_string($scope) || '' === trim($scope)) { + return $this->defaultScopes; + } + + $requested = array_filter(explode(' ', $scope), static fn (string $s): bool => '' !== $s); + + if ([] === $this->defaultScopes) { + return array_values($requested); + } + + return array_values(array_filter($requested, fn (string $s): bool => \in_array($s, $this->defaultScopes, true))); + } +} diff --git a/src/Server/Transport/Http/OAuth/TokenGranterInterface.php b/src/Server/Transport/Http/OAuth/TokenGranterInterface.php new file mode 100644 index 00000000..d126a5c9 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/TokenGranterInterface.php @@ -0,0 +1,32 @@ + $params The parsed application/x-www-form-urlencoded body, + * with client_id/client_secret normalized from any Basic auth header + * + * @throws OAuthException On any protocol error (mapped to an RFC 6749 §5.2 response) + */ + public function grant(string $grantType, array $params): TokenResponse; +} diff --git a/src/Server/Transport/Http/OAuth/TokenResponse.php b/src/Server/Transport/Http/OAuth/TokenResponse.php new file mode 100644 index 00000000..4c8b99ee --- /dev/null +++ b/src/Server/Transport/Http/OAuth/TokenResponse.php @@ -0,0 +1,52 @@ + $scopes + */ + public function __construct( + public readonly string $accessToken, + public readonly int $expiresIn, + public readonly array $scopes = [], + public readonly ?string $refreshToken = null, + public readonly string $tokenType = 'Bearer', + ) { + } + + /** + * @return array + */ + public function toArray(): array + { + $data = [ + 'access_token' => $this->accessToken, + 'token_type' => $this->tokenType, + 'expires_in' => $this->expiresIn, + ]; + + if ([] !== $this->scopes) { + $data['scope'] = implode(' ', $this->scopes); + } + + if (null !== $this->refreshToken) { + $data['refresh_token'] = $this->refreshToken; + } + + return $data; + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/AuthorizationEndpointMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/AuthorizationEndpointMiddlewareTest.php new file mode 100644 index 00000000..f4088554 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/AuthorizationEndpointMiddlewareTest.php @@ -0,0 +1,165 @@ +middleware($codes, $this->resolver(new ResourceOwner('user-1'))); + + $response = $middleware->process($this->authorizeRequest(), $this->handlerReturning(404)); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame('no-store', $response->getHeaderLine('Cache-Control')); + + parse_str(parse_url($response->getHeaderLine('Location'), \PHP_URL_QUERY) ?: '', $query); + $this->assertArrayHasKey('code', $query); + $this->assertSame('xyz', $query['state']); + $this->assertNotNull($codes->consume($query['code'])); + } + + #[TestDox('renders a 400 (no redirect) for an unknown client')] + public function testUnknownClient(): void + { + $middleware = $this->middleware(new InMemoryAuthorizationCodeStore(), $this->resolver(new ResourceOwner('user-1'))); + $request = $this->authorizeRequest(['client_id' => 'nope']); + + $response = $middleware->process($request, $this->handlerReturning(404)); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Location')); + } + + #[TestDox('renders a 400 (no redirect) for an unregistered redirect_uri')] + public function testRedirectMismatch(): void + { + $middleware = $this->middleware(new InMemoryAuthorizationCodeStore(), $this->resolver(new ResourceOwner('user-1'))); + $request = $this->authorizeRequest(['redirect_uri' => 'https://evil.example.com/cb']); + + $response = $middleware->process($request, $this->handlerReturning(404)); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Location')); + } + + #[TestDox('redirects with invalid_request when PKCE is missing')] + public function testMissingPkceRedirectsError(): void + { + $middleware = $this->middleware(new InMemoryAuthorizationCodeStore(), $this->resolver(new ResourceOwner('user-1'))); + $params = $this->validParams(); + unset($params['code_challenge'], $params['code_challenge_method']); + $request = $this->factory + ->createServerRequest('GET', 'https://mcp.example.com/authorize') + ->withQueryParams($params); + + $response = $middleware->process($request, $this->handlerReturning(404)); + + $this->assertSame(302, $response->getStatusCode()); + parse_str(parse_url($response->getHeaderLine('Location'), \PHP_URL_QUERY) ?: '', $query); + $this->assertSame('invalid_request', $query['error']); + $this->assertSame('xyz', $query['state']); + } + + #[TestDox('delegates to the host login when the user is unauthenticated')] + public function testUnauthenticated(): void + { + $middleware = $this->middleware(new InMemoryAuthorizationCodeStore(), $this->resolver(null)); + + $response = $middleware->process($this->authorizeRequest(), $this->handlerReturning(404)); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame('https://mcp.example.com/login', $response->getHeaderLine('Location')); + } + + private function middleware(InMemoryAuthorizationCodeStore $codes, ResourceOwnerResolverInterface $resolver): AuthorizationEndpointMiddleware + { + $clients = new InMemoryClientRepository([ + new Client('client-1', null, [self::REDIRECT], ['authorization_code'], ['mcp:tools'], Client::AUTH_METHOD_NONE), + ]); + + return new AuthorizationEndpointMiddleware( + $clients, + new NativeAuthorizationCodeIssuer($codes), + $resolver, + new AutoApproveConsent(), + ['mcp:tools'], + ); + } + + /** + * @param array $overrides + */ + private function authorizeRequest(array $overrides = []): ServerRequestInterface + { + $params = array_merge($this->validParams(), $overrides); + + return $this->factory + ->createServerRequest('GET', 'https://mcp.example.com/authorize') + ->withQueryParams($params); + } + + /** + * @return array + */ + private function validParams(): array + { + return [ + 'response_type' => 'code', + 'client_id' => 'client-1', + 'redirect_uri' => self::REDIRECT, + 'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', + 'code_challenge_method' => 'S256', + 'scope' => 'mcp:tools', + 'state' => 'xyz', + ]; + } + + private function resolver(?ResourceOwner $owner): ResourceOwnerResolverInterface + { + $factory = $this->factory; + + return new class($owner, $factory) implements ResourceOwnerResolverInterface { + public function __construct( + private ?ResourceOwner $owner, + private \Nyholm\Psr7\Factory\Psr17Factory $factory, + ) { + } + + public function resolve(ServerRequestInterface $request): ?ResourceOwner + { + return $this->owner; + } + + public function onUnauthenticated(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(302)->withHeader('Location', 'https://mcp.example.com/login'); + } + }; + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/AuthorizationServerMetadataMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/AuthorizationServerMetadataMiddlewareTest.php new file mode 100644 index 00000000..a8f6bf50 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/AuthorizationServerMetadataMiddlewareTest.php @@ -0,0 +1,44 @@ +factory->createServerRequest('GET', 'https://mcp.example.com/.well-known/oauth-authorization-server'); + + $response = $middleware->process($request, $this->handlerReturning(404)); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $body = json_decode((string) $response->getBody(), true); + $this->assertSame('https://mcp.example.com', $body['issuer']); + } + + #[TestDox('passes other requests through')] + public function testPassthrough(): void + { + $middleware = new AuthorizationServerMetadataMiddleware(new AuthorizationServerMetadata('https://mcp.example.com')); + $request = $this->factory->createServerRequest('GET', 'https://mcp.example.com/other'); + + $response = $middleware->process($request, $this->handlerReturning(204)); + + $this->assertSame(204, $response->getStatusCode()); + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/JwksMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/JwksMiddlewareTest.php new file mode 100644 index 00000000..c9408b43 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/JwksMiddlewareTest.php @@ -0,0 +1,52 @@ +signingKey('kid-1')); + $request = $this->factory->createServerRequest('GET', 'https://mcp.example.com/.well-known/jwks.json'); + + $response = $middleware->process($request, $this->handlerReturning(404)); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertCount(1, $body['keys']); + $this->assertSame('kid-1', $body['keys'][0]['kid']); + } + + #[TestDox('passes other requests through')] + public function testPassthrough(): void + { + $middleware = new JwksMiddleware($this->signingKey('kid-1')); + $request = $this->factory->createServerRequest('GET', 'https://mcp.example.com/other'); + + $this->assertSame(204, $middleware->process($request, $this->handlerReturning(204))->getStatusCode()); + } + + private function signingKey(string $kid): RsaSigningKey + { + $key = openssl_pkey_new(['private_key_type' => \OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048]); + $this->assertNotFalse($key); + $pem = ''; + $this->assertTrue(openssl_pkey_export($key, $pem)); + + return new RsaSigningKey($pem, $kid); + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/TokenEndpointMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/TokenEndpointMiddlewareTest.php new file mode 100644 index 00000000..587e862b --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/TokenEndpointMiddlewareTest.php @@ -0,0 +1,107 @@ +granter(static fn (): TokenResponse => new TokenResponse('jwt-token', 3600, ['mcp:tools'], 'refresh-1')); + $middleware = new TokenEndpointMiddleware($granter); + + $request = $this->factory + ->createServerRequest('POST', 'https://mcp.example.com/token') + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withBody($this->factory->createStream('grant_type=authorization_code&code=abc&client_id=c1')); + + $response = $middleware->process($request, $this->handlerReturning(404)); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('no-store', $response->getHeaderLine('Cache-Control')); + $body = json_decode((string) $response->getBody(), true); + $this->assertSame('jwt-token', $body['access_token']); + $this->assertSame('Bearer', $body['token_type']); + $this->assertSame('refresh-1', $body['refresh_token']); + } + + #[TestDox('maps an OAuthException to an RFC 6749 error response')] + public function testError(): void + { + $granter = $this->granter(static fn () => throw OAuthException::invalidGrant('bad code')); + $middleware = new TokenEndpointMiddleware($granter); + + $request = $this->factory + ->createServerRequest('POST', 'https://mcp.example.com/token') + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withBody($this->factory->createStream('grant_type=authorization_code')); + + $response = $middleware->process($request, $this->handlerReturning(404)); + + $this->assertSame(400, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertSame('invalid_grant', $body['error']); + } + + #[TestDox('decodes HTTP Basic client credentials into the params')] + public function testBasicAuth(): void + { + $captured = null; + $granter = $this->granter(static function (string $grant, array $params) use (&$captured): TokenResponse { + $captured = $params; + + return new TokenResponse('jwt', 3600); + }); + $middleware = new TokenEndpointMiddleware($granter); + + $request = $this->factory + ->createServerRequest('POST', 'https://mcp.example.com/token') + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withHeader('Authorization', 'Basic '.base64_encode('client-1:s3cret')) + ->withBody($this->factory->createStream('grant_type=refresh_token&refresh_token=r1')); + + $middleware->process($request, $this->handlerReturning(404)); + + $this->assertSame('client-1', $captured['client_id']); + $this->assertSame('s3cret', $captured['client_secret']); + } + + #[TestDox('passes non-token requests through')] + public function testPassthrough(): void + { + $middleware = new TokenEndpointMiddleware($this->granter(static fn () => new TokenResponse('x', 1))); + $request = $this->factory->createServerRequest('GET', 'https://mcp.example.com/token'); + + $this->assertSame(204, $middleware->process($request, $this->handlerReturning(204))->getStatusCode()); + } + + private function granter(callable $grant): TokenGranterInterface + { + return new class($grant) implements TokenGranterInterface { + /** @param callable(string, array): TokenResponse $grant */ + public function __construct(private $grant) + { + } + + public function grant(string $grantType, array $params): TokenResponse + { + return ($this->grant)($grantType, $params); + } + }; + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/AuthorizationServerMetadataTest.php b/tests/Unit/Server/Transport/Http/OAuth/AuthorizationServerMetadataTest.php new file mode 100644 index 00000000..9cca7127 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/AuthorizationServerMetadataTest.php @@ -0,0 +1,59 @@ +jsonSerialize(); + + $this->assertSame('https://mcp.example.com', $data['issuer']); + $this->assertSame('https://mcp.example.com/authorize', $data['authorization_endpoint']); + $this->assertSame('https://mcp.example.com/token', $data['token_endpoint']); + $this->assertSame('https://mcp.example.com/.well-known/jwks.json', $data['jwks_uri']); + $this->assertSame(['S256'], $data['code_challenge_methods_supported']); + $this->assertArrayNotHasKey('registration_endpoint', $data); + } + + #[TestDox('includes registration_endpoint and extra fields when provided')] + public function testOverrides(): void + { + $metadata = new AuthorizationServerMetadata( + issuer: 'https://mcp.example.com', + registrationEndpoint: 'https://mcp.example.com/register', + scopesSupported: ['mcp:tools'], + extra: ['service_documentation' => 'https://docs.example.com'], + ); + + $data = $metadata->jsonSerialize(); + + $this->assertSame('https://mcp.example.com/register', $data['registration_endpoint']); + $this->assertSame(['mcp:tools'], $data['scopes_supported']); + $this->assertSame('https://docs.example.com', $data['service_documentation']); + } + + #[TestDox('rejects an empty issuer')] + public function testEmptyIssuer(): void + { + $this->expectException(InvalidArgumentException::class); + new AuthorizationServerMetadata(' '); + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/NativeTokenGranterTest.php b/tests/Unit/Server/Transport/Http/OAuth/NativeTokenGranterTest.php new file mode 100644 index 00000000..301c8056 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/NativeTokenGranterTest.php @@ -0,0 +1,227 @@ + \OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048]); + $this->assertNotFalse($key); + $pem = ''; + $this->assertTrue(openssl_pkey_export($key, $pem)); + $this->signingKey = new RsaSigningKey($pem, 'test-kid'); + + $this->clients = new InMemoryClientRepository([ + new Client('public-client', null, [self::REDIRECT], ['authorization_code', 'refresh_token'], ['mcp:tools'], Client::AUTH_METHOD_NONE), + new Client('conf-client', 'topsecret', [self::REDIRECT], ['authorization_code', 'refresh_token'], ['mcp:tools'], Client::AUTH_METHOD_CLIENT_SECRET_BASIC), + ]); + $this->codes = new InMemoryAuthorizationCodeStore(); + $this->refreshTokens = new InMemoryRefreshTokenStore(); + $this->codeIssuer = new NativeAuthorizationCodeIssuer($this->codes); + + $issuer = new JwtAccessTokenIssuer($this->signingKey, self::ISSUER); + $this->granter = new NativeTokenGranter($this->clients, $this->codes, $this->refreshTokens, $issuer, resource: self::ISSUER); + } + + #[TestDox('authorization_code grant issues a token that validates through JwtTokenValidator (self-issued round-trip)')] + public function testAuthorizationCodeGrantRoundTrip(): void + { + $code = $this->issueCode('public-client'); + + $response = $this->granter->grant('authorization_code', [ + 'client_id' => 'public-client', + 'code' => $code, + 'redirect_uri' => self::REDIRECT, + 'code_verifier' => self::VERIFIER, + ]); + + $this->assertSame('Bearer', $response->tokenType); + $this->assertSame(['mcp:tools'], $response->scopes); + $this->assertNotNull($response->refreshToken); + + $validator = new JwtTokenValidator(self::ISSUER, self::ISSUER, new StaticJwksProvider($this->signingKey)); + $result = $validator->validate($response->accessToken); + + $this->assertTrue($result->isAllowed()); + $this->assertSame('user-1', $result->getAttributes()['oauth.subject']); + $this->assertSame(['mcp:tools'], $result->getAttributes()['oauth.scopes']); + } + + #[TestDox('authorization code is single-use')] + public function testCodeIsSingleUse(): void + { + $code = $this->issueCode('public-client'); + $params = ['client_id' => 'public-client', 'code' => $code, 'redirect_uri' => self::REDIRECT, 'code_verifier' => self::VERIFIER]; + + $this->granter->grant('authorization_code', $params); + + try { + $this->granter->grant('authorization_code', $params); + $this->fail('Expected OAuthException'); + } catch (OAuthException $e) { + $this->assertSame('invalid_grant', $e->error); + } + } + + #[TestDox('rejects an invalid PKCE verifier')] + public function testPkceMismatch(): void + { + $code = $this->issueCode('public-client'); + + $this->expectException(OAuthException::class); + $this->expectExceptionMessage('PKCE'); + $this->granter->grant('authorization_code', [ + 'client_id' => 'public-client', + 'code' => $code, + 'redirect_uri' => self::REDIRECT, + 'code_verifier' => 'not-the-verifier', + ]); + } + + #[TestDox('rejects a mismatching redirect_uri')] + public function testRedirectUriMismatch(): void + { + $code = $this->issueCode('public-client'); + + $this->expectException(OAuthException::class); + $this->granter->grant('authorization_code', [ + 'client_id' => 'public-client', + 'code' => $code, + 'redirect_uri' => 'https://evil.example.com/callback', + 'code_verifier' => self::VERIFIER, + ]); + } + + #[TestDox('confidential client with a bad secret is rejected with invalid_client (401)')] + public function testConfidentialClientBadSecret(): void + { + $code = $this->issueCode('conf-client'); + + try { + $this->granter->grant('authorization_code', [ + 'client_id' => 'conf-client', + 'client_secret' => 'wrong', + 'code' => $code, + 'redirect_uri' => self::REDIRECT, + 'code_verifier' => self::VERIFIER, + ]); + $this->fail('Expected OAuthException'); + } catch (OAuthException $e) { + $this->assertSame('invalid_client', $e->error); + $this->assertSame(401, $e->httpStatus); + } + } + + #[TestDox('refresh tokens rotate: the old token is invalid after use')] + public function testRefreshTokenRotation(): void + { + $code = $this->issueCode('public-client'); + $first = $this->granter->grant('authorization_code', [ + 'client_id' => 'public-client', + 'code' => $code, + 'redirect_uri' => self::REDIRECT, + 'code_verifier' => self::VERIFIER, + ]); + $this->assertNotNull($first->refreshToken); + + $second = $this->granter->grant('refresh_token', [ + 'client_id' => 'public-client', + 'refresh_token' => $first->refreshToken, + ]); + $this->assertNotNull($second->refreshToken); + $this->assertNotSame($first->refreshToken, $second->refreshToken); + + $this->expectException(OAuthException::class); + $this->granter->grant('refresh_token', ['client_id' => 'public-client', 'refresh_token' => $first->refreshToken]); + } + + #[TestDox('rejects an unsupported grant type')] + public function testUnsupportedGrantType(): void + { + try { + $this->granter->grant('password', ['client_id' => 'public-client']); + $this->fail('Expected OAuthException'); + } catch (OAuthException $e) { + $this->assertSame('unsupported_grant_type', $e->error); + } + } + + #[TestDox('rejects an expired authorization code')] + public function testExpiredCode(): void + { + $this->codes->store('expired', new AuthorizationCode( + clientId: 'public-client', + redirectUri: self::REDIRECT, + scopes: ['mcp:tools'], + codeChallenge: Pkce::challenge(self::VERIFIER), + codeChallengeMethod: 'S256', + userId: 'user-1', + userClaims: [], + resource: self::ISSUER, + expiresAt: new \DateTimeImmutable('-1 minute'), + )); + + $this->expectException(OAuthException::class); + $this->expectExceptionMessage('expired'); + $this->granter->grant('authorization_code', [ + 'client_id' => 'public-client', + 'code' => 'expired', + 'redirect_uri' => self::REDIRECT, + 'code_verifier' => self::VERIFIER, + ]); + } + + private function issueCode(string $clientId): string + { + $client = $this->clients->find($clientId); + $this->assertNotNull($client); + + return $this->codeIssuer->issueCode( + $client, + new ResourceOwner('user-1'), + self::REDIRECT, + ['mcp:tools'], + Pkce::challenge(self::VERIFIER), + 'S256', + self::ISSUER, + ); + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/PkceTest.php b/tests/Unit/Server/Transport/Http/OAuth/PkceTest.php new file mode 100644 index 00000000..dfb0509a --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/PkceTest.php @@ -0,0 +1,38 @@ +assertSame($expectedChallenge, Pkce::challenge($verifier)); + } + + #[TestDox('verifies a matching verifier and rejects a mismatching one')] + public function testVerify(): void + { + $verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; + $challenge = Pkce::challenge($verifier); + + $this->assertTrue(Pkce::verify($verifier, $challenge)); + $this->assertFalse(Pkce::verify('wrong-verifier', $challenge)); + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/RsaSigningKeyTest.php b/tests/Unit/Server/Transport/Http/OAuth/RsaSigningKeyTest.php new file mode 100644 index 00000000..c337d06c --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/RsaSigningKeyTest.php @@ -0,0 +1,54 @@ +generatePem(), 'my-kid'); + + $jwk = $key->getPublicJwk(); + + $this->assertSame('RSA', $jwk['kty']); + $this->assertSame('sig', $jwk['use']); + $this->assertSame('RS256', $jwk['alg']); + $this->assertSame('my-kid', $jwk['kid']); + $this->assertNotEmpty($jwk['n']); + $this->assertNotEmpty($jwk['e']); + // base64url: no +, /, or = padding + $this->assertDoesNotMatchRegularExpression('#[+/=]#', $jwk['n']); + } + + #[TestDox('derives a stable key id when none is given')] + public function testDerivesStableKeyId(): void + { + $pem = $this->generatePem(); + + $this->assertSame((new RsaSigningKey($pem))->getKeyId(), (new RsaSigningKey($pem))->getKeyId()); + } + + private function generatePem(): string + { + $key = openssl_pkey_new(['private_key_type' => \OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048]); + $this->assertNotFalse($key); + $pem = ''; + $this->assertTrue(openssl_pkey_export($key, $pem)); + + return $pem; + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/StoredClientRegistrarTest.php b/tests/Unit/Server/Transport/Http/OAuth/StoredClientRegistrarTest.php new file mode 100644 index 00000000..1794e21e --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/StoredClientRegistrarTest.php @@ -0,0 +1,92 @@ +register([ + 'redirect_uris' => ['https://client.example.com/callback'], + 'client_name' => 'My Client', + ]); + + $this->assertArrayHasKey('client_id', $response); + $this->assertArrayHasKey('client_secret', $response); + $this->assertSame('My Client', $response['client_name']); + $this->assertContains('authorization_code', $response['grant_types']); + $this->assertContains('refresh_token', $response['grant_types']); + + $stored = $repository->find($response['client_id']); + $this->assertInstanceOf(Client::class, $stored); + $this->assertFalse($stored->isPublic()); + } + + #[TestDox('registers a public client (token_endpoint_auth_method=none) without a secret')] + public function testRegistersPublicClient(): void + { + $repository = new InMemoryClientRepository(); + $registrar = new StoredClientRegistrar($repository); + + $response = $registrar->register([ + 'redirect_uris' => ['https://client.example.com/callback'], + 'token_endpoint_auth_method' => 'none', + ]); + + $this->assertArrayNotHasKey('client_secret', $response); + $this->assertSame('none', $response['token_endpoint_auth_method']); + $this->assertTrue($repository->find($response['client_id'])?->isPublic()); + } + + #[TestDox('allows http loopback redirect URIs')] + public function testAllowsLoopbackRedirect(): void + { + $registrar = new StoredClientRegistrar(new InMemoryClientRepository()); + + $response = $registrar->register(['redirect_uris' => ['http://localhost:1234/cb', 'http://127.0.0.1/cb']]); + + $this->assertCount(2, $response['redirect_uris']); + } + + #[TestDox('rejects a disallowed redirect URI scheme')] + public function testRejectsDisallowedScheme(): void + { + $registrar = new StoredClientRegistrar(new InMemoryClientRepository()); + + try { + $registrar->register(['redirect_uris' => ['http://evil.example.com/cb']]); + $this->fail('Expected ClientRegistrationException'); + } catch (ClientRegistrationException $e) { + $this->assertSame('invalid_redirect_uri', $e->errorCode); + } + } + + #[TestDox('rejects missing redirect URIs')] + public function testRejectsMissingRedirectUris(): void + { + $registrar = new StoredClientRegistrar(new InMemoryClientRepository()); + + $this->expectException(ClientRegistrationException::class); + $registrar->register(['client_name' => 'No Redirects']); + } +}