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.
This commit is contained in:
2026-02-11 21:29:18 -05:00
parent 75757f1403
commit f0e191b2cb
8 changed files with 105 additions and 184 deletions

View File

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

View File

@@ -14,6 +14,7 @@ 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\OpenIdController; use Siteworxpro\App\Controllers\OpenIdController;
use Siteworxpro\App\Controllers\UserInfo;
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;
@@ -89,6 +90,8 @@ class Api
$group->get('/.well-known/openid-configuration', OpenIdController::class . '::get'); $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 CorsMiddleware());
$this->router->middleware(new JwtMiddleware()); $this->router->middleware(new JwtMiddleware());
$this->router->middleware(new ScopeMiddleware()); $this->router->middleware(new ScopeMiddleware());

View File

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

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\OAuth; namespace Siteworxpro\App\Cli\Commands\OAuth;
use Siteworxpro\App\Cli\ClimateOutput;
use Siteworxpro\App\CommandBus\Commands\CreateClient as CreateClientCommand; use Siteworxpro\App\CommandBus\Commands\CreateClient as CreateClientCommand;
use Siteworxpro\App\CommandBus\Exceptions\CommandHandlerException; use Siteworxpro\App\CommandBus\Exceptions\CommandHandlerException;
use Siteworxpro\App\Models\Enums\ClientGrant as ClientGrantAlias; use Siteworxpro\App\Models\Enums\ClientGrant as ClientGrantAlias;
@@ -11,13 +12,14 @@ use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\App\Services\Facades\CommandBus; use Siteworxpro\App\Services\Facades\CommandBus;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion; use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Question\Question;
#[AsCommand(name: 'oauth:client:create', description: 'Create a new OAuth client.')] #[AsCommand(name: 'oauth:client:create', description: 'Create a new OAuth client.')]
class CreateClient extends \Siteworxpro\App\Cli\Commands\Command 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: '); $question = new Question('Enter client name: ');
$clientName = $this->helper->ask($input, $output, $question); $clientName = $this->helper->ask($input, $output, $question);

View File

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

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

@@ -8,8 +8,6 @@ use Carbon\Carbon;
use Carbon\WrapperClock; use Carbon\WrapperClock;
use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\GuzzleException;
use Lcobucci\JWT\JwtFacade; 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\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256; use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Token\InvalidTokenStructure; use Lcobucci\JWT\Token\InvalidTokenStructure;
@@ -26,9 +24,8 @@ use Psr\Http\Server\RequestHandlerInterface;
use Siteworxpro\App\Attributes\Guards\Jwt; use Siteworxpro\App\Attributes\Guards\Jwt;
use Siteworxpro\App\Controllers\Controller; use Siteworxpro\App\Controllers\Controller;
use Siteworxpro\App\Http\JsonResponseFactory; use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\App\Services\Facades\Config; use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Guzzle;
use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\HttpStatus\CodesEnum; use Siteworxpro\HttpStatus\CodesEnum;
/** /**
@@ -91,7 +88,6 @@ class JwtMiddleware extends Middleware
// Extract Bearer token from Authorization header. // Extract Bearer token from Authorization header.
$token = str_replace('Bearer ', '', $request->getHeaderLine('Authorization')); $token = str_replace('Bearer ', '', $request->getHeaderLine('Authorization'));
if (empty($token)) { if (empty($token)) {
return JsonResponseFactory::createJsonResponse([ return JsonResponseFactory::createJsonResponse([
'status_code' => 401, 'status_code' => 401,
@@ -99,31 +95,39 @@ class JwtMiddleware extends Middleware
], CodesEnum::UNAUTHORIZED); ], CodesEnum::UNAUTHORIZED);
} }
// Aggregate required issuers and audience from attributes. $jwt = explode('.', $token);
$requiredIssuers = []; if (count($jwt) !== 3) {
$requiredAudience = ''; return JsonResponseFactory::createJsonResponse([
'status_code' => 401,
foreach ($attributes as $attribute) { 'message' => 'Unauthorized: Invalid token format',
/** @var Jwt $jwtInstance */ ], CodesEnum::UNAUTHORIZED);
$jwtInstance = $attribute->newInstance();
if ($jwtInstance->getAudience() !== '') {
$requiredAudience = $jwtInstance->getAudience();
}
$requiredIssuers[] = $jwtInstance->getIssuer();
} }
$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 { try {
// Parse and validate the token with signature, time, issuer and audience constraints. // Parse and validate the token with signature, time, issuer and audience constraints.
$key = InMemory::plainText($client->publicKey());
$jwt = new JwtFacade()->parse( $jwt = new JwtFacade()->parse(
$token, $token,
$this->getSignedWith($token), new SignedWith(new Sha256(), $key),
Config::get('jwt.strict_validation') ? Config::get('jwt.strict_validation') ?
new StrictValidAt(new WrapperClock(Carbon::now())) : new StrictValidAt(new WrapperClock(Carbon::now())) :
new LooseValidAt(new WrapperClock(Carbon::now())), new LooseValidAt(new WrapperClock(Carbon::now())),
new IssuedBy(...$requiredIssuers), new IssuedBy($iss),
new PermittedFor($requiredAudience) new PermittedFor($aud)
); );
} catch (RequiredConstraintsViolated $exception) { } catch (RequiredConstraintsViolated $exception) {
// Collect human-readable violations to return to the client. // Collect human-readable violations to return to the client.
@@ -159,164 +163,4 @@ class JwtMiddleware extends Middleware
return $handler->handle($request); 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

@@ -260,4 +260,13 @@ class Client extends Model implements ClientEntityInterface
$clientScope->save(); $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'];
}
} }