Basics of auth

This commit is contained in:
2026-01-01 10:32:17 -05:00
parent 23f2b6432b
commit 9f895bbb85
66 changed files with 5967 additions and 156 deletions

View File

@@ -9,6 +9,8 @@ use League\Route\Http\Exception\NotFoundException;
use League\Route\RouteGroup;
use League\Route\Router;
use Nyholm\Psr7\Factory\Psr17Factory;
use Siteworxpro\App\Controllers\AuthorizeController;
use Siteworxpro\App\Controllers\CapabilitiesController;
use Siteworxpro\App\Controllers\HealthcheckController;
use Siteworxpro\App\Controllers\IndexController;
use Siteworxpro\App\Controllers\OpenApiController;
@@ -71,8 +73,6 @@ class Api
);
$this->router = new Router();
$this->router->get('/', IndexController::class . '::get');
$this->router->post('/', IndexController::class . '::post');
$this->router->get('/healthz', HealthcheckController::class . '::get');
$this->router->group('/.well-known', function (RouteGroup $router) {
@@ -80,6 +80,12 @@ class Api
$router->get('/swagger.json', OpenApiController::class . '::get');
});
$this->router->group('/client', function (RouteGroup $group) {
$group->get('/capabilities', CapabilitiesController::class . '::get');
});
$this->router->get('/authorize', AuthorizeController::class . '::get');
$this->router->middleware(new CorsMiddleware());
$this->router->middleware(new JwtMiddleware());
$this->router->middleware(new ScopeMiddleware());

View File

