more updates
Some checks failed
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m25s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m35s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 2m45s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m36s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Failing after 2m25s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Failing after 1m10s

This commit is contained in:
2026-01-02 13:57:41 -05:00
parent d0cee7b48f
commit b5b6caa400
20 changed files with 1251 additions and 872 deletions

View File

@@ -20,7 +20,6 @@
"predis/predis": "^v3.2.0",
"siteworxpro/http-status": "0.0.2",
"lcobucci/jwt": "^5.6",
"adhocore/cli": "^1.9",
"robinvdvleuten/ulid": "^5.0",
"monolog/monolog": "^3.9",
"react/promise": "^3",
@@ -32,13 +31,14 @@
"league/oauth2-server": "^9.3",
"ext-sodium": "*",
"league/climate": "^3.10",
"hansott/psr7-cookies": "^4.0"
"hansott/psr7-cookies": "^4.0",
"symfony/console": "^v7.4.3"
},
"require-dev": {
"phpunit/phpunit": "^12.4",
"mockery/mockery": "^1.6",
"squizlabs/php_codesniffer": "^4.0",
"lendable/composer-license-checker": "^1.2",
"lendable/composer-license-checker": "^1.3.0",
"phpstan/phpstan": "^2.1.31",
"kwn/php-rdkafka-stubs": "^2.2"
},

1391
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,3 +4,4 @@ drop table if exists scopes;
drop table if exists client_redirect_uris;
drop table if exists clients;
drop table if exists users;
drop table if exists user_scopes;

View File

@@ -38,7 +38,9 @@ create table scopes
name varchar not null
constraint scopes_name_key
unique,
description varchar
description varchar not null,
created_at timestamp default now(),
updated_at timestamp default now()
);
create table client_scopes
@@ -89,3 +91,20 @@ create table client_users
constraint client_users_client_id_user_id_key
unique (client_id, user_id)
);
create table user_scopes
(
id VARCHAR(26) not null
constraint user_scopes_pk
primary key,
user_id VARCHAR(26) not null
constraint user_scopes_user_id_fk
references users
on delete cascade,
scope_id VARCHAR(26) not null
constraint user_scopes_scope_id_fk
references scopes
on delete cascade,
constraint user_scopes_user_id_scope_id_key
unique (user_id, scope_id)
);

View File

@@ -78,22 +78,17 @@ class Api
$this->router = new Router();
$this->router->get('/healthz', HealthcheckController::class . '::get');
$this->router->group('/.well-known', function (RouteGroup $router) {
$router->get('/swagger.yaml', OpenApiController::class . '::get');
$router->get('/swagger.json', OpenApiController::class . '::get');
});
$this->router->group('/client', function (RouteGroup $group) {
$group->get('/capabilities', CapabilitiesController::class . '::get');
});
// Authorize URL
$this->router->get('/authorize', AuthorizeController::class . '::get');
$this->router->post('/authorize', AuthorizeController::class . '::post');
$this->router->group('/client', function (RouteGroup $group) {
$group->get('/capabilities', CapabilitiesController::class . '::get');
$group->post('/access_token', AccessTokenController::class . '::post');
});
$this->router->group('/client/{client_id}', function (RouteGroup $group) {
$group->get('/.well-known/openid-configuration', OpenIdController::class . '::get');
$group->post('/access_token', AccessTokenController::class . '::post');
});
$this->router->middleware(new CorsMiddleware());

View File

@@ -7,6 +7,7 @@ namespace Siteworxpro\App\Async;
use Siteworxpro\App\Attributes\Async\HandlesMessage;
use Siteworxpro\App\Async\Messages\Message;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Cli\ClimateOutput;
use Siteworxpro\App\Services\Facades\Broker;
use Siteworxpro\App\Services\Facades\Logger;

View File

