Compare commits

...

8 Commits

Author SHA1 Message Date
f0e191b2cb Add UserInfo endpoint and enhance client management
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 5m21s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 5m43s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 5m33s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 5m28s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 5m52s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Failing after 3m10s
- Introduced a new UserInfo controller to handle user information retrieval based on JWT authentication and scopes.
- Added a publicKey method in the Client model to convert private keys to public RSA PEM keys.
- Updated the JwtMiddleware to validate tokens against client IDs and improved error handling.
- Modified the CreateUserHandler to save the user's password directly instead of hashing it.
- Adjusted the .env file to include a new APP_URL variable.
2026-02-11 21:29:18 -05:00
75757f1403 Enhance client management by adding PKCE option and refactoring client retrieval
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Has been cancelled
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Has been cancelled
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Has been cancelled
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Has been cancelled
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Has been cancelled
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Has been cancelled
- Introduced a boolean parameter to `askForClient` method to allow retrieval of all clients.
- Updated client creation process to include an option for requiring PKCE for Authorization Code grants.
- Refactored related code for improved clarity and functionality.
2026-02-08 00:01:08 -05:00
f28d8f2ec8 fixed linting
All checks were successful
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in -24s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in -29s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in -30s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in -19s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in -14s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in -47s
2026-01-30 15:26:54 -05:00
b4c892c104 Add scope management functionality for clients and enhance client creation process
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 1m5s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 1m25s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m24s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m14s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Failing after 1m20s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in -19s
2026-01-30 12:43:26 -05:00
5ec683890e Enhance user and audit logging by adding client ID to user scopes and login events
All checks were successful
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in -21s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in -22s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in -12s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in -20s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in -14s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in -36s
2026-01-29 23:45:23 -05:00
eaff081e44 Refactor password reset handler to use a variable for client redirect URI
All checks were successful
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in -34s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in -9s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in -18s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in -22s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in -11s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in -32s
2026-01-29 23:01:53 -05:00
8f5f57f5f6 Add DeleteClient command and enhance token settings management
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after -33s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in -36s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Has been cancelled
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Has been cancelled
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Has been cancelled
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Has been cancelled
2026-01-29 23:00:01 -05:00
96409973bf password reset 2026-01-29 22:34:15 -05:00
33 changed files with 455 additions and 279 deletions

View File

@@ -1,3 +1,4 @@
APP_URL: https://localhost
JWT_ISSUER: https://auth.siteworxpro.com/application/o/postman/
JWT_AUDIENCE: 1RWyqJFlyA4hmsDzq6kSxs0LXvk7UgEAfgmBCpQ9
JWT_SIGNING_KEY: https://auth.siteworxpro.com/application/o/postman/.well-known/openid-configuration

View File

@@ -105,6 +105,10 @@ create table user_scopes
constraint user_scopes_scope_id_fk
references scopes
on delete cascade,
client_id VARCHAR(26) not null
constraint user_scopes_client_id_fk
references clients
on delete cascade,
constraint user_scopes_user_id_scope_id_key
unique (user_id, scope_id)
unique (user_id, scope_id, client_id)
);

View File

@@ -3,7 +3,7 @@ create table audit_logs
id VARCHAR(26) not null
constraint audit_logs_pkey
primary key,
user_id integer,
user_id varchar(26) default null,
action varchar(255) not null,
timestamp timestamptz default current_timestamp,
details jsonb

View File

@@ -14,6 +14,7 @@ use Siteworxpro\App\Controllers\AuthorizeController;
use Siteworxpro\App\Controllers\CapabilitiesController;
use Siteworxpro\App\Controllers\HealthcheckController;
use Siteworxpro\App\Controllers\OpenIdController;
use Siteworxpro\App\Controllers\UserInfo;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Middleware\CorsMiddleware;
use Siteworxpro\App\Http\Middleware\JwtMiddleware;
@@ -89,6 +90,8 @@ class Api
$group->get('/.well-known/openid-configuration', OpenIdController::class . '::get');
});
$this->router->get('/user_info', UserInfo::class . '::get');
$this->router->middleware(new CorsMiddleware());
$this->router->middleware(new JwtMiddleware());
$this->router->middleware(new ScopeMiddleware());

View File

@@ -16,7 +16,7 @@ readonly class Scope
*/
public function __construct(
private array $scopes = [],
private string $claim = 'scope',
private string $claim = 'scopes',
private string $separator = ' '
) {
}

View File

@@ -9,6 +9,7 @@ use Siteworxpro\App\Cli\Commands\OAuth\AddRedirectUri;
use Siteworxpro\App\Cli\Commands\OAuth\AddScope;
use Siteworxpro\App\Cli\Commands\OAuth\ClientCapabilities;
use Siteworxpro\App\Cli\Commands\OAuth\CreateClient;
use Siteworxpro\App\Cli\Commands\OAuth\DeleteClient;
use Siteworxpro\App\Cli\Commands\OAuth\ListClients;
use Siteworxpro\App\Cli\Commands\Queue\Start;
use Siteworxpro\App\Cli\Commands\User\Add;
@@ -42,6 +43,7 @@ class App
$this->app->addCommand(new AddScope());
$this->app->addCommand(new ResetPassword());
$this->app->addCommand(new ClientCapabilities());
$this->app->addCommand(new DeleteClient());
}
public function run(): int

