Hi @adsofts ,
This is a Laravel convention I forgot to mention.
Let's look into Laravel\Passport\Http\Middleware\CheckClientCredentials code to see what it does when the request is unauthenticated:
protected function validateCredentials($token)
{
if (! $token) {
throw new AuthenticationException;
}
}
https://github.com/laravel/passport/blob/5758a35582156ca0a116106389dd3d7f1055e1bf/src/Http/Middleware/CheckClientCredentials.php#L18-L23
So when no token is present it throws an AuthenticationException.
Then the base Exception Handler class handles an AuthenticationException like this:
protected function unauthenticated($request, AuthenticationException $exception)
{
return $request->expectsJson()
? response()->json(['message' => $exception->getMessage()], 401)
: redirect()->guest($exception->redirectTo() ?? route('login'));
}
https://github.com/laravel/framework/blob/c2d60b5ac186af29219549daf0806b4c9cdc4a21/src/Illuminate/Foundation/Exceptions/Handler.php#L384-L389
Problem is it checks for $request->expectsJson(). Let's look into the Request object (actually in the trait InteractsWithContentTypes used by it) to see what it returns in this method:
public function expectsJson()
{
return ($this->ajax() && ! $this->pjax() && $this->acceptsAnyContentType()) || $this->wantsJson();
}
https://github.com/laravel/framework/blob/c2d60b5ac186af29219549daf0806b4c9cdc4a21/src/Illuminate/Http/Concerns/InteractsWithContentTypes.php#L42-L45
So we have a couple of checks:
-
$this->ajax() checks if the header X-Requested-With with the exact value XMLHttpRequest is present
-
$this->pjax() checks if the header X-PJAX is present
-
$this->acceptsAnyContentType() checks if header Accept is missing or it has one of these values: */*, or *
-
$this->wantsJson() checks if header Accept is missing or it value contains one o these strings: /json, or +json
As by the example I sent you no additional headers beyond the Authorization header are sent, $request->expectsJson() evaluates to false, and Laravel assumes the request is a regular browser request, thus trying to redirect to the login page.
One note: When comparing each constraint return value form the list above, remember to check against the
$request->expectsJson() return boolean expression to see if the combined result evaluates to true.
Failing to meet this requirements is a very common as we often forget to add one of these headers.
Even Laravel acknowledges that in some way, by including this line in the default JavaScript scaffold:
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
https://github.com/laravel/laravel/blob/3adc2196f79fa4d8470d41d5a7584f2b0432a6fc/resources/js/bootstrap.js#L11
So it configures Axios (the most used HTTP client in JavaScript these days) to add the X-Requested-With: XMLHttpRequest header in every call to avoid unwanted redirects.
So what to do? Ask API consumers to meet additional requirements beyond sending the Authorization header? Doing this defeats the "Principle of least astonishment":
https://en.wikipedia.org/wiki/Principle_of_least_astonishment
What I do in my apps that expose an API is creating this middleware:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class EnsureJsonResponseForApi
{
public function handle(Request $request, Closure $next)
{
if ($request->is('api/*')) {
$request->headers->set('Accept', 'application/json', true);
}
return $next($request);
}
}
What it does is checking if the request's path starts with api/, and if so it sets the Accept header with a value of application/json. So later, if an exception is thrown, every check call to $request->expectsJson() returns true.
Note that the last parameter on the $request->headers->set(...) is true. The tells Laravel to replace any previous value if an Accept header is already present.
If your API can return content types different than json you can try setting the X-Requested-With header to XMLHttpRequest. Or just removing that parameter.
With this middleware available, I add it as the very first middleware to be run on the $middleware array in my ./app/Http/Kernel.php file:
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array
*/
protected $middleware = [
// Add as the very first one, to the first middleware array,
// the global HTTP middleware stack.
// not inside $middlewareGroups or $routeMiddleware
\App\Http\Middleware\EnsureJsonResponseForApi::class,
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Fruitcake\Cors\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
// ... other code
}
This will ensure JSON responses for every request in the /api path, even when the app is in maintenance mode.
If your app only exposes an API, and you want to prevent such redirects altogether, you could even skip the if check in the EnsureJsonResponseForApi and always set the Accept: application/json header.
Hope it makes sense. Forgot to mention about this issue before, sorry.
As an additional note:
This issue is not related on using Laravel Passport, Laravel Sanctum is also prone to it, or even using the built in Token Guard.
It is related on how Laravel handles some exceptions assuming a request is from a regular web app.