@@ -4,14 +4,17 @@ declare(strict_types=1);
namespace Siteworxpro\App\Cli;
use Ahc\Cli\Application;
use Siteworxpro\App\Cli\Commands\Crypt\GenerateKey;
use Siteworxpro\App\Cli\Commands\OAuth\AddRedirectUri;
use Siteworxpro\App\Cli\Commands\OAuth\AddScope;
use Siteworxpro\App\Cli\Commands\OAuth\CreateClient;
use Siteworxpro\App\Cli\Commands\OAuth\ListClients;
use Siteworxpro\App\Cli\Commands\Queue\Start;
use Siteworxpro\App\Cli\Commands\User\Add;
use Siteworxpro\App\Helpers\Version;
use Siteworxpro\App\Kernel;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\ArgvInput;
class App
{
@@ -23,27 +26,32 @@ class App
public function __construct()
{
Kernel::boot();
$this->app = new Application('Php-Auth', Version::VERSION);
$this->app->add(new CreateClient());
$this->app->add(new AddRedirectUri());
$this->app->add(new Add());
$this->app->add(new Start());
$this->app->add(new GenerateKey());
$this->app = new Application('Siteworxpro Auth', Version::VERSION);
$this->app->setCatchErrors();
$this->app->addCommand(new CreateClient());
$this->app->addCommand(new ListClients());
$this->app->addCommand(new AddRedirectUri());
$this->app->addCommand(new Add());
$this->app->addCommand(new Start());
$this->app->addCommand(new GenerateKey());
$this->app->addCommand(new AddScope());
}
public function run(): int
{
$this->app->logo(
<<<EOF
▄▄▄▄▄ ▄ ▄ ▄▄▄▄▄ ▄▄▄▄▄▄▄ ▀▀█ ▄
█ ▀█ █ █ █ ▀█ █ ▄▄▄ ▄▄▄▄▄ ▄▄▄▄ █ ▄▄▄ ▄▄█▄▄ ▄▄▄
█▄▄▄█▀ █▄▄▄▄█ █▄▄▄█▀ █ █▀ █ █ █ █ █▀ ▀█ █ ▀ █ █ █▀ █
█ █ █ ▀▀▀ █ █▀▀▀▀ █ █ █ █ █ █ ▄▀▀▀█ █ █▀▀▀▀
█ █ █ █ █ ▀█▄▄▀ █ █ █ ██▄█▀ ▀▄▄ ▀▄▄▀█ ▀▄▄ ▀█▄▄▀
EOF
);
return $this->app->handle($_SERVER['argv']);
$output = new ClimateOutput();
try {
return $this->app->run(new ArgvInput(), $output);
} catch (\Exception $e) {
$output->error($e->getMessage());
$output->error($e->getTraceAsString());
return $e->getCode() ?: 1;
}
}
}

213
src/Cli/ClimateOutput.php Normal file
View File