View File

@@ -28,13 +28,22 @@ abstract class Command extends \Symfony\Component\Console\Command\Command implem
/**
* @param ClimateOutput $output
* @param ClimateOutput|ArgvInput $input
* @param bool $allClients
* @return Client | null
*/
protected function askForClient(ClimateOutput $output, ClimateOutput|ArgvInput $input): ?Client
{
/** @var Collection<Client> $clients */
$clients = Client::whereJsonContains('grant_types', 'authorization_code')
->get(['id', 'name']);
protected function askForClient(
ClimateOutput $output,
ClimateOutput|ArgvInput $input,
bool $allClients = false
): ?Client {
if ($allClients) {
/** @var Collection<Client> $clients */
$clients = Client::all(['id', 'name']);
} else {
/** @var Collection<Client> $clients */
$clients = Client::whereJsonContains('grant_types', 'authorization_code')
->get(['id', 'name']);
}
if ($clients->isEmpty()) {
$output->error('No OAuth clients available.');

View File

@@ -6,6 +6,8 @@ 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\OAuth\Entities\Scope;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
@@ -30,6 +32,8 @@ class ClientCapabilities extends Command
$output->info('[1] Username Password: ' . ($capabilities['userPass'] ? 'Enabled' : 'Disabled'));
$output->info('[2] Magic Link: ' . ($capabilities['magicLink'] ? 'Enabled' : 'Disabled'));
$output->info('[3] Social Logins: ' . ($capabilities['socials'] ? 'Enabled' : 'Disabled'));
$output->info('[4] External Client (require pkce): ' . (!$client->confidential ? 'Enabled' : 'Disabled'));
$output->info('[5] Manage Scopes');
$question = new Question('What do you want to edit: ', '');
$selection = $this->helper->ask($input, $output, $question);
@@ -48,6 +52,12 @@ class ClientCapabilities extends Command
case '3':
$output->info('Social Logins cannot be modified via CLI at this time.');
break;
case '4':
$client->confidential = !$client->confidential;
break;
case '5':
$this->manageClientScopes($input, $output, $client);
break;
default:
$output->error('Invalid selection. Please try again.');
continue 2;
@@ -60,4 +70,48 @@ class ClientCapabilities extends Command
return \Symfony\Component\Console\Command\Command::SUCCESS;
}
private function manageClientScopes($input, ClimateOutput $output, Client $client): void
{
$allScopes = Scope::all();
$output->info('These are scope that are available for this client.');
$output->info('Type "exit" to finish managing scopes.');
while (true) {
$clientScopes = $client->scopes()->get();
$output->info('Available Scopes:');
/** @var Scope $scope */
foreach ($allScopes as $scope) {
$status = $clientScopes->contains($scope) ? 'Enabled' : 'Disabled';
$output->info("$scope->id - $scope->name $status");
}
$question = new Question('Enter scope ID to toggle (or type "exit" to finish): ', '');
$question->setAutocompleterValues($allScopes->pluck('id')->toArray());
$scopeId = $this->helper->ask($input, $output, $question);
if (strtolower($scopeId) === 'exit') {
break;
}
$scope = $allScopes->where('id', $scopeId)->first();
if ($scope === null) {
$output->error('Scope not found. Please try again.');
continue;
}
if ($clientScopes->contains($scope)) {
$client->disableScope($scope);
$output->info("Scope '$scope->name' disabled for client.");
} else {
$client->enableScope($scope);
$output->info("Scope '$scope->name' enabled for client.");
}
}
}
}

View File

@@ -12,8 +12,6 @@ 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;
@@ -21,7 +19,7 @@ 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 __invoke($input, $output): int
public function __invoke($input, ClimateOutput | OutputInterface $output): int
{
$question = new Question('Enter client name: ');
$clientName = $this->helper->ask($input, $output, $question);
@@ -31,8 +29,8 @@ class CreateClient extends \Siteworxpro\App\Cli\Commands\Command
$question = new ChoiceQuestion('Enter client grants', [
'authorization_code',
'client_credentials',
'refresh_token',
'client_credentials',
'password',
], 0);
$question->setMultiselect(true);
@@ -44,7 +42,22 @@ class CreateClient extends \Siteworxpro\App\Cli\Commands\Command
$grantsEnum[] = ClientGrantAlias::from($grant);
}
$command = new CreateClientCommand($clientName, $grantsEnum, $clientDescription);
$isExternal = false;
if (in_array('authorization_code', $grants)) {
$question = $this->helper->ask(
$input,
$output,
new \Symfony\Component\Console\Question\ConfirmationQuestion(
'Require PKCE for Authorization Code grant? (y/N): ',
false,
'/^(y|yes)/i'
)
);
$isExternal = $question === 'y' || $question === true;
}
$command = new CreateClientCommand($clientName, $grantsEnum, $clientDescription, $isExternal);
try {
/** @var Client $client */
$client = CommandBus::handle($command);

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\OAuth;
use Siteworxpro\App\Cli\ClimateOutput;
use Siteworxpro\App\Cli\Commands\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\ArgvInput;
#[AsCommand('oauth:client:delete', 'Delete an OAuth client')]
class DeleteClient extends Command
{
public function __invoke(ClimateOutput|ArgvInput $input, $output): int
{
$client = $this->askForClient($output, $input, true);
if ($client === null) {
$output->red('No client selected, aborting.');
return \Symfony\Component\Console\Command\Command::FAILURE;
}
$output->red()->bold('You are about to delete the following OAuth client:');
$output->red("ID: $client->id");
$output->red("Name: $client->name");
$output->red("Description: $client->description");
$output
->br()
->backgroundRed()
->yellow()
->bold('This action is irreversible and will remove all associated data.');
$question = $this->helper->ask(
$input,
$output,
new \Symfony\Component\Console\Question\ConfirmationQuestion(
'Are you sure you want to proceed? (y/N): ',
false,
'/^(y|yes)/i'
)
);
if (!$question) {
$output->info('Operation cancelled by user.');
return \Symfony\Component\Console\Command\Command::SUCCESS;
}
$client->delete();
$output->green('OAuth client deleted successfully.');
return \Symfony\Component\Console\Command\Command::SUCCESS;
}
}

View File

@@ -46,7 +46,8 @@ class ListClients extends Command
'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()
'Scopes' => $client->scopes->pluck('name')->toArray(),
'Capabilities' => $client->capabilities->toArray(),
]);
return self::SUCCESS;

