From f0e191b2cbb6808e6914aac5f29f6e56e446dd37 Mon Sep 17 00:00:00 2001 From: Ron Rise Date: Wed, 11 Feb 2026 21:29:18 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20UserInfo=20endpoint=20and=20e?= =?UTF-8?q?nhance=20client=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .dev/.env | 1 + src/Api.php | 3 + src/Attributes/Guards/Scope.php | 2 +- src/Cli/Commands/OAuth/CreateClient.php | 4 +- src/CommandBus/Handlers/CreateUserHandler.php | 4 +- src/Controllers/UserInfo.php | 60 +++++ src/Http/Middleware/JwtMiddleware.php | 206 +++--------------- src/OAuth/Entities/Client.php | 9 + 8 files changed, 105 insertions(+), 184 deletions(-) create mode 100644 src/Controllers/UserInfo.php diff --git a/.dev/.env b/.dev/.env index e0cbb66..95b5ba0 100644 --- a/.dev/.env +++ b/.dev/.env @@ -1,3 +1,4 @@ +APP_URL: https://localhost JWT_ISSUER: https://auth.siteworxpro.com/application/o/postman/ JWT_AUDIENCE: 1RWyqJFlyA4hmsDzq6kSxs0LXvk7UgEAfgmBCpQ9 JWT_SIGNING_KEY: https://auth.siteworxpro.com/application/o/postman/.well-known/openid-configuration diff --git a/src/Api.php b/src/Api.php index d2985cd..89d63e4 100644 --- a/src/Api.php +++ b/src/Api.php @@ -14,6 +14,7 @@ use Siteworxpro\App\Controllers\AuthorizeController; use Siteworxpro\App\Controllers\CapabilitiesController; use Siteworxpro\App\Controllers\HealthcheckController; use Siteworxpro\App\Controllers\OpenIdController; +use Siteworxpro\App\Controllers\UserInfo; use Siteworxpro\App\Http\JsonResponseFactory; use Siteworxpro\App\Http\Middleware\CorsMiddleware; use Siteworxpro\App\Http\Middleware\JwtMiddleware; @@ -89,6 +90,8 @@ class Api $group->get('/.well-known/openid-configuration', OpenIdController::class . '::get'); }); + $this->router->get('/user_info', UserInfo::class . '::get'); + $this->router->middleware(new CorsMiddleware()); $this->router->middleware(new JwtMiddleware()); $this->router->middleware(new ScopeMiddleware()); diff --git a/src/Attributes/Guards/Scope.php b/src/Attributes/Guards/Scope.php index 7f467d9..e581c0c 100644 --- a/src/Attributes/Guards/Scope.php +++ b/src/Attributes/Guards/Scope.php @@ -16,7 +16,7 @@ readonly class Scope */ public function __construct( private array $scopes = [], - private string $claim = 'scope', + private string $claim = 'scopes', private string $separator = ' ' ) { } diff --git a/src/Cli/Commands/OAuth/CreateClient.php b/src/Cli/Commands/OAuth/CreateClient.php index 8459e16..1130a5b 100644 --- a/src/Cli/Commands/OAuth/CreateClient.php +++ b/src/Cli/Commands/OAuth/CreateClient.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Siteworxpro\App\Cli\Commands\OAuth; +use Siteworxpro\App\Cli\ClimateOutput; use Siteworxpro\App\CommandBus\Commands\CreateClient as CreateClientCommand; use Siteworxpro\App\CommandBus\Exceptions\CommandHandlerException; 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 Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ChoiceQuestion; use Symfony\Component\Console\Question\Question; #[AsCommand(name: 'oauth:client:create', description: 'Create a new OAuth client.')] class CreateClient extends \Siteworxpro\App\Cli\Commands\Command { - public function __invoke($input, $output): int + public function __invoke($input, ClimateOutput | OutputInterface $output): int { $question = new Question('Enter client name: '); $clientName = $this->helper->ask($input, $output, $question); diff --git a/src/CommandBus/Handlers/CreateUserHandler.php b/src/CommandBus/Handlers/CreateUserHandler.php index 10a49e3..ca996e9 100644 --- a/src/CommandBus/Handlers/CreateUserHandler.php +++ b/src/CommandBus/Handlers/CreateUserHandler.php @@ -16,11 +16,13 @@ class CreateUserHandler { $user = User::create([ 'email' => $command->getEmail(), - 'password' => password_hash($command->getPassword(), PASSWORD_BCRYPT), 'first_name' => $command->getFirstName(), 'last_name' => $command->getLastName(), ]); + $user->password = $command->getPassword(); + $user->save(); + $clientUser = new ClientUser(); $clientUser->client_id = $command->getClient()->id; $clientUser->user_id = $user->id; diff --git a/src/Controllers/UserInfo.php b/src/Controllers/UserInfo.php new file mode 100644 index 0000000..948fa63 --- /dev/null +++ b/src/Controllers/UserInfo.php @@ -0,0 +1,60 @@ +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); + } +} diff --git a/src/Http/Middleware/JwtMiddleware.php b/src/Http/Middleware/JwtMiddleware.php index 2a27099..be68a0a 100644 --- a/src/Http/Middleware/JwtMiddleware.php +++ b/src/Http/Middleware/JwtMiddleware.php @@ -8,8 +8,6 @@ use Carbon\Carbon; use Carbon\WrapperClock; use GuzzleHttp\Exception\GuzzleException; use Lcobucci\JWT\JwtFacade; -use Lcobucci\JWT\Signer\Hmac\Sha256 as Hmac256; -use Lcobucci\JWT\Signer\Key; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; use Lcobucci\JWT\Token\InvalidTokenStructure; @@ -26,9 +24,8 @@ use Psr\Http\Server\RequestHandlerInterface; use Siteworxpro\App\Attributes\Guards\Jwt; use Siteworxpro\App\Controllers\Controller; use Siteworxpro\App\Http\JsonResponseFactory; +use Siteworxpro\App\OAuth\Entities\Client; use Siteworxpro\App\Services\Facades\Config; -use Siteworxpro\App\Services\Facades\Guzzle; -use Siteworxpro\App\Services\Facades\Redis; use Siteworxpro\HttpStatus\CodesEnum; /** @@ -91,7 +88,6 @@ class JwtMiddleware extends Middleware // Extract Bearer token from Authorization header. $token = str_replace('Bearer ', '', $request->getHeaderLine('Authorization')); - if (empty($token)) { return JsonResponseFactory::createJsonResponse([ 'status_code' => 401, @@ -99,31 +95,39 @@ class JwtMiddleware extends Middleware ], CodesEnum::UNAUTHORIZED); } - // Aggregate required issuers and audience from attributes. - $requiredIssuers = []; - $requiredAudience = ''; - - foreach ($attributes as $attribute) { - /** @var Jwt $jwtInstance */ - $jwtInstance = $attribute->newInstance(); - - if ($jwtInstance->getAudience() !== '') { - $requiredAudience = $jwtInstance->getAudience(); - } - - $requiredIssuers[] = $jwtInstance->getIssuer(); + $jwt = explode('.', $token); + if (count($jwt) !== 3) { + return JsonResponseFactory::createJsonResponse([ + 'status_code' => 401, + 'message' => 'Unauthorized: Invalid token format', + ], CodesEnum::UNAUTHORIZED); } + $payload = json_decode(base64_decode($jwt[1]), true, 512, JSON_THROW_ON_ERROR); + $clientId = str_replace(Config::get('app.url') . '/', '', $payload['iss'] ?? ''); + + $client = Client::find($clientId); + if (!$client) { + return JsonResponseFactory::createJsonResponse([ + 'status_code' => 401, + 'message' => 'Unauthorized: Invalid token issuer', + ], CodesEnum::UNAUTHORIZED); + } + + $iss = Config::get('app.url') . '/' . $client->id; + $aud = $clientId; + try { // Parse and validate the token with signature, time, issuer and audience constraints. + $key = InMemory::plainText($client->publicKey()); $jwt = new JwtFacade()->parse( $token, - $this->getSignedWith($token), + new SignedWith(new Sha256(), $key), Config::get('jwt.strict_validation') ? new StrictValidAt(new WrapperClock(Carbon::now())) : new LooseValidAt(new WrapperClock(Carbon::now())), - new IssuedBy(...$requiredIssuers), - new PermittedFor($requiredAudience) + new IssuedBy($iss), + new PermittedFor($aud) ); } catch (RequiredConstraintsViolated $exception) { // Collect human-readable violations to return to the client. @@ -159,164 +163,4 @@ class JwtMiddleware extends Middleware return $handler->handle($request); } - - /** - * Build the signature validation constraint from configured key. - * - * - If the configured key content includes the string `PUBLIC KEY`, use RSA SHA-256. - * - Otherwise assume an HMAC SHA-256 shared secret. - * - Supports raw key strings or `file://` paths. - * - * @return SignedWith Signature constraint used during JWT parsing. - * - * @throws \RuntimeException When no signing key is configured. - * @throws \JsonException - */ - private function getSignedWith(string $token): SignedWith - { - $keyConfig = Config::get('jwt.signing_key'); - - if ($keyConfig === null) { - throw new \RuntimeException('JWT signing key is not configured.'); - } - - // file:// path to key - if (str_starts_with($keyConfig, 'file://')) { - $key = InMemory::file(substr($keyConfig, 7)); - // openid jwks url - } elseif (str_contains($keyConfig, '.well-known/')) { - $jwt = explode('.', $token); - if (count($jwt) !== 3) { - throw new InvalidTokenStructure('Invalid JWT structure for JWKS key retrieval.'); - } - $header = json_decode(base64_decode($jwt[0]), true, 512, JSON_THROW_ON_ERROR); - $keyId = $header['kid'] ?? '0'; // Default to '0' if no kid present - $key = $this->getJwksKey($keyConfig, $keyId); - } else { - $key = InMemory::plainText($keyConfig); - } - - // Heuristic: if PEM public key content is detected, use RSA; otherwise use HMAC. - if (str_contains($key->contents(), 'PUBLIC KEY')) { - return new SignedWith(new Sha256(), $key); - } - - return new SignedWith(new Hmac256(), $key); - } - - private function getJwksKey(string $url, string $keyId): Key - { - $cached = Redis::get('jwks_key_' . $keyId); - if ($cached !== null) { - return InMemory::plainText($cached); - } - - $openIdConfig = Guzzle::get($url); - $body = json_decode($openIdConfig->getBody()->getContents(), true, JSON_THROW_ON_ERROR); - $jwksUri = $body['jwks_uri'] ?? ''; - if (empty($jwksUri)) { - throw new \RuntimeException('JWKS URI not found in OpenID configuration.'); - } - - $jwksResponse = Guzzle::get($jwksUri); - $jwksBody = json_decode( - $jwksResponse->getBody()->getContents(), - true, - JSON_THROW_ON_ERROR - ); - - // For simplicity, we take the first key in the JWKS. - $firstKey = array_filter( - $jwksBody['keys'], - fn($key) => $key['kid'] === $keyId - )[0] ?? $jwksBody['keys'][0] ?? null; - - if (empty($firstKey)) { - throw new \RuntimeException('No matching key found in JWKS for key ID: ' . $keyId); - } - - $n = $firstKey['n']; - $e = $firstKey['e']; - $publicKeyPem = "-----BEGIN PUBLIC KEY-----\n" . - chunk_split(base64_encode($this->convertJwkToPem($n, $e)), 64) . - "-----END PUBLIC KEY-----\n"; - - Redis::set('jwks_key_' . $keyId, $publicKeyPem, 'EX', 3600); - - return InMemory::plainText($publicKeyPem); - } - - /** - * Build a DER-encoded SubjectPublicKeyInfo from JWK 'n' and 'e'. - * Returns raw DER bytes; caller base64-encodes and wraps with PEM headers. - */ - private function convertJwkToPem(string $n, string $e): string - { - $modulus = $this->base64UrlDecode($n); - $exponent = $this->base64UrlDecode($e); - - $derN = $this->derEncodeInteger($modulus); - $derE = $this->derEncodeInteger($exponent); - - // RSAPublicKey (PKCS#1): SEQUENCE { n INTEGER, e INTEGER } - $rsaPublicKey = $this->derEncodeSequence($derN . $derE); - - // AlgorithmIdentifier for rsaEncryption: 1.2.840.113549.1.1.1 with NULL - $algId = hex2bin('300d06092a864886f70d0101010500'); - - // SubjectPublicKey (SPKI) BIT STRING, 0 unused bits + RSAPublicKey - $subjectPublicKey = $this->derEncodeBitString($rsaPublicKey); - - // SubjectPublicKeyInfo: SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING } - return $this->derEncodeSequence($algId . $subjectPublicKey); - } - - private function base64UrlDecode(string $data): string - { - $data = strtr($data, '-_', '+/'); - $pad = strlen($data) % 4; - if ($pad) { - $data .= str_repeat('=', 4 - $pad); - } - return base64_decode($data); - } - - private function derEncodeLength(int $len): string - { - if ($len < 0x80) { - return chr($len); - } - $bytes = ''; - while ($len > 0) { - $bytes = chr($len & 0xFF) . $bytes; - $len >>= 8; - } - return chr(0x80 | strlen($bytes)) . $bytes; - } - - private function derEncodeInteger(string $bytes): string - { - // Remove leading zeroes - $bytes = ltrim($bytes, "\x00"); - if ($bytes === '') { - $bytes = "\x00"; - } - // Ensure positive INTEGER (prepend 0x00 if MSB set) - if ((ord($bytes[0]) & 0x80) !== 0) { - $bytes = "\x00" . $bytes; - } - return "\x02" . $this->derEncodeLength(strlen($bytes)) . $bytes; - } - - private function derEncodeSequence(string $bytes): string - { - return "\x30" . $this->derEncodeLength(strlen($bytes)) . $bytes; - } - - private function derEncodeBitString(string $bytes): string - { - // 0 unused bits + data - $payload = "\x00" . $bytes; - return "\x03" . $this->derEncodeLength(strlen($payload)) . $payload; - } } diff --git a/src/OAuth/Entities/Client.php b/src/OAuth/Entities/Client.php index c5f8092..127f344 100644 --- a/src/OAuth/Entities/Client.php +++ b/src/OAuth/Entities/Client.php @@ -260,4 +260,13 @@ class Client extends Model implements ClientEntityInterface $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']; + } }