1
0
Fork 0

Forgejo: implement and document authentication for forgejo repositories

pull/12307/head
Stephan Vock 2025-02-10 19:07:36 +00:00
parent f5ea243160
commit cad36a0fbd
No known key found for this signature in database
9 changed files with 309 additions and 3 deletions

View File

@ -371,10 +371,10 @@ To create a new access token, go to your [applications section on Forgejo](https
When creating a Forgejo access token, make sure it has the `read:repository` scope.
### Command line gitlab-token
### Command line forgejo-token
```shell
php composer.phar config [--global] forgejo-token.gitlab.example.org username access-token
php composer.phar config [--global] forgejo-token.forgejo.example.org username access-token
```
In the above command, the config key `forgejo-token.forgejo.example.org` consists of two parts:

View File

@ -1,5 +1,15 @@
<?php declare(strict_types=1);
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Repository\Vcs;
use Composer\Cache;
@ -8,6 +18,7 @@ use Composer\Downloader\TransportException;
use Composer\IO\IOInterface;
use Composer\Json\JsonFile;
use Composer\Pcre\Preg;
use Composer\Util\Forgejo;
use Composer\Util\ForgejoRepositoryData;
use Composer\Util\ForgejoUrl;
use Composer\Util\Http\Response;
@ -274,12 +285,16 @@ class ForgejoDriver extends VcsDriver
protected function getContents(string $url, bool $fetchingRepoData = false): Response
{
$forgejo = new Forgejo($this->io, $this->config, $this->httpDownloader);
try {
return parent::getContents($url);
} catch (TransportException $e) {
switch ($e->getCode()) {
case 401:
case 403:
case 404:
// try to authorize only if we are fetching the main /repos/foo/bar data, otherwise it must be a real 404
case 429:
if (!$fetchingRepoData) {
throw $e;
}
@ -289,6 +304,15 @@ class ForgejoDriver extends VcsDriver
return new Response(['url' => 'dummy'], 200, [], 'null');
}
if (
!$this->io->hasAuthentication($this->originUrl) &&
$forgejo->authorizeOAuthInteractively($this->forgejoUrl->originUrl, $e->getCode() === 429 ? 'API limit exhausted. Enter your Forgejo credentials to get a larger API limit (<info>'.$this->url.'</info>)' : null)
) {
return parent::getContents($url);
}
throw $e;
default:
throw $e;
}

View File

@ -0,0 +1,108 @@
<?php declare(strict_types=1);
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Util;
use Composer\Config;
use Composer\Downloader\TransportException;
use Composer\IO\IOInterface;
/**
* @internal
* @readonly
*/
final class Forgejo
{
/** @var IOInterface */
private $io;
/** @var Config */
private $config;
/** @var HttpDownloader */
private $httpDownloader;
/**
* @param IOInterface $io
* @param Config $config
* @param HttpDownloader $httpDownloader
*/
public function __construct(IOInterface $io, Config $config, HttpDownloader $httpDownloader)
{
$this->io = $io;
$this->config = $config;
$this->httpDownloader = $httpDownloader;
}
/**
* Authorizes a Forgejo domain interactively
*
* @param string $originUrl The host this Forgejo instance is located at
* @param string $message The reason this authorization is required
* @throws \RuntimeException
* @throws TransportException|\Exception
* @return bool true on success
*/
public function authorizeOAuthInteractively(string $originUrl, ?string $message = null): bool
{
if ($message !== null) {
$this->io->writeError($message);
}
$url = 'https://'.$originUrl.'/user/settings/applications';
$this->io->writeError('Setup a personal access token with repository:read permissions on:');
$this->io->writeError($url);
$localAuthConfig = $this->config->getLocalAuthConfigSource();
$this->io->writeError(sprintf('Tokens will be stored in plain text in "%s" for future use by Composer.', ($localAuthConfig !== null ? $localAuthConfig->getName() . ' OR ' : '') . $this->config->getAuthConfigSource()->getName()));
$this->io->writeError('For additional information, check https://getcomposer.org/doc/articles/authentication-for-private-packages.md#forgejo-token');
$storeInLocalAuthConfig = false;
if ($localAuthConfig !== null) {
$storeInLocalAuthConfig = $this->io->askConfirmation('A local auth config source was found, do you want to store the token there?', true);
}
$username = trim((string) $this->io->ask('Username: '));
$token = trim((string) $this->io->askAndHideAnswer('Token (hidden): '));
$addTokenManually = sprintf('You can also add it manually later by using "composer config --global --auth forgejo-token.%s <username> <token>"', $originUrl);
if ($token === '' || $username === '') {
$this->io->writeError('<warning>No username/token given, aborting.</warning>');
$this->io->writeError($addTokenManually);
return false;
}
$this->io->setAuthentication($originUrl, $username, $token);
try {
$this->httpDownloader->get('https://'. $originUrl . '/api/v1/version', [
'retry-auth-failure' => false,
]);
} catch (TransportException $e) {
if (in_array($e->getCode(), [403, 401, 404], true)) {
$this->io->writeError('<error>Invalid access token provided.</error>');
$this->io->writeError($addTokenManually);
return false;
}
throw $e;
}
// store value in local/user config
$authConfigSource = $storeInLocalAuthConfig && $localAuthConfig !== null ? $localAuthConfig : $this->config->getAuthConfigSource();
$this->config->getConfigSource()->removeConfigSetting('forgejo-token.'.$originUrl);
$authConfigSource->addConfigSetting('forgejo-token.'.$originUrl, ['username' => $username, 'token' => $token]);
$this->io->writeError('<info>Token stored successfully.</info>');
return true;
}
}

View File

@ -1,5 +1,15 @@
<?php declare(strict_types=1);
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Util;
/**

View File

@ -1,5 +1,15 @@
<?php declare(strict_types=1);
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Util;
use Composer\Pcre\Preg;

View File

@ -416,6 +416,7 @@ class ConfigTest extends TestCase
'github-oauth',
'gitlab-oauth',
'gitlab-token',
'forgejo-token',
'http-basic',
'bearer',
];

View File

@ -1,5 +1,15 @@
<?php declare(strict_types=1);
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Test\Repository\Vcs;
use Composer\Config;

View File

@ -0,0 +1,133 @@
<?php declare(strict_types=1);
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Test\Util;
use Composer\Test\TestCase;
use Composer\Util\Forgejo;
use Composer\Util\GitHub;
class ForgejoTest extends TestCase
{
/** @var string */
private $username = 'username';
/** @var string */
private $accessToken = 'access-token';
/** @var string */
private $message = 'mymessage';
/** @var string */
private $origin = 'codeberg.org';
public function testUsernamePasswordAuthenticationFlow(): void
{
$io = $this->getIOMock();
$io->expects([
['text' => $this->message],
['ask' => 'Username: ', 'reply' => $this->username],
['ask' => 'Token (hidden): ', 'reply' => $this->accessToken],
]);
$httpDownloader = $this->getHttpDownloaderMock();
$httpDownloader->expects(
[['url' => sprintf('https://%s/api/v1/version', $this->origin), 'body' => '{}']],
true
);
$config = $this->getConfigMock();
$config
->expects($this->exactly(2))
->method('getAuthConfigSource')
->willReturn($this->getAuthJsonMock())
;
$config
->expects($this->once())
->method('getConfigSource')
->willReturn($this->getConfJsonMock())
;
$forgejo = new Forgejo($io, $config, $httpDownloader);
self::assertTrue($forgejo->authorizeOAuthInteractively($this->origin, $this->message));
}
public function testUsernamePasswordFailure(): void
{
$io = $this->getIOMock();
$io->expects([
['ask' => 'Username: ', 'reply' => $this->username],
['ask' => 'Token (hidden): ', 'reply' => $this->accessToken],
]);
$httpDownloader = $this->getHttpDownloaderMock();
$httpDownloader->expects(
[['url' => sprintf('https://%s/api/v1/version', $this->origin), 'status' => 404]],
true
);
$config = $this->getConfigMock();
$config
->expects($this->exactly(1))
->method('getAuthConfigSource')
->willReturn($this->getAuthJsonMock())
;
$forgejo = new Forgejo($io, $config, $httpDownloader);
self::assertFalse($forgejo->authorizeOAuthInteractively($this->origin));
}
/**
* @return \PHPUnit\Framework\MockObject\MockObject&\Composer\Config
*/
private function getConfigMock()
{
return $this->getMockBuilder('Composer\Config')->getMock();
}
/**
* @return \PHPUnit\Framework\MockObject\MockObject&\Composer\Config\JsonConfigSource
*/
private function getAuthJsonMock()
{
$authjson = $this
->getMockBuilder('Composer\Config\JsonConfigSource')
->disableOriginalConstructor()
->getMock()
;
$authjson
->expects($this->atLeastOnce())
->method('getName')
->willReturn('auth.json')
;
return $authjson;
}
/**
* @return \PHPUnit\Framework\MockObject\MockObject&\Composer\Config\JsonConfigSource
*/
private function getConfJsonMock()
{
$confjson = $this
->getMockBuilder('Composer\Config\JsonConfigSource')
->disableOriginalConstructor()
->getMock()
;
$confjson
->expects($this->atLeastOnce())
->method('removeConfigSetting')
->with('forgejo-token.'.$this->origin)
;
return $confjson;
}
}

View File

@ -1,5 +1,15 @@
<?php declare(strict_types=1);
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Test\Util;
use Composer\Test\TestCase;