Skip to content

Commit 9f94b13

Browse files
committed
Authentication: trusted publishing setup for artifact publishing
1 parent e389d73 commit 9f94b13

File tree

8 files changed

+207
-24
lines changed

8 files changed

+207
-24
lines changed

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: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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 PHPUnit\Framework\MockObject\MockObject;
16+
use PrivatePackagist\ApiClient\HttpClient\HttpPluginClientBuilder;
17+
use PrivatePackagist\OIDC\Identities\Token;
18+
use PrivatePackagist\OIDC\Identities\TokenGeneratorInterface;
19+
20+
class TrustedPublishingTokenExchangeTest extends PluginTestCase
21+
{
22+
/** @var TrustedPublishingTokenExchange */
23+
private $plugin;
24+
/** @var Client */
25+
private $httpClient;
26+
/** @var TokenGeneratorInterface&MockObject */
27+
private $tokenGenerator;
28+
29+
protected function setUp(): void
30+
{
31+
parent::setUp();
32+
33+
$this->plugin = new TrustedPublishingTokenExchange(
34+
'organization',
35+
'acme/package',
36+
new HttpPluginClientBuilder($this->httpClient = new Client()),
37+
$this->tokenGenerator = $this->createMock(TokenGeneratorInterface::class)
38+
);
39+
}
40+
41+
public function testTokenExchange(): void
42+
{
43+
$request = new Request('GET', '/api/packages/acme/package');
44+
45+
$this->tokenGenerator
46+
->expects($this->once())
47+
->method('generate')
48+
->with($this->identicalTo('test'))
49+
->willReturn(Token::fromTokenString('test.test.test'));
50+
51+
$this->httpClient->addResponse(new Response(200, [], json_encode(['audience' => 'test'])));
52+
$this->httpClient->addResponse(new Response(200, [], json_encode(['key' => 'key', 'secret' => 'secret'])));
53+
54+
$this->plugin->handleRequest($request, $this->next, $this->first);
55+
56+
$requests = $this->httpClient->getRequests();
57+
$this->assertCount(2, $requests);
58+
$this->assertSame('/oidc/audience', (string) $requests[0]->getUri());
59+
$this->assertSame('/oidc/token-exchange/organization/acme/package', (string) $requests[1]->getUri());
60+
}
61+
62+
public function testNoTokenGenerated(): void
63+
{
64+
$request = new Request('GET', '/api/packages/acme/package');
65+
66+
$this->tokenGenerator
67+
->expects($this->once())
68+
->method('generate')
69+
->with($this->identicalTo('test'))
70+
->willReturn(null);
71+
72+
$this->httpClient->addResponse(new Response(200, [], json_encode(['audience' => 'test'])));
73+
74+
$this->expectException(\RuntimeException::class);
75+
$this->expectExceptionMessage('Unable to generate OIDC token');
76+
77+
$this->plugin->handleRequest($request, $this->next, $this->first);
78+
}
79+
}

0 commit comments

Comments
 (0)