Laravel Sanctum and OpenAPI Auth
Introduction
Welcome to part five. If you have not read any of the previous parts we highly recommend that you go back and read those, starting here.
A link to the github repository used, will be provided at the end of this article.
What is Sanctum?
Laravel Sanctum is a Laravel package, which provides an authentication for SPA, also known as single-page-applications, mobile applications and token based APIs.
For each user of your application Sanctum
allows each of those users to generate multiple API tokens for their account. Each token can be granted an ability which specifies what the given token is allowed to perform of actions.
Sanctum on API based applications
With Sanctum
you can issue API tokens to your users without the complication of using OAuth
. Similar to how Github issues PSA (personal-access-tokens) to their users, Sanctum
works in that same way. With Sanctum
you can generate and manage those API tokens.
The lifetime of a token is very long, typically the expiration time is years, though a token given to a user can be revoked manually at any time by that said user.
How Laravels Sanctum
offers this feature is by storing the generated API tokens for each user in a database table and authenticates incoming HTTP requests by using the Authorization
header, in which the header should contain a valid API token.
Getting started with Sanctum
The most recent Laravel applications already include Sanctum
in them. If you can’t locate the package inside your composer.json
file you can use the composer package manager to install it.
In your terminal which points to your Laravel project, use the following:
composer require laravel/sanctum
The next step that should be taken is to publish the Sanctum
configuration and migration files by using the vendor:publish
Artisan command.
php artisan vendor:publish provider=”Laravel\Sanctum\SanctumServiceProvider”
The Sanctum
configuration file will be placed inside your application’s config
file.
After you have run the above mentioned command, you should finally be able to run your database migrations. Sanctum
will create in your database a table that will store the API tokens. The Artisan command to run a migration is php artisan migrate
.
The result should be a generated database table named personal_access_tokens
.
Adding Sanctum to our middleware
We add this middleware to our /users
route, inside api.php
:
Route::middleware('auth:sanctum')->apiResource('/users', UserController::class);
What this does is that now all the endpoints related to /users
have now been protected. This means that any unauthenticated and unauthorized attempt to access any of these /users
endpoints will not be working and a HTTP status code 401 (unauthorized)
will be returned. By adding Sanctum
to our middleware, we now also have to adjust our UserController
and our UserTest
accordingly, and this will be addressed in the “Changes and additions along the way” section at the end of this article.
How to annotate Sanctum
For this purpose we have created a new Controller
named SanctumController
.
In the SanctumController
:
namespace App\Http\Controllers;use OpenApi\Generator;
use App\Forms\TokenForm as Form;
class SanctumController extends Controller
{
/**
* @OA\Post(path="/api/auth", tags={"Retrieve Authorization Token"},
* summary="Post your email and password and we will return a token. Use the token in the 'Authorization' header like so 'Bearer YOUR_TOKEN'",
* operationId="",
* description="",
* @OA\RequestBody(
* required=true,
* description="The Token Request",
* @OA\JsonContent(
* @OA\Property(property="email",type="string",example="your@email.com"),
* @OA\Property(property="password",type="string",example="YOUR_PASSWORD"),
* )
* ),
* @OA\Response(
* response=200,
* description="OK",
* @OA\JsonContent(ref="#/components/schemas/TokenRequest")
* ),
* @OA\Response(response=422, description="The provided credentials are incorrect.")
* )
*/
public function create(Form $request)
{
return $request->generate();
}
}
As seen with UserController
, we have also annotated our SanctumController
in similar way. Except we have added a few more @OA
annotations, but the principle remains the same. If you are unsure about how to annotate feel free to go back to one of the previous parts where the use of OpenAPI annotations have been explained.
We have created a create
function which takes our TokenForm
as a param. The class TokenForm
is created based on the same idea as with UserForm
. TokenForm
looks as following:
<?phpnamespace App\Forms;use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Request;
use Validator;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Facades\Hash;
use App\Models\User;class TokenForm extends FormRequest { public function authorize()
{
return true;
} public function rules()
{
return [
'email' => 'required|string|email',
'password' => 'required',
];
} public function generate()
{
$validator = Validator::make($this->all(), [
'email' => 'required|string|email',
'password' => 'required'
]); $validated = $validator->validated(); $user = User::where('email', $validated['email'])->first(); if(!$user || !Hash::check($validated['password'], $user->password))
{
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
return $user->createToken($user->id)->plainTextToken;
}
}
The authorize()
and rules()
functions are a part of Laravels FormRequest
.
The function that plays the major role is the generate
function.
We make a validator that checks for if the form request data properties email
and password
are passed along with the request. Then we retrieve the validated request and store it inside the $validated
variable.
Then we need to make sure that the email
from the form request is actually an existing one, therefore we look up the email
inside the users
database table by using Laravels eloquent way to interact with a database, and we retrieve the first data we find in the users
table. This gets stored inside the $user
variable.
Just like in a log in system, we want to make sure that the form request data matches with the users
table data, we want to verify it. We need to validate that we actually have an existing user, and that the password from the form request data matches with the password that is stored in the users
table for that given user.
In our if
statement we try to validate that form request data, we check for if the user in general does not exist, or if the users password
that we retrieve does not match with the form request password
. We throw a validation exception if any of those two scenarios turns out to be the case.
Regarding Hash
, Hash
is a package or facade provided by Laravel. With this, the Hash
facade provides Bcrypt
and Argon2
to hash passwords. In our case we want to “check” if the form request data password matches with the password that is stored in the users
table for that requested email data.
By using the check
which is provided by Hash
, we take both passwords and compare them with each other. This check
method allows you to verify that a given plain-text string, in our case the form request data password, corresponds to a given hash, which is the password from the users
table.
Again we check for these conditions inside the if
statement and handle the potential error inside it.
If all these data matches, meaning the form request email
and password
with the database email
and password
for the given user, we issue out a token to that user by calling the createToken
method on the user object. The createToken
method returns a new instance of Laravel\Sanctum\NewAccessToken
in which this gets stored inside the database table named personal_access_tokens
table.
Testing SanctumController
With all set and done, we can’t move further if do not test our functionalities. We need to test the OpenAPI specs and how the server handles the form request data.
For this we have created a new test file by using the Artisan command php artisan make:test
, and named our test file SanctumTest
.
Testing serverside alone
The first test we conducted is how our serverside would handle correct inputted data, and if the token that gets created, gets stored inside personal_access_tokens
table. The test looks like below and looks very familiar to another test we have conducted inside UserTest
.
...
use App\Models\User;
...
use Illuminate\Support\Facades\DB;class SanctumTest extends TestCase { private string $tokenEndpoint = "/api/auth"; ... private User $user; protected function setUp(): void
{
parent::setUp(); ... $this->user = User::find(1);
} public function test_that_token_gets_created_serverside()
{
DB::table('personal_access_tokens')->where('tokenable_id', $this->user->id)->delete(); $userInput = [
'email' => $this->user->email,
'password' => 'asd',
]; if(!$this->user || !Hash::check($userInput['password'], $this->user->password))
dd('You did not supply the actual password of the user'); $this->postJson($this->tokenEndpoint, $userInput)->assertStatus(200); $this->assertDatabaseHas('personal_access_tokens', [
'tokenable_id' => $this->user->id,
]);
}
}
First things first, since a user can have multiple tokens, the best way to assert that a user gets a token created, is by first deleting all the tokens for that user. Therefore we use Laravels DB
method delete
to delete all tokens for the user by the given id
inside the personal_access_tokens
table.
We then create $userInput
which would correspond to the potential form request data. Inside it we use the email of an already existing user, which we set inside the setUp
method. Since we already know that we have a user with the id
of 1 and we already know the password
of that user, it makes for the perfect mock data.
Next we need to make sure that we have a user, and that the users password matches, to make it similar to how the TokenForm
generate()
function was made, otherwise when using postJson
we would always get HTTP status code 200 (OK)
, regardless of if these passwords actually matched or not, so we wanted to make it more realistic. And therefore we use an if
statement to check on the data.
As with similar tests from previous, postJson
is a helper method provided by Laravel to test Json APIs and their responses. Followed by that we assertStatus
and see that the response we do get from postJson
matches with the OpenAPI specs we specified for our create
function inside SanctumController
.
Expecting that we do get HTTP status code 200 (OK)
we can go ahead and assert that a token gets created and stored inside the personal_access_tokens
database table. The field tokenable_id
is the field that is linked to the users id
, therefore we can assertDatabaseHas
this newly created token which is linked to the users id
.
Testing that Swagger accepts a payload
The next test to conduct is a test where we assert that Swagger accepts a correctly filled payload. As this test is similar to test_that_swagger_validates_good_payload
from UserTest
we will not dive into an explanation, as an explanation would be similar to the one we have for test_that_swagger_validates_good_payload
. If you want to know the whys and hows behind test_that_swagger_validates_correctly_filled_payload_for_token_creation
you can find the explanation in a previous article.
For now here is how test_that_swagger_validates_correctly_filled_payload_for_token_creation
looks like:
...class SanctumTest extends TestCase
{
private string $tokenEndpoint = "/api/auth"; private OperationAddress $address;
private RoutedServerRequestValidator $validator; private array $mockPayload = [
'email' => 'updating@email.com',
'password' => 'asd'
]; protected function setUp(): void
{
... $this->address = new OperationAddress($this->tokenEndpoint, 'post');
$this->validator = (new ValidatorBuilder)
->fromJson(file_get_contents('http://127.0.0.1:8000/api/swagger.json ))
->getRoutedRequestValidator(); ...
} public function test_that_swagger_validates_correctly_filled_payload_for_token_creation()
{
$request = $this->wrapServerRequest($this->mockPayload); try {
$this->validator->validate($this->address, $request);
$this->addToAssertionCount(1);
} catch (InvalidBody $e) {
$latestException = $e->getMessage();
$previousException = $e->getPrevious()->getMessage();
$exceptionLocation = implode(".", $e->getPrevious()->dataBreadCrumb()->buildChain());
$this->fail("$latestException $previousException $exceptionLocation");
}
} private function wrapServerRequest(array $payload): ServerRequest
{
return (new ServerRequest('post', $this->tokenEndpoint))
->withHeader('Content-Type', 'application/json')
->withBody(Utils::streamFor(json_encode($payload)));
}
}
What you need to know about this test is, that as long as your form request data contains email
and password
, regardless of if they exist in the users
database table, this test will always pass, because you test your Swagger side which has nothing to do with your database.
The wrapServerRequest
is the one that makes your server request.
Testing swagger and serverside fails, together
The last test conducted it to the test_that_swagger_and_serverside_fails_if_payload_is_not_filled_correct
.
...class SanctumTest extends TestCase
{
... private array $mockPayload = [
'email' => 'updating@email.com',
'password' => 'asd' ]; public function createStatusProvider()
{
return [
'Missing password' => [422, ['email' => $this->mockPayload['email']], 'password'],
'Missing email' => [422, ['password' => $this->mockPayload['password']], 'email'],
];
} /**
* @dataProvider createStatusProvider
*/
public function test_that_swagger_and_serverside_fails_if_payload_is_not_filled_correct($status, array $payload, string $expectedKeyOfFailure)
{
DB::table('personal_access_tokens')->where('tokenable_id', $this->user->id)->delete(); $request = $this->wrapServerRequest($payload); try {
$this->validator->validate($this->address, $request);
} catch (InvalidBody $e) {
$latestException = $e->getMessage();
$previousException = $e->getPrevious()->getMessage();
$exceptionLocation = implode(".", $e->getPrevious()->dataBreadCrumb()->buildChain());
$expectedKeyOfFailure == $exceptionLocation ? $this->addToAssertionCount(1) : $this->fail("$latestException $previousException $exceptionLocation");
}
$response = $this->postJson($this->tokenEndpoint, $payload);
$response->assertStatus($status, $response);
$response->assertJsonValidationErrors([$expectedKeyOfFailure]);
$this->assertDatabaseMissing('personal_access_tokens', [
'tokenable_id' => $this->user->id,
]);
}
}
As you might have noticed we are using DataProvider
once again, if you want to know why we are using a DataProvider
you can go back and read about it here.
This test in itself is also similar to the UserTest
test_that_swagger_and_serverside_fails_if_payload_is_not_correct
test. Basically the data we send with our DataProvider
named createStatusProvider
is a form request that is not filled correctly, and our test function runs those scenarios set up inside the return
statement.
Followed by that we make sure to empty the fields inside the personal_access_tokens
for our given user
, due to the fact that a user
can have multiple tokens, and we want to try and assert that a token on a wrongly filled form request does not generate any tokens for the user trying to generate the given token.
Changes and additions along the way
- Inside
UserController
the following OpenAPI specs have been added
.../**
* @OA\SecurityScheme(
* securityScheme="bearerAuth",
* type="http",
* scheme="bearer",
* bearerFormat="JWT",
* )
*/
...
class UserController extends Controller
{
/**
* @OA\Get(path="/api/users",
* security={"bearerAuth": {}},
* ...
* ...
* ...
* ...
* ...
* @OA\Response(response=401, description="Unauthorized"),
* ...
* )
*/
public function index(Request $request)
{
return User::all();
} /**
* @OA\Post(path="/api/users",
* security={"bearerAuth": {}},
* ...
* ...
* ...
* ...
* @OA\Response(response=401, description="Unauthorized"),
* ...
* )
*/
public function store(Form $request)
{
return $request->saveOrUpdate();
} /**
* @OA\Patch(path="/api/users/{userId}",
* security={"bearerAuth": {}},
* description="Update user based on user id",
* operationId="",
* @OA\Parameter(
* name="userId",
* in="path",
* @OA\Schema(
* type="string",
* ),
* required=true,
* description="Numeric ID of the user to patch",
* ),
* ...
* ...
* @OA\Response(response=401, description="Unauthorized"),
* ...
* )
*/
public function update(Form $request, int $id)
{
return $request->saveOrUpdate($id);
}}
On top of adding the security={"bearerAuth": {}}
and the @OA\Response(response=401, description="Unauthorized")
we also fixed a syntax error for our Swagger documentation that addressed the {userId}
endpoint param, when trying to update
a user.
- Inside our
UserTest
due to the addition of above, all test that was made would now fail, due to theSanctum
middleware applied on the/users
endpoint. That is because we are trying to perform actions which are not authorized, and to address that inside each test that was created before the addition of theSanctum
middleware, we authorize ouruser
to being able to perform any action without being unathorized. This was addressed in this manner:
...
use Laravel\Sanctum\Sanctum;class UserTest extends TestCase
{
...
private User $privilegedUser; protected function setUp(): void
{
...
$this->privilegedUser = User::find(2);
}
}
And inside each previously test adding the following line Sanctum::actingAs($this->privilegedUser)
before each attempt of reaching our endpoint
. The Sanctum::actingAs
method may be used to authenticate a user.
This line was not added inside the test_that_swagger_validates_good_payload
and the test_that_swagger_reaches_correct_endpoint_method
due to the reason that Swagger does not have the job of authorization, but only to validate the form request.
- Lastly three extra tests have been made to assert that an
unauthorized
attempt returns HTTPstatus code 401 (unauthorized)
as specified in the OpenAPI specs.
class UserTest extends TestCase
{
... public function test_that_correct_payload_cannot_be_created_due_to_unathorization()
{
$initialCount = User::all()->count();
$response = $this->postJson($this->userEndpoint, $this->mockPayload);
$response->assertStatus(401, $response);
$this->assertDatabaseCount('users', $initialCount);
} public function test_that_existing_user_cannot_be_updated_due_to_unathorization()
{
$user = User::find(1);
$updatedMockPayloadUser = [
'name' => 'unauthorizedNameChange',
'email' => 'updating@email.com',
]; $response = $this->patchJson("{$this->userEndpoint}/{$user->id}", $updatedMockPayloadUser);
$response->assertStatus(401); $this->assertDatabaseMissing('users', [
'name' => $updatedMockPayloadUser['name'],
'email' => $updatedMockPayloadUser['email']
]);
} public function test_that_user_cannot_see_list_of_all_users_due_to_unauthorization()
{
$userIdsInDatabase = User::pluck('id');
$response = $this->json('GET', $this->userEndpoint);
$response->assertStatus(401);
$userIdsInResponse = collect(json_decode($response->content()))->pluck('id');
$this->assertFalse($userIdsInResponse->diff($userIdsInDatabase)->isEmpty());
}
}
These are three tests are almost the exact same as the other tests, except this time we test for HTTP status code 401 (unauthorized)
and that we do not fiddle with the users
database table since the user trying to attempt these different operations, was unauthorized
.
It would be easier to view the changes and additions applied by viewing the github repo for this commit. This will be linked right below.
Github repository for this article can be found here.