Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

JSON Errors in your API

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Whenever something goes wrong in our API, we have a great setup: we always get back a descriptive JSON structure with keys that describe what went wrong:

... lines 1 - 6
class ProgrammerControllerTest extends ApiTestCase
{
... lines 9 - 255
public function test404Exception()
{
... lines 258 - 261
$this->assertEquals(404, $response->getStatusCode());
$this->assertEquals('application/problem+json', $response->getHeader('Content-Type')[0]);
$this->asserter()->assertResponsePropertyEquals($response, 'type', 'about:blank');
$this->asserter()->assertResponsePropertyEquals($response, 'title', 'Not Found');
$this->asserter()->assertResponsePropertyEquals($response, 'detail', 'No programmer found with nickname "fake"');
}
... lines 268 - 276
}

I want to do the exact same thing when something goes wrong with authentication.

Open up the TokenControllerTest:

... lines 1 - 6
class TokenControllerTest extends ApiTestCase
{
... lines 9 - 22
public function testPOSTTokenInvalidCredentials()
{
$this->createUser('weaverryan', 'I<3Pizza');
$response = $this->client->post('/api/tokens', [
'auth' => ['weaverryan', 'IH8Pizza']
]);
$this->assertEquals(401, $response->getStatusCode());
}
}

Here, we purposefully send an invalid username and password combination. This actually hits TokenController, we throw this new BadCredentialsException and that kicks us out:

... lines 1 - 12
class TokenController extends BaseController
{
... lines 15 - 18
public function newTokenAction(Request $request)
{
... lines 21 - 31
if (!$isValid) {
throw new BadCredentialsException();
}
... lines 35 - 39
}
}

It turns out that doing this this also triggers the entry point. And if you think about it, that makes sense: any time an anonymous user is able to get into your application:

... lines 1 - 17
class JwtTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 20 - 79
public function start(Request $request, AuthenticationException $authException = null)
{
// called when authentication info is missing from a
// request that requires it
return new JsonResponse([
'error' => 'auth required'
], 401);
}
}

And then you throw an exception to deny access, that will trigger the entry point. And our entry point is not yet returning the nice API problem structure.

Testing for the API Problem Response

Copy the last four lines from one of the tests in ProgrammerControllerTest:

... lines 1 - 6
class ProgrammerControllerTest extends ApiTestCase
{
... lines 9 - 255
public function test404Exception()
{
... lines 258 - 262
$this->assertEquals('application/problem+json', $response->getHeader('Content-Type')[0]);
$this->asserter()->assertResponsePropertyEquals($response, 'type', 'about:blank');
$this->asserter()->assertResponsePropertyEquals($response, 'title', 'Not Found');
$this->asserter()->assertResponsePropertyEquals($response, 'detail', 'No programmer found with nickname "fake"');
}
... lines 268 - 276
}

And add that to testPostTokenInvalidCredentials():

... lines 1 - 6
class TokenControllerTest extends ApiTestCase
{
... lines 9 - 22
public function testPOSTTokenInvalidCredentials()
{
... lines 25 - 29
$this->assertEquals(401, $response->getStatusCode());
$this->assertEquals('application/problem+json', $response->getHeader('Content-Type')[0]);
$this->asserter()->assertResponsePropertyEquals($response, 'type', 'about:blank');
$this->asserter()->assertResponsePropertyEquals($response, 'title', 'Unauthorized');
$this->asserter()->assertResponsePropertyEquals($response, 'detail', 'Invalid credentials.');
}
}

The header should be application/problem+json. The type should be about:blank: that's what you should use when the status code - 401 here - already fully describes what went wrong. For the title use Unauthorized - that's the standard text that always goes with a 401 status code. The ApiProblem class will actually set that for us: when we pass a null type, it sets type to about:blank and looks up the correct title.

Finally, for detail - which is an optional field for an API problem response - use Invalid Credentials. with a period. I'll show you why we're expecting that in a second.

ApiProblem in start()

Head to the JwtTokenAuthenticator. In start(), create a new $apiProblem = new ApiProblem(). Pass it a 401 status code with no type:

... lines 1 - 18
class JwtTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 21 - 80
public function start(Request $request, AuthenticationException $authException = null)
{
// called when authentication info is missing from a
// request that requires it
$apiProblem = new ApiProblem(401);
... lines 87 - 91
}
}

The detail key should tell the API client any other information about what went wrong. And check this out: when the start() method is called, it has an optional $authException argument. Most of the time, when Symfony calls start() its because an AuthenticationException has been thrown. And this class gives us some information about what caused this situation.

And in fact, in TokenController, we're throwing a BadCredentialsException, which is a sub-class of AuthenticationException. Hold command to look inside the class:

... lines 1 - 19
class BadCredentialsException extends AuthenticationException
{
... lines 22 - 24
public function getMessageKey()
{
return 'Invalid credentials.';
}
}

It has a getMessageKey() method set to Invalid Credentials.: make sure you test matches this string exactly:

... lines 1 - 6
class TokenControllerTest extends ApiTestCase
{
... lines 9 - 22
public function testPOSTTokenInvalidCredentials()
{
... lines 25 - 33
$this->asserter()->assertResponsePropertyEquals($response, 'detail', 'Invalid credentials.');
}
}

The AuthenticationException - and its sub-classes - are special: each has a getMessageKey() method that you can safely return to the user to help hint as to what went wrong.

Add $message = $authException ? $authException->getMessageKey() : 'Missing Credentials';:

... lines 1 - 18
class JwtTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 21 - 80
public function start(Request $request, AuthenticationException $authException = null)
{
// called when authentication info is missing from a
// request that requires it
$apiProblem = new ApiProblem(401);
// you could translate this
$message = $authException ? $authException->getMessageKey() : 'Missing credentials';
... lines 89 - 91
}
}