@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli;
use League\CLImate\CLImate;
use League\CLImate\TerminalObject\Dynamic\Spinner;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\ConsoleSectionOutput;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @method CLImate black(string $str = null)
* @method CLImate red(string $str = null)
* @method CLImate green(string $str = null)
* @method CLImate yellow(string $str = null)
* @method CLImate blue(string $str = null)
* @method CLImate magenta(string $str = null)
* @method CLImate cyan(string $str = null)
* @method CLImate lightGray(string $str = null)
* @method CLImate darkGray(string $str = null)
* @method CLImate lightRed(string $str = null)
* @method CLImate lightGreen(string $str = null)
* @method CLImate lightYellow(string $str = null)
* @method CLImate lightBlue(string $str = null)
* @method CLImate lightMagenta(string $str = null)
* @method CLImate lightCyan(string $str = null)
* @method CLImate white(string $str = null)
*
* @method CLImate backgroundBlack(string $str = null)
* @method CLImate backgroundRed(string $str = null)
* @method CLImate backgroundGreen(string $str = null)
* @method CLImate backgroundYellow(string $str = null)
* @method CLImate backgroundBlue(string $str = null)
* @method CLImate backgroundMagenta(string $str = null)
* @method CLImate backgroundCyan(string $str = null)
* @method CLImate backgroundLightGray(string $str = null)
* @method CLImate backgroundDarkGray(string $str = null)
* @method CLImate backgroundLightRed(string $str = null)
* @method CLImate backgroundLightGreen(string $str = null)
* @method CLImate backgroundLightYellow(string $str = null)
* @method CLImate backgroundLightBlue(string $str = null)
* @method CLImate backgroundLightMagenta(string $str = null)
* @method CLImate backgroundLightCyan(string $str = null)
* @method CLImate backgroundWhite(string $str = null)
*
* @method CLImate bold(string $str = null)
* @method CLImate dim(string $str = null)
* @method CLImate underline(string $str = null)
* @method CLImate blink(string $str = null)
* @method CLImate invert(string $str = null)
* @method CLImate hidden(string $str = null)
*
* @method CLImate info(string $str = null)
* @method CLImate comment(string $str = null)
* @method CLImate whisper(string $str = null)
* @method CLImate shout(string $str = null)
* @method CLImate error(string $str = null)
*
* @method mixed out(string $str)
* @method mixed inline(string $str)
* @method mixed table(array $data)
* @method mixed json(mixed $var)
* @method mixed br($count = 1)
* @method mixed tab($count = 1)
* @method mixed draw(string $art)
* @method mixed border(string $char = null, integer $length = null)
* @method mixed dump(mixed $var)
* @method mixed flank(string $output, string $char = null, integer $length = null)
* @method mixed progress(integer $total = null)
* @method Spinner spinner(string $label = null, string ...$characters = null)
* @method mixed padding(integer $length = 0, string $char = '.')
* @method mixed input(string $prompt, Util\Reader\ReaderInterface $reader = null)
* @method mixed confirm(string $prompt, Util\Reader\ReaderInterface $reader = null)
* @method mixed password(string $prompt, Util\Reader\ReaderInterface $reader = null)
* @method mixed checkboxes(string $prompt, array $options, Util\Reader\ReaderInterface $reader = null)
* @method mixed radio(string $prompt, array $options, Util\Reader\ReaderInterface $reader = null)
* @method mixed animation(string $art, TerminalObject\Helper\Sleeper $sleeper = null)
* @method mixed columns(array $data, $column_count = null)
* @method mixed clear()
* @method CLImate clearLine()
*
* @method CLImate addArt(string $dir)
*/
class ClimateOutput extends ConsoleSectionOutput implements ConsoleOutputInterface
{
private CLImate $CLImate;
private OutputFormatterInterface|OutputFormatter $formatter;
private int $verbosity = self::VERBOSITY_NORMAL;
private bool $decorated = true;
public function __construct()
{
$this->CLImate = new CLImate();
$this->formatter = new OutputFormatter($this->isDecorated());
// create stream output
$output = fopen('php://stdout', 'w');
stream_set_blocking($output, true);
$sections = [];
parent::__construct(
$output,
$sections,
$this->getVerbosity(),
$this->isDecorated(),
$this->getFormatter()
);
}
public function write(iterable|string $messages, bool $newline = false, int $options = 0): void
{
if (is_string($messages)) {
$messages = [$messages];
}
foreach ($messages as $message) {
$message = $this->formatter->format($message);
if (!$newline) {
$this->CLImate->inline($message);
continue;
}
$this->CLImate->out($message);
}
}
protected function doWrite(string $message, bool $newline): void
{
if ($newline) {
$this->CLImate->out($message);
} else {
$this->CLImate->inline($message);
}
}
public function setVerbosity(int $level): void
{
$this->verbosity = $level;
}
public function getVerbosity(): int
{
return $this->verbosity;
}
public function isQuiet(): bool
{
return $this->verbosity === self::VERBOSITY_QUIET;
}
public function isVerbose(): bool
{
return $this->verbosity >= self::VERBOSITY_VERBOSE;
}
public function isVeryVerbose(): bool
{
return $this->verbosity >= self::VERBOSITY_VERY_VERBOSE;
}
public function isDebug(): bool
{
return $this->verbosity >= self::VERBOSITY_DEBUG;
}
public function setDecorated(bool $decorated): void
{
$this->decorated = $decorated;
}
public function isDecorated(): bool
{
return $this->decorated;
}
public function setFormatter(OutputFormatterInterface $formatter): void
{
$this->formatter = $formatter;
}
public function getFormatter(): OutputFormatterInterface
{
return $this->formatter;
}
public function __call(string $name, array $arguments)
{
return $this->CLImate->$name(...$arguments);
}
public function getErrorOutput(): OutputInterface
{
return $this;
}
public function setErrorOutput(OutputInterface $error): void
{
// no-op
}
public function section(): ConsoleSectionOutput
{
return $this;
}
}

