Basics of auth
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m31s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m24s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m57s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m14s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Failing after 2m58s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Failing after 1m24s

This commit is contained in:
2026-01-01 15:38:19 -05:00
parent 9f895bbb85
commit d0cee7b48f
35 changed files with 664 additions and 202 deletions

16
.dev/.env Normal file
View File

@@ -0,0 +1,16 @@
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
QUEUE_BROKER: redis
PHP_IDE_CONFIG: serverName=localhost
WORKERS: 1
GRPC_WORKERS: 1
DEBUG: 1
REDIS_HOST: redis
DB_HOST: postgres
DEV_MODE: 1
APP_ENCRYPTION_KEY: base64:40U+IWaPTpp5o23quMfxcZJ0lOzkNy07SQ1rH6AV13o=
DB_USERNAME: siteworxpro
DB_PASSWORD: password
DB_DATABASE: siteworxpro
DB_PORT: 5432

View File

@@ -73,12 +73,8 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
environment: env_file:
DB_USERNAME: ${DB_USERNAME:-siteworxpro} - .env
DB_PASSWORD: ${DB_PASSWORD:-password}
DB_DATABASE: ${DB_DATABASE:-siteworxpro}
DB_HOST: ${DB_HOST-postgres}
DB_PORT: ${DB_PORT-5432}
dev-runtime: dev-runtime:
labels: labels:
@@ -116,18 +112,8 @@ services:
condition: service_healthy condition: service_healthy
postgres: postgres:
condition: service_healthy condition: service_healthy
environment: env_file:
JWT_ISSUER: https://auth.siteworxpro.com/application/o/postman/ - .env
JWT_AUDIENCE: 1RWyqJFlyA4hmsDzq6kSxs0LXvk7UgEAfgmBCpQ9
JWT_SIGNING_KEY: https://auth.siteworxpro.com/application/o/postman/.well-known/openid-configuration
QUEUE_BROKER: redis
PHP_IDE_CONFIG: serverName=localhost
WORKERS: 1
GRPC_WORKERS: 1
DEBUG: 1
REDIS_HOST: redis
DB_HOST: postgres
DEV_MODE: 1
## Kafka and Zookeeper for local development ## Kafka and Zookeeper for local development
kafka-ui: kafka-ui:

View File

