fix: make Scope attribute repeatable and improve scope handling in middleware (#21)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m55s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m55s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m58s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m1s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m40s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m0s

Reviewed-on: #21
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
This commit was merged in pull request #21.
This commit is contained in:
2025-11-19 19:32:52 +00:00
committed by Siteworx Pro Gitea
parent 9b736eb879
commit 7d9eb96bea
4 changed files with 59 additions and 23 deletions

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Attributes\Guards;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
readonly class RequireAllScopes
{
}

View File

@@ -6,13 +6,18 @@ namespace Siteworxpro\App\Attributes\Guards;
use Attribute; use Attribute;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
readonly class Scope readonly class Scope
{ {
/**
* @param array<int, string> $scopes the required scopes
* @param string $claim the claim to check for scopes
* @param string $separator the separator used to split scopes in the claim
*/
public function __construct( public function __construct(
private array $scopes = [], private array $scopes = [],
private string $claim = 'scope', private string $claim = 'scope',
private string $separator = ' ', private string $separator = ' '
) { ) {
} }

View File

@@ -22,7 +22,8 @@ class IndexController extends Controller
* @throws \JsonException * @throws \JsonException
*/ */
#[Guards\Jwt] #[Guards\Jwt]
#[Guards\Scope(['get.index'])] #[Guards\Scope(['get.index', 'status.check'])]
#[Guards\RequireAllScopes]
public function get(ServerRequest $request): ResponseInterface public function get(ServerRequest $request): ResponseInterface
{ {
return JsonResponseFactory::createJsonResponse(['status_code' => 200, 'message' => 'Server is running']); return JsonResponseFactory::createJsonResponse(['status_code' => 200, 'message' => 'Server is running']);

View File

@@ -8,6 +8,7 @@ use League\Route\Dispatcher;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use Siteworxpro\App\Attributes\Guards\RequireAllScopes;
use Siteworxpro\App\Attributes\Guards\Scope; use Siteworxpro\App\Attributes\Guards\Scope;
use Siteworxpro\App\Controllers\Controller; use Siteworxpro\App\Controllers\Controller;
use Siteworxpro\App\Http\JsonResponseFactory; use Siteworxpro\App\Http\JsonResponseFactory;
@@ -32,7 +33,7 @@ class ScopeMiddleware extends Middleware
* Expected user scopes are provided on the request under the attribute name \`scopes\` * Expected user scopes are provided on the request under the attribute name \`scopes\`
* as an array of strings. * as an array of strings.
* *
* @param ServerRequestInterface $request Incoming PSR-7 request (expects \`scopes\` attribute). * @param ServerRequestInterface $request Incoming PSR-7 request (expects \`scopes\` attribute).
* @param RequestHandlerInterface|Dispatcher $handler Next handler or League\Route dispatcher. * @param RequestHandlerInterface|Dispatcher $handler Next handler or League\Route dispatcher.
* *
* @return ResponseInterface A 403 JSON response when scopes are insufficient; otherwise the handler response. * @return ResponseInterface A 403 JSON response when scopes are insufficient; otherwise the handler response.
@@ -42,7 +43,7 @@ class ScopeMiddleware extends Middleware
*/ */
public function process( public function process(
ServerRequestInterface $request, ServerRequestInterface $request,
RequestHandlerInterface | Dispatcher $handler RequestHandlerInterface|Dispatcher $handler
): ResponseInterface { ): ResponseInterface {
// Attempt to resolve the route's callable [Controller instance, method name]. // Attempt to resolve the route's callable [Controller instance, method name].
$callable = $this->extractRouteCallable($handler); $callable = $this->extractRouteCallable($handler);
@@ -57,38 +58,55 @@ class ScopeMiddleware extends Middleware
// Ensure the controller exists and the method is defined before reflecting. // Ensure the controller exists and the method is defined before reflecting.
if (class_exists($class::class)) { if (class_exists($class::class)) {
$reflectionClass = new \ReflectionClass($class); $reflectionClass = new \ReflectionClass($class);
if ($reflectionClass->hasMethod($method)) { if ($reflectionClass->hasMethod($method)) {
$reflectionMethod = $reflectionClass->getMethod($method); $reflectionMethod = $reflectionClass->getMethod($method);
// Fetch all Scope attributes declared on the method. // Fetch all Scope attributes declared on the method.
$attributes = $reflectionMethod->getAttributes(Scope::class); $attributes = $reflectionMethod->getAttributes(Scope::class);
$requireAllAttributes = $reflectionMethod->getAttributes(RequireAllScopes::class);
if (empty($attributes)) {
// No scope attributes; delegate to the next handler.
return $handler->handle($request);
}
$requiredScopes = [];
$userScopes = [];
$requireAll = false;
foreach ($attributes as $attribute) { foreach ($attributes as $attribute) {
/** @var Scope $scopeInstance Concrete Scope attribute instance. */ /** @var Scope $scopeInstance Concrete Scope attribute instance. */
$scopeInstance = $attribute->newInstance(); $scopeInstance = $attribute->newInstance();
$requiredScopes = $scopeInstance->getScopes(); $requiredScopes = array_merge($requiredScopes, $scopeInstance->getScopes());
// Retrieve user scopes from the request (defaults to an empty array). // If any attribute requires all scopes, set the flag.
$userScopes = $request->getAttribute($scopeInstance->getClaim(), []); $requireAll = $requireAll || !empty($requireAllAttributes);
if (!is_array($userScopes)) { $scopes = $request->getAttribute($scopeInstance->getClaim());
if (!is_array($scopes)) {
// If user scopes are not an array, treat as no scopes provided. // If user scopes are not an array, treat as no scopes provided.
$userScopes = explode($scopeInstance->getSeparator(), (string) $userScopes); $scopes = explode($scopeInstance->getSeparator(), (string) $scopes);
} }
// Deny if any required scope is missing from the user's scopes. $userScopes = array_merge(
if ( $userScopes,
array_any( $scopes
$requiredScopes, );
fn($requiredScope) => !in_array($requiredScope, $userScopes, true) }
)
) { $userScopes = array_unique($userScopes);
return JsonResponseFactory::createJsonResponse([
'error' => 'insufficient_scope', // Deny if any required scope is missing from the user's scopes.
'error_description' => if (
'The request requires higher privileges than provided by the access token.' (!$requireAll && array_intersect($userScopes, $requiredScopes) === []) ||
], CodesEnum::FORBIDDEN); ($requireAll && array_diff($requiredScopes, $userScopes) !== [])
} ) {
return JsonResponseFactory::createJsonResponse([
'error' => 'insufficient_scope',
'error_description' =>
'The request requires higher privileges than provided by the access token.'
], CodesEnum::FORBIDDEN);
} }
} }
} }