Skip to content

Commit f27bb9b

Browse files
authored
Add Bearer authentication support as alternative (#1565)
1 parent ca90946 commit f27bb9b

File tree

4 files changed

+173
-21
lines changed

4 files changed

+173
-21
lines changed

css/app.scss

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1832,3 +1832,26 @@ body {
18321832
.icon-lock:before { content: '🔒'; } /* '\1f512' */
18331833
.icon-lock-open:before { content: '🔓'; } /* '\1f513' */
18341834
.icon-rocket:before { content: '🚀'; } /* '\1f680' */
1835+
1836+
// API Documentation badges
1837+
.badge {
1838+
display: inline-block;
1839+
min-width: 10px;
1840+
padding: 3px 7px;
1841+
font-size: 11px;
1842+
font-weight: 600;
1843+
line-height: 1;
1844+
color: #fff;
1845+
text-align: center;
1846+
white-space: nowrap;
1847+
vertical-align: middle;
1848+
border-radius: 10px;
1849+
margin-left: 8px;
1850+
1851+
&.bg-success {
1852+
background-color: #83c129;
1853+
}
1854+
&.bg-warning {
1855+
background-color: #ed9e2e;
1856+
}
1857+
}

src/Controller/ApiController.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,20 @@ protected function findUser(Request $request, ApiType $apiType = ApiType::Unsafe
514514
? $request->request->get('apiToken')
515515
: $request->query->get('apiToken');
516516

517+
// Check for Bearer token authentication in format "username:apiToken"
518+
// Bearer token takes precedence over query parameters
519+
if ($request->headers->has('Authorization')) {
520+
$authHeader = $request->headers->get('Authorization', '');
521+
if (str_starts_with($authHeader, 'Bearer ')) {
522+
$bearerToken = substr($authHeader, 7); // Remove 'Bearer ' prefix
523+
if (str_contains($bearerToken, ':')) {
524+
[$bearerUsername, $bearerApiToken] = explode(':', $bearerToken, 2);
525+
$username = $bearerUsername;
526+
$apiToken = $bearerApiToken;
527+
}
528+
}
529+
}
530+
517531
if (!$apiToken || !$username) {
518532
return null;
519533
}

templates/api_doc/index.html.twig

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
<ul class="toc">
77
<li><a href="#best-practices">Best practices</a></li>
8+
<li><a href="#authentication">Authentication</a></li>
89
<li><a href="#list-packages">{{ 'api_doc.listing_names'|trans }}</a>
910
<ul>
1011
<li><a href="#list-packages-all">{{ 'api_doc.all_packages'|trans }}</a></li>
@@ -43,7 +44,19 @@
4344
</section>
4445

4546
<section class="col-d-12">
46-
<h3 id="list-packages">{{ 'api_doc.listing_names'|trans }}</h3>
47+
<h3 id="authentication">Authentication</h3>
48+
<p>A lot of our API is accessible <span class="badge bg-success">Anonymously</span>. For API endpoints that <span class="badge bg-warning">require authentication</span>, you can authenticate using either Bearer token authorization or query/request parameters:</p>
49+
<ul>
50+
<li><strong>Bearer token:</strong> <code>Authorization: Bearer [username]:[apiToken]</code> header</li>
51+
<li><strong>Query or request parameters:</strong> <code>username</code> and <code>apiToken</code> as query or POST parameters</li>
52+
</ul>
53+
<p>The Bearer token method is recommended as it keeps credentials out of URLs and logs.</p>
54+
<p>You have two API tokens accessible in your <a href="{{ path('my_profile') }}">profile page</a>. The SAFE one is meant to be used for readonly-style operations and can be leaked without causing too much trouble, so use this if possible when putting it in CI systems etc where it might not remain safe. The MAIN token is required for all API endpoints that are marked UNSAFE.</p>
55+
</p>
56+
</section>
57+
58+
<section class="col-d-12">
59+
<h3 id="list-packages">{{ 'api_doc.listing_names'|trans }} <span class="badge bg-success">Anonymous</span></h3>
4760
<h4 id="list-packages-all">{{ 'api_doc.all_packages'|trans }}</h4>
4861
<pre>
4962
GET https://{{ packagist_host }}/packages/list.json
@@ -108,7 +121,7 @@ GET https://{{ packagist_host }}/packages/list.json?vendor=[type]&amp;fields[]=t
108121

109122

110123
<section class="col-d-12">
111-
<h3 id="list-popular-packages">List popular packages</h3>
124+
<h3 id="list-popular-packages">List popular packages <span class="badge bg-success">Anonymous</span></h3>
112125
<p>If you need to retrieve the most popular (this is sorted by downloads over the last week, not overall downloads to ensure we demote formerly-popular packages) packages please use this endpoint to avoid having to request the download stats for all of our 300K+ packages individually. This also includes faver count (github stars + packagist.org favorites) and downloads count. The API is paginated if you need more than 100.</p>
113126
<pre>
114127
GET https://{{ packagist_host }}/explore/popular.json?per_page=100
@@ -134,7 +147,7 @@ GET https://{{ packagist_host }}/explore/popular.json?per_page=100
134147

135148

136149
<section class="col-d-12">
137-
<h3 id="search-packages">{{ 'api_doc.searching'|trans }}</h3>
150+
<h3 id="search-packages">{{ 'api_doc.searching'|trans }} <span class="badge bg-success">Anonymous</span></h3>
138151

139152
<p>Search results are paginated and you can change the pagination step by using the per_page parameter. For example <code>https://{{ packagist_host }}/search.json?q=[query]&amp;per_page=5</code></p>
140153

@@ -206,7 +219,7 @@ GET https://{{ packagist_host }}/search.json?q=[query]&amp;type=symfony-bundle
206219

207220

208221
<section class="col-d-12">
209-
<h3 id="get-package-data">{{ 'api_doc.get_package_data'|trans }}</h3>
222+
<h3 id="get-package-data">{{ 'api_doc.get_package_data'|trans }} <span class="badge bg-success">Anonymous</span></h3>
210223

211224
<h4 id="get-package-metadata-v2">Using the Composer v2 metadata</h4>
212225

@@ -319,7 +332,7 @@ GET https://{{ packagist_host }}/packages/[vendor]/[package].json
319332
</section>
320333

321334
<section class="col-d-12">
322-
<h3 id="get-package-stats">Get package download stats</h3>
335+
<h3 id="get-package-stats">Get package download stats <span class="badge bg-success">Anonymous</span></h3>
323336
<p>If you need complete package information and use <a href="#get-package-by-name">the JSON API</a> already then please use the <code>downloads</code> key to retrieve stats from that response.</p>
324337

325338
<p>However if you are only interested in download stats for a set of package names, you can use the stats endpoint which includes overall download stats + the faver count (github stars + packagist.org favorites):</p>
@@ -345,7 +358,7 @@ GET https://{{ packagist_host }}/packages/[vendor]/[package]/stats.json
345358

346359

347360
<section class="col-d-12">
348-
<h3 id="track-package-updates">Track package updates</h3>
361+
<h3 id="track-package-updates">Track package updates <span class="badge bg-success">Anonymous</span></h3>
349362

350363
<p>This endpoint provides you with a feed of metadata changes you can poll to know what packages you need to update.</p>
351364

@@ -416,7 +429,7 @@ GET https://{{ packagist_host }}/metadata/changes.json?since=16140636710498
416429

417430

418431
<section class="col-d-12">
419-
<h3 id="get-statistics">{{ 'api_doc.get_statistics'|trans }}</h3>
432+
<h3 id="get-statistics">{{ 'api_doc.get_statistics'|trans }} <span class="badge bg-success">Anonymous</span></h3>
420433

421434
<p>This endpoint provides basic some statistics.</p>
422435

@@ -434,7 +447,7 @@ GET https://{{ packagist_host }}/statistics.json
434447
</section>
435448

436449
<section class="col-d-12">
437-
<h3 id="list-security-advisories">{{ 'api_doc.list_security_advisory'|trans }}</h3>
450+
<h3 id="list-security-advisories">{{ 'api_doc.list_security_advisory'|trans }} <span class="badge bg-success">Anonymous</span></h3>
438451

439452
<p>This endpoint provides a list of security advisories. Either a list of packages as query or request parameter or a timestamp as updatedSince query parameter need to be passed.</p>
440453

@@ -479,60 +492,60 @@ GET https://{{ packagist_host }}/api/security-advisories/?updatedSince=[timestam
479492

480493

481494
<section class="col-d-12">
482-
<h3 id="create-package">{{ 'api_doc.api_create_package'|trans }}</h3>
495+
<h3 id="create-package">{{ 'api_doc.api_create_package'|trans }} <span class="badge bg-warning">Authentication Required</span></h3>
483496

484-
<p>This endpoint creates a package for a specific repo. Parameters <code>username</code> and <code>apiToken</code> are required. Only the <code>POST</code> method is allowed. The <code>content-type: application/json</code> header is required.</p>
497+
<p>This endpoint creates a package for a specific repo. Only the <code>POST</code> method is allowed. The <code>content-type: application/json</code> header is required.</p>
485498
<p>This endpoint is considered UNSAFE and requires your main <a href="{{ path('my_profile') }}">API token</a> to be used.</p>
486499

487500
<pre>
488-
POST https://{{ packagist_host }}/api/create-package?username=[username]&amp;apiToken=[apiToken] {"repository":"[url]"}
501+
POST https://{{ packagist_host }}/api/create-package {"repository":"[url]"}
489502
<code>
490503
{
491504
"status": "success"
492505
}
493506
</code></pre>
494507
<p>Working example:<br/>
495-
<code>curl -X POST -H'Content-Type:application/json' 'https://{{ packagist_host }}/api/create-package?username={{ app.user.username|default('USERNAME') }}&amp;apiToken=********' -d '{"repository":"https://github.com/Seldaek/monolog"}'</code>
508+
<code>curl -X POST -H'Content-Type:application/json' -H'Authorization: Bearer {{ app.user.username|default('USERNAME') }}:********' 'https://{{ packagist_host }}/api/create-package' -d '{"repository":"https://github.com/Seldaek/monolog"}'</code>
496509
</p>
497510

498511
</section>
499512

500513
<section class="col-d-12">
501-
<h3 id="edit-package">Edit a package</h3>
514+
<h3 id="edit-package">Edit a package <span class="badge bg-warning">Authentication Required</span></h3>
502515

503-
<p>This endpoint allows you to edit a package URL. Parameters <code>username</code> and <code>apiToken</code> are required. Only the <code>PUT</code> method is allowed. The <code>content-type: application/json</code> header is required.</p>
516+
<p>This endpoint allows you to edit a package URL. Only the <code>PUT</code> method is allowed. The <code>content-type: application/json</code> header is required.</p>
504517
<p>This endpoint is considered UNSAFE and requires your main <a href="{{ path('my_profile') }}">API token</a> to be used.</p>
505518

506519
<pre>
507-
PUT https://{{ packagist_host }}/api/packages/[package name]?username=[username]&amp;apiToken=[apiToken] {"repository":"[url]"}
520+
PUT https://{{ packagist_host }}/api/packages/[package name] {"repository":"[url]"}
508521
<code>
509522
{
510523
"status": "success"
511524
}
512525
</code></pre>
513526
<p>Working example:<br/>
514-
<code>curl -X POST -H'Content-Type:application/json' 'https://{{ packagist_host }}/api/packages/monolog/monolog?username={{ app.user.username|default('USERNAME') }}&amp;apiToken=********' -d '{"repository":"https://github.com/Seldaek/monolog"}'</code>
527+
<code>curl -X PUT -H'Content-Type:application/json' -H'Authorization: Bearer {{ app.user.username|default('USERNAME') }}:********' 'https://{{ packagist_host }}/api/packages/monolog/monolog' -d '{"repository":"https://github.com/Seldaek/monolog"}'</code>
515528
</p>
516529

517530
</section>
518531

519532
<section class="col-d-12">
520-
<h3 id="update-package">Update a package</h3>
533+
<h3 id="update-package">Update a package <span class="badge bg-warning">Authentication Required</span></h3>
521534

522-
<p>This endpoint updates a package by canonical repository URL or packagist.org package URL. Parameters <code>username</code> and <code>apiToken</code> are required. Only the <code>POST</code> method is allowed. The <code>content-type: application/json</code> header is required.</p>
535+
<p>This endpoint updates a package by canonical repository URL or packagist.org package URL. Only the <code>POST</code> method is allowed. The <code>content-type: application/json</code> header is required.</p>
523536
<p>This endpoint is considered SAFE and allows either your main or safe <a href="{{ path('my_profile') }}">API token</a> to be used.</p>
524537

525538
<pre>
526-
POST https://{{ packagist_host }}/api/update-package?username=[username]&amp;apiToken=[apiToken] {"repository":"[url]"}
539+
POST https://{{ packagist_host }}/api/update-package {"repository":"[url]"}
527540
<code>
528541
{
529542
"status": "success",
530543
"jobs": ["job-id", ".."]
531544
}
532545
</code></pre>
533546
<p>Working examples:<br/>
534-
<code>curl -X POST -H'Content-Type:application/json' 'https://{{ packagist_host }}/api/update-package?username={{ app.user.username|default('USERNAME') }}&amp;apiToken=********' -d '{"repository":"https://github.com/Seldaek/monolog"}'</code><br/>
535-
<code>curl -X POST -H'Content-Type:application/json' 'https://{{ packagist_host }}/api/update-package?username={{ app.user.username|default('USERNAME') }}&amp;apiToken=********' -d '{"repository":"https://packagist.org/monolog/monolog"}'</code>
547+
<code>curl -X POST -H'Content-Type:application/json' -H'Authorization: Bearer {{ app.user.username|default('USERNAME') }}:********' 'https://{{ packagist_host }}/api/update-package' -d '{"repository":"https://github.com/Seldaek/monolog"}'</code><br/>
548+
<code>curl -X POST -H'Content-Type:application/json' -H'Authorization: Bearer {{ app.user.username|default('USERNAME') }}:********' 'https://{{ packagist_host }}/api/update-package' -d '{"repository":"https://packagist.org/monolog/monolog"}'</code>
536549
</p>
537550

538551
</section>

tests/Controller/ApiControllerTest.php

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,106 @@ public function testSecurityAdvisories(): void
179179
$this->assertArrayHasKey('acme/package', $content['advisories']);
180180
$this->assertCount(1, $content['advisories']['acme/package']);
181181
}
182+
183+
public function testBearerTokenAuthentication(): void
184+
{
185+
$url = 'https://github.com/composer/composer';
186+
$user = self::createUser();
187+
$package = self::createPackage('test/'.bin2hex(random_bytes(10)), $url, maintainers: [$user]);
188+
$this->store($user, $package);
189+
190+
$payload = json_encode(['repository' => $url]);
191+
192+
// Test with Bearer token in format username:apiToken
193+
$this->client->request(
194+
'POST',
195+
'/api/update-package',
196+
[],
197+
[],
198+
['HTTP_Authorization' => 'Bearer test:api-token', 'CONTENT_TYPE' => 'application/json'],
199+
$payload
200+
);
201+
202+
$this->assertEquals(202, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
203+
}
204+
205+
public function testBearerTokenAuthenticationWithSafeToken(): void
206+
{
207+
$url = 'https://github.com/composer/composer';
208+
$user = self::createUser();
209+
$package = self::createPackage('test/'.bin2hex(random_bytes(10)), $url, maintainers: [$user]);
210+
$this->store($user, $package);
211+
212+
$payload = json_encode(['repository' => $url]);
213+
214+
// Test with Bearer token using safe API token for safe endpoint
215+
$this->client->request(
216+
'POST',
217+
'/api/update-package',
218+
[],
219+
[],
220+
['HTTP_Authorization' => 'Bearer test:safe-api-token', 'CONTENT_TYPE' => 'application/json'],
221+
$payload
222+
);
223+
224+
$this->assertEquals(202, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
225+
}
226+
227+
public function testBearerTokenAuthenticationInvalidCredentials(): void
228+
{
229+
$payload = json_encode(['repository' => 'https://github.com/composer/composer']);
230+
231+
// Test with invalid Bearer token
232+
$this->client->request(
233+
'POST',
234+
'/api/update-package',
235+
[],
236+
[],
237+
['HTTP_Authorization' => 'Bearer invalid:invalid-token', 'CONTENT_TYPE' => 'application/json'],
238+
$payload
239+
);
240+
241+
$this->assertEquals(403, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
242+
$this->assertEquals(json_encode(['status' => 'error', 'message' => 'Missing or invalid username/apiToken in request']), $this->client->getResponse()->getContent());
243+
}
244+
245+
public function testBearerTokenAuthenticationMalformed(): void
246+
{
247+
$payload = json_encode(['repository' => 'https://github.com/composer/composer']);
248+
249+
// Test with malformed Bearer token (no colon)
250+
$this->client->request(
251+
'POST',
252+
'/api/update-package',
253+
[],
254+
[],
255+
['HTTP_Authorization' => 'Bearer invalidtoken', 'CONTENT_TYPE' => 'application/json'],
256+
$payload
257+
);
258+
259+
$this->assertEquals(403, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
260+
$this->assertEquals(json_encode(['status' => 'error', 'message' => 'Missing or invalid username/apiToken in request']), $this->client->getResponse()->getContent());
261+
}
262+
263+
public function testBearerTokenAuthenticationOverridesQuery(): void
264+
{
265+
$url = 'https://github.com/composer/composer';
266+
$user = self::createUser();
267+
$package = self::createPackage('test/'.bin2hex(random_bytes(10)), $url, maintainers: [$user]);
268+
$this->store($user, $package);
269+
270+
$payload = json_encode(['repository' => $url]);
271+
272+
// Test that Bearer token is used even when query params are present but invalid
273+
$this->client->request(
274+
'POST',
275+
'/api/update-package?username=invalid&apiToken=invalid',
276+
[],
277+
[],
278+
['HTTP_Authorization' => 'Bearer test:api-token', 'CONTENT_TYPE' => 'application/json'],
279+
$payload
280+
);
281+
282+
$this->assertEquals(202, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
283+
}
182284
}

0 commit comments

Comments
 (0)