If no $authException is passed, this is the best message we can return to the client. Finish this with $apiProblem->set('details', $message).:

... lines 1 - 82
// called when authentication info is missing from a
// request that requires it
$apiProblem = new ApiProblem(401);
// you could translate this
$message = $authException ? $authException->getMessageKey() : 'Missing credentials';
$apiProblem->set('detail', $message);
... lines 90 - 94

Finally, return a new JsonResponse with $apiProblem->toArray() and then a 401:

... lines 1 - 18
class JwtTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 21 - 80
public function start(Request $request, AuthenticationException $authException = null)
{
// called when authentication info is missing from a
// request that requires it
$apiProblem = new ApiProblem(401);
// you could translate this
$message = $authException ? $authException->getMessageKey() : 'Missing credentials';
$apiProblem->set('detail', $message);
return new JsonResponse($apiProblem->toArray(), 401);
}
}

Perfect! Well, not actually perfect, but it's getting close.

Copy the invalid credentials test method and run:

./vendor/bin/phpunit --filter testPOSTTokenInvalidCredentials

It's close! The response looks right, but the Content-Type header is application/json instead of the more descriptive application/problem+json.

Well that's no problem! We just need to set the header inside of the start() method. But wait! Don't do that! Because we've done all of this work before.

Leave a comment!

5
Login or Register to join the conversation
Vladimir Z. Avatar
Vladimir Z. Avatar Vladimir Z. | posted 5 years ago | edited

Well, unfortunately, with Symfony 3.3 I ran into the issue well-described here: https://github.com/symfony/symfony/issues/23253
<strong>BadCredentialsException</strong> does not trigger the entry point. It simply goes directly to <strong>ApiExceptionSubscriber</strong>, bypassing the <strong>start()</strong> method. Also, instead of an <strong>AuthenticationException</strong> a <strong>DenyAccessException</strong> is thrown, which is neither an <strong>AuthenticationException</strong> nor an <strong>HttpException</strong>.

At this moment, I have added the following code into the <strong>onKernelException</strong> method of <strong>ApiExceptionSubscriber</strong>:

`
# Let Symfony handle all other exceptions
if (!$exception instanceof HttpExceptionInterface) {

return;

}
`

Are you aware of this issue? Any possible solution to it?

14 Reply

Hey Vladimir Z.!

Haha, I'm aware of this issue now that you've mentioned it! And since you posted, a PR has been opened (I'm actually chatting right now with the author of that PR about it). So, in short, we will hopefully fix the issue soon. But, you should be able to fix it yourself by giving your ApiExceptionSubscriber a priority of -1 (that will make a bit more sense if you read the issue you linked to). You can see how to add a priority in the phpdoc for the EventSubscriberInterface: https://github.com/symfony/symfony/blob/master/src/Symfony/Component/EventDispatcher/EventSubscriberInterface.php

Cheers!

Reply
Default user avatar

Hi guys, sorry if I missed something, but I couldn't find.
With JwtTokenAuthenticator working in every end point, what would we do in the end point api/tokens. How could we define a public end point as we need to ask the token first to be used in the others ones ;)

Reply

Yo Gisele!

It's a good question :). I'll say two things:

1) JwtTokenAuthenticator works on every endpoint. But, in getCredentials(), if we don't see the Authorization header, then we return null. When this happens, the authenticator doesn't do anything: the request is allowed to continue to your controller anonymously. So, even though the authenticator works on every endpoint, this doesn't mean that every endpoint *requires* a JWT: it simply means that the authenticator is ready to authenticate the user *if* there is a token. If there is no token, the request continues anonymously. Then, it's up to your controller - or access_control in security.yml - to determine whether or not each endpoint does in fact require authentication. If you don't check for a role in either of these places, then that endpoint is public.

2) So, in the case of /api/tokens (where we obviously don't have a JWT yet), this is a public endpoint, and in the controller, we manually check for a username+password combination on the request. If that's there, then we send back the JWT.

Does that help?

Cheers!

Reply
Default user avatar
Default user avatar Gisele | weaverryan | posted 5 years ago | edited

weaverryan
Thanks for your explanation, makes total sense. You've been doing a great job.. thanks again ;)

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial uses an older version of Symfony. The concepts of API tokens & JWT are still valid, but integration in newer Symfony versions may be different.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=5.5.9",
        "symfony/symfony": "3.0.*", // v3.0.3
        "doctrine/orm": "^2.5", // v2.5.4
        "doctrine/doctrine-bundle": "^1.6", // 1.6.2
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.3.0
        "symfony/swiftmailer-bundle": "^2.3", // v2.3.11
        "symfony/monolog-bundle": "^2.8", // v2.10.0
        "sensio/distribution-bundle": "^5.0", // v5.0.4
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.14
        "incenteev/composer-parameter-handler": "~2.0", // v2.1.2
        "jms/serializer-bundle": "^1.1.0", // 1.1.0
        "white-october/pagerfanta-bundle": "^1.0", // v1.0.5
        "lexik/jwt-authentication-bundle": "^1.4" // v1.4.3
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0", // v3.0.6
        "symfony/phpunit-bridge": "^3.0", // v3.0.3
        "behat/behat": "~3.1@dev", // dev-master
        "behat/mink-extension": "~2.2.0", // v2.2
        "behat/mink-goutte-driver": "~1.2.0", // v1.2.1
        "behat/mink-selenium2-driver": "~1.3.0", // v1.3.1
        "phpunit/phpunit": "~4.6.0", // 4.6.10
        "doctrine/doctrine-fixtures-bundle": "^2.3" // 2.3.0
    }
}
userVoice