skip to Main Content

I’m working on a Laravel 11 app, and using the API tokens functionality of Sanctum (no SPA, no session, no CSRF etc, REST API and tokens, routes in api.php not web.php). All is working well – I can issue tokens and use them to access routes protected by auth:sanctum middleware. I’m now adding abilities to the tokens, and using the abilities middleware to control access to some routes.

I’ve added the required middleware aliases in bootstrap/app.php:

$middleware->alias([
    ...
    'abilities' => LaravelSanctumHttpMiddlewareCheckAbilities::class,
    'ability' => LaravelSanctumHttpMiddlewareCheckForAnyAbility::class,
]);

And added an abilities check in a route in routes/api.php:

Route::middleware(['auth:sanctum', 'abilities:foo'])->group(function () {
    Route::get('/foo', [FooController::class, 'foo']);
});

Now I generate a token without any abilities:

$token_without_abilities = $user->createToken('name', [], Carbon::now()->addMinutes(10))->plainTextToken;

When I try to access the /foo route described above, using that token which does not include the required foo ability, I expect it to fail, and sure enough an exception is thrown. According to the source, in LaravelSanctumHttpMiddlewareCheckAbilities, MissingAbilityException should be thrown. I added some debug logging and verified that indeed is what seems to be happening, I reach the constructor of MissingAbilityException, and I am able to log the missing ability as $ability at that point.

I want to catch that exception and return a JSON response with the right error msg, so I added the following in my bootstrap/app.php:

use LaravelSanctumExceptionsMissingAbilityException;

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (MissingAbilityException $exception, Request $request) {
        Log::debug('MissingAbilityException exception: ' . printr_r($exception->abilities(), true);

        return response()->json([
            'message' => 'Missing ability ' . $exception->getMessage(),
        ]);
    });
});

But the exception is not caught, instead a AccessDeniedHttpException is thrown, and I end up with:

{
    "message": "Invalid ability provided.",
    "exception": "Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException",
    "file": "/var/www/vendor/laravel/framework/src/Illuminate/Foundation/Exceptions/Handler.php",
    "line": 633,
    "trace": [
        {
            "file": "/var/www/vendor/laravel/framework/src/Illuminate/Foundation/Exceptions/Handler.php",
            "line": 577,
            "function": "prepareException",
            "class": "Illuminate\Foundation\Exceptions\Handler",
            "type": "->"
        },
        {
            "file": "/var/www/vendor/laravel/framework/src/Illuminate/Routing/Pipeline.php",
            "line": 51,
            "function": "render",
            "class": "Illuminate\Foundation\Exceptions\Handler",
            "type": "->"
        },
... 

Edit: linked to wrong stack trace, fixed. Full stack trace.

The only hint I can find about this is this answer to a Github issue asking more or less the same as me. That answer suggests what I’m already doing, AFAICT, though in pre-Laravel 11 syntax.

If I try to catch AccessDeniedHttpException in bootstrap/app.php the same way, it does work, but I can’t seem to access the missing ability any more, $exception->abilities(); gives me a Call to undefined method.

How can I catch a MissingAbilityException and access the actual missing ability? The goal is to return a JSON response to the client with a message like You don't have the "foo" permission.

2

Answers


  1. To catch the MissingAbilityException and access the associated missing ability in a Laravel 11 application using Sanctum, I think you’ll need to customize the exception handling in your application correctly. The AccessDeniedHttpException is the one being thrown when the ability check fails, which is why your catch block is not being triggered as expected.

    Try Follow this:

    1. Catch the Exception at the Middleware Level: You should catch the MissingAbilityException directly in the middleware where the abilities are being checked. Since you already have the middleware setup, you can extend the middleware to handle the exception and return the desired JSON response.

    2. Create a Custom Middleware: Instead of using the default CheckAbilities middleware, you can create your own middleware to wrap the functionality. Here’s an example:

      // app/Http/Middleware/CustomCheckAbilities.php
      namespace AppHttpMiddleware;
      use Closure;
      use IlluminateHttpRequest;
      use LaravelSanctumExceptionsMissingAbilityException;
      use SymfonyComponentHttpKernelExceptionAccessDeniedHttpException;

       class CustomCheckAbilities
       {
           public function handle(Request $request, Closure $next, ...$abilities)
           {
               try {
                   // Call the original CheckAbilities logic
                   return app(LaravelSanctumHttpMiddlewareCheckAbilities::class)->handle($request, $next, ...$abilities);
               } catch (MissingAbilityException $exception) {
                   return response()->json([
                       'message' => 'You don't have the "' . $exception->getMessage() . '" permission.'
                   ], 403);
               } catch (AccessDeniedHttpException $exception) {
                   // Handle other access denied exceptions if necessary
                   return response()->json([
                       'message' => 'Access denied.'
                   ], 403);
               }
           }
       }
      
    3. Register Your Middleware: Register your custom middleware in

      app/Http/Kernel.php:

       protected $routeMiddleware = [
       // Other middleware
       'custom_abilities' => AppHttpMiddlewareCustomCheckAbilities::class,
      

      ];

    4. Use Your Custom Middleware in Routes: Now, you can use your custom middleware in your routes:

       Route::middleware(['auth:sanctum', 'custom_abilities:foo'])->group(function () {
       Route::get('/foo', [FooController::class, 'foo']);
      

      });

    5. Test Your Implementation: When you try to access the /foo route with a token that doesn’t have the "foo" ability, it should now return a JSON response indicating the missing ability:

      {
      "message": "You don’t have the ‘foo’ permission."
      }

    Using a custom middleware that extends the functionality of the existing CheckAbilities middleware, you gain access to the MissingAbilityException and can handle it appropriately, providing a clear response to the client.

    Login or Signup to reply.
  2. the MissingAbilityException is passed to the AccessDeniedHttpException, this one is inherits from some other Exception classes and more importantly from the RuntimeException class. The MissingAbilityException is passed up all the levels until it reaches the RuntimeException and you can (should, as i have not tested myself) access it with the getPrevious() method.

    So you would still catch the AccessDeniedHttpException $e, but you can access the MissingAbilityException by doing $e->getPrevious();

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search