View File

@@ -4,17 +4,18 @@ declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands;
use Ahc\Cli\Application as App;
use League\CLImate\CLImate;
use Symfony\Component\Console\Helper\HelperSet;
use Symfony\Component\Console\Helper\QuestionHelper;
abstract class Command extends \Ahc\Cli\Input\Command implements CommandInterface
abstract class Command extends \Symfony\Component\Console\Command\Command implements CommandInterface
{
protected Climate $climate;
protected QuestionHelper $helper;
public function __construct(string $_name, string $_desc = '', bool $_allowUnknown = false, ?App $_app = null)
public function __construct(?string $name = null, ?callable $code = null)
{
parent::__construct($_name, $_desc, $_allowUnknown, $_app);
parent::__construct($name, $code);
$this->climate = new CLImate();
$this->helper = new QuestionHelper();
$this->setHelperSet(new HelperSet([$this->helper]));
}
}

View File

@@ -4,6 +4,11 @@ declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands;
use Siteworxpro\App\Cli\ClimateOutput;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
interface CommandInterface
{
/**
@@ -11,5 +16,5 @@ interface CommandInterface
*
* @return int
*/
public function execute(): int;
public function __invoke(InputInterface | ArgvInput $input, OutputInterface | ClimateOutput $output): int;
}

View File

@@ -5,25 +5,23 @@ declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\Crypt;
use Random\RandomException;
use Siteworxpro\App\Cli\ClimateOutput;
use Siteworxpro\App\Cli\Commands\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\ArgvInput;
#[AsCommand('crypt:generate-key', 'Generate a new encryption key for the application')]
class GenerateKey extends Command
{
public function __construct()
{
parent::__construct('crypt:generate-key', 'Generate a new encryption key for the application');
}
/**
* @throws RandomException
*/
public function execute(): int
public function __invoke($input, $output): int
{
$key = base64_encode(random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES));
$this->climate->info('Generated Encryption Key:');
$this->climate->out('base64:' . $key);
$output->info('Generated Encryption Key:');
$output->out('base64:' . $key);
return 0;
}

View File

