Buy

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:

278 lines tests/AppBundle/Controller/Api/ProgrammerControllerTest.php
... 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:

33 lines tests/AppBundle/Controller/Api/TokenControllerTest.php
... 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:

41 lines src/AppBundle/Controller/Api/TokenController.php
... 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:

90 lines src/AppBundle/Security/JwtTokenAuthenticator.php
... 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:

278 lines tests/AppBundle/Controller/Api/ProgrammerControllerTest.php
... 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():

37 lines tests/AppBundle/Controller/Api/TokenControllerTest.php
... 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:

94 lines src/AppBundle/Security/JwtTokenAuthenticator.php
... 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:

30 lines vendor/symfony/symfony/src/Symfony/Component/Security/Core/Exception/BadCredentialsException.php
... 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:

37 lines tests/AppBundle/Controller/Api/TokenControllerTest.php
... 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';:

94 lines src/AppBundle/Security/JwtTokenAuthenticator.php
... 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).:

94 lines src/AppBundle/Security/JwtTokenAuthenticator.php
... 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:

94 lines src/AppBundle/Security/JwtTokenAuthenticator.php
... 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!

  • 2016-10-13 Gisele

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

  • 2016-10-13 weaverryan

    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!

  • 2016-10-12 Gisele

    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 ;)