View File

@@ -20,7 +20,7 @@ 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 __invoke(ClimateOutput|ArgvInput|InputInterface $input, ClimateOutput $output): int
public function __invoke(ClimateOutput|ArgvInput|InputInterface $input, $output): int
{
$client = $this->askForClient($output, $input);
if (!$client) {

View File

@@ -7,6 +7,7 @@ namespace Siteworxpro\App\Cli\Commands\User;
use Siteworxpro\App\Cli\ClimateOutput;
use Siteworxpro\App\Cli\Commands\Command;
use Siteworxpro\App\CommandBus\Commands\SendPasswordReset;
use Siteworxpro\App\Mailer\Message;
use Siteworxpro\App\Services\Facades\CommandBus;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Helper\QuestionHelper;
@@ -41,9 +42,13 @@ class ResetPassword extends Command
}
if ($input->getOption('send-email')) {
CommandBus::handle(new SendPasswordReset($user, $client));
/** @var Message $message */
$message = CommandBus::handle(new SendPasswordReset($user, $client));
$output->info('Password reset email sent to the user.');
$output->info('Email Subject: ' . $message->getSubject());
$output->info('Email Body: ' . $message->getBody());
return \Symfony\Component\Console\Command\Command::SUCCESS;
}

View File

@@ -10,11 +10,13 @@ readonly class CreateClient extends Command
* @param string $clientName
* @param array<ClientGrant | mixed> $clientGrants
* @param string $clientDescription
* @param bool $isExternal
*/
public function __construct(
private string $clientName,
private array $clientGrants = [],
private string $clientDescription = ''
private string $clientDescription = '',
private bool $isExternal = false
) {
foreach ($this->clientGrants as $grant) {
if ($grant instanceof ClientGrant === false) {
@@ -32,6 +34,14 @@ readonly class CreateClient extends Command
}
}
/**
* @return bool
*/
public function isExternal(): bool
{
return $this->isExternal;
}
/**
* @return string
*/

View File

@@ -16,11 +16,13 @@ class CreateUserHandler
{
$user = User::create([
'email' => $command->getEmail(),
'password' => password_hash($command->getPassword(), PASSWORD_BCRYPT),
'first_name' => $command->getFirstName(),
'last_name' => $command->getLastName(),
]);
$user->password = $command->getPassword();
$user->save();
$clientUser = new ClientUser();
$clientUser->client_id = $command->getClient()->id;
$clientUser->user_id = $user->id;

View File

@@ -26,6 +26,7 @@ class CreateClient extends CommandHandler
$client->description = $command->getClientDescription();
$client->grant_types = new Collection($command->getClientGrants()); // @phpstan-ignore-line assign.propertyType
$client->capabilities = new ClientCapabilities();
$client->confidential = !$command->isExternal();
$client->save();

View File

@@ -11,6 +11,7 @@ use Siteworxpro\App\CommandBus\Commands\SendPasswordReset;
use Siteworxpro\App\CommandBus\Exceptions\CommandHandlerException;
use Siteworxpro\App\Helpers\Rand;
use Siteworxpro\App\Mailer\Message;
use Siteworxpro\App\Models\ClientRedirectUri;
use Siteworxpro\App\Models\User;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Mailer;
@@ -23,7 +24,7 @@ class SendPasswordResetHandler extends CommandHandler
/**
* @throws RandomException
*/
public function __invoke(Command|SendPasswordReset $command): User
public function __invoke(Command|SendPasswordReset $command): Message
{
if (!$command instanceof SendPasswordReset) {
throw new CommandHandlerException('Invalid command type provided to handler.');
@@ -31,15 +32,28 @@ class SendPasswordResetHandler extends CommandHandler
$token = Rand::string(64);
/** @var ClientRedirectUri $redirectUri */
$redirectUri = $command->getClient()->clientRedirectUris->first();
$content = Twig::render('password-reset.twig', [
'user' => $command->getUser(),
'resetLink' => Config::get('app.url') . '/reset-password?token=' . $token,
'resetLink' => sprintf(
'%s/authorize?token=%s&client_id=%s&response_type=code&redirect_uri=%s#/password-reset',
Config::get('app.url'),
$token,
$command->getClient()->client_id,
urlencode($redirectUri->redirect_uri)
),
'client' => $command->getClient()
]);
$from = $command->getClient()->capabilities->toArray()['support_email'] != ''
? $command->getClient()->capabilities->toArray()['support_email']
: Config::get('app.default_support_email');
$message = new Message(
$command->getUser()->email,
$command->getClient()->capabilities->toArray()['support_email'] ?? Config::get('app.default_support_email'),
$from,
'Password Reset Request',
$content
);
@@ -50,6 +64,6 @@ class SendPasswordResetHandler extends CommandHandler
Redis::set('password_reset:' . $command->getUser()->id, $token, 'EX', Redis::MINUTE * 15);
return $command->getUser();
return $message;
}
}

View File

@@ -40,9 +40,12 @@ final class AccessTokenController extends Controller
/** @var Response $response */
$response = $client
->getAuthorizationServer()
->respondToAccessTokenRequest($request, JsonResponseFactory::createJsonResponse([]));
->respondToAccessTokenRequest(
$request,
JsonResponseFactory::createJsonResponse([])
);
Dispatcher::push(new Issued($response));
Dispatcher::push(new Issued($client, $response));
return $response;
} catch (OAuthServerException $e) {

View File

@@ -12,7 +12,6 @@ use League\OAuth2\Server\RequestTypes\AuthorizationRequest;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
use Nyholm\Psr7\Stream;
use Siteworxpro\App\Events\Login\LoginAttempt;
use Siteworxpro\App\Events\Login\LoginFailed;
use Siteworxpro\App\Events\Login\LoginSuccess;
use Siteworxpro\App\Helpers\Rand;
@@ -35,8 +34,6 @@ final class AuthorizeController extends Controller
*/
public function post(ServerRequest $request): Response
{
Dispatcher::push(new LoginAttempt($request));
$s = $request->getCookieParams()['s'] ?? '';
$password = $request->getParsedBody()['password'] ?? '';
@@ -69,7 +66,7 @@ final class AuthorizeController extends Controller
$user = $client->loginUser($email, $password);
if (!$user) {
Dispatcher::push(new LoginFailed($request));
Dispatcher::push(new LoginFailed($request, $client));
return JsonResponseFactory::createJsonResponse([
'success' => false,
@@ -84,7 +81,7 @@ final class AuthorizeController extends Controller
Redis::del('session:' . $s);
Dispatcher::push(new LoginSuccess($request, $user));
Dispatcher::push(new LoginSuccess($request, $client, $user));
return JsonResponseFactory::createJsonResponse([
'success' => true,

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Attributes\Guards\Jwt;
use Siteworxpro\App\Attributes\Guards\Scope;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Responses\NotFoundResponse;
use Siteworxpro\App\Models\User;
class UserInfo extends Controller
{
/**
* @throws \JsonException
*/
#[Scope(['profile', 'email', 'openid'])]
#[Jwt]
public function get(ServerRequest $request): ResponseInterface
{
$userId = $request->getAttribute('sub');
/** @var User | null $user */
$user = User::find($userId);
if (!$user) {
return JsonResponseFactory::createJsonResponse(
new NotFoundResponse($request->getUri()->getPath())
);
}
$responseData = [];
$scopes = $request->getAttribute('scopes', []);
$responseData['id'] = $user->id;
if (in_array('profile', $scopes, true)) {
$responseData['name'] = $user->first_name . ' ' . $user->last_name;
$responseData['given_name'] = $user->first_name;
$responseData['middle_name'] = ''; // todo
$responseData['family_name'] = $user->last_name;
$responseData['nickname'] = ''; // todo
$responseData['preferred_username'] = substr($user->first_name, 0, 1) . $user->last_name;
$responseData['picture'] = ''; // todo
$responseData['birthdate'] = ''; // todo
$responseData['phone_number'] = ''; // todo
$responseData['phone_number_verified'] = false; // todo
}
if (in_array('email', $scopes, true)) {
$responseData['email'] = $user->email;
$responseData['email_verified'] = false; // todo
}
return JsonResponseFactory::createJsonResponse($responseData);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\EventListeners;
use Siteworxpro\App\Attributes\Events\ListensFor;
use Siteworxpro\App\Events\AccessToken\Issued as IssuedEvent;
use Siteworxpro\App\Models\AuditLog;
use Siteworxpro\App\Models\Enums\AuditLogAction;
#[ListensFor(IssuedEvent::class)]
class AccessTokenIssuedListener extends Listener
{
/**
* Handle the event.
*
* @param string | IssuedEvent $event
* @param array $payload
* @return AuditLog|null
*/
public function __invoke(mixed $event, array $payload = []): ?AuditLog
{
if (is_string($event)) {
$event = $payload[0] ?? null;
}
if (!$event instanceof IssuedEvent) {
return null;
}
return AuditLog::create([
'user_id' => null,
'action' => AuditLogAction::TOKEN_ISSUED,
'details' => [
'response_status' => $event->getResponse()->getStatusCode(),
'client_id' => $event->getClient()->getIdentifier(),
'client_name' => $event->getClient()->getName(),
],
]);
}
}

View File

@@ -9,7 +9,8 @@ use Siteworxpro\App\Models\AuditLog;
use Siteworxpro\App\Models\Enums\AuditLogAction;
#[ListensFor(\Siteworxpro\App\Events\Login\LoginFailed::class)]
class LoginFailed extends Listener
#[ListensFor(\Siteworxpro\App\Events\Login\LoginSuccess::class)]
class LoginListener extends Listener
{
/**
* Handle the event.
@@ -24,15 +25,24 @@ class LoginFailed extends Listener
$event = $payload[0] ?? null;
}
if (!$event instanceof \Siteworxpro\App\Events\Login\LoginFailed) {
if (
!$event instanceof \Siteworxpro\App\Events\Login\LoginFailed
&& !$event instanceof \Siteworxpro\App\Events\Login\LoginSuccess
) {
return null;
}
$action = $event instanceof \Siteworxpro\App\Events\Login\LoginSuccess
? AuditLogAction::LOGIN_SUCCESS
: AuditLogAction::LOGIN_FAIL;
return AuditLog::create([
'user_id' => $event->getUser()?->id,
'action' => AuditLogAction::LOGIN_FAIL,
'action' => $action,
'details' => [
'username_attempted' => $event->getUsernameAttempted(),
'client_id' => $event->getClient()->client_id,
'client_name' => $event->getClient()->name,
'username' => $event->getUsernameAttempted(),
'ip_address' => $event->getRequestIp(),
],
]);

View File

@@ -5,10 +5,11 @@ declare(strict_types=1);
namespace Siteworxpro\App\Events\AccessToken;
use Nyholm\Psr7\Response;
use Siteworxpro\App\OAuth\Entities\Client;
readonly class Issued
{
public function __construct(private Response $response)
public function __construct(private Client $client, private Response $response)
{
}
@@ -16,4 +17,9 @@ readonly class Issued
{
return $this->response;
}
public function getClient(): Client
{
return $this->client;
}
}

View File

@@ -5,16 +5,56 @@ declare(strict_types=1);
namespace Siteworxpro\App\Events\Login;
use Nyholm\Psr7\ServerRequest;
use Siteworxpro\App\Models\User;
use Siteworxpro\App\OAuth\Entities\Client;
readonly class LoginAttempt
abstract class LoginAttempt
{
public function __construct(
private ServerRequest $request,
private readonly ServerRequest $request,
private readonly Client $client,
private readonly ?User $user = null,
) {
}
public function getEmail(): string
public function getRequestIp(): string
{
if ($this->request->getHeader('X-Forwarded-For')) {
$ipAddresses = explode(',', $this->request->getHeaderLine('X-Forwarded-For'));
return trim($ipAddresses[0]);
}
if ($this->request->getHeader('X-Real-IP')) {
return $this->request->getHeaderLine('X-Real-IP');
}
if ($this->request->getServerParams()['HTTP_CLIENT_IP'] ?? false) {
return $this->request->getServerParams()['HTTP_CLIENT_IP'];
}
if ($this->request->getServerParams()['HTTP_X_FORWARDED_FOR'] ?? false) {
$ipAddresses = explode(',', $this->request->getServerParams()['HTTP_X_FORWARDED_FOR']);
return trim($ipAddresses[0]);
}
return $this->request->getServerParams()['REMOTE_ADDR'] ?? 'unknown';
}
public function getUsernameAttempted(): string
{
return $this->request->getParsedBody()['email'] ?? '';
}
public function getUser(): ?User
{
return $this->user;
}
/**
* @return Client
*/
public function getClient(): Client
{
return $this->client;
}
}

View File

@@ -7,44 +7,6 @@ namespace Siteworxpro\App\Events\Login;
use Nyholm\Psr7\ServerRequest;
use Siteworxpro\App\Models\User;
readonly class LoginFailed
class LoginFailed extends LoginAttempt
{
public function __construct(
private ServerRequest $request,
private ?User $user = null,
) {
}
public function getRequestIp(): string
{
if ($this->request->getHeader('X-Forwarded-For')) {
$ipAddresses = explode(',', $this->request->getHeaderLine('X-Forwarded-For'));
return trim($ipAddresses[0]);
}
if ($this->request->getHeader('X-Real-IP')) {
return $this->request->getHeaderLine('X-Real-IP');
}
if ($this->request->getServerParams()['HTTP_CLIENT_IP'] ?? false) {
return $this->request->getServerParams()['HTTP_CLIENT_IP'];
}
if ($this->request->getServerParams()['HTTP_X_FORWARDED_FOR'] ?? false) {
$ipAddresses = explode(',', $this->request->getServerParams()['HTTP_X_FORWARDED_FOR']);
return trim($ipAddresses[0]);
}
return $this->request->getServerParams()['REMOTE_ADDR'] ?? 'unknown';
}
public function getUsernameAttempted(): string
{
return $this->request->getParsedBody()['email'] ?? '';
}
public function getUser(): ?User
{
return $this->user;
}
}

View File

@@ -7,21 +7,6 @@ namespace Siteworxpro\App\Events\Login;
use Nyholm\Psr7\ServerRequest;
use Siteworxpro\App\Models\User;
readonly class LoginSuccess
class LoginSuccess extends LoginAttempt
{
public function __construct(
private ServerRequest $serverRequest,
private User $user
) {
}
public function getRequest(): ServerRequest
{
return $this->serverRequest;
}
public function getUser(): User
{
return $this->user;
}
}

View File

@@ -8,8 +8,6 @@ use Carbon\Carbon;
use Carbon\WrapperClock;
use GuzzleHttp\Exception\GuzzleException;
use Lcobucci\JWT\JwtFacade;
use Lcobucci\JWT\Signer\Hmac\Sha256 as Hmac256;
use Lcobucci\JWT\Signer\Key;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Token\InvalidTokenStructure;
@@ -26,9 +24,8 @@ use Psr\Http\Server\RequestHandlerInterface;
use Siteworxpro\App\Attributes\Guards\Jwt;
use Siteworxpro\App\Controllers\Controller;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Guzzle;
use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\HttpStatus\CodesEnum;
/**
@@ -91,7 +88,6 @@ class JwtMiddleware extends Middleware
// Extract Bearer token from Authorization header.
$token = str_replace('Bearer ', '', $request->getHeaderLine('Authorization'));
if (empty($token)) {
return JsonResponseFactory::createJsonResponse([
'status_code' => 401,
@@ -99,31 +95,39 @@ class JwtMiddleware extends Middleware
], CodesEnum::UNAUTHORIZED);
}
// Aggregate required issuers and audience from attributes.
$requiredIssuers = [];
$requiredAudience = '';
foreach ($attributes as $attribute) {
/** @var Jwt $jwtInstance */
$jwtInstance = $attribute->newInstance();
if ($jwtInstance->getAudience() !== '') {
$requiredAudience = $jwtInstance->getAudience();
}
$requiredIssuers[] = $jwtInstance->getIssuer();
$jwt = explode('.', $token);
if (count($jwt) !== 3) {
return JsonResponseFactory::createJsonResponse([
'status_code' => 401,
'message' => 'Unauthorized: Invalid token format',
], CodesEnum::UNAUTHORIZED);
}
$payload = json_decode(base64_decode($jwt[1]), true, 512, JSON_THROW_ON_ERROR);
$clientId = str_replace(Config::get('app.url') . '/', '', $payload['iss'] ?? '');
$client = Client::find($clientId);
if (!$client) {
return JsonResponseFactory::createJsonResponse([
'status_code' => 401,
'message' => 'Unauthorized: Invalid token issuer',
], CodesEnum::UNAUTHORIZED);
}
$iss = Config::get('app.url') . '/' . $client->id;
$aud = $clientId;
try {
// Parse and validate the token with signature, time, issuer and audience constraints.
$key = InMemory::plainText($client->publicKey());
$jwt = new JwtFacade()->parse(
$token,
$this->getSignedWith($token),
new SignedWith(new Sha256(), $key),
Config::get('jwt.strict_validation') ?
new StrictValidAt(new WrapperClock(Carbon::now())) :
new LooseValidAt(new WrapperClock(Carbon::now())),
new IssuedBy(...$requiredIssuers),
new PermittedFor($requiredAudience)
new IssuedBy($iss),
new PermittedFor($aud)
);
} catch (RequiredConstraintsViolated $exception) {
// Collect human-readable violations to return to the client.
@@ -159,164 +163,4 @@ class JwtMiddleware extends Middleware
return $handler->handle($request);
}
/**
* Build the signature validation constraint from configured key.
*
* - If the configured key content includes the string `PUBLIC KEY`, use RSA SHA-256.
* - Otherwise assume an HMAC SHA-256 shared secret.
* - Supports raw key strings or `file://` paths.
*
* @return SignedWith Signature constraint used during JWT parsing.
*
* @throws \RuntimeException When no signing key is configured.
* @throws \JsonException
*/
private function getSignedWith(string $token): SignedWith
{
$keyConfig = Config::get('jwt.signing_key');
if ($keyConfig === null) {
throw new \RuntimeException('JWT signing key is not configured.');
}
// file:// path to key
if (str_starts_with($keyConfig, 'file://')) {
$key = InMemory::file(substr($keyConfig, 7));
// openid jwks url
} elseif (str_contains($keyConfig, '.well-known/')) {
$jwt = explode('.', $token);
if (count($jwt) !== 3) {
throw new InvalidTokenStructure('Invalid JWT structure for JWKS key retrieval.');
}
$header = json_decode(base64_decode($jwt[0]), true, 512, JSON_THROW_ON_ERROR);
$keyId = $header['kid'] ?? '0'; // Default to '0' if no kid present
$key = $this->getJwksKey($keyConfig, $keyId);
} else {
$key = InMemory::plainText($keyConfig);
}
// Heuristic: if PEM public key content is detected, use RSA; otherwise use HMAC.
if (str_contains($key->contents(), 'PUBLIC KEY')) {
return new SignedWith(new Sha256(), $key);
}
return new SignedWith(new Hmac256(), $key);
}
private function getJwksKey(string $url, string $keyId): Key
{
$cached = Redis::get('jwks_key_' . $keyId);
if ($cached !== null) {
return InMemory::plainText($cached);
}
$openIdConfig = Guzzle::get($url);
$body = json_decode($openIdConfig->getBody()->getContents(), true, JSON_THROW_ON_ERROR);
$jwksUri = $body['jwks_uri'] ?? '';
if (empty($jwksUri)) {
throw new \RuntimeException('JWKS URI not found in OpenID configuration.');
}
$jwksResponse = Guzzle::get($jwksUri);
$jwksBody = json_decode(
$jwksResponse->getBody()->getContents(),
true,
JSON_THROW_ON_ERROR
);
// For simplicity, we take the first key in the JWKS.
$firstKey = array_filter(
$jwksBody['keys'],
fn($key) => $key['kid'] === $keyId
)[0] ?? $jwksBody['keys'][0] ?? null;
if (empty($firstKey)) {
throw new \RuntimeException('No matching key found in JWKS for key ID: ' . $keyId);
}
$n = $firstKey['n'];
$e = $firstKey['e'];
$publicKeyPem = "-----BEGIN PUBLIC KEY-----\n" .
chunk_split(base64_encode($this->convertJwkToPem($n, $e)), 64) .
"-----END PUBLIC KEY-----\n";
Redis::set('jwks_key_' . $keyId, $publicKeyPem, 'EX', 3600);
return InMemory::plainText($publicKeyPem);
}
/**
* Build a DER-encoded SubjectPublicKeyInfo from JWK 'n' and 'e'.
* Returns raw DER bytes; caller base64-encodes and wraps with PEM headers.
*/
private function convertJwkToPem(string $n, string $e): string
{
$modulus = $this->base64UrlDecode($n);
$exponent = $this->base64UrlDecode($e);
$derN = $this->derEncodeInteger($modulus);
$derE = $this->derEncodeInteger($exponent);
// RSAPublicKey (PKCS#1): SEQUENCE { n INTEGER, e INTEGER }
$rsaPublicKey = $this->derEncodeSequence($derN . $derE);
// AlgorithmIdentifier for rsaEncryption: 1.2.840.113549.1.1.1 with NULL
$algId = hex2bin('300d06092a864886f70d0101010500');
// SubjectPublicKey (SPKI) BIT STRING, 0 unused bits + RSAPublicKey
$subjectPublicKey = $this->derEncodeBitString($rsaPublicKey);
// SubjectPublicKeyInfo: SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }
return $this->derEncodeSequence($algId . $subjectPublicKey);
}
private function base64UrlDecode(string $data): string
{
$data = strtr($data, '-_', '+/');
$pad = strlen($data) % 4;
if ($pad) {
$data .= str_repeat('=', 4 - $pad);
}
return base64_decode($data);
}
private function derEncodeLength(int $len): string
{
if ($len < 0x80) {
return chr($len);
}
$bytes = '';
while ($len > 0) {
$bytes = chr($len & 0xFF) . $bytes;
$len >>= 8;
}
return chr(0x80 | strlen($bytes)) . $bytes;
}
private function derEncodeInteger(string $bytes): string
{
// Remove leading zeroes
$bytes = ltrim($bytes, "\x00");
if ($bytes === '') {
$bytes = "\x00";
}
// Ensure positive INTEGER (prepend 0x00 if MSB set)
if ((ord($bytes[0]) & 0x80) !== 0) {
$bytes = "\x00" . $bytes;
}
return "\x02" . $this->derEncodeLength(strlen($bytes)) . $bytes;
}
private function derEncodeSequence(string $bytes): string
{
return "\x30" . $this->derEncodeLength(strlen($bytes)) . $bytes;
}
private function derEncodeBitString(string $bytes): string
{
// 0 unused bits + data
$payload = "\x00" . $bytes;
return "\x03" . $this->derEncodeLength(strlen($payload)) . $payload;
}
}

View File

@@ -5,7 +5,7 @@
{% endblock %}
{% block body %}
<p>Dear {{ user.firstName }},</p>
<p>{{ user.first_name }},</p>
<p>We received a request to reset your password. Click the link below to set a new password:</p>

View File

@@ -14,4 +14,5 @@ namespace Siteworxpro\App\Models;
*/
class ClientScope extends Model
{
public $timestamps = false;
}

View File

@@ -9,4 +9,6 @@ enum AuditLogAction: string
case LOGIN_SUCCESS = 'login_success';
case LOGIN_FAIL = 'login_fail';
case LOGOUT = 'logout';
case TOKEN_ISSUED = 'token_issued';
}

View File

@@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use League\OAuth2\Server\AuthorizationServer;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Grant\ClientCredentialsGrant as ClientCredentialsGrant;
use Random\RandomException;
use Siteworxpro\App\Helpers\Rand;
use Siteworxpro\App\Models\ClientRedirectUri;
@@ -195,6 +196,9 @@ class Client extends Model implements ClientEntityInterface
Key::loadFromAsciiSafeString($this->encryption_key)
);
$accessTokenTtl = $this->capabilities->toArray()['tokenSettings']['accessTokenTTL'] ?? 'PT1H';
$refreshTokenTtl = $this->capabilities->toArray()['tokenSettings']['refreshTokenTTL'] ?? 'P1M';
if (!empty($this->grant_types)) {
foreach ($this->grant_types as $grantType) {
switch ($grantType) {
@@ -204,22 +208,22 @@ class Client extends Model implements ClientEntityInterface
new \Siteworxpro\App\OAuth\RefreshTokenRepository(),
new \DateInterval('PT10M')
);
$grant->setRefreshTokenTTL(new \DateInterval('P1M'));
$grant->setRefreshTokenTTL(new \DateInterval($refreshTokenTtl));
break;
case 'client_credentials':
$grant = new \League\OAuth2\Server\Grant\ClientCredentialsGrant();
$grant = new ClientCredentialsGrant();
break;
case 'refresh_token':
$grant = new \League\OAuth2\Server\Grant\RefreshTokenGrant(
new \Siteworxpro\App\OAuth\RefreshTokenRepository()
);
$grant->setRefreshTokenTTL(new \DateInterval('P1M'));
$grant->setRefreshTokenTTL(new \DateInterval($refreshTokenTtl));
break;
default:
continue 2;
}
$authorizationServer->enableGrantType($grant);
$authorizationServer->enableGrantType($grant, new \DateInterval($accessTokenTtl));
}
}
@@ -237,4 +241,32 @@ class Client extends Model implements ClientEntityInterface
return $user->verifyPassword($password) ? $user : null;
}
public function disableScope(Scope $scope): void
{
/** @var ClientScope | null $clientScope */
$clientScope = ClientScope::where('client_id', $this->id)
->where('scope_id', $scope->id)
->first();
$clientScope?->delete();
}
public function enableScope(Scope $scope): void
{
$clientScope = new ClientScope();
$clientScope->client_id = $this->id;
$clientScope->scope_id = $scope->id;
$clientScope->save();
}
// convert private key to public rsa pem key and return it
public function publicKey(): string
{
$res = openssl_pkey_get_private($this->private_key);
$details = openssl_pkey_get_details($res);
return $details['key'];
}
}

View File

@@ -22,6 +22,11 @@ class ClientCapabilities implements Arrayable
'logoUrl' => null,
];
private array $tokenSettings = [
'accessTokenTTL' => 'PT1H',
'refreshTokenTTL' => 'P1M',
];
public function __construct(array $capabilities = [])
{
if (isset($capabilities['userPass'])) {
@@ -47,6 +52,10 @@ class ClientCapabilities implements Arrayable
if (isset($capabilities['support_email'])) {
$this->support_email = (string)$capabilities['support_email'];
}
if (isset($capabilities['tokenSettings']) && is_array($capabilities['tokenSettings'])) {
$this->tokenSettings = array_merge($this->tokenSettings, $capabilities['tokenSettings']);
}
}
public static function fromJson(string $data): self
@@ -66,7 +75,8 @@ class ClientCapabilities implements Arrayable
'passkey' => "bool",
'socials' => "array",
'branding' => "array",
'support_email' => "string"
'support_email' => "string",
'tokenSettings' => "array",
])]
public function toArray(): array
{
@@ -77,6 +87,7 @@ class ClientCapabilities implements Arrayable
'socials' => $this->socials,
'branding' => $this->branding,
'support_email' => $this->support_email,
'tokenSettings' => $this->tokenSettings,
];
}

View File

@@ -20,6 +20,10 @@ class Scope extends Model implements ScopeEntityInterface
{
use ScopeTrait;
protected $casts = [
'id' => 'string',
];
public function getIdentifier(): string
{
return $this->name;