@@ -7,6 +7,8 @@ return [
'app' => [ 'app' => [
'log_level' => Env::get('LOG_LEVEL', 'debug'), 'log_level' => Env::get('LOG_LEVEL', 'debug'),
'dev_mode' => Env::get('DEV_MODE', false, 'bool'), 'dev_mode' => Env::get('DEV_MODE', false, 'bool'),
'url' => Env::get('APP_URL', 'https://localhost'),
'encryption_key' => Env::get('APP_ENCRYPTION_KEY', 'base64:change_me'),
], ],
/** /**

View File

@@ -1,41 +1,41 @@
create table clients create table clients
( (
id uuid default gen_random_uuid() id VARCHAR(26) not null
constraint client_pk constraint client_pk
primary key, primary key,
client_id varchar not null client_id varchar not null
constraint client_client_id_key constraint client_client_id_key
unique, unique,
client_secret varchar not null, client_secret varchar not null,
name varchar not null, name varchar not null,
description varchar default '', description varchar default '',
private_key text not null, private_key text not null,
encryption_key text not null, encryption_key text not null,
grant_types jsonb not null default '[]'::jsonb, grant_types jsonb not null default '[]'::jsonb,
capabilities jsonb not null default '[]'::jsonb, capabilities jsonb not null default '[]'::jsonb,
confidential boolean not null default true, confidential boolean not null default true,
created_at timestamp default now(), created_at timestamp default now(),
updated_at timestamp default now() updated_at timestamp default now()
); );
create table client_redirect_uris create table client_redirect_uris
( (
id uuid default gen_random_uuid() id VARCHAR(26) not null
constraint client_redirect_uris_pk constraint client_redirect_uris_pk
primary key, primary key,
client_id uuid not null client_id VARCHAR(26) not null
constraint client_redirect_uris_client_id_fk constraint client_redirect_uris_client_id_fk
references clients references clients
on delete cascade, on delete cascade,
redirect_uri varchar not null redirect_uri varchar not null
); );
create table scopes create table scopes
( (
id uuid default gen_random_uuid() id VARCHAR(26) not null
constraint scopes_pk constraint scopes_pk
primary key, primary key,
name varchar not null name varchar not null
constraint scopes_name_key constraint scopes_name_key
unique, unique,
description varchar description varchar
@@ -43,14 +43,14 @@ create table scopes
create table client_scopes create table client_scopes
( (
id uuid default gen_random_uuid() id VARCHAR(26) not null
constraint client_scopes_pk constraint client_scopes_pk
primary key, primary key,
client_id uuid not null client_id VARCHAR(26) not null
constraint client_scopes_client_id_fk constraint client_scopes_client_id_fk
references clients references clients
on delete cascade, on delete cascade,
scope_id uuid not null scope_id VARCHAR(26) not null
constraint client_scopes_scope_id_fk constraint client_scopes_scope_id_fk
references scopes references scopes
on delete cascade, on delete cascade,
@@ -60,29 +60,29 @@ create table client_scopes
create table users create table users
( (
id uuid default gen_random_uuid() id VARCHAR(26) not null
constraint users_pk constraint users_pk
primary key, primary key,
first_name varchar not null, first_name varchar not null,
last_name varchar not null, last_name varchar not null,
email varchar not null email varchar not null
constraint users_email_key constraint users_email_key
unique, unique,
password varchar not null, password varchar not null,
created_at timestamp default now(), created_at timestamp default now(),
updated_at timestamp default now() updated_at timestamp default now()
); );
create table client_users create table client_users
( (
id uuid default gen_random_uuid() id VARCHAR(26) not null
constraint client_users_pk constraint client_users_pk
primary key, primary key,
client_id uuid not null client_id VARCHAR(26) not null
constraint client_users_client_id_fk constraint client_users_client_id_fk
references clients references clients
on delete cascade, on delete cascade,
user_id uuid not null user_id VARCHAR(26) not null
constraint client_users_user_id_fk constraint client_users_user_id_fk
references users references users
on delete cascade, on delete cascade,

View File

@@ -4,9 +4,7 @@
<template #header> <template #header>
<div class="flex flex-col items-center justify-center"> <div class="flex flex-col items-center justify-center">
<div> <div>
<Image width="300"
src="https://i.careeruprising.com/_Pa5TnsUJ5v-EHQQZy3BHnbaiCjMGxusd7qNcvhd8jA/pr:sm/sm:1/enc/Ec8S-CxpyLc2M5XdibEf85vGU5KNfdR0Dx8Qf6DI2nbZG85hSSFtDV7TuynR5djSw5jhdTIyjd5xDX5z-Dgemw"
/>
</div> </div>
<div class="text-2xl mt-5"> <div class="text-2xl mt-5">
<span v-if="capabilities.client_name !== ''">{{ capabilities.client_name }}</span> <span v-if="capabilities.client_name !== ''">{{ capabilities.client_name }}</span>
@@ -270,7 +268,7 @@ export default defineComponent({
this.loading = true this.loading = true
axios.post('/login', this.form).then(r => { axios.post('/authorize', this.form).then(r => {
window.location.href = r.data.location window.location.href = r.data.location
}).catch(() => { }).catch(() => {
this.$toast.add({ this.$toast.add({

View File

@@ -9,11 +9,13 @@ use League\Route\Http\Exception\NotFoundException;
use League\Route\RouteGroup; use League\Route\RouteGroup;
use League\Route\Router; use League\Route\Router;
use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Factory\Psr17Factory;
use Siteworxpro\App\Controllers\AccessTokenController;
use Siteworxpro\App\Controllers\AuthorizeController; use Siteworxpro\App\Controllers\AuthorizeController;
use Siteworxpro\App\Controllers\CapabilitiesController; use Siteworxpro\App\Controllers\CapabilitiesController;
use Siteworxpro\App\Controllers\HealthcheckController; use Siteworxpro\App\Controllers\HealthcheckController;
use Siteworxpro\App\Controllers\IndexController; use Siteworxpro\App\Controllers\IndexController;
use Siteworxpro\App\Controllers\OpenApiController; use Siteworxpro\App\Controllers\OpenApiController;
use Siteworxpro\App\Controllers\OpenIdController;
use Siteworxpro\App\Http\JsonResponseFactory; use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Middleware\CorsMiddleware; use Siteworxpro\App\Http\Middleware\CorsMiddleware;
use Siteworxpro\App\Http\Middleware\JwtMiddleware; use Siteworxpro\App\Http\Middleware\JwtMiddleware;
@@ -22,6 +24,7 @@ use Siteworxpro\App\Http\Responses\NotFoundResponse;
use Siteworxpro\App\Http\Responses\ServerErrorResponse; use Siteworxpro\App\Http\Responses\ServerErrorResponse;
use Siteworxpro\App\Services\Facades\Config; use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Logger; use Siteworxpro\App\Services\Facades\Logger;
use Siteworxpro\HttpStatus\CodesEnum;
use Spiral\RoadRunner\Http\PSR7Worker; use Spiral\RoadRunner\Http\PSR7Worker;
use Spiral\RoadRunner\Worker; use Spiral\RoadRunner\Worker;
@@ -84,7 +87,14 @@ class Api
$group->get('/capabilities', CapabilitiesController::class . '::get'); $group->get('/capabilities', CapabilitiesController::class . '::get');
}); });
// Authorize URL
$this->router->get('/authorize', AuthorizeController::class . '::get'); $this->router->get('/authorize', AuthorizeController::class . '::get');
$this->router->post('/authorize', AuthorizeController::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()); $this->router->middleware(new CorsMiddleware());
$this->router->middleware(new JwtMiddleware()); $this->router->middleware(new JwtMiddleware());
@@ -125,14 +135,17 @@ class Api
} }
$this->worker->respond( $this->worker->respond(
JsonResponseFactory::createJsonResponse(new NotFoundResponse($uri)) JsonResponseFactory::createJsonResponse(new NotFoundResponse($uri), CodesEnum::NOT_FOUND)
); );
} catch (\Throwable $e) { } catch (\Throwable $e) {
Logger::error($e->getMessage()); Logger::error($e->getMessage());
Logger::error($e->getTraceAsString()); Logger::error($e->getTraceAsString());
$this->worker->respond( $this->worker->respond(
JsonResponseFactory::createJsonResponse(new ServerErrorResponse($e)) JsonResponseFactory::createJsonResponse(
new ServerErrorResponse($e),
CodesEnum::INTERNAL_SERVER_ERROR
)
); );
} }
} }

View File

@@ -5,9 +5,11 @@ declare(strict_types=1);
namespace Siteworxpro\App\Cli; namespace Siteworxpro\App\Cli;
use Ahc\Cli\Application; use Ahc\Cli\Application;
use Siteworxpro\App\Cli\Commands\Crypt\GenerateKey;
use Siteworxpro\App\Cli\Commands\OAuth\AddRedirectUri; use Siteworxpro\App\Cli\Commands\OAuth\AddRedirectUri;
use Siteworxpro\App\Cli\Commands\OAuth\CreateClient; use Siteworxpro\App\Cli\Commands\OAuth\CreateClient;
use Siteworxpro\App\Cli\Commands\Queue\Start; use Siteworxpro\App\Cli\Commands\Queue\Start;
use Siteworxpro\App\Cli\Commands\User\Add;
use Siteworxpro\App\Helpers\Version; use Siteworxpro\App\Helpers\Version;
use Siteworxpro\App\Kernel; use Siteworxpro\App\Kernel;
@@ -25,7 +27,9 @@ class App
$this->app->add(new CreateClient()); $this->app->add(new CreateClient());
$this->app->add(new AddRedirectUri()); $this->app->add(new AddRedirectUri());
$this->app->add(new Add());
$this->app->add(new Start()); $this->app->add(new Start());
$this->app->add(new GenerateKey());
} }
public function run(): int public function run(): int

View File

@@ -7,7 +7,7 @@ namespace Siteworxpro\App\Cli\Commands;
use Ahc\Cli\Application as App; use Ahc\Cli\Application as App;
use League\CLImate\CLImate; use League\CLImate\CLImate;
abstract class Command extends \Ahc\Cli\Input\Command abstract class Command extends \Ahc\Cli\Input\Command implements CommandInterface
{ {
protected Climate $climate; protected Climate $climate;

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\Crypt;
use Random\RandomException;
use Siteworxpro\App\Cli\Commands\Command;
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
{
$key = base64_encode(random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES));
$this->climate->info('Generated Encryption Key:');
$this->climate->out('base64:' . $key);
return 0;
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\User;
use Siteworxpro\App\Cli\Commands\Command;
use Siteworxpro\App\Models\ClientUser;
use Siteworxpro\App\Models\User;
use Siteworxpro\App\OAuth\Entities\Client;
class Add extends Command
{
public function __construct()
{
parent::__construct('user:add', 'Add a new user to the system');
}
public function execute(): int
{
$clients = Client::all(['id', 'name']);
$this->climate->info('Available OAuth Clients:');
foreach ($clients as $client) {
$this->climate->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()
);
$id = $input->prompt();
$client = Client::find($id);
if (!$client) {
$this->climate->error('Client not found.');
return 1;
}
$this->climate->info("Adding a new user for client: $client->name");
$input = $this->climate->input('Enter the user\'s email:');
$email = $input->prompt();
$user = User::where('email', $email)->first();
if ($user) {
$this->climate->error('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;
}
$passwordInput = $this->climate->password('Enter the user\'s password: 🔐');
$password = $passwordInput->prompt();
$firstNameInput = $this->climate->input('Enter the user\'s first name: ');
$firstName = $firstNameInput->prompt();
$lastNameInput = $this->climate->input('Enter the user\'s last name: ');
$lastName = $lastNameInput->prompt();
$user = new User();
$user->email = $email;
$user->password = $password;
$user->first_name = $firstName;
$user->last_name = $lastName;
$user->save();
$clientUser = new ClientUser();
$clientUser->client_id = $client->id;
$clientUser->user_id = $user->id;
$clientUser->save();
return 0;
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use Defuse\Crypto\Exception\BadFormatException;
use Defuse\Crypto\Exception\EnvironmentIsBrokenException;
use League\OAuth2\Server\Exception\OAuthServerException;
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;
final class AccessTokenController extends Controller
{
/**
* @param ServerRequest $request
* @return ResponseInterface
* @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'));
if ($client === null) {
return JsonResponseFactory::createJsonResponse(
new GenericResponse('Invalid client'),
CodesEnum::BAD_REQUEST,
);
}
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());
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers\Admin;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Attributes\Guards\Jwt;
use Siteworxpro\App\Attributes\Guards\Scope;
use Siteworxpro\App\Controllers\Controller;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\OAuth\Entities\Client;
#[Jwt]
final class ClientsController extends Controller
{
/**
* @throws \JsonException
*/
#[Scope(['admin:clients:view'])]
public function get(ServerRequest $request): ResponseInterface
{
$clients = Client::all();
return JsonResponseFactory::createJsonResponse($clients->toArray());
}
}

