From 9f94b13dfdbb65dbf8226bda1306f2c5e2cc31b4 Mon Sep 17 00:00:00 2001 From: Stephan Vock Date: Tue, 22 Jul 2025 11:18:12 +0100 Subject: [PATCH 1/4] Authentication: trusted publishing setup for artifact publishing --- composer.json | 3 +- src/Client.php | 15 +++- src/HttpClient/HttpPluginClientBuilder.php | 9 +++ .../Plugin/TrustedPublishingTokenExchange.php | 65 +++++++++++++++ tests/HttpClient/Plugin/PathPrependTest.php | 12 +-- tests/HttpClient/Plugin/PluginTestCase.php | 34 ++++++++ .../Plugin/RequestSignatureTest.php | 14 +--- .../TrustedPublishingTokenExchangeTest.php | 79 +++++++++++++++++++ 8 files changed, 207 insertions(+), 24 deletions(-) create mode 100644 src/HttpClient/Plugin/TrustedPublishingTokenExchange.php create mode 100644 tests/HttpClient/Plugin/PluginTestCase.php create mode 100644 tests/HttpClient/Plugin/TrustedPublishingTokenExchangeTest.php 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..c11b9f2 --- /dev/null +++ b/tests/HttpClient/Plugin/TrustedPublishingTokenExchangeTest.php @@ -0,0 +1,79 @@ + + * + * 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 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, $this->next, $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()); + } + + 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); + } +} From 22bd57bd8982c3ee882817c9b1ec95807850309a Mon Sep 17 00:00:00 2001 From: Stephan Vock Date: Wed, 3 Sep 2025 11:00:35 +0100 Subject: [PATCH 2/4] Trusted publishing: add docs --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 From d406bca75a3fde5466a95f1bf3feb2a3100d9ba9 Mon Sep 17 00:00:00 2001 From: Stephan Date: Wed, 3 Sep 2025 16:31:47 +0100 Subject: [PATCH 3/4] Update tests/HttpClient/Plugin/TrustedPublishingTokenExchangeTest.php Co-authored-by: Steven Rombauts --- .../HttpClient/Plugin/TrustedPublishingTokenExchangeTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/HttpClient/Plugin/TrustedPublishingTokenExchangeTest.php b/tests/HttpClient/Plugin/TrustedPublishingTokenExchangeTest.php index c11b9f2..b7e95d5 100644 --- a/tests/HttpClient/Plugin/TrustedPublishingTokenExchangeTest.php +++ b/tests/HttpClient/Plugin/TrustedPublishingTokenExchangeTest.php @@ -57,6 +57,10 @@ public function testTokenExchange(): void $this->assertCount(2, $requests); $this->assertSame('/oidc/audience', (string) $requests[0]->getUri()); $this->assertSame('/oidc/token-exchange/organization/acme/package', (string) $requests[1]->getUri()); + + // Verify that the signature is configured using the correct key + $this->builder->getHttpClient()->get('/api/foo'); + $this->assertStringContainsString('PACKAGIST-HMAC-SHA256 Key=key', $requests[2]->getHeader('Authorization')[0]); } public function testNoTokenGenerated(): void From 559809a3d74b8771caa15fb8afcd3f8a6283e270 Mon Sep 17 00:00:00 2001 From: Stephan Vock Date: Wed, 3 Sep 2025 16:40:25 +0100 Subject: [PATCH 4/4] Assert request made with trusted publishing has proper signature after token exchange --- .../Plugin/TrustedPublishingTokenExchangeTest.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/HttpClient/Plugin/TrustedPublishingTokenExchangeTest.php b/tests/HttpClient/Plugin/TrustedPublishingTokenExchangeTest.php index b7e95d5..da3c0ed 100644 --- a/tests/HttpClient/Plugin/TrustedPublishingTokenExchangeTest.php +++ b/tests/HttpClient/Plugin/TrustedPublishingTokenExchangeTest.php @@ -12,6 +12,7 @@ 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; @@ -51,16 +52,18 @@ public function testTokenExchange(): void $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, $this->next, $this->first); + $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()); - - // Verify that the signature is configured using the correct key - $this->builder->getHttpClient()->get('/api/foo'); - $this->assertStringContainsString('PACKAGIST-HMAC-SHA256 Key=key', $requests[2]->getHeader('Authorization')[0]); + + $this->assertStringContainsString('PACKAGIST-HMAC-SHA256 Key=key', $requestAfterPlugin->getHeader('Authorization')[0]); } public function testNoTokenGenerated(): void