Buy

Graceful Errors for an Invalid JWT

We already know that if the client forgets to send a token, Symfony calls the start() method:

97 lines src/AppBundle/Security/JwtTokenAuthenticator.php
... lines 1 - 19
class JwtTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 22 - 83
public function start(Request $request, AuthenticationException $authException = null)
{
... lines 86 - 94
}
}

But what happens if authentication fails?

Testing with a bad Token

Let's find out! Copy testRequiresAuthentication(), paste it, and rename it to testBadToken():

290 lines tests/AppBundle/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 6
class ProgrammerControllerTest extends ApiTestCase
{
... lines 9 - 277
public function testBadToken()
{
$response = $this->client->post('/api/programmers', [
'body' => '[]',
'headers' => [
'Authorization' => 'Bearer WRONG'
]
]);
$this->assertEquals(401, $response->getStatusCode());
$this->assertEquals('application/problem+json', $response->getHeader('Content-Type')[0]);
}
}

In this case, we will add a headers key and we will send an Authorization header... but set to Bearer WRONG.

If this happens, we definitely want a 401 status code and - like always - an application/problem+json response header. Let's just look for these two things for now.

How Authentication Fails

When JWT authentication fails, what handles that? Well, onAuthenticationFailure() of course:

97 lines src/AppBundle/Security/JwtTokenAuthenticator.php
... lines 1 - 19
class JwtTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 22 - 68
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
}
... lines 73 - 95
}

The getUser() method must return a User object. If it doesn't, then onAuthenticationFailure() is called. In our case, there are two possible reasons: the token might be corrupted or expired or - somehow - the decoded username doesn't exist in our database. In both cases, we are not returning a User object, and this triggers onAuthenticationFailure().

To start, just return a new JsonResponse that says Hello, but with the proper 401 status code:

97 lines src/AppBundle/Security/JwtTokenAuthenticator.php
... lines 1 - 19
class JwtTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 22 - 68
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
return new JsonResponse('Hello!', 401);
}
... lines 73 - 95
}

Copy the testBadToken method name and give it a try!

./vendor/bin/phpunit --filter testBadToken

ApiProblem on Failure

It almost works - that's a good start. It proves our code in onAuthenticationFailure() is handling things. Now, let's setup a proper API problem response, just like we did before: $apiProblem = new ApiProblem with a 401 status code:

101 lines src/AppBundle/Security/JwtTokenAuthenticator.php
... lines 1 - 19
class JwtTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 22 - 68
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
$apiProblem = new ApiProblem(401);
... lines 72 - 75
}
... lines 77 - 99
}

Then, use $apiProblem->set() to add a detail field. And in this case, we always have an AuthenticationException that can hint what went wrong. Use its getMessageKey() method:

101 lines src/AppBundle/Security/JwtTokenAuthenticator.php
... lines 1 - 70
$apiProblem = new ApiProblem(401);
// you could translate this
$apiProblem->set('detail', $exception->getMessageKey());
... lines 74 - 101

Oh, and by the way - if you want, you can send this through the translator service and translate into multiple languages.

Finish this with return $this–>responseFactory->createResponse() to turn the $apiProblem into a nice JSON response:

101 lines src/AppBundle/Security/JwtTokenAuthenticator.php
... lines 1 - 70
$apiProblem = new ApiProblem(401);
// you could translate this
$apiProblem->set('detail', $exception->getMessageKey());
return $this->responseFactory->createResponse($apiProblem);
... lines 76 - 101

That's it! We did all the hard work earlier.

I want to actually see how this response looks. So, add a $this->debugResponse() at the end of testBadToken():

291 lines tests/AppBundle/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 6
class ProgrammerControllerTest extends ApiTestCase
{
... lines 9 - 277
public function testBadToken()
{
... lines 280 - 287
$this->debugResponse($response);
}
}

Now, re-run the test!

./vendor/bin/phpunit --filter testBadToken

Check that out - it's beautiful! It has all the fields it needs, including detail, which is set to Invalid token.

Controlling Error Message

That text is coming from our code, when we throw the CustomUserMessageAuthenticationException. The text - Invalid token - becomes the "message key" and this exception is passed to onAuthenticationFailure().

This gives you complete control over how your errors look.