@@ -5,51 +5,52 @@ declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\OAuth;
use League\CLImate\TerminalObject\Dynamic\Input;
use Siteworxpro\App\Cli\ClimateOutput;
use Siteworxpro\App\Models\ClientRedirectUri;
use Siteworxpro\App\OAuth\Entities\Client;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Question\Question;
#[AsCommand(name: 'oauth:redirect-uri:create', description: 'Add a redirect URI to an existing OAuth client.')]
class AddRedirectUri extends \Siteworxpro\App\Cli\Commands\Command
{
public function __construct()
{
parent::__construct('oauth:redirect-uri:add', 'Add a redirect URI to an existing OAuth client.');
}
public function execute(): int
public function __invoke($input, $output): int
{
$clients = Client::all('id', 'name');
/** @var Input $input */
$input = $this->climate->input(
'Select the OAuth client to add a redirect URI to' . PHP_EOL .
$clients->map(fn(Client $client) => "[$client->id $client->name]")->implode(PHP_EOL) .
PHP_EOL .
'Enter the client ID: '
);
$input->accept(
$clients->pluck('id')->toArray()
);
$clients->map(function (Client $client) use ($output) {
$output->info("[$client->id] $client->name");
});
$id = $input->prompt();
$question = new Question('Enter the client ID: ');
$question->setAutocompleterValues($clients->pluck('id')->toArray());
$client = Client::find($id);
$clientId = $this->helper->ask($input, $output, $question);
$client = Client::find($clientId);
if (!$client) {
$this->climate->error('Client not found.');
return 1;
$output->error('Client not found. Not a valid client ID.');
return Command::FAILURE;
}
/** @var Input $uriInput */
$uriInput = $this->climate->input('Enter the redirect URI to add: ');
$uriInput->accept(function (string $value) {
return filter_var($value, FILTER_VALIDATE_URL) !== false;
}, 'Please enter a valid URL.');
$question = new Question('Enter the redirect URI to add: ');
$question->setValidator(function ($input): string {
if (filter_var($input, FILTER_VALIDATE_URL) === false) {
throw new \InvalidArgumentException('Please enter a valid URL.');
}
$redirectUri = $uriInput->prompt();
return $input;
});
$redirectUri = $this->helper->ask($input, $output, $question);
$redirectUris = $client->clientRedirectUris;
if (in_array($redirectUri, $redirectUris->toArray(), true)) {
$this->climate->error('The redirect URI already exists for this client.');
return 1;
$output->error('The redirect URI already exists for this client.');
return Command::FAILURE;
}
$clientRedirectUri = new ClientRedirectUri();
@@ -57,6 +58,8 @@ class AddRedirectUri extends \Siteworxpro\App\Cli\Commands\Command
$clientRedirectUri->redirect_uri = $redirectUri;
$clientRedirectUri->save();
return 0;
$output->green('Redirect URI added successfully.');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\OAuth;
use Siteworxpro\App\Cli\Commands\Command;
use Siteworxpro\App\OAuth\Entities\Scope;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command as SympCommand;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
#[AsCommand('oauth:scope:add', 'Add a new OAuth scope')]
class AddScope extends Command
{
protected function configure(): void
{
$this
->addArgument('name', InputArgument::REQUIRED, 'The name of the scope')
->addArgument('description', InputArgument::OPTIONAL, 'The description of the scope');
}
public function __invoke(ArgvInput | InputInterface $input, $output): int
{
$name = $input->getArgument('name');
$description = $input->getArgument('description') ?? '';
$scope = Scope::where('name', $name)->first();
if ($scope) {
$output->error("Scope with name '$name' already exists.");
return SympCommand::FAILURE;
}
$scope = new Scope();
$scope->name = $name;
$scope->description = $description;
$scope->save();
$output->green("Scope '$name' added successfully.");
return SympCommand::SUCCESS;
}
}

View File

@@ -4,45 +4,54 @@ declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\OAuth;
use Ahc\Cli\IO\Interactor;
use Siteworxpro\App\Cli\ClimateOutput;
use Siteworxpro\App\CommandBus\Commands\CreateClient as CreateClientCommand;
use Siteworxpro\App\CommandBus\Exceptions\CommandHandlerException;
use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\App\Services\Facades\CommandBus;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\Question;
#[AsCommand(name: 'oauth:client:create', description: 'Create a new OAuth client.')]
class CreateClient extends \Siteworxpro\App\Cli\Commands\Command
{
public function __construct()
public function __invoke($input, $output): int
{
parent::__construct('oauth:client:create', 'Create a new OAuth client.');
}
$question = new Question('Enter client name: ');
$clientName = $this->helper->ask($input, $output, $question);
public function execute(): int
{
$interactor = new Interactor();
$clientName = $interactor->prompt('Enter client name');
$clientDescription = $interactor->prompt('Enter client description (optional)', '');
$clientGrantsString = $interactor->prompt(
'Enter client grants (comma separated, e.g. "authorization_code,refresh_token")',
false
);
$question = new Question('Enter client description (optional): ', '');
$clientDescription = $this->helper->ask($input, $output, $question);
$grants = explode(',', $clientGrantsString);
$question = new ChoiceQuestion('Enter client grants', [
'authorization_code',
'client_credentials',
'refresh_token',
'password',
], 0);
$question->setMultiselect(true);
$grants = $this->helper->ask($input, $output, $question);
$command = new CreateClientCommand($clientName, $grants, $clientDescription);
try {
/** @var Client $client */
$client = CommandBus::handle($command);
$this->climate->green('OAuth client created successfully');
$this->climate->info('Client ID: ' . $client->client_id);
$this->climate->info('Client Secret: ' . $client->client_secret)->br(2);
$output->green('OAuth client created successfully');
$output->info('Client ID: ' . $client->client_id);
$output->info('Client Secret: ' . $client->client_secret)->br(2);
} catch (CommandHandlerException $exception) {
$this->climate->error($exception->getMessage());
$output->error($exception->getMessage());
return 1;
return Command::FAILURE;
}
return 0;
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\OAuth;
use Siteworxpro\App\Cli\ClimateOutput;
use Siteworxpro\App\Cli\Commands\Command;
use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\App\Services\Facades\Config;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand('oauth:client:list', 'List all OAuth clients')]
class ListClients extends Command
{
protected function configure(): void
{
$this->addArgument('client-id', null, 'Filter by client ID');
}
public function __invoke(ArgvInput|InputInterface $input, ClimateOutput|OutputInterface $output): int
{
if ($input->getArgument('client-id')) {
$client = Client::find($input->getArgument('client-id'));
if (!$client) {
$output->red('No OAuth client found with the specified client ID.');
return self::SUCCESS;
}
$output->json([
'ID' => $client->id,
'Name' => $client->name,
'Client ID' => $client->client_id,
'Client Secret' => $client->client_secret,
'Grant Types' => implode(', ', $client->grant_types->toArray()),
'Access Token Url' => Config::get('app.url') . '/client/access_token',
'OAuth Config Url' => Config::get('app.url') .
'/client/' . $client->id . '/.well-known/openid-configuration',
'Scopes' => $client->scopes->toArray()
]);
return self::SUCCESS;
}
$clients = Client::all('id', 'name', 'client_id', 'client_secret', 'grant_types');
if ($clients->isEmpty()) {
$output->yellow('No OAuth clients found.');
return self::SUCCESS;
}
$outputArray = [];
$clients->map(function (Client $client) use (&$outputArray, $input) {
$outputValues = [
'ID' => $client->id,
'Name' => $client->name,
'Client ID' => $client->client_id,
'Grant Types' => implode(', ', $client->grant_types->toArray()),
];
$outputArray[] = $outputValues;
});
$output->table($outputArray);
return self::SUCCESS;
}
}

View File

@@ -4,29 +4,41 @@ declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\Queue;
use Ahc\Cli\Input\Command;
use Siteworxpro\App\Async\Consumer;
use Siteworxpro\App\Async\Messages\SayHelloMessage;
use Siteworxpro\App\Cli\Commands\Command;
use Siteworxpro\App\Cli\Commands\CommandInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
#[AsCommand('queue:start', 'Start the queue consumer')]
class Start extends Command implements CommandInterface
{
public function __construct()
protected function configure(): void
{
parent::__construct('queue:start', 'Start the queue consumer to process messages.');
$this->argument('[queues]', 'The name of the queue to consume from. ex. "first_queue,second_queue"');
$this->addArgument(
'queues',
InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
'Space separated list of queues to consume from. If omitted the default queue is used.',
['default']
);
}
public function execute(): int
public function __invoke($input, $output): int
{
$queues = [];
if ($this->values()['queues'] !== null) {
$queues = explode(',', $this->values()['queues']);
$queues = $input->getArgument('queues');
if ($output->isVerbose()) {
$output->lightCyan('Consuming from queues: ' . implode(', ', $queues))->br();
}
$consumer = new Consumer($queues);
if ($output->isVerbose()) {
$output->lightCyan('Queue consumer started. Waiting for messages...')->br();
}
$consumer->start();
return 0;
return \Symfony\Component\Console\Command\Command::SUCCESS;
}
}

View File

@@ -8,60 +8,70 @@ use Siteworxpro\App\Cli\Commands\Command;
use Siteworxpro\App\Models\ClientUser;
use Siteworxpro\App\Models\User;
use Siteworxpro\App\OAuth\Entities\Client;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command as SCommand;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Question\Question as QuestionInput;
#[AsCommand('user:add', 'Add a new user associated with an OAuth client')]
class Add extends Command
{
public function __construct()
public function __invoke($input, $output): int
{
parent::__construct('user:add', 'Add a new user to the system');
}
$clients = Client::whereJsonContains('grant_types', 'authorization_code')
->get(['id', 'name']);
public function execute(): int
{
$clients = Client::all(['id', 'name']);
$this->climate->info('Available OAuth Clients:');
$output->info('Available OAuth Clients:');
foreach ($clients as $client) {
$this->climate->out("[$client->id] $client->name");
$output->out("[$client->id] $client->name");
}
$input = $this->climate->input('Enter the client ID to associate the new user with: ');
$input->accept(
$clients->pluck('id')->toArray()
);
$question = new QuestionInput('Enter the client ID to associate the new user with: ');
$question->setAutocompleterValues($clients->pluck('id')->toArray());
$id = $input->prompt();
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$id = $helper->ask($input, $output, $question);
$client = Client::find($id);
if (!$client) {
$this->climate->error('Client not found.');
return 1;
$output->error('Client not found.');
return SCommand::FAILURE;
}
$this->climate->info("Adding a new user for client: $client->name");
$output->info("Adding a new user for client: $client->name");
$emailQuestion = new QuestionInput('Enter the user\'s email: ');
$emailQuestion->setValidator(function ($input): string {
if (empty($input) || !filter_var($input, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Invalid email address.');
}
$input = $this->climate->input('Enter the user\'s email:');
$email = $input->prompt();
return $input;
});
$email = $helper->ask($input, $output, $emailQuestion);
$user = User::where('email', $email)->first();
if ($user) {
$this->climate->error('A user with this email already exists. Associating the user with the client.');
$output->yellow('A user with this email already exists. Associating the user with the client.');
$clientUser = new ClientUser();
$clientUser->client_id = $client->id;
$clientUser->user_id = $user->id;
$clientUser->save();
return 0;
return SCommand::SUCCESS;
}
$passwordInput = $this->climate->password('Enter the user\'s password: 🔐');
$password = $passwordInput->prompt();
$passwordQuestion = new QuestionInput('Enter the user\'s password: ');
$passwordQuestion->setHidden(true);
$passwordQuestion->setHiddenFallback(false);
$password = $helper->ask($input, $output, $passwordQuestion);
$firstNameInput = $this->climate->input('Enter the user\'s first name: ');
$firstName = $firstNameInput->prompt();
$firstNameQuestion = new QuestionInput('Enter the user\'s first name: ');
$firstName = $helper->ask($input, $output, $firstNameQuestion);
$lastNameInput = $this->climate->input('Enter the user\'s last name: ');
$lastName = $lastNameInput->prompt();
$lastNameQuestion = new QuestionInput('Enter the user\'s last name: ');
$lastName = $helper->ask($input, $output, $lastNameQuestion);
$user = new User();
$user->email = $email;
@@ -75,6 +85,8 @@ class Add extends Command
$clientUser->user_id = $user->id;
$clientUser->save();
return 0;
$output->green('User added and associated with the client successfully.');
return SCommand::SUCCESS;
}
}

View File

@@ -11,7 +11,6 @@ use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Responses\GenericResponse;
use Siteworxpro\App\OAuth\Entities\AuthorizationCode;
use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\HttpStatus\CodesEnum;
@@ -23,13 +22,11 @@ final class AccessTokenController extends Controller
* @throws BadFormatException
* @throws EnvironmentIsBrokenException
* @throws \JsonException
* @throws OAuthServerException
*/
public function post(ServerRequest $request): ResponseInterface
{
try {
$grantType = $request->getParsedBody()['grant_type'] ?? null;
$client = Client::find($request->getAttribute('client_id'));
$client = Client::byClientId($request->getParsedBody()['client_id'] ?? '');
if ($client === null) {
return JsonResponseFactory::createJsonResponse(
new GenericResponse('Invalid client'),
@@ -37,30 +34,14 @@ final class AccessTokenController extends Controller
);
}
switch ($grantType) {
case 'authorization_code':
return $client
->getAuthorizationServer()
->respondToAccessTokenRequest($request, JsonResponseFactory::createJsonResponse([]));
case 'refresh_token':
break;
default:
return JsonResponseFactory::createJsonResponse(
new GenericResponse('Unsupported grant type'),
CodesEnum::BAD,
);
}
$response = $this->authorizationServer->respondToAccessTokenRequest(
$request,
new Response(),
);
return $response;
} catch (OAuthServerException $e) {
return $e->generateHttpResponse(new Response());
return JsonResponseFactory::createJsonResponse(
$e->getPayload(),
CodesEnum::fromCode($e->getHttpStatusCode()),
);
}
}
}

View File

@@ -13,6 +13,7 @@ use Siteworxpro\App\Helpers\Ulid;
* @package Siteworxpro\App\Models
* @method static static|null find(string $id, array $columns = ['*'])
* @method static where(string $column, string $operator = null, string $value = null, string $boolean = 'and')
* @method static whereJsonContains(string $column, mixed $value, string $boolean = 'and', bool $not = false)
*/
abstract class Model extends ORM
{

View File

@@ -6,10 +6,12 @@ namespace Siteworxpro\App\OAuth\Entities;
use Carbon\Carbon;
use DateTimeImmutable;
use Lcobucci\JWT\Token;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Entities\Traits\AccessTokenTrait;
use Siteworxpro\App\Services\Facades\Config;
class AccessToken extends RedisModel implements AccessTokenEntityInterface
{
@@ -27,6 +29,26 @@ class AccessToken extends RedisModel implements AccessTokenEntityInterface
return $this->client;
}
private function convertToJWT(): Token
{
$this->initJwtConfiguration();
$builder = $this->jwtConfiguration->builder()
->permittedFor($this->getClient()->getIdentifier())
->identifiedBy($this->getIdentifier())
->issuedAt(new DateTimeImmutable())
->issuedBy(Config::get('app.url') . '/' . $this->getClient()->getIdentifier())
->canOnlyBeUsedAfter(new DateTimeImmutable())
->expiresAt($this->getExpiryDateTime())
->withClaim('scopes', $this->getScopes());
if ($this->getUserIdentifier() !== null) {
$builder = $builder->relatedTo($this->getSubjectIdentifier());
}
return $builder->getToken($this->jwtConfiguration->signer(), $this->jwtConfiguration->signingKey());
}
public function getExpiryDateTime(): DateTimeImmutable
{
return $this->expiryDateTime->toDateTimeImmutable();
@@ -52,7 +74,7 @@ class AccessToken extends RedisModel implements AccessTokenEntityInterface
$this->expiryDateTime = Carbon::instance($dateTime);
}
public function setUserIdentifier(string $identifier): void
public function setUserIdentifier(?string $identifier): void
{
$this->userIdentifier = $identifier;
}