View File

@@ -4,12 +4,14 @@ declare(strict_types=1);
namespace Siteworxpro\App\Controllers; namespace Siteworxpro\App\Controllers;
use Defuse\Crypto\Exception\BadFormatException;
use Defuse\Crypto\Exception\EnvironmentIsBrokenException;
use HansOtt\PSR7Cookies\SetCookie; use HansOtt\PSR7Cookies\SetCookie;
use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\RequestTypes\AuthorizationRequest;
use Nyholm\Psr7\Response; use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest; use Nyholm\Psr7\ServerRequest;
use Nyholm\Psr7\Stream; use Nyholm\Psr7\Stream;
use Psr\SimpleCache\InvalidArgumentException;
use Siteworxpro\App\Helpers\Rand; use Siteworxpro\App\Helpers\Rand;
use Siteworxpro\App\Http\JsonResponseFactory; use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Responses\ServerErrorResponse; use Siteworxpro\App\Http\Responses\ServerErrorResponse;
@@ -21,75 +23,64 @@ use Siteworxpro\HttpStatus\CodesEnum;
final class AuthorizeController extends Controller final class AuthorizeController extends Controller
{ {
/** /**
* @throws InvalidArgumentException * @param ServerRequest $request
* @return Response
* @throws BadFormatException
* @throws EnvironmentIsBrokenException
* @throws \JsonException
*/ */
// #[\Override] public function post(ServerRequest $request): Response public function post(ServerRequest $request): Response
// { {
// $s = $request->getCookieParams()['s'] ?? ''; $s = $request->getCookieParams()['s'] ?? '';
//
// $password = $request->getParsedBody()['password'] ?? ''; $password = $request->getParsedBody()['password'] ?? '';
// $email = $request->getParsedBody()['email'] ?? ''; $email = $request->getParsedBody()['email'] ?? '';
//
// if (!$this->redis->get('session:' . $s)) { if (!Redis::get('session:' . $s)) {
// $this->log->error('Session Timed out', ['session' => $s]); Logger::warning('Session Timed out', ['session' => $s]);
//
// return $this->sendJsonResponse( return JsonResponseFactory::createJsonResponse([]);
// [ }
// 'error' => "your login session has timed out. please try again."
// ], /** @var AuthorizationRequest $authRequest */
// 400 $authRequest = unserialize(Redis::get('session:' . $s));
// );
// } /** @var Client $client */
// $client = $authRequest->getClient();
// /** @var AuthorizationRequest $authRequest */
// $authRequest = unserialize($this->redis->get('session:' . $s)); $authorizationServer = $client->getAuthorizationServer();
//
// if ($authRequest->isAuthorizationApproved()) { if ($authRequest->isAuthorizationApproved()) {
// $response = $this $response = $authorizationServer
// ->authorizationServer ->completeAuthorizationRequest($authRequest, JsonResponseFactory::createJsonResponse([]));
// ->completeAuthorizationRequest($authRequest, $this->sendJsonResponse());
// return JsonResponseFactory::createJsonResponse([
// return $this->sendJsonResponse( 'success' => true,
// [ 'location' => $response->getHeader('Location')[0]
// 'success' => true, ]);
// 'location' => $response->getHeader('Location')[0] }
// ]
// ); $user = $client->loginUser($email, $password);
// }
// if (!$user) {
// /** @var Client $client */ return JsonResponseFactory::createJsonResponse([
// $client = $authRequest->getClient(); 'success' => false,
// 'reason' => 'login failed'
// /** @var LoginInterface $entitiesModel */ ], CodesEnum::UNAUTHORIZED);
// $entitiesModel = $client->entities_model; }
//
// /** @var User | null $entity */ $authRequest->setUser($user);
// $entity = $entitiesModel::performLogin($email, $password); $authRequest->setAuthorizationApproved(true);
// if (!$entity) { $response = $authorizationServer
// return $this->sendJsonResponse( ->completeAuthorizationRequest($authRequest, JsonResponseFactory::createJsonResponse([]));
// [
// 'success' => false, Redis::del('session:' . $s);
// 'reason' => 'login failed'
// ], return JsonResponseFactory::createJsonResponse([
// 401 'success' => true,
// ); 'location' => $response->getHeader('Location')[0]
// } ]);
// }
// $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 * @throws \Exception

View File

@@ -25,6 +25,8 @@ final class CapabilitiesController extends Controller
return JsonResponseFactory::createJsonResponse(new NotFoundResponse($request->getUri()->getPath())); return JsonResponseFactory::createJsonResponse(new NotFoundResponse($request->getUri()->getPath()));
} }
return JsonResponseFactory::createJsonResponse($client->capabilities->toArray()); return JsonResponseFactory::createJsonResponse(array_merge($client->capabilities->toArray(), [
'client_name' => $client->name,
]));
} }
} }