Leave a comment!

  • 2016-11-04 irishdan

    Had the same issue. Code above works like a charm. Thanks

  • 2016-10-21 weaverryan

    Hey John!

    Ah, ok - I've got it on my list to run through the tutorial with v2 and see what we need to change on our side.

    The problem (actually it's awesome - the bundle author we great enough to make this change by my request for version 2) is in getUser() of our JwtTokenAuthenticator. In v1.4, if decode() failed, it returned false - and we're handling this very nicely. The problem was that, as a user of the bundle, we couldn't get more information about *why* it failed (was the token invalid? expired?). So in v2, the bundle throws different types of exceptions on failure, which kind of awesome (different exception classes for each failure type).

    Because of this change, we need to surround that line in a try-catch, something like this:


    public function getUser($credentials, UserProviderInterface $userProvider)
    {
    try {
    $data = $this->jwtEncoder->decode($credentials);
    } catch (JWTDecodeFailureException $e) {
    // if you want to, use can use $e->getReason() to find out which of the 3 possible things went wrong
    // and tweak the message accordingly
    // https://github.com/lexik/LexikJWTAuthenticationBundle/blob/05e15967f4dab94c8a75b275692d928a2fbf6d18/Exception/JWTDecodeFailureException.php

    throw new CustomUserMessageAuthenticationException('Invalid Token');
    }

    // ...
    }

    That should do it :). Also, another cool thing about the new version is that they have added their *own* Guard authenticator to the bundle, which is based off of our's in this tutorial. If you want to, you can actually use it instead of building your own (building your own is still a good exercise, but now you have the opportunity to not *need* to do this).

    Cheers!

  • 2016-10-20 John Armstrong

    I was passing an invalid token and that's when it responds with that html error. But on the 1.4 version, with an invalid token, I get the json response of "Invalid token".

  • 2016-10-20 weaverryan

    Hey John!

    Well, I'm actually glad you installed the brand-new version 2 - I'm hoping we can make that work without too much effort! And thanks to your comment and the awesome maintainer of that bundle, version 2.0.1 fixes the autowiring problem: https://github.com/lexik/Lexik.... Fast service!

    About your error, if the token is correct, you don't get that error. That's good :). So, when exactly *do* you get that error? Is it when you pass *no* token? Or is it when you pass an invalid token (perhaps because you're testing to see how that works)? Is that behavior different on 2.0 versus 1.4 of the bundle?

    Cheers!

  • 2016-10-20 John Armstrong

    Just for the record, I dropped version 2 of the jwt bundle and installed the version you used 1.4. Everything works like a charm!

  • 2016-10-20 John Armstrong

    I found the h1 in the terminal, this error makes no sense to me:
    Unable to verify the given JWT through the given configuration. If the "lexik_jwt_authentication.encoder" encryption options have been changed since your last authentication, please renew the token. If the problem persists, verify that the configured keys/passphrase are valid.

    If the token is right, I don't get this error. I get my response from the controller.

    Also, I did post another question on part 3 of this course about trying to get those key files up to Heroku and configured properly. I've found nothing on the subject for guidance. Does anyone use Heroku for Symfony apps/apis??

  • 2016-10-20 weaverryan

    Hey John!

    Hmm, so usually, when you see a ton of HTML in your terminal, it's because you're seeing Symfony's HTML 500 exception page. But, it's also possible is that you're somehow seeing one of *your* HTML pages (not an exception page). But, it's probably an exception of some sort - our system only returns JSON if (a) we return JSON from the matched controller or (B) we are handling some ApiProblemException, where we've built extra logic to return JSON.

    Try one of these 2 things:
    1) Make the request that fails (I assume when you do this, you're hitting your application in the "dev" environment?). Then, go to http://localhost:8000/app_dev.php/_profiler. You should see a list of requests - and the one that you JUST made should be on top (or perhaps second, but at least very near the top). Click the sha link on the right to enter the profiler for that request. Then, check out the Exception tab - it should show you the HTML exception so you can read it. If you're doing this in a test, then you can also do this - but you'll need to temporarily turn the profiler on by setting the "collect" key to true and the "toolbar" key also to true in config_test.yml (https://github.com/symfony/sym.... Then, make sure you go to http://localhost:8000/app_test.php/_profiler

    2) Or, just try to read the HTML exception message in your terminal. It's not super-easy at first, but if you scroll all to the top and start scanning down, you'll eventually see an h1 that has the exception message in it. You could alternatively try to tail var/cache/dev.log (or test.log) to see the exception there.

    So, these are both strategies to see *what* the exception is, so that we can fix it (I'm still assuming that the problem is an exception!).

    About the JWT fix, thanks for posting that! It sounds like an update to the library may have introduced a second service which implements this interface. I'll go look at the library now to see if I can have those guys fix that in the bundle to make our lives easier.

    Cheers!

  • 2016-10-19 John Armstrong

    I am having trouble with the error not returning as a json response when I use a wrong or invalid token. I get a ton of html that overloads my terminal on a curl. It works fine if I put a valid token in the Bearer Header. My route method is GET. Any ideas what maybe happening? also, another issue I ran into earlier but forgot to comment there was the autowire for jwt service. I got this error:

    Unable to autowire argument of type "Lexik\Bundle\JWTAuthenticationBundle\Encoder\JWTEncoderInterface" for the service "app.security.jwt_token_authenticator". Multiple services exist for this interface (lexik_jwt_authentication.encoder.default, lexik_jwt_authentication.encoder.lcobucci).

    So I used arguments: ['@lexik_jwt_authentication.encoder.default', '@doctrine.orm.entity_manager', '@api.response_factory']