@@ -5,12 +5,11 @@ declare(strict_types=1);
namespace Siteworxpro\App\Cli;
use Ahc\Cli\Application;
use Siteworxpro\App\Cli\Commands\DemoCommand;
use Siteworxpro\App\Cli\Commands\OAuth\AddRedirectUri;
use Siteworxpro\App\Cli\Commands\OAuth\CreateClient;
use Siteworxpro\App\Cli\Commands\Queue\Start;
use Siteworxpro\App\Cli\Commands\Queue\TestJob;
use Siteworxpro\App\Helpers\Version;
use Siteworxpro\App\Kernel;
use Siteworxpro\App\Services\Facades\Config;
class App
{
@@ -22,11 +21,11 @@ class App
public function __construct()
{
Kernel::boot();
$this->app = new Application('Php-Template', Version::VERSION);
$this->app = new Application('Php-Auth', Version::VERSION);
$this->app->add(new DemoCommand());
$this->app->add(new CreateClient());
$this->app->add(new AddRedirectUri());
$this->app->add(new Start());
$this->app->add(new TestJob());
}
public function run(): int

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands;
use Ahc\Cli\Application as App;
use League\CLImate\CLImate;
abstract class Command extends \Ahc\Cli\Input\Command
{
protected Climate $climate;
public function __construct(string $_name, string $_desc = '', bool $_allowUnknown = false, ?App $_app = null)
{
parent::__construct($_name, $_desc, $_allowUnknown, $_app);
$this->climate = new CLImate();
}
}

View File

@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands;
use Ahc\Cli\Input\Command;
use Siteworxpro\App\CommandBus\Commands\ExampleCommand;
use Siteworxpro\App\Services\Facades\CommandBus;
class DemoCommand extends Command implements CommandInterface
{
public function __construct()
{
parent::__construct('api:demo', 'A demo command to showcase the CLI functionality.');
$this->argument('[name]', 'Your name')
->option('-g, --greet', 'Include a greeting message');
}
public function execute(): int
{
$pb = $this->progress(100);
for ($i = 0; $i < 100; $i += 10) {
usleep(100000); // Simulate work
$pb->advance(10);
}
$pb->finish();
$this->writer()->boldBlue("Demo Command Executed!\n");
$name = $this->values()['name'];
$greet = $this->values()['greet'] ?? false;
if ($greet) {
$this->writer()->green("Hello, $name! Welcome to the CLI demo.\n");
} else {
$exampleCommand = new ExampleCommand($name);
$this->writer()->yellow(CommandBus::handle($exampleCommand));
}
return 0;
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\OAuth;
use League\CLImate\TerminalObject\Dynamic\Input;
use Siteworxpro\App\Models\ClientRedirectUri;
use Siteworxpro\App\OAuth\Entities\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
{
$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()
);
$id = $input->prompt();
$client = Client::find($id);
if (!$client) {
$this->climate->error('Client not found.');
return 1;
}
/** @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.');
$redirectUri = $uriInput->prompt();
$redirectUris = $client->clientRedirectUris;
if (in_array($redirectUri, $redirectUris->toArray(), true)) {
$this->climate->error('The redirect URI already exists for this client.');
return 1;
}
$clientRedirectUri = new ClientRedirectUri();
$clientRedirectUri->client_id = $client->id;
$clientRedirectUri->redirect_uri = $redirectUri;
$clientRedirectUri->save();
return 0;
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\OAuth;
use Ahc\Cli\IO\Interactor;
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;
class CreateClient extends \Siteworxpro\App\Cli\Commands\Command
{
public function __construct()
{
parent::__construct('oauth:client:create', 'Create a new OAuth client.');
}
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
);
$grants = explode(',', $clientGrantsString);
$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);
} catch (CommandHandlerException $exception) {
$this->climate->error($exception->getMessage());
return 1;
}
return 0;
}
}

View File

@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\Queue;
use Ahc\Cli\Input\Command;
use Siteworxpro\App\Async\Messages\SayHelloMessage;
use Siteworxpro\App\Cli\Commands\CommandInterface;
/**
* Class TestJob
*
* A CLI command to schedule a demo job that dispatches a SayHelloMessage.
*/
class TestJob extends Command implements CommandInterface
{
public function __construct()
{
parent::__construct('queue:demo', 'Schedule a demo job.');
}
/**
* Execute the command to dispatch a SayHelloMessage.
*
* @return int Exit code
*/
public function execute(): int
{
SayHelloMessage::dispatch('World from TestJob Command!');
return 0;
}
}

View File

@@ -17,11 +17,50 @@ class AttributeLocator implements HandlerLocator
public function __construct()
{
$directory = __DIR__ . '/Handlers';
$this->scanDir($directory);
}
public function getHandlerForCommand($commandName)
{
if (isset($this->handlers[$commandName])) {
$handlerClass = $this->handlers[$commandName];
return new $handlerClass();
}
throw new CanNotInvokeHandlerException("No handler found for command: " . $commandName);
}
/**
* @param string $directory
* @return void
*/
public function scanDir(string $directory): void
{
$files = scandir($directory);
foreach ($files as $file) {
if ($file === '.' || $file === '..') {
continue;
}
$fullPath = $directory . DIRECTORY_SEPARATOR . $file;
if (is_dir($fullPath)) {
$this->scanDir($fullPath);
continue;
}
if (pathinfo($file, PATHINFO_EXTENSION) === 'php') {
$className = pathinfo($file, PATHINFO_FILENAME);
$fullClassName = self::HANDLER_NAMESPACE . $className;
$relativePath = str_replace(__DIR__ . '/Handlers/', '', $fullPath);
$namespacePath = str_replace(DIRECTORY_SEPARATOR, '\\', dirname($relativePath));
if ($namespacePath === '.') {
$namespacePath = '';
} else {
$namespacePath .= '\\';
}
$fullClassName = self::HANDLER_NAMESPACE . $namespacePath . $className;
if (class_exists($fullClassName)) {
$reflectionClass = new \ReflectionClass($fullClassName);
@@ -36,14 +75,4 @@ class AttributeLocator implements HandlerLocator
}
}
}
public function getHandlerForCommand($commandName)
{
if (isset($this->handlers[$commandName])) {
$handlerClass = $this->handlers[$commandName];
return new $handlerClass();
}
throw new CanNotInvokeHandlerException("No handler found for command: " . $commandName);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Siteworxpro\App\CommandBus\Commands;
readonly class CreateClient extends Command
{
private const array VALID_GRANTS = [
'authorization_code',
'password',
'client_credentials',
'refresh_token',
'implicit',
];
public function __construct(
private string $clientName,
private array $clientGrants = [],
private string $clientDescription = ''
) {
foreach ($this->clientGrants as $grant) {
if (!in_array($grant, self::VALID_GRANTS, true)) {
throw new \InvalidArgumentException("Invalid grant type: $grant");
}
}
}
/**
* @return string
*/
public function getClientName(): string
{
return $this->clientName;
}
/**
* @return string
*/
public function getClientDescription(): string
{
return $this->clientDescription;
}
/**
* @return array
*/
public function getClientGrants(): array
{
return $this->clientGrants;
}
}

View File

@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Commands;
readonly class ExampleCommand extends Command
{
public function __construct(
private string $name
) {
}
public function getName(): string
{
return $this->name;
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Exceptions;
class CommandHandlerException extends \InvalidArgumentException
{
}

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Handlers;
use Siteworxpro\App\Attributes\CommandBus\HandlesCommand;
use Siteworxpro\App\CommandBus\Commands\Command;
use Siteworxpro\App\CommandBus\Commands\ExampleCommand;
use Siteworxpro\App\Services\Facades\Logger;
#[HandlesCommand(ExampleCommand::class)]
class ExampleHandler extends CommandHandler
{
/**
* @param Command|ExampleCommand $command
* @return string
*/
public function __invoke(Command|ExampleCommand $command): string
{
if (!method_exists($command, 'getName')) {
throw new \TypeError('Invalid command type provided to ExampleHandler.');
}
$name = $command->getName();
Logger::info('Handling ExampleCommand for name: ' . $name);
return 'Hello, ' . $name . '!';
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Handlers\OAuth;
use Siteworxpro\App\Attributes\CommandBus\HandlesCommand;
use Siteworxpro\App\CommandBus\Commands\Command;
use Siteworxpro\App\CommandBus\Exceptions\CommandHandlerException;
use Siteworxpro\App\CommandBus\Handlers\CommandHandler;
use Siteworxpro\App\OAuth\Entities\Client;
#[HandlesCommand(\Siteworxpro\App\CommandBus\Commands\CreateClient::class)]
class CreateClient extends CommandHandler
{
public function __invoke(Command $command): Client
{
if (!$command instanceof \Siteworxpro\App\CommandBus\Commands\CreateClient) {
throw new CommandHandlerException('Invalid command type');
}
$client = new Client();
$client->name = $command->getClientName();
$client->description = $command->getClientDescription();
$client->grant_types = $command->getClientGrants();
$client->save();
return $client;
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use HansOtt\PSR7Cookies\SetCookie;
use League\OAuth2\Server\Exception\OAuthServerException;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
use Nyholm\Psr7\Stream;
use Psr\SimpleCache\InvalidArgumentException;
use Siteworxpro\App\Helpers\Rand;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Responses\ServerErrorResponse;
use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\App\Services\Facades\Logger;
use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\HttpStatus\CodesEnum;
final class AuthorizeController extends Controller
{
/**
* @throws InvalidArgumentException
*/
// #[\Override] public function post(ServerRequest $request): Response
// {
// $s = $request->getCookieParams()['s'] ?? '';
//
// $password = $request->getParsedBody()['password'] ?? '';
// $email = $request->getParsedBody()['email'] ?? '';
//
// if (!$this->redis->get('session:' . $s)) {
// $this->log->error('Session Timed out', ['session' => $s]);
//
// return $this->sendJsonResponse(
// [
// 'error' => "your login session has timed out. please try again."
// ],
// 400
// );
// }
//
// /** @var AuthorizationRequest $authRequest */
// $authRequest = unserialize($this->redis->get('session:' . $s));
//
// if ($authRequest->isAuthorizationApproved()) {
// $response = $this
// ->authorizationServer
// ->completeAuthorizationRequest($authRequest, $this->sendJsonResponse());
//
// return $this->sendJsonResponse(
// [
// 'success' => true,
// 'location' => $response->getHeader('Location')[0]
// ]
// );
// }
//
// /** @var Client $client */
// $client = $authRequest->getClient();
//
// /** @var LoginInterface $entitiesModel */
// $entitiesModel = $client->entities_model;
//
// /** @var User | null $entity */
// $entity = $entitiesModel::performLogin($email, $password);
// if (!$entity) {
// return $this->sendJsonResponse(
// [
// 'success' => false,
// 'reason' => 'login failed'
// ],
// 401
// );
// }
//
// $authRequest->setUser($entity);
// $authRequest->setAuthorizationApproved(true);
// $response = $this
// ->authorizationServer
// ->completeAuthorizationRequest($authRequest, $this->sendJsonResponse());
//
// $this->redis->delete('session:' . $s);
//
// return $this->sendJsonResponse(
// [
// 'success' => true,
// 'location' => $response->getHeader('Location')[0]
// ]
// );
// }
/**
* @throws \Exception
*/
public function get(ServerRequest $request): Response
{
try {
if (!file_exists('public/index.html')) {
throw new \RuntimeException('Frontend not built. Please run `npm run build`.');
}
$contents = file_get_contents('public/index.html');
if ($request->getQueryParams()['e']) {
return new Response(
200,
['content-type' => 'text/html'],
Stream::create($contents)
);
}
if (
isset($request->getCookieParams()['s']) &&
Redis::exists('session:' . $request->getCookieParams()['s'] ?? '')
) {
$s = $request->getCookieParams()['s'];
} else {
$s = Rand::string();
}
$clientId = $request->getQueryParams()['client_id'] ?? '';
Logger::info('Authorization request', ['client_id' => $clientId]);
$client = Client::byClientId($clientId);
if ($client === null) {
Logger::warning('Invalid client in authorization request', ['client_id' => $clientId]);
throw OAuthServerException::invalidClient($request);
}
$authRequest = $client->getAuthorizationServer()->validateAuthorizationRequest($request);
Redis::set('session:' . $s, serialize($authRequest), 'EX', 60 * 60 * 24);
$response = new Response(
200,
['content-type' => 'text/html'],
Stream::create($contents)
);
$cookie = new SetCookie('s', $s, time() + 3600, '/', secure: true);
/** @var Response $response */
$response = $cookie->addToResponse($response);
return $response;
} catch (OAuthServerException $e) {
return new Response(
CodesEnum::TEMPORARY_REDIRECT->value,
[
'Location' => sprintf(
'/authorize?e=%s&client_id=%s&response_type=%s&redirect_uri=%s#/error',
$e->getMessage(),
$request->getQueryParams()['client_id'] ?? '',
$request->getQueryParams()['response_type'] ?? '',
$request->getQueryParams()['redirect_uri'] ?? ''
)
]
);
} catch (\Exception $e) {
Logger::error($e->getMessage(), ['exception' => $e]);
return JsonResponseFactory::createJsonResponse(new ServerErrorResponse($e));
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Responses\NotFoundResponse;
use Siteworxpro\App\OAuth\Entities\Client;
final class CapabilitiesController extends Controller
{
/**
* @throws \JsonException
*/
public function get(ServerRequest $request): ResponseInterface
{
$clientId = $request->getQueryParams()['client_id'] ?? '0';
$client = Client::byClientId($clientId);
if (!$client) {
return JsonResponseFactory::createJsonResponse(new NotFoundResponse($request->getUri()->getPath()));
}
return JsonResponseFactory::createJsonResponse($client->capabilities->toArray());
}
}

View File

@@ -23,7 +23,7 @@ use OpenApi\Attributes as OA;
*
* @package Siteworxpro\App\Controllers
*/
class HealthcheckController extends Controller
final class HealthcheckController extends Controller
{
/**
* Handles the GET request for health check.

View File

@@ -20,7 +20,7 @@ use Siteworxpro\App\Services\Facades\CommandBus;
*
* This class handles the index route of the application.
*/
class IndexController extends Controller
final class IndexController extends Controller
{
/**
* Handles the GET request for the index route.

View File

@@ -9,7 +9,7 @@ use Nyholm\Psr7\ServerRequest;
use OpenApi\Generator;
use Psr\Http\Message\ResponseInterface;
class OpenApiController extends Controller
final class OpenApiController extends Controller
{
/**
* Handles the GET request to generate and return the OpenAPI specification.

View File

@@ -28,8 +28,6 @@ class Connected extends Listener
throw new \TypeError("Invalid event type passed to listener " . static::class);
}
Logger::info("Database connection event", [get_class($event), $event->connectionName]);
return null;
}
}

24
src/Helpers/Rand.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Helpers;
use Random\RandomException;
class Rand
{
/**
* @throws RandomException
*/
public static function string(int $length = 16): string
{
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$charactersLength = strlen($characters);
$randomString = '';
for ($i = 0; $i < $length; $i++) {
$randomString .= $characters[random_int(0, $charactersLength - 1)];
}
return $randomString;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Siteworxpro\App\OAuth\Entities\Client;
/**
* Class ClientRedirectUrl
* @package Siteworxpro\App\Models
*
* @property string $id
* @property string $client_id
* @property string $redirect_uri
*/
class ClientRedirectUri extends Model
{
public $timestamps = false;
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Models;
/**
* Class ClientScope
* @package Siteworxpro\App\Models
*
* @property string $id
* @property string $client_id
* @property string $scope_id
*/
class ClientScope extends Model
{
}

18
src/Models/ClientUser.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Models;
/**
* Class ClientUser
* @package Siteworxpro\App\Models
*
* @property string $id
* @property string $client_id
* @property string $user_id
*/
class ClientUser extends Model
{
}

View File

@@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\Model as ORM;
* Class Model
*
* @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')
*/
abstract class Model extends ORM
{

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Siteworxpro\App\Models;
use Carbon\Carbon;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use OpenApi\Attributes as OA;
use Siteworxpro\App\Helpers\Ulid;
@@ -40,6 +41,8 @@ use Siteworxpro\App\Helpers\Ulid;
)]
class User extends Model
{
use EntityTrait;
protected $casts = [
'created_at' => 'datetime',
];

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface;
use Siteworxpro\App\OAuth\Entities\AccessToken;
use Siteworxpro\App\OAuth\Entities\Client;
class AccessTokenRepository implements AccessTokenRepositoryInterface
{
public function getNewToken(
ClientEntityInterface | Client $clientEntity,
array $scopes,
?string $userIdentifier = null
): AccessTokenEntityInterface | AccessToken {
$accessToken = new AccessToken();
$accessToken->setClient($clientEntity);
foreach ($scopes as $scope) {
$accessToken->addScope($scope);
}
$accessToken->setUserIdentifier($userIdentifier);
return $accessToken;
}
public function persistNewAccessToken(AccessTokenEntityInterface | AccessToken $accessTokenEntity): void
{
$accessTokenEntity->save();
}
public function revokeAccessToken(string $tokenId): void
{
$accessToken = AccessToken::find($tokenId);
if ($accessToken) {
$accessToken->delete();
}
}
public function isAccessTokenRevoked(string $tokenId): bool
{
$accessToken = AccessToken::find($tokenId);
return $accessToken === null;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
class AuthCodeRepository implements AuthCodeRepositoryInterface
{
public function getNewAuthCode(): AuthCodeEntityInterface
{
// TODO: Implement getNewAuthCode() method.
}
public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity): void
{
// TODO: Implement persistNewAuthCode() method.
}
public function revokeAuthCode(string $codeId): void
{
// TODO: Implement revokeAuthCode() method.
}
public function isAuthCodeRevoked(string $codeId): bool
{
// TODO: Implement isAuthCodeRevoked() method.
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
use Siteworxpro\App\OAuth\Entities\Client;
readonly class ClientRepository implements ClientRepositoryInterface
{
public function __construct(private Client $client)
{
}
/**
* get a client entity.
*
* @param string $clientIdentifier
* @return ClientEntityInterface|null
*/
public function getClientEntity(string $clientIdentifier): ?ClientEntityInterface
{
if ($this->client->client_id === $clientIdentifier) {
return $this->client;
}
return null;
}
/**
* validate a client with given data.
*
* @param string $clientIdentifier
* @param string|null $clientSecret
* @param string|null $grantType
* @return bool
*/
public function validateClient(string $clientIdentifier, ?string $clientSecret, ?string $grantType): bool
{
$client = Client::find($clientIdentifier);
if ($client === null) {
return false;
}
if ($clientSecret && $client->client_secret != $clientSecret) {
return false;
}
if ($grantType && !in_array($grantType, $client->grant_types)) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use DateTimeImmutable;
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;
class AccessToken extends RedisModel implements AccessTokenEntityInterface
{
use AccessTokenTrait;
public function getClient(): ClientEntityInterface
{
// TODO: Implement getClient() method.
}
public function getExpiryDateTime(): DateTimeImmutable
{
// TODO: Implement getExpiryDateTime() method.
}
public function getUserIdentifier(): string|null
{
// TODO: Implement getUserIdentifier() method.
}
public function getScopes(): array
{
// TODO: Implement getScopes() method.
}
protected static function getRedisPrefix(): string
{
return 'oauth_access_token';
}
public function setExpiryDateTime(DateTimeImmutable $dateTime): void
{
// TODO: Implement setExpiryDateTime() method.
}
public function setUserIdentifier(string $identifier): void
{
// TODO: Implement setUserIdentifier() method.
}
public function setClient(ClientEntityInterface $client): void
{
// TODO: Implement setClient() method.
}
public function addScope(ScopeEntityInterface $scope): void
{
// TODO: Implement addScope() method.
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use DateTimeImmutable;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Entities\Traits\AuthCodeTrait;
class AuthorizationCode extends RedisModel implements AuthCodeEntityInterface
{
use AuthCodeTrait;
protected static function getRedisPrefix(): string
{
return 'oauth_auth_code';
}
public function getExpiryDateTime(): DateTimeImmutable
{
// TODO: Implement getExpiryDateTime() method.
}
public function setExpiryDateTime(DateTimeImmutable $dateTime): void
{
// TODO: Implement setExpiryDateTime() method.
}
public function setUserIdentifier(string $identifier): void
{
// TODO: Implement setUserIdentifier() method.
}
public function getUserIdentifier(): string|null
{
// TODO: Implement getUserIdentifier() method.
}
public function getClient(): ClientEntityInterface
{
// TODO: Implement getClient() method.
}
public function setClient(ClientEntityInterface $client): void
{
// TODO: Implement setClient() method.
}
public function addScope(ScopeEntityInterface $scope): void
{
// TODO: Implement addScope() method.
}
public function getScopes(): array
{
// TODO: Implement getScopes() method.
}
}

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use Defuse\Crypto\Exception\BadFormatException;
use Defuse\Crypto\Exception\EnvironmentIsBrokenException;
use Defuse\Crypto\Key;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\HasMany;
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 Random\RandomException;
use Siteworxpro\App\Helpers\Rand;
use Siteworxpro\App\Models\ClientRedirectUri;
use Siteworxpro\App\Models\ClientScope;
use Siteworxpro\App\Models\ClientUser;
use Siteworxpro\App\Models\Model;
use Siteworxpro\App\Models\User;
use Siteworxpro\App\OAuth\AccessTokenRepository;
use Siteworxpro\App\OAuth\ClientRepository;
use Siteworxpro\App\OAuth\ScopeRepository;
/**
* Class Client
* @package Siteworxpro\App\Models
*
* @property string $id
* @property string $client_id
* @property string $client_secret
* @property string $name
* @property string $description
* @property string $private_key
* @property string $encryption_key
* @property string[] $grant_types
* @property bool $confidential
*
* @property-read ClientCapabilities $capabilities
* @property-read Collection<ClientRedirectUri> $clientRedirectUris
* @property-read Scope[]|Collection $scopes
*/
class Client extends Model implements ClientEntityInterface
{
use EntityTrait;
protected $casts = [
'id' => 'string',
'grant_types' => 'collection',
'confidential' => 'boolean',
];
/**
* @throws RandomException|EnvironmentIsBrokenException
*/
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->client_id = Rand::string(32);
$this->client_secret = Rand::string(64);
$this->generatePrivateKey();
}
public static function byClientId(string $clientId): ?Client
{
return self::where('client_id', $clientId)->first();
}
/**
* @return void
* @throws EnvironmentIsBrokenException
*/
private function generatePrivateKey(): void
{
// generate rsa private and public key pair
$config = [
"digest_alg" => "sha256",
"private_key_bits" => 4096,
"private_key_type" => OPENSSL_KEYTYPE_RSA,
];
$res = openssl_pkey_new($config);
openssl_pkey_export($res, $privateKey);
$this->private_key = $privateKey;
$this->encryption_key = Key::createNewRandomKey()->saveToAsciiSafeString();
}
/**
* @return HasMany
*/
public function clientRedirectUris(): HasMany
{
return $this->hasMany(ClientRedirectUri::class);
}
/**
* @return HasManyThrough
*/
public function scopes(): HasManyThrough
{
return $this->hasManyThrough(Scope::class, ClientScope::class);
}
/**
* @return HasManyThrough
*/
public function users(): HasManyThrough
{
return $this->hasManyThrough(User::class, ClientUser::class);
}
/**
* @return string
*/
public function getIdentifier(): string
{
return $this->id;
}
/**
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* @return string|array
*/
public function getRedirectUri(): string|array
{
return $this->clientRedirectUris->pluck('redirect_uri')->toArray();
}
/**
* @return bool
*/
public function isConfidential(): bool
{
return $this->confidential;
}
public function getCapabilitiesAttribute(string $capabilities): ClientCapabilities
{
return ClientCapabilities::fromJson($capabilities);
}
/**
* @throws \JsonException
*/
public function setCapabilitiesAttribute(ClientCapabilities $capabilities): void
{
$this->attributes->capabilities = $capabilities->toJson();
}
/**
* @throws BadFormatException
* @throws EnvironmentIsBrokenException
* @throws \Exception
*/
public function getAuthorizationServer(): AuthorizationServer
{
$authorizationServer = new AuthorizationServer(
new ClientRepository($this),
new AccessTokenRepository(),
new ScopeRepository(),
$this->private_key,
Key::loadFromAsciiSafeString($this->encryption_key)
);
if (!empty($this->grant_types)) {
foreach ($this->grant_types as $grantType) {
switch ($grantType) {
case 'authorization_code':
$grant = new \League\OAuth2\Server\Grant\AuthCodeGrant(
new \Siteworxpro\App\OAuth\AuthCodeRepository(),
new \Siteworxpro\App\OAuth\RefreshTokenRepository(),
new \DateInterval('PT10M')
);
$grant->setRefreshTokenTTL(new \DateInterval('P1M'));
break;
case 'client_credentials':
$grant = new \League\OAuth2\Server\Grant\ClientCredentialsGrant();
break;
case 'refresh_token':
$grant = new \League\OAuth2\Server\Grant\RefreshTokenGrant(
new \Siteworxpro\App\OAuth\RefreshTokenRepository()
);
$grant->setRefreshTokenTTL(new \DateInterval('P1M'));
break;
default:
continue 2;
}
$authorizationServer->enableGrantType($grant);
}
}
return $authorizationServer;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use Illuminate\Contracts\Support\Arrayable;
class ClientCapabilities implements Arrayable
{
private bool $userPass = false;
private bool $magicLink = false;
private bool $passkey = false;
private array $socials = [];
private array $theme = [
'primaryColor' => '#000000',
'secondaryColor' => '#FFFFFF',
'logoUrl' => null,
];
public function __construct(array $capabilities)
{
if (isset($capabilities['userPass'])) {
$this->userPass = (bool)$capabilities['userPass'];
}
if (isset($capabilities['magicLink'])) {
$this->magicLink = (bool)$capabilities['magicLink'];
}
if (isset($capabilities['passkey'])) {
$this->passkey = (bool)$capabilities['passkey'];
}
if (isset($capabilities['socials']) && is_array($capabilities['socials'])) {
$this->socials = $capabilities['socials'];
}
if (isset($capabilities['theme']) && is_array($capabilities['theme'])) {
$this->theme = array_merge($this->theme, $capabilities['theme']);
}
}
public static function fromJson(string $data): self
{
try {
$arrayData = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
return new self($arrayData);
} catch (\JsonException $e) {
return new self([]);
}
}
public function toArray(): array
{
return [
'userPass' => $this->userPass,
'magicLink' => $this->magicLink,
'passkey' => $this->passkey,
'socials' => $this->socials,
'theme' => $this->theme,
];
}
/**
* @throws \JsonException
*/
public function toJson(): string
{
return json_encode($this->toArray(), JSON_THROW_ON_ERROR);
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use Carbon\Carbon;
use DateTimeImmutable;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use Psr\SimpleCache\InvalidArgumentException;
use Siteworxpro\App\Services\Facades\Redis;
abstract class RedisModel
{
use EntityTrait;
private \Predis\Client $redis;
protected ?DateTimeImmutable $expireTime;
public function __construct()
{
$this->redis = Redis::getFacadeRoot();
}
abstract protected static function getRedisPrefix(): string;
public static function find(string $identifier): ?self
{
$instance = Redis::get(static::getRedisPrefix() . ':' . $identifier);
if ($instance !== null) {
return unserialize($instance);
}
return null;
}
public function save(): void
{
$diff = 0;
if ($this->expireTime) {
$diff = $this->expireTime->getTimestamp() - Carbon::now()->timestamp;
}
$this->redis->set(
static::getRedisPrefix() . ':' . $this->getIdentifier(),
serialize($this),
$diff
);
}
/**
* @throws InvalidArgumentException
*/
public function delete(): void
{
$this->redis->delete(static::getRedisPrefix() . ':' . $this->getIdentifier());
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait;
class RefreshToken extends RedisModel implements RefreshTokenEntityInterface
{
use RefreshTokenTrait;
protected static function getRedisPrefix(): string
{
return 'oauth_refresh_token';
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Entities\Traits\ScopeTrait;
use Siteworxpro\App\Models\Model;
/**
* Class Scope
* @package Siteworxpro\App\Models
*
* @property string $id
* @property string $name
* @property string $description
*/
class Scope extends Model implements ScopeEntityInterface
{
use ScopeTrait;
public function getIdentifier(): string
{
return $this->name;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
class RefreshTokenRepository implements RefreshTokenRepositoryInterface
{
public function getNewRefreshToken(): ?RefreshTokenEntityInterface
{
// TODO: Implement getNewRefreshToken() method.
}
public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void
{
// TODO: Implement persistNewRefreshToken() method.
}
public function revokeRefreshToken(string $tokenId): void
{
// TODO: Implement revokeRefreshToken() method.
}
public function isRefreshTokenRevoked(string $tokenId): bool
{
// TODO: Implement isRefreshTokenRevoked() method.
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\OAuth;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
use Siteworxpro\App\OAuth\Entities\Scope;
class ScopeRepository implements ScopeRepositoryInterface
{
public function getScopeEntityByIdentifier(string $identifier): ?ScopeEntityInterface
{
return Scope::where('name', $identifier)->first();
}
public function finalizeScopes(
array $scopes,
string $grantType,
ClientEntityInterface $clientEntity,
?string $userIdentifier = null,
?string $authCodeId = null
): array {
return $scopes;
}
}

View File

@@ -18,6 +18,7 @@ use Siteworxpro\App\Services\Facade;
* @method static Status|null set(string $key, $value, $expireResolution = null, $expireTTL = null, $flag = null)
* @method static array keys(string $pattern)
* @method static int del(string $key)
* @method static bool exists(string $key)
* @method static Status ping()
*/
class Redis extends Facade