diff --git a/composer.json b/composer.json
index f160c801..84f36eae 100644
--- a/composer.json
+++ b/composer.json
@@ -17,12 +17,14 @@
],
"require": {
"php": "^8.1",
+ "ext-openssl": "*",
"doctrine/doctrine-bundle": "^2.8.0",
"doctrine/orm": "^2.14|^3.0",
"league/oauth2-server": "^8.3",
"nyholm/psr7": "^1.4",
"psr/http-factory": "^1.0",
"symfony/event-dispatcher": "^5.4|^6.2|^7.0",
+ "symfony/filesystem": "^5.4|^6.0|^7.0",
"symfony/framework-bundle": "^5.4|^6.2|^7.0",
"symfony/polyfill-php81": "^1.22",
"symfony/psr-http-message-bridge": "^2.0|^6|^7",
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index e075eb9f..2d369183 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -22,6 +22,9 @@
./tests/Acceptance
+
+ ./tests/Functional
+
./tests/Integration
diff --git a/src/Command/GenerateKeyPairCommand.php b/src/Command/GenerateKeyPairCommand.php
new file mode 100644
index 00000000..4938478b
--- /dev/null
+++ b/src/Command/GenerateKeyPairCommand.php
@@ -0,0 +1,222 @@
+
+ */
+#[AsCommand(name: 'league:oauth2-server:generate-keypair', description: 'Generate public/private keys for use in your application.')]
+final class GenerateKeyPairCommand extends Command
+{
+ private const ACCEPTED_ALGORITHMS = [
+ 'RS256',
+ 'RS384',
+ 'RS512',
+ 'HS256',
+ 'HS384',
+ 'HS512',
+ 'ES256',
+ 'ES384',
+ 'ES512',
+ ];
+
+ /**
+ * @deprecated
+ */
+ protected static $defaultName = 'league:oauth2-server:generate-keypair';
+
+ private Filesystem $filesystem;
+
+ private string $secretKey;
+
+ private string $publicKey;
+
+ private ?string $passphrase;
+
+ private string $algorithm;
+
+ public function __construct(Filesystem $filesystem, string $secretKey, string $publicKey, ?string $passphrase, string $algorithm)
+ {
+ parent::__construct();
+ $this->filesystem = $filesystem;
+ $this->secretKey = $secretKey;
+ $this->publicKey = $publicKey;
+ $this->passphrase = $passphrase;
+ $this->algorithm = $algorithm;
+ }
+
+ protected function configure(): void
+ {
+ $this->setDescription('Generate public/private keys for use in your application.');
+ $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do not update key files.');
+ $this->addOption('skip-if-exists', null, InputOption::VALUE_NONE, 'Do not update key files if they already exist.');
+ $this->addOption('overwrite', null, InputOption::VALUE_NONE, 'Overwrite key files if they already exist.');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+
+ if (!\in_array($this->algorithm, self::ACCEPTED_ALGORITHMS, true)) {
+ $io->error(sprintf('Cannot generate key pair with the provided algorithm `%s`.', $this->algorithm));
+
+ return Command::FAILURE;
+ }
+
+ [$secretKey, $publicKey] = $this->generateKeyPair($this->passphrase);
+
+ if ($input->getOption('dry-run')) {
+ $io->success('Your keys have been generated!');
+ $io->newLine();
+ $io->writeln(sprintf('Update your private key in %s:', $this->secretKey));
+ $io->writeln($secretKey);
+ $io->newLine();
+ $io->writeln(sprintf('Update your public key in %s:', $this->publicKey));
+ $io->writeln($publicKey);
+
+ return Command::SUCCESS;
+ }
+
+ $alreadyExists = $this->filesystem->exists($this->secretKey) || $this->filesystem->exists($this->publicKey);
+
+ if ($alreadyExists) {
+ try {
+ $this->handleExistingKeys($input);
+ } catch (\RuntimeException $e) {
+ if (0 === $e->getCode()) {
+ $io->comment($e->getMessage());
+
+ return Command::SUCCESS;
+ }
+
+ $io->error($e->getMessage());
+
+ return Command::FAILURE;
+ }
+
+ if (!$io->confirm('You are about to replace your existing keys. Are you sure you wish to continue?')) {
+ $io->comment('Your action was canceled.');
+
+ return Command::SUCCESS;
+ }
+ }
+
+ $this->filesystem->dumpFile($this->secretKey, $secretKey);
+ $this->filesystem->dumpFile($this->publicKey, $publicKey);
+
+ $io->success('Done!');
+
+ return Command::SUCCESS;
+ }
+
+ private function handleExistingKeys(InputInterface $input): void
+ {
+ if ($input->getOption('skip-if-exists') && $input->getOption('overwrite')) {
+ throw new \RuntimeException('Both options `--skip-if-exists` and `--overwrite` cannot be combined.', 1);
+ }
+
+ if ($input->getOption('skip-if-exists')) {
+ throw new \RuntimeException('Your key files already exist, they won\'t be overridden.', 0);
+ }
+
+ if (!$input->getOption('overwrite')) {
+ throw new \RuntimeException('Your keys already exist. Use the `--overwrite` option to force regeneration.', 1);
+ }
+ }
+
+ /**
+ * @return array{0: string, 1: string}
+ */
+ private function generateKeyPair(?string $passphrase): array
+ {
+ $config = $this->buildOpenSSLConfiguration();
+
+ $resource = openssl_pkey_new($config);
+ if (false === $resource) {
+ throw new \RuntimeException(openssl_error_string());
+ }
+
+ $success = openssl_pkey_export($resource, $privateKey, $passphrase);
+
+ if (false === $success) {
+ throw new \RuntimeException(openssl_error_string());
+ }
+
+ $publicKeyData = openssl_pkey_get_details($resource);
+
+ if (!\is_array($publicKeyData)) {
+ throw new \RuntimeException(openssl_error_string());
+ }
+
+ if (!\array_key_exists('key', $publicKeyData) || !\is_string($publicKeyData['key'])) {
+ throw new \RuntimeException('Invalid public key type.');
+ }
+
+ return [$privateKey, $publicKeyData['key']];
+ }
+
+ private function buildOpenSSLConfiguration(): array
+ {
+ $digestAlgorithms = [
+ 'RS256' => 'sha256',
+ 'RS384' => 'sha384',
+ 'RS512' => 'sha512',
+ 'HS256' => 'sha256',
+ 'HS384' => 'sha384',
+ 'HS512' => 'sha512',
+ 'ES256' => 'sha256',
+ 'ES384' => 'sha384',
+ 'ES512' => 'sha512',
+ ];
+ $privateKeyBits = [
+ 'RS256' => 2048,
+ 'RS384' => 2048,
+ 'RS512' => 4096,
+ 'HS256' => 512,
+ 'HS384' => 512,
+ 'HS512' => 512,
+ 'ES256' => 384,
+ 'ES384' => 512,
+ 'ES512' => 1024,
+ ];
+ $privateKeyTypes = [
+ 'RS256' => \OPENSSL_KEYTYPE_RSA,
+ 'RS384' => \OPENSSL_KEYTYPE_RSA,
+ 'RS512' => \OPENSSL_KEYTYPE_RSA,
+ 'HS256' => \OPENSSL_KEYTYPE_DH,
+ 'HS384' => \OPENSSL_KEYTYPE_DH,
+ 'HS512' => \OPENSSL_KEYTYPE_DH,
+ 'ES256' => \OPENSSL_KEYTYPE_EC,
+ 'ES384' => \OPENSSL_KEYTYPE_EC,
+ 'ES512' => \OPENSSL_KEYTYPE_EC,
+ ];
+
+ $curves = [
+ 'ES256' => 'secp256k1',
+ 'ES384' => 'secp384r1',
+ 'ES512' => 'secp521r1',
+ ];
+
+ $config = [
+ 'digest_alg' => $digestAlgorithms[$this->algorithm],
+ 'private_key_type' => $privateKeyTypes[$this->algorithm],
+ 'private_key_bits' => $privateKeyBits[$this->algorithm],
+ ];
+
+ if (isset($curves[$this->algorithm])) {
+ $config['curve_name'] = $curves[$this->algorithm];
+ }
+
+ return $config;
+ }
+}
diff --git a/src/DependencyInjection/LeagueOAuth2ServerExtension.php b/src/DependencyInjection/LeagueOAuth2ServerExtension.php
index 780c3e1b..25e76df3 100644
--- a/src/DependencyInjection/LeagueOAuth2ServerExtension.php
+++ b/src/DependencyInjection/LeagueOAuth2ServerExtension.php
@@ -7,6 +7,7 @@
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use League\Bundle\OAuth2ServerBundle\AuthorizationServer\GrantTypeInterface;
use League\Bundle\OAuth2ServerBundle\Command\CreateClientCommand;
+use League\Bundle\OAuth2ServerBundle\Command\GenerateKeyPairCommand;
use League\Bundle\OAuth2ServerBundle\DBAL\Type\Grant as GrantType;
use League\Bundle\OAuth2ServerBundle\DBAL\Type\RedirectUri as RedirectUriType;
use League\Bundle\OAuth2ServerBundle\DBAL\Type\Scope as ScopeType;
@@ -74,6 +75,13 @@ public function load(array $configs, ContainerBuilder $container)
->findDefinition(CreateClientCommand::class)
->replaceArgument(1, $config['client']['classname'])
;
+
+ $container
+ ->findDefinition(GenerateKeyPairCommand::class)
+ ->replaceArgument(1, $config['authorization_server']['private_key'])
+ ->replaceArgument(2, $config['resource_server']['public_key'])
+ ->replaceArgument(3, $config['authorization_server']['private_key_passphrase'])
+ ;
}
public function getAlias(): string
diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php
index 21977b43..7de150bb 100644
--- a/src/Resources/config/services.php
+++ b/src/Resources/config/services.php
@@ -11,6 +11,7 @@
use League\Bundle\OAuth2ServerBundle\Command\ClearExpiredTokensCommand;
use League\Bundle\OAuth2ServerBundle\Command\CreateClientCommand;
use League\Bundle\OAuth2ServerBundle\Command\DeleteClientCommand;
+use League\Bundle\OAuth2ServerBundle\Command\GenerateKeyPairCommand;
use League\Bundle\OAuth2ServerBundle\Command\ListClientsCommand;
use League\Bundle\OAuth2ServerBundle\Command\UpdateClientCommand;
use League\Bundle\OAuth2ServerBundle\Controller\AuthorizationController;
@@ -268,6 +269,16 @@
->tag('console.command', ['command' => 'league:oauth2-server:clear-expired-tokens'])
->alias(ClearExpiredTokensCommand::class, 'league.oauth2_server.command.clear_expired_tokens')
+ ->set('league.oauth2_server.command.generate_keypair', GenerateKeyPairCommand::class)
+ ->args([
+ service('filesystem'),
+ abstract_arg('Private key'),
+ abstract_arg('Public key'),
+ abstract_arg('Private key passphrase'),
+ ])
+ ->tag('consome.command', ['command' => 'league:oauth2-server:generate-keypair'])
+ ->alias(GenerateKeyPairCommand::class, 'league.oauth2_server.command.generate_keypair')
+
// Utility services
->set('league.oauth2_server.converter.user', UserConverter::class)
->alias(UserConverterInterface::class, 'league.oauth2_server.converter.user')
diff --git a/tests/Functional/Command/GenerateKeyPairCommandTest.php b/tests/Functional/Command/GenerateKeyPairCommandTest.php
new file mode 100644
index 00000000..22a8dc5d
--- /dev/null
+++ b/tests/Functional/Command/GenerateKeyPairCommandTest.php
@@ -0,0 +1,221 @@
+execute([], ['interactive' => false]);
+ $this->assertSame(0, $returnCode);
+
+ $privateKey = file_get_contents($privateKeyFile);
+ $publicKey = file_get_contents($publicKeyFile);
+ $this->assertStringContainsString('Done!', $tester->getDisplay(true));
+ $this->assertNotFalse($privateKey);
+ $this->assertNotFalse($publicKey);
+ $this->assertStringContainsString('PRIVATE KEY', $privateKey);
+ $this->assertStringContainsString('PUBLIC KEY', $publicKey);
+
+ // Encryption / decryption test
+ /*$payload = 'Despite the constant negative press covfefe';
+ \openssl_public_encrypt($payload, $encryptedData, \openssl_pkey_get_public($publicKey));
+ \openssl_private_decrypt($encryptedData, $decryptedData, \openssl_pkey_get_private($privateKey, $passphrase));
+ $this->assertSame($payload, $decryptedData);*/
+ }
+
+ public function providePassphrase()
+ {
+ yield ['RS256', null];
+ yield ['RS384', null];
+ yield ['RS512', null];
+ yield ['HS256', null];
+ yield ['HS384', null];
+ yield ['HS512', null];
+ yield ['ES256', null];
+ yield ['ES384', null];
+ yield ['ES512', null];
+ yield ['RS256', 'dummy'];
+ yield ['RS384', 'dummy'];
+ yield ['RS512', 'dummy'];
+ yield ['HS256', 'dummy'];
+ yield ['HS384', 'dummy'];
+ yield ['HS512', 'dummy'];
+ yield ['ES256', 'dummy'];
+ yield ['ES384', 'dummy'];
+ yield ['ES512', 'dummy'];
+ }
+
+ public function testOverwriteAndSkipCannotBeCombined()
+ {
+ $privateKeyFile = tempnam(sys_get_temp_dir(), 'private_');
+ $publicKeyFile = tempnam(sys_get_temp_dir(), 'public_');
+
+ file_put_contents($privateKeyFile, 'foobar');
+ file_put_contents($publicKeyFile, 'foobar');
+
+ $tester = new CommandTester(
+ new GenerateKeyPairCommand(
+ new Filesystem(),
+ $privateKeyFile,
+ $publicKeyFile,
+ null,
+ 'RS256'
+ )
+ );
+ $input = ['--overwrite' => true, '--skip-if-exists' => true];
+ $returnCode = $tester->execute($input, ['interactive' => false]);
+ $this->assertSame(1, $returnCode);
+ $this->assertStringContainsString(
+ 'Both options `--skip-if-exists` and `--overwrite` cannot be combined.',
+ $tester->getDisplay(true)
+ );
+
+ $privateKey = file_get_contents($privateKeyFile);
+ $publicKey = file_get_contents($publicKeyFile);
+ $this->assertStringContainsString('foobar', $privateKey);
+ $this->assertStringContainsString('foobar', $publicKey);
+ }
+
+ public function testNoOverwriteDoesNotOverwrite()
+ {
+ $privateKeyFile = tempnam(sys_get_temp_dir(), 'private_');
+ $publicKeyFile = tempnam(sys_get_temp_dir(), 'public_');
+
+ file_put_contents($privateKeyFile, 'foobar');
+ file_put_contents($publicKeyFile, 'foobar');
+
+ $tester = new CommandTester(
+ new GenerateKeyPairCommand(
+ new Filesystem(),
+ $privateKeyFile,
+ $publicKeyFile,
+ null,
+ 'RS256'
+ )
+ );
+
+ $returnCode = $tester->execute([], ['interactive' => false]);
+ $this->assertSame(1, $returnCode);
+ $this->assertStringContainsString(
+ 'Your keys already exist. Use the `--overwrite` option to force regeneration.',
+ preg_replace('/\s+/', ' ', $tester->getDisplay(true))
+ );
+
+ $privateKey = file_get_contents($privateKeyFile);
+ $publicKey = file_get_contents($publicKeyFile);
+ $this->assertStringContainsString('foobar', $privateKey);
+ $this->assertStringContainsString('foobar', $publicKey);
+ }
+
+ public function testOverwriteActuallyOverwrites()
+ {
+ $privateKeyFile = tempnam(sys_get_temp_dir(), 'private_');
+ $publicKeyFile = tempnam(sys_get_temp_dir(), 'public_');
+
+ file_put_contents($privateKeyFile, 'foobar');
+ file_put_contents($publicKeyFile, 'foobar');
+
+ $tester = new CommandTester(
+ new GenerateKeyPairCommand(
+ new Filesystem(),
+ $privateKeyFile,
+ $publicKeyFile,
+ null,
+ 'RS256'
+ )
+ );
+
+ $returnCode = $tester->execute(['--overwrite' => true], ['interactive' => false]);
+ $privateKey = file_get_contents($privateKeyFile);
+ $publicKey = file_get_contents($publicKeyFile);
+
+ $this->assertSame(0, $returnCode);
+ $this->assertStringContainsString('PRIVATE KEY', $privateKey);
+ $this->assertStringContainsString('PUBLIC KEY', $publicKey);
+ }
+
+ public function testSkipIfExistsWritesIfNotExists()
+ {
+ $privateKeyFile = tempnam(sys_get_temp_dir(), 'private_');
+ $publicKeyFile = tempnam(sys_get_temp_dir(), 'public_');
+
+ // tempnam() actually create the files, but we have to simulate they don't exist
+ unlink($privateKeyFile);
+ unlink($publicKeyFile);
+
+ $tester = new CommandTester(
+ new GenerateKeyPairCommand(
+ new Filesystem(),
+ $privateKeyFile,
+ $publicKeyFile,
+ null,
+ 'RS256'
+ )
+ );
+
+ $this->assertSame(0, $tester->execute(['--skip-if-exists' => true], ['interactive' => false]));
+ $this->assertStringContainsString('Done!', $tester->getDisplay(true));
+ $privateKey = file_get_contents($privateKeyFile);
+ $publicKey = file_get_contents($publicKeyFile);
+ $this->assertStringContainsString('PRIVATE KEY', $privateKey);
+ $this->assertStringContainsString('PUBLIC KEY', $publicKey);
+ }
+
+ public function testSkipIfExistsDoesNothingIfExists()
+ {
+ $privateKeyFile = tempnam(sys_get_temp_dir(), 'private_');
+ $publicKeyFile = tempnam(sys_get_temp_dir(), 'public_');
+
+ file_put_contents($privateKeyFile, 'foobar');
+ file_put_contents($publicKeyFile, 'foobar');
+
+ $tester = new CommandTester(
+ new GenerateKeyPairCommand(
+ new Filesystem(),
+ $privateKeyFile,
+ $publicKeyFile,
+ null,
+ 'RS256'
+ )
+ );
+
+ $this->assertSame(0, $tester->execute(['--skip-if-exists' => true], ['interactive' => false]));
+ $this->assertStringContainsString(
+ 'Your key files already exist, they won\'t be overridden.',
+ $tester->getDisplay(true)
+ );
+
+ $privateKey = file_get_contents($privateKeyFile);
+ $publicKey = file_get_contents($publicKeyFile);
+ $this->assertStringContainsString('foobar', $privateKey);
+ $this->assertStringContainsString('foobar', $publicKey);
+ }
+}