Laravel

Laravel Passport – revoking the access token when testing

Introduction

This post is based on the comment of patricus on Stack Overflow. Visit the link for more information and examples.

Laravel Passport is an OAuth 2.0 server implementation allowing to authenticate users easily (for developers) and safely (for users) through API in Laravel. It is an additional package that can be downloaded and painlessly installed in Laravel application.

Typical case

Creating access tokens in Laravel Passport is no rocket science. We just take the user that made a request and utilize available methods to create such token:

$accessToken = auth()
    ->user()
    ->createToken("Access Token")
    ->accessToken;

This snippet may of course be seen mostly in the login() (or similar) method of some auth controller. Complementary, most likely in the logout() method of the same controller, there is going to be some snippet that revokes the created token:

$revoked = auth()
    ->user()
    ->token()
    ->revoke();

Without loss of generality, if both the app and Laravel Passport are installed and configured properly, everything will be working just fine... until we test it.

Testing problem

First, without focusing on the implementation of the auth controller, let's take a look at two unit tests that won't yet show any problems. The first one is about logging in using Laravel Passport:

/**
 * @test
 */
public function existingUserCanLogIn(): void
{
    // We try to log the user in...
    $loginResponse = $this->postJson("/api/login", [
        "email" => "example@email",
        "password" = "123",
    ]);

    // and we assert that the response contains the access token...
    $loginResponse
        ->assertSuccessful()
        ->assertJsonStructure(["access_token"]);
}

Whereas the second one logs the user in, and then asserts the successful logout:

/**
 * @test
 */
public function existingUserCanLogIn(): void
{
    // We try to log the user in...
    $loginResponse = $this->postJson("/api/login", [
        "email" => "example@email",
        "password" = "123",
    ]);

    // then we get the access token...
    $token = $loginResponse->getAccessToken();

    // and we try to log the user out...
    $logoutResponse = $this
        ->withBearerToken($token)
        ->postJson("/api/logout");

    // and we assert that the response is successful...
    $logoutResponse->assertOk();
}

Assuming that the auth controller works correctly, both tests are going to pass. The problem will appear when, inside a single test, we logout and then try to perform a request which requires the user to be authorized. At that point the request will succeed and a successful response will be returned by the server. Let's take a look at such test:

/**
 * @test
 */
public function userThatLoggedOutCannotAccessAuthorizedRoutes(): void
{
    // We create a user and authenticate them...
    $authentication = $this->authenticateExampleUser();

    // we get the access token...
    $token = $authentication->getAccessToken();

    // and then log the user out...
    $logoutResponse = $this
        ->withBearerToken($token)
        ->postJson("/api/logout");

    // and assert that we successfully logged the user out...
    $logoutResponse->assertOk();

    // and then try to get user details...
    $detailsResponse = $this
        ->withBearerToken($token)
        ->getJson("/api/user");

    // and assert that we get an error response...
    $detailsResponse->assertUnauthorized();
}

Despite the authentication shortcut (not to get verbose), after running the test, an assertion error will occur:

Response status code [200] is not an unauthorized status code.
Failed asserting that 200 is identical to 401.

If one looks at it, it seems that the server returned a 200 response while it should return a 401 error. That's right - it seems. In fact, the tested code does NOT result in any server being run or any request being sent. In reality everything happens under the hood, and the request call is "simulated". This causes a behavioural ambiguity between the actual server and the tests.

This case can actually be easily verified by using, for example, the Postman. After calling the same requests in the same order with the application, the server will return a 401 error as it should. It is caused by the fact, that the server watches the changes within the auth guards and clears the data of revoked tokens through the Auth Manager. This doesn't happen when testing, though.

Solution

The solution is rather intuitive. If the revoked tokens are not deleted between the tests (inside a single test method), we shall delete them manually. Here comes the function that I mentioned in the beginning of this post. It clears all the guards of the application. Because the properties are protected, they need to be accessed via the ReflectionProperty.

/**
 * Resets the authorization guards.
 *
 * @param array|null $guards
 * @return void
 */
protected function resetAuth(?array $guards = null): void
{
    $guards = $guards ?: array_keys(config("auth.guards"));

    foreach ($guards as $guard) {
        $guard = $this->app["auth"]->guard($guard);

        if ($guard instanceof SessionGuard) {
            $guard->logout();
        }
    }

    try {
        $protectedProperty = new ReflectionProperty($this->app["auth"], "guards");
        $protectedProperty->setAccessible(true);
        $protectedProperty->setValue($this->app["auth"], []);
    } catch (ReflectionException $exception) {
        echo $exception->getTraceAsString();
        exit;
    }
}

Now we only need to call this method just after logging out the user inside the test:

/**
 * @test
 */
public function userThatLoggedOutCannotAccessAuthorizedRoutes(): void
{
    // We create a user and authenticate them...
    $authentication = $this->authenticateExampleUser();

    // we get the access token...
    $token = $authentication->getAccessToken();

    // and then log the user out...
    $logoutResponse = $this
        ->withBearerToken($token)
        ->postJson("/api/logout");

    // and assert that we successfully logged the user out...
    $logoutResponse->assertOk();

    // and we reset the authorization guards...
    $this->resetAuth();

    // and then try to get user details...
    $detailsResponse = $this
        ->withBearerToken($token)
        ->getJson("/api/user");

    // and assert that we get an error response...
    $detailsResponse->assertUnauthorized();
}

And that's it. Thanks to this method, the simulated request will return a correct 401 reponse and the test will pass.

If one doesn't want the fun to be over, they might think of some abstraction to this solution, so that the method doesn't have to be called each time manually.

  • admin
  • , updated

Leave a Reply

Your email address will not be published. Required fields are marked *