You've already forked php-auth
generated from siteworxpro/Php-Template
✨ 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
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:
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -16,7 +16,7 @@ readonly class Scope
|
||||
*/
|
||||
public function __construct(
|
||||
private array $scopes = [],
|
||||
private string $claim = 'scope',
|
||||
private string $claim = 'scopes',
|
||||
private string $separator = ' '
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
60
src/Controllers/UserInfo.php
Normal file
60
src/Controllers/UserInfo.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user