Skip to content

Commit 451dd2e

Browse files
Merge pull request #83 from packagist/push-znvyqqxyvqwv
Authentication: trusted publishing setup for artifact publishing
2 parents 15ba730 + 559809a commit 451dd2e

File tree

9 files changed

+229
-24
lines changed

9 files changed

+229
-24
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,21 @@ From `$client` object, you can access the full Private Packagist API.
169169

170170
Full documentation can be found in the [Private Packagist documentation](https://packagist.com/docs/api).
171171

172+
### Trusted publishing
173+
174+
To upload artifact files, trusted publishing can be used in certain environment like GitHub Actions without the need to
175+
configure authentication via API key and secret.
176+
177+
```php
178+
$fileName = 'package1.zip';
179+
$file = file_get_contents($fileName);
180+
$client = new \PrivatePackagist\ApiClient\Client();
181+
$client->authenticateWithTrustedPublishing('acme-org', 'acme/package');
182+
$client->packages()->artifacts()->add('acme/package', $file, 'application/zip', $fileName);
183+
```
184+
185+
We recommend using the [GitHub action](https://github.com/packagist/artifact-publish-github-action) directly.
186+
172187
### Organization
173188

174189
#### Trigger a full synchronization

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"php-http/discovery": "^1.0",
1919
"psr/http-client-implementation": "^1.0",
2020
"php-http/message-factory": "^1.0",
21-
"psr/http-message-implementation": "^1.0"
21+
"psr/http-message-implementation": "^1.0",
22+
"private-packagist/oidc-identities": "^1.0.1"
2223
},
2324
"require-dev": {
2425
"friendsofphp/php-cs-fixer": "^3.0",

src/Client.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,27 @@
1616
use PrivatePackagist\ApiClient\HttpClient\Plugin\ExceptionThrower;
1717
use PrivatePackagist\ApiClient\HttpClient\Plugin\PathPrepend;
1818
use PrivatePackagist\ApiClient\HttpClient\Plugin\RequestSignature;
19+
use PrivatePackagist\ApiClient\HttpClient\Plugin\TrustedPublishingTokenExchange;
20+
use PrivatePackagist\OIDC\Identities\TokenGenerator;
21+
use Psr\Log\LoggerInterface;
22+
use Psr\Log\NullLogger;
1923

2024
class Client
2125
{
2226
/** @var HttpPluginClientBuilder */
2327
private $httpClientBuilder;
2428
/** @var ResponseMediator */
2529
private $responseMediator;
30+
/** @var LoggerInterface */
31+
private $logger;
2632

2733
/** @param string $privatePackagistUrl */
28-
public function __construct(?HttpPluginClientBuilder $httpClientBuilder = null, $privatePackagistUrl = null, ?ResponseMediator $responseMediator = null)
34+
public function __construct(?HttpPluginClientBuilder $httpClientBuilder = null, $privatePackagistUrl = null, ?ResponseMediator $responseMediator = null, ?LoggerInterface $logger = null)
2935
{
3036
$this->httpClientBuilder = $builder = $httpClientBuilder ?: new HttpPluginClientBuilder();
3137
$privatePackagistUrl = $privatePackagistUrl ? : 'https://packagist.com';
3238
$this->responseMediator = $responseMediator ? : new ResponseMediator();
39+
$this->logger = $logger ? : new NullLogger();
3340

3441
$builder->addPlugin(new Plugin\AddHostPlugin(Psr17FactoryDiscovery::findUriFactory()->createUri($privatePackagistUrl)));
3542
$builder->addPlugin(new PathPrepend('/api'));
@@ -58,6 +65,12 @@ public function authenticate(
5865
$this->httpClientBuilder->addPlugin(new RequestSignature($key, $secret));
5966
}
6067

68+
public function authenticateWithTrustedPublishing(string $organizationUrlName, string $packageName)
69+
{
70+
$this->httpClientBuilder->removePlugin(TrustedPublishingTokenExchange::class);
71+
$this->httpClientBuilder->addPlugin(new TrustedPublishingTokenExchange($organizationUrlName, $packageName, $this->getHttpClientBuilder(), new TokenGenerator($this->logger, $this->getHttpClientBuilder()->getHttpClientWithoutPlugins())));
72+
}
73+
6174
public function credentials()
6275
{
6376
return new Api\Credentials($this, $this->responseMediator);

src/HttpClient/HttpPluginClientBuilder.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,13 @@ public function getHttpClient()
9898

9999
return $this->pluginClient;
100100
}
101+
102+
public function getHttpClientWithoutPlugins(): HttpMethodsClient
103+
{
104+
return new HttpMethodsClient(
105+
$this->httpClient,
106+
$this->requestFactory,
107+
$this->streamFactory
108+
);
109+
}
101110
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php declare(strict_types=1);
2+
3+
/**
4+
* (c) Packagist Conductors GmbH <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace PrivatePackagist\ApiClient\HttpClient\Plugin;
11+
12+
use Http\Client\Common\Plugin;
13+
use PrivatePackagist\ApiClient\HttpClient\HttpPluginClientBuilder;
14+
use PrivatePackagist\OIDC\Identities\TokenGeneratorInterface;
15+
use Psr\Http\Message\RequestInterface;
16+
17+
/**
18+
* @internal
19+
*/
20+
final class TrustedPublishingTokenExchange implements Plugin
21+
{
22+
use Plugin\VersionBridgePlugin;
23+
24+
/** @var string */
25+
private $organizationUrlName;
26+
/** @var string */
27+
private $packageName;
28+
/** @var HttpPluginClientBuilder $httpPluginClientBuilder */
29+
private $httpPluginClientBuilder;
30+
/** @var TokenGeneratorInterface */
31+
private $tokenGenerator;
32+
33+
public function __construct(string $organizationUrlName, string $packageName, HttpPluginClientBuilder $httpPluginClientBuilder, TokenGeneratorInterface $tokenGenerator)
34+
{
35+
$this->organizationUrlName = $organizationUrlName;
36+
$this->packageName = $packageName;
37+
$this->httpPluginClientBuilder = $httpPluginClientBuilder;
38+
$this->tokenGenerator = $tokenGenerator;
39+
}
40+
41+
protected function doHandleRequest(RequestInterface $request, callable $next, callable $first)
42+
{
43+
$this->httpPluginClientBuilder->removePlugin(self::class);
44+
45+
$privatePackagistHttpclient = $this->httpPluginClientBuilder->getHttpClient();
46+
$audience = json_decode((string) $privatePackagistHttpclient->get('/oidc/audience')->getBody(), true);
47+
if (!isset($audience['audience'])) {
48+
throw new \RuntimeException('Unable to get audience');
49+
}
50+
51+
$token = $this->tokenGenerator->generate($audience['audience']);
52+
if (!$token) {
53+
throw new \RuntimeException('Unable to generate OIDC token');
54+
}
55+
56+
$apiCredentials = json_decode((string) $privatePackagistHttpclient->post('/oidc/token-exchange/' . $this->organizationUrlName . '/' . $this->packageName, ['Authorization' => 'Bearer ' . $token->token])->getBody(), true);
57+
if (!isset($apiCredentials['key'], $apiCredentials['secret'])) {
58+
throw new \RuntimeException('Unable to exchange token');
59+
}
60+
61+
$this->httpPluginClientBuilder->addPlugin($requestSignature = new RequestSignature($apiCredentials['key'], $apiCredentials['secret']));
62+
63+
return $requestSignature->handleRequest($request, $next, $first);
64+
}
65+
}

tests/HttpClient/Plugin/PathPrependTest.php

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,17 @@
1010
namespace PrivatePackagist\ApiClient\HttpClient\Plugin;
1111

1212
use GuzzleHttp\Psr7\Request;
13-
use Http\Promise\FulfilledPromise;
14-
use PHPUnit\Framework\TestCase;
1513

16-
class PathPrependTest extends TestCase
14+
class PathPrependTest extends PluginTestCase
1715
{
1816
/** @var PathPrepend */
1917
private $plugin;
20-
private $next;
21-
private $first;
2218

2319
protected function setUp(): void
2420
{
2521
parent::setUp();
2622

2723
$this->plugin = new PathPrepend('/api');
28-
$this->next = function (Request $request) {
29-
return new FulfilledPromise($request);
30-
};
31-
$this->first = function () {
32-
throw new \RuntimeException('Did not expect plugin to call first');
33-
};
3424
}
3525

3626
/**
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php declare(strict_types=1);
2+
3+
/**
4+
* (c) Packagist Conductors GmbH <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace PrivatePackagist\ApiClient\HttpClient\Plugin;
11+
12+
use GuzzleHttp\Psr7\Request;
13+
use Http\Promise\FulfilledPromise;
14+
use PHPUnit\Framework\TestCase;
15+
16+
class PluginTestCase extends TestCase
17+
{
18+
/** @var \Closure */
19+
protected $next;
20+
/** @var \Closure */
21+
protected $first;
22+
23+
protected function setUp(): void
24+
{
25+
parent::setUp();
26+
27+
$this->next = function (Request $request) {
28+
return new FulfilledPromise($request);
29+
};
30+
$this->first = function () {
31+
throw new \RuntimeException('Did not expect plugin to call first');
32+
};
33+
}
34+
}

tests/HttpClient/Plugin/RequestSignatureTest.php

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,34 +10,26 @@
1010
namespace PrivatePackagist\ApiClient\HttpClient\Plugin;
1111

1212
use GuzzleHttp\Psr7\Request;
13-
use Http\Promise\FulfilledPromise;
14-
use PHPUnit\Framework\TestCase;
1513

16-
class RequestSignatureTest extends TestCase
14+
class RequestSignatureTest extends PluginTestCase
1715
{
1816
/** @var RequestSignature */
1917
private $plugin;
20-
private $next;
21-
private $first;
2218
private $key;
2319
private $secret;
2420
private $timestamp;
2521
private $nonce;
2622

2723
protected function setUp(): void
2824
{
25+
parent::setUp();
26+
2927
$this->key = 'token';
3028
$this->secret = 'secret';
3129
$this->timestamp = 1518721253;
3230
$this->nonce = '78b9869e96cf58b5902154e0228f8576f042e5ac';
3331
$this->plugin = new RequestSignatureMock($this->key, $this->secret);
3432
$this->plugin->init($this->timestamp, $this->nonce);
35-
$this->next = function (Request $request) {
36-
return new FulfilledPromise($request);
37-
};
38-
$this->first = function () {
39-
throw new \RuntimeException('Did not expect plugin to call first');
40-
};
4133
}
4234

4335
public function testPrefixRequestPath()
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php declare(strict_types=1);
2+
3+
/**
4+
* (c) Packagist Conductors GmbH <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace PrivatePackagist\ApiClient\HttpClient\Plugin;
11+
12+
use GuzzleHttp\Psr7\Request;
13+
use GuzzleHttp\Psr7\Response;
14+
use Http\Mock\Client;
15+
use Http\Promise\FulfilledPromise;
16+
use PHPUnit\Framework\MockObject\MockObject;
17+
use PrivatePackagist\ApiClient\HttpClient\HttpPluginClientBuilder;
18+
use PrivatePackagist\OIDC\Identities\Token;
19+
use PrivatePackagist\OIDC\Identities\TokenGeneratorInterface;
20+
21+
class TrustedPublishingTokenExchangeTest extends PluginTestCase
22+
{
23+
/** @var TrustedPublishingTokenExchange */
24+
private $plugin;
25+
/** @var Client */
26+
private $httpClient;
27+
/** @var TokenGeneratorInterface&MockObject */
28+
private $tokenGenerator;
29+
30+
protected function setUp(): void
31+
{
32+
parent::setUp();
33+
34+
$this->plugin = new TrustedPublishingTokenExchange(
35+
'organization',
36+
'acme/package',
37+
new HttpPluginClientBuilder($this->httpClient = new Client()),
38+
$this->tokenGenerator = $this->createMock(TokenGeneratorInterface::class)
39+
);
40+
}
41+
42+
public function testTokenExchange(): void
43+
{
44+
$request = new Request('GET', '/api/packages/acme/package');
45+
46+
$this->tokenGenerator
47+
->expects($this->once())
48+
->method('generate')
49+
->with($this->identicalTo('test'))
50+
->willReturn(Token::fromTokenString('test.test.test'));
51+
52+
$this->httpClient->addResponse(new Response(200, [], json_encode(['audience' => 'test'])));
53+
$this->httpClient->addResponse(new Response(200, [], json_encode(['key' => 'key', 'secret' => 'secret'])));
54+
55+
$this->plugin->handleRequest($request, function (Request $request) use (&$requestAfterPlugin) {
56+
$requestAfterPlugin = $request;
57+
58+
return new FulfilledPromise($request);
59+
}, $this->first);
60+
61+
$requests = $this->httpClient->getRequests();
62+
$this->assertCount(2, $requests);
63+
$this->assertSame('/oidc/audience', (string) $requests[0]->getUri());
64+
$this->assertSame('/oidc/token-exchange/organization/acme/package', (string) $requests[1]->getUri());
65+
66+
$this->assertStringContainsString('PACKAGIST-HMAC-SHA256 Key=key', $requestAfterPlugin->getHeader('Authorization')[0]);
67+
}
68+
69+
public function testNoTokenGenerated(): void
70+
{
71+
$request = new Request('GET', '/api/packages/acme/package');
72+
73+
$this->tokenGenerator
74+
->expects($this->once())
75+
->method('generate')
76+
->with($this->identicalTo('test'))
77+
->willReturn(null);
78+
79+
$this->httpClient->addResponse(new Response(200, [], json_encode(['audience' => 'test'])));
80+
81+
$this->expectException(\RuntimeException::class);
82+
$this->expectExceptionMessage('Unable to generate OIDC token');
83+
84+
$this->plugin->handleRequest($request, $this->next, $this->first);
85+
}
86+
}

0 commit comments

Comments
 (0)