diff --git a/src/Client/OAuth2Client.php b/src/Client/OAuth2Client.php index 040cd31..dc31a98 100644 --- a/src/Client/OAuth2Client.php +++ b/src/Client/OAuth2Client.php @@ -215,4 +215,46 @@ private function tokenRequest(array $params): TokenSet return TokenSet::fromArray($data); } + + /** + * Revoke a token at the provider's revocation endpoint (RFC 7009). + * + * @param string $token The token to revoke (access or refresh). + * @param string $tokenTypeHint 'access_token' or 'refresh_token'. + * @throws OAuthException If the provider has no revocation endpoint + * or the request fails. + */ + public function revokeToken(string $token, string $tokenTypeHint = 'access_token'): void + { + if ($this->provider->revocationEndpoint === null) { + throw new OAuthException('invalid_request', 'Provider does not support a revocation endpoint'); + } + + $params = [ + 'token' => $token, + 'token_type_hint' => $tokenTypeHint, + 'client_id' => $this->clientId, + ]; + + if ($this->clientSecret !== null) { + $params['client_secret'] = $this->clientSecret; + } + + $body = $this->streamFactory->createStream(http_build_query($params)); + + $request = $this->requestFactory->createRequest('POST', $this->provider->revocationEndpoint) + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withHeader('Accept', 'application/json') + ->withBody($body); + + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() >= 400) { + $data = json_decode((string) $response->getBody(), true) ?? []; + throw new OAuthException( + $data['error'] ?? 'server_error', + $data['error_description'] ?? 'Token revocation failed', + ); + } + } } diff --git a/test/Client/OAuth2ClientTest.php b/test/Client/OAuth2ClientTest.php new file mode 100644 index 0000000..83d3c52 --- /dev/null +++ b/test/Client/OAuth2ClientTest.php @@ -0,0 +1,240 @@ + + */ + +namespace Horde\OAuth\Test\Client; + +use Horde\OAuth\Client\OAuth2Client; +use Horde\OAuth\Client\ProviderConfig; +use Horde\OAuth\Exception\OAuthException; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Message\StreamInterface; + +#[CoversClass(OAuth2Client::class)] +final class OAuth2ClientTest extends TestCase +{ + private ProviderConfig $provider; + private RequestInterface $request; + private StreamInterface $stream; + private StreamFactoryInterface $streamFactory; + + protected function setUp(): void + { + $this->provider = ProviderConfig::fromArray([ + 'issuer' => 'https://idp.example.org', + 'authorization_endpoint' => 'https://idp.example.org/authorize', + 'token_endpoint' => 'https://idp.example.org/token', + 'revocation_endpoint' => 'https://idp.example.org/revoke', + ]); + + $this->stream = $this->createStub(StreamInterface::class); + $this->request = $this->createStub(RequestInterface::class); + $this->streamFactory = $this->createStub(StreamFactoryInterface::class); + + $this->request->method('withHeader')->willReturnSelf(); + $this->request->method('withBody')->willReturnSelf(); + $this->streamFactory->method('createStream')->willReturn($this->stream); + } + + private function makeClient( + ClientInterface $httpClient, + RequestFactoryInterface $requestFactory, + ?string $clientSecret = 'secret', + ?ProviderConfig $provider = null, + ): OAuth2Client { + return new OAuth2Client( + provider: $provider ?? $this->provider, + clientId: 'test-client', + clientSecret: $clientSecret, + redirectUri: 'https://horde.example.org/callback', + httpClient: $httpClient, + requestFactory: $requestFactory, + streamFactory: $this->streamFactory, + ); + } + + public function testRevokeTokenSendsPostToRevocationEndpoint(): void + { + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $requestFactory + ->expects(self::once()) + ->method('createRequest') + ->with('POST', 'https://idp.example.org/revoke') + ->willReturn($this->request); + + $response = $this->createStub(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient + ->expects(self::once()) + ->method('sendRequest') + ->willReturn($response); + + $this->makeClient($httpClient, $requestFactory)->revokeToken('my-access-token'); + } + + public function testRevokeTokenIncludesTokenInBody(): void + { + $requestFactory = $this->createStub(RequestFactoryInterface::class); + $requestFactory->method('createRequest')->willReturn($this->request); + + $capturedBody = null; + $streamFactory = $this->createStub(StreamFactoryInterface::class); + $streamFactory->method('createStream') + ->willReturnCallback(function (string $body) use (&$capturedBody): StreamInterface { + $capturedBody = $body; + return $this->stream; + }); + + $response = $this->createStub(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + + $httpClient = $this->createStub(ClientInterface::class); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new OAuth2Client( + provider: $this->provider, + clientId: 'test-client', + clientSecret: 'secret', + redirectUri: 'https://horde.example.org/callback', + httpClient: $httpClient, + requestFactory: $requestFactory, + streamFactory: $streamFactory, + ); + + $client->revokeToken('my-access-token', 'access_token'); + + self::assertStringContainsString('token=my-access-token', $capturedBody); + self::assertStringContainsString('token_type_hint=access_token', $capturedBody); + self::assertStringContainsString('client_id=test-client', $capturedBody); + self::assertStringContainsString('client_secret=secret', $capturedBody); + } + + public function testRevokeTokenWithDefaultHintIsAccessToken(): void + { + $requestFactory = $this->createStub(RequestFactoryInterface::class); + $requestFactory->method('createRequest')->willReturn($this->request); + + $capturedBody = null; + $streamFactory = $this->createStub(StreamFactoryInterface::class); + $streamFactory->method('createStream') + ->willReturnCallback(function (string $body) use (&$capturedBody): StreamInterface { + $capturedBody = $body; + return $this->stream; + }); + + $response = $this->createStub(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + + $httpClient = $this->createStub(ClientInterface::class); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new OAuth2Client( + provider: $this->provider, + clientId: 'test-client', + clientSecret: 'secret', + redirectUri: 'https://horde.example.org/callback', + httpClient: $httpClient, + requestFactory: $requestFactory, + streamFactory: $streamFactory, + ); + + $client->revokeToken('my-token'); + + self::assertStringContainsString('token_type_hint=access_token', $capturedBody); + } + + public function testRevokeTokenWithoutClientSecretOmitsIt(): void + { + $requestFactory = $this->createStub(RequestFactoryInterface::class); + $requestFactory->method('createRequest')->willReturn($this->request); + + $capturedBody = null; + $streamFactory = $this->createStub(StreamFactoryInterface::class); + $streamFactory->method('createStream') + ->willReturnCallback(function (string $body) use (&$capturedBody): StreamInterface { + $capturedBody = $body; + return $this->stream; + }); + + $response = $this->createStub(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + + $httpClient = $this->createStub(ClientInterface::class); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new OAuth2Client( + provider: $this->provider, + clientId: 'test-client', + clientSecret: null, + redirectUri: 'https://horde.example.org/callback', + httpClient: $httpClient, + requestFactory: $requestFactory, + streamFactory: $streamFactory, + ); + + $client->revokeToken('my-token'); + + self::assertStringNotContainsString('client_secret', $capturedBody); + } + + public function testRevokeTokenThrowsWhenNoRevocationEndpoint(): void + { + $provider = ProviderConfig::fromArray([ + 'issuer' => 'https://idp.example.org', + 'authorization_endpoint' => 'https://idp.example.org/authorize', + 'token_endpoint' => 'https://idp.example.org/token', + ]); + + $httpClient = $this->createStub(ClientInterface::class); + $requestFactory = $this->createStub(RequestFactoryInterface::class); + + $client = new OAuth2Client( + provider: $provider, + clientId: 'test-client', + clientSecret: 'secret', + redirectUri: 'https://horde.example.org/callback', + httpClient: $httpClient, + requestFactory: $requestFactory, + streamFactory: $this->streamFactory, + ); + + $this->expectException(OAuthException::class); + $client->revokeToken('my-token'); + } + + public function testRevokeTokenThrowsOnErrorResponse(): void + { + $requestFactory = $this->createStub(RequestFactoryInterface::class); + $requestFactory->method('createRequest')->willReturn($this->request); + + $body = $this->createStub(StreamInterface::class); + $body->method('__toString')->willReturn('{"error":"invalid_token","error_description":"Token expired"}'); + + $response = $this->createStub(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(400); + $response->method('getBody')->willReturn($body); + + $httpClient = $this->createStub(ClientInterface::class); + $httpClient->method('sendRequest')->willReturn($response); + + $this->expectException(OAuthException::class); + $this->makeClient($httpClient, $requestFactory)->revokeToken('bad-token'); + } +}