View File

@@ -0,0 +1,48 @@
<?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\OAuth\Entities\Client;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\HttpStatus\CodesEnum;
final class OpenIdController extends Controller
{
/**
* @throws \JsonException
*/
public function get(ServerRequest $request): ResponseInterface
{
$clientId = $request->getAttribute('client_id');
$client = Client::find($clientId);
if (!$client) {
return JsonResponseFactory::createJsonResponse(
['error' => 'invalid_client'],
CodesEnum::BAD_REQUEST
);
}
$data = [
'issuer' => Config::get('app.url') . '/' . $clientId,
'authorization_endpoint' => Config::get('app.url') . '/authorize',
'token_endpoint' => Config::get('app.url') . '/client/' . $clientId . '/access_token',
'userinfo_endpoint' => Config::get('app.url') . '/user_info',
'end_session_endpoint' => Config::get('app.url') . '/end-session',
'introspection_endpoint' => Config::get('app.url') . '/introspect',
'revocation_endpoint' => Config::get('app.url') . '/revoke',
'device_authorization_endpoint' => Config::get('app.url') . '/device',
'jwks_uri' => Config::get('app.url') . '/client/' . $clientId . '/jwks',
'grant_types_supported' => $client->grant_types,
'scopes_supported' => $client->scopes->pluck('name')->toArray()
];
return JsonResponseFactory::createJsonResponse($data);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Helpers;
use Random\RandomException;
readonly class Encryption
{
public function __construct(private string $key)
{
}
/**
* Encrypt the given data with AES256-GCM
* @param string $data
* @return string
* @throws RandomException
* @throws \SodiumException
*/
public function encrypt(string $data): string
{
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$enc = sodium_crypto_secretbox(
$data,
$nonce,
$this->key
);
return $nonce . $enc;
}
/**
* Decrypt the given data with AES256-GCM
* @param string $encryptedData
* @return string
* @throws \SodiumException
*/
public function decrypt(string $encryptedData): string
{
$nonce = substr($encryptedData, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = substr($encryptedData, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
return sodium_crypto_secretbox_open(
$ciphertext,
$nonce,
$this->key
);
}
}

View File

@@ -12,12 +12,15 @@ abstract class Env
{ {
/** /**
* @param string $key * @param string $key
* @param null $default * @param mixed $default
* @param string $castTo * @param string $castTo
* @return float|bool|int|string * @return float|bool|int|string
*/ */
public static function get(string $key, $default = null, string $castTo = 'string'): float | bool | int | string public static function get(
{ string $key,
mixed $default = null,
string $castTo = 'string'
): float | bool | int | string {
$env = getenv($key) !== false ? getenv($key) : $default; $env = getenv($key) !== false ? getenv($key) : $default;
return match ($castTo) { return match ($castTo) {

View File

@@ -17,6 +17,6 @@ class Ulid
*/ */
public static function generate(): string public static function generate(): string
{ {
return \Ulid\Ulid::generate()->getRandomness(); return (string) \Ulid\Ulid::generate();
} }
} }

View File

@@ -12,6 +12,7 @@ use Siteworxpro\App\Services\Facades\Dispatcher;
use Siteworxpro\App\Services\ServiceProviders\BrokerServiceProvider; use Siteworxpro\App\Services\ServiceProviders\BrokerServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\CommandBusProvider; use Siteworxpro\App\Services\ServiceProviders\CommandBusProvider;
use Siteworxpro\App\Services\ServiceProviders\DispatcherServiceProvider; use Siteworxpro\App\Services\ServiceProviders\DispatcherServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\EncryptionServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\LoggerServiceProvider; use Siteworxpro\App\Services\ServiceProviders\LoggerServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\RedisServiceProvider; use Siteworxpro\App\Services\ServiceProviders\RedisServiceProvider;
@@ -36,6 +37,7 @@ class Kernel
DispatcherServiceProvider::class, DispatcherServiceProvider::class,
BrokerServiceProvider::class, BrokerServiceProvider::class,
CommandBusProvider::class, CommandBusProvider::class,
EncryptionServiceProvider::class,
]; ];
/** /**

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Siteworxpro\App\Models; namespace Siteworxpro\App\Models;
use Illuminate\Database\Eloquent\Model as ORM; use Illuminate\Database\Eloquent\Model as ORM;
use Siteworxpro\App\Helpers\Ulid;
/** /**
* Class Model * Class Model
@@ -16,4 +17,10 @@ use Illuminate\Database\Eloquent\Model as ORM;
abstract class Model extends ORM abstract class Model extends ORM
{ {
protected $dateFormat = 'Y-m-d H:i:s'; protected $dateFormat = 'Y-m-d H:i:s';
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->attributes['id'] = $this->attributes['id'] ?? Ulid::generate();
}
} }

View File

@@ -6,6 +6,7 @@ namespace Siteworxpro\App\Models;
use Carbon\Carbon; use Carbon\Carbon;
use League\OAuth2\Server\Entities\Traits\EntityTrait; use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\UserEntityInterface;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Siteworxpro\App\Helpers\Ulid; use Siteworxpro\App\Helpers\Ulid;
@@ -39,11 +40,10 @@ use Siteworxpro\App\Helpers\Ulid;
new OA\Property(property: "created_at", type: "string", format: "date-time"), new OA\Property(property: "created_at", type: "string", format: "date-time"),
] ]
)] )]
class User extends Model class User extends Model implements UserEntityInterface
{ {
use EntityTrait;
protected $casts = [ protected $casts = [
'id' => 'string',
'created_at' => 'datetime', 'created_at' => 'datetime',
]; ];
@@ -58,12 +58,6 @@ class User extends Model
'password', 'password',
]; ];
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->attributes['id'] = $this->attributes['id'] ?? Ulid::generate();
}
public function getFullNameAttribute(): string public function getFullNameAttribute(): string
{ {
return "$this->first_name $this->last_name"; return "$this->first_name $this->last_name";
@@ -77,4 +71,20 @@ class User extends Model
strtolower($this->email) strtolower($this->email)
); );
} }
public function verifyPassword(string $password): bool
{
// Verify the provided password against the stored hashed password
return password_verify($password, $this->password);
}
public function setPasswordAttribute(string $password): void
{
$this->attributes['password'] = password_hash($password, PASSWORD_ARGON2ID);
}
public function getIdentifier(): string
{
return $this->id;
}
} }

View File

@@ -6,26 +6,35 @@ namespace Siteworxpro\App\OAuth;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface; use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface; use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
use Psr\SimpleCache\InvalidArgumentException;
use Siteworxpro\App\OAuth\Entities\AuthorizationCode;
class AuthCodeRepository implements AuthCodeRepositoryInterface class AuthCodeRepository implements AuthCodeRepositoryInterface
{ {
public function getNewAuthCode(): AuthCodeEntityInterface public function getNewAuthCode(): AuthCodeEntityInterface
{ {
// TODO: Implement getNewAuthCode() method. return new AuthorizationCode();
} }
public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity): void public function persistNewAuthCode(AuthCodeEntityInterface | AuthorizationCode $authCodeEntity): void
{ {
// TODO: Implement persistNewAuthCode() method. $authCodeEntity->save();
} }
/**
* @throws InvalidArgumentException
*/
public function revokeAuthCode(string $codeId): void public function revokeAuthCode(string $codeId): void
{ {
// TODO: Implement revokeAuthCode() method. $authCode = AuthorizationCode::find($codeId);
$authCode?->delete();
} }
public function isAuthCodeRevoked(string $codeId): bool public function isAuthCodeRevoked(string $codeId): bool
{ {
// TODO: Implement isAuthCodeRevoked() method. $authCode = AuthorizationCode::find($codeId);
return $authCode === null;
} }
} }

View File

@@ -40,17 +40,15 @@ readonly class ClientRepository implements ClientRepositoryInterface
*/ */
public function validateClient(string $clientIdentifier, ?string $clientSecret, ?string $grantType): bool public function validateClient(string $clientIdentifier, ?string $clientSecret, ?string $grantType): bool
{ {
$client = Client::find($clientIdentifier); if ($this->client->client_id != $clientIdentifier) {
if ($client === null) {
return false; return false;
} }
if ($clientSecret && $client->client_secret != $clientSecret) { if ($clientSecret && $this->client->client_secret != $clientSecret) {
return false; return false;
} }
if ($grantType && !in_array($grantType, $client->grant_types)) { if ($grantType && !in_array($grantType, $this->client->grant_types->toArray())) {
return false; return false;
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities; namespace Siteworxpro\App\OAuth\Entities;
use Carbon\Carbon;
use DateTimeImmutable; use DateTimeImmutable;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Entities\ClientEntityInterface;
@@ -14,24 +15,31 @@ class AccessToken extends RedisModel implements AccessTokenEntityInterface
{ {
use AccessTokenTrait; use AccessTokenTrait;
private Client |null $client = null;
private string | null $userIdentifier = null;
/** @var ScopeEntityInterface|Scope[] */
private array $scopes = [];
public function getClient(): ClientEntityInterface public function getClient(): ClientEntityInterface
{ {
// TODO: Implement getClient() method. return $this->client;
} }
public function getExpiryDateTime(): DateTimeImmutable public function getExpiryDateTime(): DateTimeImmutable
{ {
// TODO: Implement getExpiryDateTime() method. return $this->expiryDateTime->toDateTimeImmutable();
} }
public function getUserIdentifier(): string|null public function getUserIdentifier(): string|null
{ {
// TODO: Implement getUserIdentifier() method. return $this->userIdentifier;
} }
public function getScopes(): array public function getScopes(): array
{ {
// TODO: Implement getScopes() method. return $this->scopes;
} }
protected static function getRedisPrefix(): string protected static function getRedisPrefix(): string
@@ -41,21 +49,21 @@ class AccessToken extends RedisModel implements AccessTokenEntityInterface
public function setExpiryDateTime(DateTimeImmutable $dateTime): void public function setExpiryDateTime(DateTimeImmutable $dateTime): void
{ {
// TODO: Implement setExpiryDateTime() method. $this->expiryDateTime = Carbon::instance($dateTime);
} }
public function setUserIdentifier(string $identifier): void public function setUserIdentifier(string $identifier): void
{ {
// TODO: Implement setUserIdentifier() method. $this->userIdentifier = $identifier;
} }
public function setClient(ClientEntityInterface $client): void public function setClient(ClientEntityInterface $client): void
{ {
// TODO: Implement setClient() method. $this->client = $client;
} }
public function addScope(ScopeEntityInterface $scope): void public function addScope(ScopeEntityInterface $scope): void
{ {
// TODO: Implement addScope() method. $this->scopes[] = $scope;
} }
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities; namespace Siteworxpro\App\OAuth\Entities;
use Carbon\Carbon;
use DateTimeImmutable; use DateTimeImmutable;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface; use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Entities\ClientEntityInterface;
@@ -14,6 +15,15 @@ class AuthorizationCode extends RedisModel implements AuthCodeEntityInterface
{ {
use AuthCodeTrait; use AuthCodeTrait;
private Client | null $client = null;
private string | null $userIdentifier = null;
/**
* @var array<ScopeEntityInterface | Scope> $scopes
*/
private array $scopes = [];
protected static function getRedisPrefix(): string protected static function getRedisPrefix(): string
{ {
return 'oauth_auth_code'; return 'oauth_auth_code';
@@ -21,41 +31,41 @@ class AuthorizationCode extends RedisModel implements AuthCodeEntityInterface
public function getExpiryDateTime(): DateTimeImmutable public function getExpiryDateTime(): DateTimeImmutable
{ {
// TODO: Implement getExpiryDateTime() method. return $this->expiryDateTime->toDateTimeImmutable();
} }
public function setExpiryDateTime(DateTimeImmutable $dateTime): void public function setExpiryDateTime(DateTimeImmutable $dateTime): void
{ {
// TODO: Implement setExpiryDateTime() method. $this->expiryDateTime = Carbon::instance($dateTime);
} }
public function setUserIdentifier(string $identifier): void public function setUserIdentifier(string $identifier): void
{ {
// TODO: Implement setUserIdentifier() method. $this->userIdentifier = $identifier;
} }
public function getUserIdentifier(): string|null public function getUserIdentifier(): string|null
{ {
// TODO: Implement getUserIdentifier() method. return $this->userIdentifier;
} }
public function getClient(): ClientEntityInterface public function getClient(): ClientEntityInterface | Client
{ {
// TODO: Implement getClient() method. return $this->client;
} }
public function setClient(ClientEntityInterface $client): void public function setClient(ClientEntityInterface $client): void
{ {
// TODO: Implement setClient() method. $this->client = $client;
} }
public function addScope(ScopeEntityInterface $scope): void public function addScope(ScopeEntityInterface $scope): void
{ {
// TODO: Implement addScope() method. $this->scopes[] = $scope;
} }
public function getScopes(): array public function getScopes(): array
{ {
// TODO: Implement getScopes() method. return $this->scopes;
} }
} }

View File

@@ -35,7 +35,7 @@ use Siteworxpro\App\OAuth\ScopeRepository;
* @property string $description * @property string $description
* @property string $private_key * @property string $private_key
* @property string $encryption_key * @property string $encryption_key
* @property string[] $grant_types * @property Collection<string> $grant_types
* @property bool $confidential * @property bool $confidential
* *
* @property-read ClientCapabilities $capabilities * @property-read ClientCapabilities $capabilities
@@ -101,7 +101,14 @@ class Client extends Model implements ClientEntityInterface
*/ */
public function scopes(): HasManyThrough public function scopes(): HasManyThrough
{ {
return $this->hasManyThrough(Scope::class, ClientScope::class); return $this->hasManyThrough(
Scope::class,
ClientScope::class,
'client_id',
'id',
'id',
'scope_id'
);
} }
/** /**
@@ -109,7 +116,14 @@ class Client extends Model implements ClientEntityInterface
*/ */
public function users(): HasManyThrough public function users(): HasManyThrough
{ {
return $this->hasManyThrough(User::class, ClientUser::class); return $this->hasManyThrough(
User::class,
ClientUser::class,
'client_id',
'id',
'id',
'user_id'
);
} }
/** /**
@@ -154,7 +168,7 @@ class Client extends Model implements ClientEntityInterface
*/ */
public function setCapabilitiesAttribute(ClientCapabilities $capabilities): void public function setCapabilitiesAttribute(ClientCapabilities $capabilities): void
{ {
$this->attributes->capabilities = $capabilities->toJson(); $this->attributes['capabilities'] = $capabilities->toJson();
} }
/** /**
@@ -203,4 +217,16 @@ class Client extends Model implements ClientEntityInterface
return $authorizationServer; return $authorizationServer;
} }
public function loginUser(string $username, string $password): ?User
{
/** @var User|null $user */
$user = $this->users()->where('email', $username)->first();
if (!$user) {
return null;
}
return $user->verifyPassword($password) ? $user : null;
}
} }

View File

@@ -13,7 +13,7 @@ class ClientCapabilities implements Arrayable
private bool $passkey = false; private bool $passkey = false;
private array $socials = []; private array $socials = [];
private array $theme = [ private array $branding = [
'primaryColor' => '#000000', 'primaryColor' => '#000000',
'secondaryColor' => '#FFFFFF', 'secondaryColor' => '#FFFFFF',
'logoUrl' => null, 'logoUrl' => null,
@@ -37,8 +37,8 @@ class ClientCapabilities implements Arrayable
$this->socials = $capabilities['socials']; $this->socials = $capabilities['socials'];
} }
if (isset($capabilities['theme']) && is_array($capabilities['theme'])) { if (isset($capabilities['branding']) && is_array($capabilities['branding'])) {
$this->theme = array_merge($this->theme, $capabilities['theme']); $this->branding = array_merge($this->branding, $capabilities['branding']);
} }
} }
@@ -60,7 +60,7 @@ class ClientCapabilities implements Arrayable
'magicLink' => $this->magicLink, 'magicLink' => $this->magicLink,
'passkey' => $this->passkey, 'passkey' => $this->passkey,
'socials' => $this->socials, 'socials' => $this->socials,
'theme' => $this->theme, 'branding' => $this->branding,
]; ];
} }

View File

@@ -8,28 +8,24 @@ use Carbon\Carbon;
use DateTimeImmutable; use DateTimeImmutable;
use League\OAuth2\Server\Entities\Traits\EntityTrait; use League\OAuth2\Server\Entities\Traits\EntityTrait;
use Psr\SimpleCache\InvalidArgumentException; use Psr\SimpleCache\InvalidArgumentException;
use Siteworxpro\App\Services\Facades\Encryption;
use Siteworxpro\App\Services\Facades\Redis; use Siteworxpro\App\Services\Facades\Redis;
abstract class RedisModel abstract class RedisModel
{ {
use EntityTrait; use EntityTrait;
private \Predis\Client $redis; protected ?Carbon $expiryDateTime;
protected ?DateTimeImmutable $expireTime;
public function __construct()
{
$this->redis = Redis::getFacadeRoot();
}
abstract protected static function getRedisPrefix(): string; abstract protected static function getRedisPrefix(): string;
public static function find(string $identifier): ?self public static function find(string $identifier): ?static
{ {
$instance = Redis::get(static::getRedisPrefix() . ':' . $identifier); $instance = Redis::get(static::getRedisPrefix() . ':' . $identifier);
if ($instance !== null) { if ($instance !== null) {
$instance = Encryption::decrypt($instance);
return unserialize($instance); return unserialize($instance);
} }
@@ -39,22 +35,20 @@ abstract class RedisModel
public function save(): void public function save(): void
{ {
$diff = 0; $diff = 0;
if ($this->expireTime) { if ($this->expiryDateTime) {
$diff = $this->expireTime->getTimestamp() - Carbon::now()->timestamp; $diff = $this->expiryDateTime->getTimestamp() - Carbon::now()->timestamp;
} }
$this->redis->set( Redis::set(
static::getRedisPrefix() . ':' . $this->getIdentifier(), static::getRedisPrefix() . ':' . $this->getIdentifier(),
serialize($this), Encryption::encrypt(serialize($this)),
'EX',
$diff $diff
); );
} }
/**
* @throws InvalidArgumentException
*/
public function delete(): void public function delete(): void
{ {
$this->redis->delete(static::getRedisPrefix() . ':' . $this->getIdentifier()); Redis::del(static::getRedisPrefix() . ':' . $this->getIdentifier());
} }
} }

View File

@@ -4,15 +4,37 @@ declare(strict_types=1);
namespace Siteworxpro\App\OAuth\Entities; namespace Siteworxpro\App\OAuth\Entities;
use Carbon\Carbon;
use DateTimeImmutable;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait;
class RefreshToken extends RedisModel implements RefreshTokenEntityInterface class RefreshToken extends RedisModel implements RefreshTokenEntityInterface
{ {
use RefreshTokenTrait; private AccessToken | null $accessToken = null;
protected static function getRedisPrefix(): string protected static function getRedisPrefix(): string
{ {
return 'oauth_refresh_token'; return 'oauth_refresh_token';
} }
public function getExpiryDateTime(): DateTimeImmutable
{
return $this->expiryDateTime->toDateTimeImmutable();
}
public function setExpiryDateTime(DateTimeImmutable $dateTime): void
{
$this->expiryDateTime = Carbon::instance($dateTime);
}
public function setAccessToken(AccessTokenEntityInterface $accessToken): void
{
$this->accessToken = $accessToken;
}
public function getAccessToken(): AccessTokenEntityInterface
{
return $this->accessToken;
}
} }

View File

@@ -6,27 +6,33 @@ namespace Siteworxpro\App\OAuth;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
use Psr\SimpleCache\InvalidArgumentException;
use Siteworxpro\App\OAuth\Entities\RefreshToken;
class RefreshTokenRepository implements RefreshTokenRepositoryInterface class RefreshTokenRepository implements RefreshTokenRepositoryInterface
{ {
public function getNewRefreshToken(): ?RefreshTokenEntityInterface public function getNewRefreshToken(): ?RefreshTokenEntityInterface
{ {
// TODO: Implement getNewRefreshToken() method. return new RefreshToken();
} }
public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void public function persistNewRefreshToken(RefreshTokenEntityInterface | RefreshToken $refreshTokenEntity): void
{ {
// TODO: Implement persistNewRefreshToken() method. $refreshTokenEntity->save();
} }
/**
* @throws InvalidArgumentException
*/
public function revokeRefreshToken(string $tokenId): void public function revokeRefreshToken(string $tokenId): void
{ {
// TODO: Implement revokeRefreshToken() method. $token = RefreshToken::find($tokenId);
$token?->delete();
} }
public function isRefreshTokenRevoked(string $tokenId): bool public function isRefreshTokenRevoked(string $tokenId): bool
{ {
// TODO: Implement isRefreshTokenRevoked() method. $token = RefreshToken::find($tokenId);
return $token === null;
} }
} }

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\Facades;
use Siteworxpro\App\Services\Facade;
/**
* @method static string encrypt(string $data)
* @method static string decrypt(string $data)
*/
class Encryption extends Facade
{
protected static function getFacadeAccessor(): string
{
return \Siteworxpro\App\Helpers\Encryption::class;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\ServiceProviders;
use Illuminate\Support\ServiceProvider;
use Siteworxpro\App\Helpers\Encryption;
use Siteworxpro\App\Services\Facades\Config;
class EncryptionServiceProvider extends ServiceProvider
{
public function provides(): array
{
return [Encryption::class];
}
public function register(): void
{
$this->app->singleton(Encryption::class, function () {
$key = Config::get('app.encryption_key');
if (empty($key)) {
throw new \RuntimeException('Encryption key is not set in configuration.');
}
if (str_contains($key, 'base64:')) {
$key = base64_decode(substr($key, 7));
}
return new Encryption($key);
});
}
}