Laravel Sanctum

Laravel Sanctum and OpenAPI Auth

Introduction

Malik Sharfo
11 min readDec 6, 2021

--

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 DBmethod 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_correcttest. 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 the Sanctum 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 the Sanctum middleware, we authorize our user 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 HTTP status 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.

--

--