diff --git a/README.md b/README.md index ea8fcc8..b24e7b4 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,21 @@ From `$client` object, you can access the full Private Packagist API. Full documentation can be found in the [Private Packagist documentation](https://packagist.com/docs/api). +### Trusted publishing + +To upload artifact files, trusted publishing can be used in certain environment like GitHub Actions without the need to +configure authentication via API key and secret. + +```php +$fileName = 'package1.zip'; +$file = file_get_contents($fileName); +$client = new \PrivatePackagist\ApiClient\Client(); +$client->authenticateWithTrustedPublishing('acme-org', 'acme/package'); +$client->packages()->artifacts()->add('acme/package', $file, 'application/zip', $fileName); +``` + +We recommend using the [GitHub action](https://github.com/packagist/artifact-publish-github-action) directly. + ### Organization #### Trigger a full synchronization diff --git a/composer.json b/composer.json index 8fa214e..69253f1 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,8 @@ "php-http/discovery": "^1.0", "psr/http-client-implementation": "^1.0", "php-http/message-factory": "^1.0", - "psr/http-message-implementation": "^1.0" + "psr/http-message-implementation": "^1.0", + "private-packagist/oidc-identities": "^1.0.1" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", diff --git a/src/Client.php b/src/Client.php index 19638cc..5d532d1 100644 --- a/src/Client.php +++ b/src/Client.php @@ -16,6 +16,10 @@ use PrivatePackagist\ApiClient\HttpClient\Plugin\ExceptionThrower; use PrivatePackagist\ApiClient\HttpClient\Plugin\PathPrepend; use PrivatePackagist\ApiClient\HttpClient\Plugin\RequestSignature; +use PrivatePackagist\ApiClient\HttpClient\Plugin\TrustedPublishingTokenExchange; +use PrivatePackagist\OIDC\Identities\TokenGenerator; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; class Client { @@ -23,13 +27,16 @@ class Client private $httpClientBuilder; /** @var ResponseMediator */ private $responseMediator; + /** @var LoggerInterface */ + private $logger; /** @param string $privatePackagistUrl */ - public function __construct(?HttpPluginClientBuilder $httpClientBuilder = null, $privatePackagistUrl = null, ?ResponseMediator $responseMediator = null) + public function __construct(?HttpPluginClientBuilder $httpClientBuilder = null, $privatePackagistUrl = null, ?ResponseMediator $responseMediator = null, ?LoggerInterface $logger = null) { $this->httpClientBuilder = $builder = $httpClientBuilder ?: new HttpPluginClientBuilder(); $privatePackagistUrl = $privatePackagistUrl ? : 'https://packagist.com'; $this->responseMediator = $responseMediator ? : new ResponseMediator(); + $this->logger = $logger ? : new NullLogger(); $builder->addPlugin(new Plugin\AddHostPlugin(Psr17FactoryDiscovery::findUriFactory()->createUri($privatePackagistUrl))); $builder->addPlugin(new PathPrepend('/api')); @@ -58,6 +65,12 @@ public function authenticate( $this->httpClientBuilder->addPlugin(new RequestSignature($key, $secret)); } + public function authenticateWithTrustedPublishing(string $organizationUrlName, string $packageName) + { + $this->httpClientBuilder->removePlugin(TrustedPublishingTokenExchange::class); + $this->httpClientBuilder->addPlugin(new TrustedPublishingTokenExchange($organizationUrlName, $packageName, $this->getHttpClientBuilder(), new TokenGenerator($this->logger, $this->getHttpClientBuilder()->getHttpClientWithoutPlugins()))); + } + public function credentials() { return new Api\Credentials($this, $this->responseMediator); diff --git a/src/HttpClient/HttpPluginClientBuilder.php b/src/HttpClient/HttpPluginClientBuilder.php index b45915c..098322e 100644 --- a/src/HttpClient/HttpPluginClientBuilder.php +++ b/src/HttpClient/HttpPluginClientBuilder.php @@ -98,4 +98,13 @@ public function getHttpClient() return $this->pluginClient; } + + public function getHttpClientWithoutPlugins(): HttpMethodsClient + { + return new HttpMethodsClient( + $this->httpClient, + $this->requestFactory, + $this->streamFactory + ); + } } diff --git a/src/HttpClient/Plugin/TrustedPublishingTokenExchange.php b/src/HttpClient/Plugin/TrustedPublishingTokenExchange.php new file mode 100644 index 0000000..7ed6105 --- /dev/null +++ b/src/HttpClient/Plugin/TrustedPublishingTokenExchange.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PrivatePackagist\ApiClient\HttpClient\Plugin; + +use Http\Client\Common\Plugin; +use PrivatePackagist\ApiClient\HttpClient\HttpPluginClientBuilder; +use PrivatePackagist\OIDC\Identities\TokenGeneratorInterface; +use Psr\Http\Message\RequestInterface; + +/** + * @internal + */ +final class TrustedPublishingTokenExchange implements Plugin +{ + use Plugin\VersionBridgePlugin; + + /** @var string */ + private $organizationUrlName; + /** @var string */ + private $packageName; + /** @var HttpPluginClientBuilder $httpPluginClientBuilder */ + private $httpPluginClientBuilder; + /** @var TokenGeneratorInterface */ + private $tokenGenerator; + + public function __construct(string $organizationUrlName, string $packageName, HttpPluginClientBuilder $httpPluginClientBuilder, TokenGeneratorInterface $tokenGenerator) + { + $this->organizationUrlName = $organizationUrlName; + $this->packageName = $packageName; + $this->httpPluginClientBuilder = $httpPluginClientBuilder; + $this->tokenGenerator = $tokenGenerator; + } + + protected function doHandleRequest(RequestInterface $request, callable $next, callable $first) + { + $this->httpPluginClientBuilder->removePlugin(self::class); + + $privatePackagistHttpclient = $this->httpPluginClientBuilder->getHttpClient(); + $audience = json_decode((string) $privatePackagistHttpclient->get('/oidc/audience')->getBody(), true); + if (!isset($audience['audience'])) { + throw new \RuntimeException('Unable to get audience'); + } + + $token = $this->tokenGenerator->generate($audience['audience']); + if (!$token) { + throw new \RuntimeException('Unable to generate OIDC token'); + } + + $apiCredentials = json_decode((string) $privatePackagistHttpclient->post('/oidc/token-exchange/' . $this->organizationUrlName . '/' . $this->packageName, ['Authorization' => 'Bearer ' . $token->token])->getBody(), true); + if (!isset($apiCredentials['key'], $apiCredentials['secret'])) { + throw new \RuntimeException('Unable to exchange token'); + } + + $this->httpPluginClientBuilder->addPlugin($requestSignature = new RequestSignature($apiCredentials['key'], $apiCredentials['secret'])); + + return $requestSignature->handleRequest($request, $next, $first); + } +} diff --git a/tests/HttpClient/Plugin/PathPrependTest.php b/tests/HttpClient/Plugin/PathPrependTest.php index e123b1b..37bef54 100644 --- a/tests/HttpClient/Plugin/PathPrependTest.php +++ b/tests/HttpClient/Plugin/PathPrependTest.php @@ -10,27 +10,17 @@ namespace PrivatePackagist\ApiClient\HttpClient\Plugin; use GuzzleHttp\Psr7\Request; -use Http\Promise\FulfilledPromise; -use PHPUnit\Framework\TestCase; -class PathPrependTest extends TestCase +class PathPrependTest extends PluginTestCase { /** @var PathPrepend */ private $plugin; - private $next; - private $first; protected function setUp(): void { parent::setUp(); $this->plugin = new PathPrepend('/api'); - $this->next = function (Request $request) { - return new FulfilledPromise($request); - }; - $this->first = function () { - throw new \RuntimeException('Did not expect plugin to call first'); - }; } /** diff --git a/tests/HttpClient/Plugin/PluginTestCase.php b/tests/HttpClient/Plugin/PluginTestCase.php new file mode 100644 index 0000000..dd19a67 --- /dev/null +++ b/tests/HttpClient/Plugin/PluginTestCase.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PrivatePackagist\ApiClient\HttpClient\Plugin; + +use GuzzleHttp\Psr7\Request; +use Http\Promise\FulfilledPromise; +use PHPUnit\Framework\TestCase; + +class PluginTestCase extends TestCase +{ + /** @var \Closure */ + protected $next; + /** @var \Closure */ + protected $first; + + protected function setUp(): void + { + parent::setUp(); + + $this->next = function (Request $request) { + return new FulfilledPromise($request); + }; + $this->first = function () { + throw new \RuntimeException('Did not expect plugin to call first'); + }; + } +} diff --git a/tests/HttpClient/Plugin/RequestSignatureTest.php b/tests/HttpClient/Plugin/RequestSignatureTest.php index e04fbfc..fc4469c 100644 --- a/tests/HttpClient/Plugin/RequestSignatureTest.php +++ b/tests/HttpClient/Plugin/RequestSignatureTest.php @@ -10,15 +10,11 @@ namespace PrivatePackagist\ApiClient\HttpClient\Plugin; use GuzzleHttp\Psr7\Request; -use Http\Promise\FulfilledPromise; -use PHPUnit\Framework\TestCase; -class RequestSignatureTest extends TestCase +class RequestSignatureTest extends PluginTestCase { /** @var RequestSignature */ private $plugin; - private $next; - private $first; private $key; private $secret; private $timestamp; @@ -26,18 +22,14 @@ class RequestSignatureTest extends TestCase protected function setUp(): void { + parent::setUp(); + $this->key = 'token'; $this->secret = 'secret'; $this->timestamp = 1518721253; $this->nonce = '78b9869e96cf58b5902154e0228f8576f042e5ac'; $this->plugin = new RequestSignatureMock($this->key, $this->secret); $this->plugin->init($this->timestamp, $this->nonce); - $this->next = function (Request $request) { - return new FulfilledPromise($request); - }; - $this->first = function () { - throw new \RuntimeException('Did not expect plugin to call first'); - }; } public function testPrefixRequestPath() diff --git a/tests/HttpClient/Plugin/TrustedPublishingTokenExchangeTest.php b/tests/HttpClient/Plugin/TrustedPublishingTokenExchangeTest.php new file mode 100644 index 0000000..da3c0ed --- /dev/null +++ b/tests/HttpClient/Plugin/TrustedPublishingTokenExchangeTest.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PrivatePackagist\ApiClient\HttpClient\Plugin; + +use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Response; +use Http\Mock\Client; +use Http\Promise\FulfilledPromise; +use PHPUnit\Framework\MockObject\MockObject; +use PrivatePackagist\ApiClient\HttpClient\HttpPluginClientBuilder; +use PrivatePackagist\OIDC\Identities\Token; +use PrivatePackagist\OIDC\Identities\TokenGeneratorInterface; + +class TrustedPublishingTokenExchangeTest extends PluginTestCase +{ + /** @var TrustedPublishingTokenExchange */ + private $plugin; + /** @var Client */ + private $httpClient; + /** @var TokenGeneratorInterface&MockObject */ + private $tokenGenerator; + + protected function setUp(): void + { + parent::setUp(); + + $this->plugin = new TrustedPublishingTokenExchange( + 'organization', + 'acme/package', + new HttpPluginClientBuilder($this->httpClient = new Client()), + $this->tokenGenerator = $this->createMock(TokenGeneratorInterface::class) + ); + } + + public function testTokenExchange(): void + { + $request = new Request('GET', '/api/packages/acme/package'); + + $this->tokenGenerator + ->expects($this->once()) + ->method('generate') + ->with($this->identicalTo('test')) + ->willReturn(Token::fromTokenString('test.test.test')); + + $this->httpClient->addResponse(new Response(200, [], json_encode(['audience' => 'test']))); + $this->httpClient->addResponse(new Response(200, [], json_encode(['key' => 'key', 'secret' => 'secret']))); + + $this->plugin->handleRequest($request, function (Request $request) use (&$requestAfterPlugin) { + $requestAfterPlugin = $request; + + return new FulfilledPromise($request); + }, $this->first); + + $requests = $this->httpClient->getRequests(); + $this->assertCount(2, $requests); + $this->assertSame('/oidc/audience', (string) $requests[0]->getUri()); + $this->assertSame('/oidc/token-exchange/organization/acme/package', (string) $requests[1]->getUri()); + + $this->assertStringContainsString('PACKAGIST-HMAC-SHA256 Key=key', $requestAfterPlugin->getHeader('Authorization')[0]); + } + + public function testNoTokenGenerated(): void + { + $request = new Request('GET', '/api/packages/acme/package'); + + $this->tokenGenerator + ->expects($this->once()) + ->method('generate') + ->with($this->identicalTo('test')) + ->willReturn(null); + + $this->httpClient->addResponse(new Response(200, [], json_encode(['audience' => 'test']))); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unable to generate OIDC token'); + + $this->plugin->handleRequest($request, $this->next, $this->first); + } +}