Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Securing Client-Side Public API Access With OAuth 2 and Symfony

DZone's Guide to

Securing Client-Side Public API Access With OAuth 2 and Symfony

Learn how to take advantage of Symfony’s security system and serialization groups to cleanly segregate access to our API.

· Security Zone ·
Free Resource

Protect your applications against today's increasingly sophisticated threat landscape.

The OAuth standard is a staple in many APIs today, with Microsoft, Twitter, GitHub, Facebook, etc. all using the protocol in their implementations of authorizing access to users’ data. A typical use-case is that you (the “resource owner”) may wish to use a third-party application which requires access to your Twitter timeline. You wouldn’t trust this application with your username and password, so instead OAuth provides a mechanism for the application to request authorization from you, e.g. redirecting to Twitter for you to log in and approve the request. If you agree with the permissions the application is asking for (such as write access) it’ll receive an access token back, which it can then use to authenticate against the Twitter API and post tweets to your timeline. This is one of several protocol flows available (see Mitchell Anicas’ excellent introduction to OAuth 2) and isn’t suitable for all use-cases.

Setting the Scene

Say you’ll be developing a web application for a customer to create and manage restaurant bookings, exposing restaurant information (name, opening times, menu contents, etc.) and booking creation as RESTful API endpoints, which are consumed by secure admin backend. You’ll need to authorize access to the API, but there is no end-user involved since the web app is its own resource owner, so the previous flow doesn’t apply. In this case, the “client credentials” grant type is suitable, as the client-side app simply sends its own credentials (a client ID and secret) to the server-side API for authorization.

However, you also need to develop a booking widget that will be embedded in a company or restaurant’s website for visitors to use. In this case, the client-side is no longer trusted enough to share the OAuth client secret that’s required to authenticate with your API. You may also want to limit its scope of access so that the widget can only fetch a subset of its own restaurant’s information, or deny access to certain endpoints entirely. To this end, we need another mechanism for requesting an access token that is scoped appropriately.

We encountered a similar use-case for a client project recently, and this blog post details the steps taken to address it. A simple companion project which provides a working implementation of the code examples given can be found on our GitHub account. It’s expected that you’ve got some prior knowledge of Symfony and its bundle ecosystem so you can find your way around.

Integrating OAuth Into Your Symfony Project

The easiest and most robust way of adding OAuth support to your Symfony project is by using the FOSOAuthServerBundle. If your project isn’t already using it, follow the documentation to install and configure the bundle, making sure to create the required model classes (you’ll notice in the example project that the OAuth entities have omitted the user field for the sake of brevity.)

Your firewall configuration must include the oauth_token and API entries, with the / API route under access control:

# security.yml

firewalls:
    oauth_token:
        pattern: ^/oauth/v2/token
        security: false

    api:
        pattern: ^/api
        fos_oauth: true
        stateless: true

    # your other firewall entries

access_control:
    - { path: ^/api, role: IS_AUTHENTICATED_FULLY }

Assigning a Client ID to a Company

In our application domain, restaurants belong to a company, so in terms of API access the company is the resource owner and therefore an OAuth client should be assigned to them. To do that we first need our company entity to declare the association.

<?php
// AppBundle\Entity\Company.php
/**
 * @ORM\OneToOne(targetEntity="OAuthClient")
 */
private $oauthClient;

public function setOAuthClient(OAuthClient $oauthClient = null)
{
  $this->oauthClient = $oauthClient;

  return $this;
}

public function getOAuthClient()
{
  return $this->oauthClient;

We’ll also need some method of creating an OAuth client and persisting the association with a company. In this example, we can get by with a simple Symfony console command, however, in your own application you’ll probably want this to happen in a post-persist event when first creating a company (see “How to Register Event Listeners and Subscribers” on how to do this.)

<?php
// AppBundle\Command\CreateOAuthClientCommand.php
namespace AppBundle\Command;

use OAuth2\OAuth2;
use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class CreateOAuthClientCommand extends ContainerAwareCommand
{
  protected function configure()
  {
    $this
      ->setName('app:oauth-client:create')
      ->setDescription('Create a new OAuth client for a company')
      ->addArgument('company', InputArgument::REQUIRED, 'The company ID');
  }

  protected function execute(InputInterface $input, OutputInterface $output)
  {
    $clientManager = $this->getContainer()->get('fos_oauth_server.client_manager');
    $em = $this->getContainer()->get('doctrine.orm.entity_manager');
    $companyId = (int) $input->getArgument('company');

    if (!($company = $em->getRepository('AppBundle:Company')->find($companyId))) {
      $output->writeln(sprintf('Could not find company with ID "%d"', $companyId));
      return false;
    }

    // create a new OAuth client and assign it to the company
    $client = $clientManager->createClient();
    $client->setAllowedGrantTypes(array(OAuth2::GRANT_TYPE_CLIENT_CREDENTIALS));
    $company->setOAuthClient($client);
    $clientManager->updateClient($client);

    $output->writeln(sprintf('client_id=%s_%s', $client->getId(), $client->getRandomId()));
    $output->writeln(sprintf('client_secret=%s', $client->getSecret()));
  }
}

Running this command with a valid company ID will then return a client ID and secret, which we will use later to test that API authentication is working.

Client created
client_id=2_6769acwdwoowwwkw8k8skcok84wwowo40scks8w44gok4gg0o0
client_secret=5iyyk14lywkcoww4k84k0s8owcsg40os8o4sk0g0c0skk8ksog

Connecting Access Tokens to Restaurants

Before we can start handing out access tokens, we need to solve two problems. Firstly, we can no longer rely on having the client credentials secret available to us, so we need another method of publicly identifying a restaurant for the authorization request. Secondly, we’ll want to know which token grants access to which restaurant, so that we can properly scope API requests.

For publicly identifying a restaurant, we can use the Hashids library to encode its primary key into a unique string. To do this, we’ll add a new field to our Restaurant entity and create another command (again, I recommend this step happens in a post-persist event in your project after creating a restaurant).

<?php
// AppBundle\Entity\Restaurant.php

/**
 * @ORM\Column(name="hashid", type="string", length=255, nullable=true)
 */
private $hashid;

public function setHashid($hashid)
{
  $this->hashid = $hashid;

  return $this;
}

public function getHashid()
{
  return $this->hashid;
}
<?php
// AppBundle\Command\EncodeRestaurantIdCommand.php
namespace AppBundle\Command;

use Hashids\Hashids;
use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class EncodeRestaurantIdCommand extends ContainerAwareCommand
{
  const MIN_HASH_LENGTH = 6;

  protected function configure()
  {
    $this
      ->setName('app:restaurant:encode-id')
      ->setDescription('Encodes the restaurant ID to a unique string')
      ->addArgument('restaurant', InputArgument::REQUIRED, 'The restaurant ID');
  }

  protected function execute(InputInterface $input, OutputInterface $output)
  {
    $em = $this->getContainer()->get('doctrine.orm.entity_manager');
    $restaurantId = (int) $input->getArgument('restaurant');

    if (!($restaurant = $em->getRepository('AppBundle:Restaurant')->find($restaurantId))) {
      $output->writeln(sprintf('Could not find restaurant with ID "%d"', $restaurantId));
      return false;
    }

    // encode the restaurant's ID
    $encoder = new Hashids($this->getContainer()->getParameter('secret'), self::MIN_HASH_LENGTH);
    $hashid = $encoder->encode($restaurant->getId());
    $restaurant->setHashid($hashid);
    $em->flush();
    $output->writeln(sprintf('Created hashid for restaurant: %s', $hashid));
  }
}

Running this command with a valid restaurant ID will return the hashid (note that your result will differ, depending on the ID and also the secret configured in parameters.yml).

Created hashid for restaurant: JxMZdw

The second part of the puzzle is being able to find an OAuth client from a restaurant’s hashid, which is useful for when we start handling authorization requests. To do this, we extend FOSOAuthServerBundle’s client manager service and add another repository method.

# config.yml

fos_oauth_server:
    db_driver: orm
    client_class:        AppBundle\Entity\OAuthClient
    access_token_class:  AppBundle\Entity\OAuthAccessToken
    refresh_token_class: AppBundle\Entity\OAuthRefreshToken
    auth_code_class:     AppBundle\Entity\OAuthAuthCode
    service:
        client_manager: app.manager.oauth_client
# services.yml

services:
    app.manager.oauth_client:
        class: AppBundle\Entity\Manager\OAuthClientManager
        arguments: ["@fos_oauth_server.entity_manager", "%fos_oauth_server.model.client.class%"]
<?php
// AppBundle\Entity\Manager\OAuthClientManager.php
namespace AppBundle\Entity\Manager;

use AppBundle\Entity\OAuthClient;
use Doctrine\ORM\NonUniqueResultException;
use FOS\OAuthServerBundle\Entity\ClientManager;

class OAuthClientManager extends ClientManager
{
  public function findOAuthClientByRestaurantHashid($hashid)
  {
    return $this->repository->createQueryBuilder('cl')
      ->from('AppBundle:Restaurant', 'r')
      ->join('r.company', 'c')
      ->join('c.oauthClient', 'ccl')
      ->where('cl = ccl')
      ->andWhere('r.hashid = :hashid')
      ->setParameter('hashid', $hashid)
      ->getQuery()
      ->getOneOrNullResult();
  }
}

Finally, we’ll need to update our access token entity to be associated with a restaurant.

<?php
// AppBundle\Entity\OAuthAccessToken.php
/**
 * @ORM\ManyToOne(targetEntity="Restaurant")
 */
protected $restaurant;

public function setRestaurant(Restaurant $restaurant = null)
{
  $this->restaurant = $restaurant;

  return $this;
}

public function getRestaurant()
{
  return $this->restaurant;
}

Handling Token Requests

So far, we can get an access token using the standard client credentials grant flow, e.g.

http://127.0.0.1:8000/oauth/v2/token?grant_type=client_credentials&client_id=2_6769acwdwoowwwkw8k8skcok84wwowo40scks8w44gok4gg0o0&client_secret=5iyyk14lywkcoww4k84k0s8owcsg40os8o4sk0g0c0skk8ksog

However, as mentioned previously, this isn’t acceptable as the token would be unscoped and the secret would be in the hands of an untrusted client. It’d be rather neat if we could do this instead:

http://127.0.0.1:8000/oauth/v2/token?restaurant_id=JxMZdw

To achieve this, we’ll have to override FOSOAuthServerBundle’s TokenController with our own implementation. What follows this paragraph may seem like a lot of code, but the concept itself is quite simple. If our restaurant_id parameter is present in the GET/POST request, we use the repository method we created earlier to find an OAuth client and reshape the request into a standard client credentials grant flow. We then call upon the parent controller to handle this new request and decode the response so that we can associate the access token with the restaurant before returning it to the requesting client.

<?php
// AppBundle\Controller\TokenController.php
namespace AppBundle\Controller;

use AppBundle\Entity\OAuthAccessToken;
use AppBundle\Entity\Manager\OAuthClientManager;
use Doctrine\ORM\EntityManagerInterface;
use FOS\OAuthServerBundle\Controller\TokenController as BaseTokenController;
use FOS\OAuthServerBundle\Model\AccessTokenManagerInterface;
use FOS\OAuthServerBundle\Model\ClientManagerInterface;
use OAuth2\OAuth2;
use OAuth2\OAuth2ServerException;
use Symfony\Component\HttpFoundation\Request;

class TokenController extends BaseTokenController
{
  protected $clientManager;
  protected $tokenManager;
  protected $entityManager;

  public function __construct(OAuth2 $server, ClientManagerInterface $clientManager, AccessTokenManagerInterface $tokenManager, EntityManagerInterface $entityManager)
  {
    parent::__construct($server);
    $this->clientManager = $clientManager;
    $this->tokenManager = $tokenManager;
    $this->entityManager = $entityManager;
  }

  public function tokenAction(Request $request)
  {
    if ($request === null) {
      $request = Request::createFromGlobals();
    }

    // get the hashid from the request
    $property = $request->isMethod(Request::METHOD_POST) ? 'request' : 'query';
    $hashid = $request->$property->get('restaurant_id');
    $request->$property->remove('restaurant_id');

    if (!$hashid) {
      // continue with creating an access token from existing parameters
      return parent::tokenAction($request);
    }

    try {
      // find the relevant oauth client
      if (!($oauthClient = $this->clientManager->findOAuthClientByRestaurantHashid($hashid))) {
        throw new OAuth2ServerException(OAuth2::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_REQUEST, 'Invalid restaurant ID.');
      }

      // build a standard client credentials request
      $request->$property->set('client_id', $oauthClient->getPublicId());
      $request->$property->set('client_secret', $oauthClient->getSecret());
      $request->$property->set('grant_type', OAuth2::GRANT_TYPE_CLIENT_CREDENTIALS);
      $request->$property->set('scope', 'widget');

      // handle the request, decoding the created token so we can get a managed entity
      $response = parent::tokenAction($request);
      $responseToken = json_decode($response->getContent());

      if (!$responseToken || !($token = $this->tokenManager->findTokenByToken($responseToken->access_token))) {
        throw new OAuth2ServerException(OAuth2::HTTP_BAD_REQUEST, OAuth2::ERROR_INVALID_REQUEST, 'Unable to decode the token.');
      }

      // associate the token with the restaurant and update
      $restaurant = $this->entityManager->getRepository('AppBundle:Restaurant')->findOneBy(array('hashid' => $hashid));
      $token->setRestaurant($restaurant);
      $this->tokenManager->updateToken($token);

      return $response;
    } catch (OAuth2ServerException $e) {
      return $e->getHttpResponse();
    }
  }
}

Since Symfony’s service container is compiled to check for problems such as circular references or missing dependencies, we have the opportunity to get the service definition for FOSOAuthServerBundle’s token controller, which is used by other parts of the bundle. By setting it to our new class and injecting our extra dependencies, we cleanly override it with our own controller.

<?php
// AppBundle\DependencyInjection\Compiler\OverrideFOSOAuthServerTokenControllerPass.php
namespace AppBundle\DependencyInjection\Compiler;

use AppBundle\Controller\TokenController;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

class OverrideFOSOAuthServerTokenControllerPass implements CompilerPassInterface
{
  public function process(ContainerBuilder $container)
  {
    $definition = $container->getDefinition('fos_oauth_server.controller.token');
    $definition->setClass(TokenController::class);
    $definition->addArgument(new Reference('fos_oauth_server.client_manager'));
    $definition->addArgument(new Reference('fos_oauth_server.access_token_manager'));
    $definition->addArgument(new Reference('doctrine.orm.entity_manager'));
  }
}

For this compiler pass to take effect, we must also register it.

<?php
// AppBundle\AppBundle.php
namespace AppBundle;

use AppBundle\DependencyInjection\Compiler\OverrideFOSOAuthServerTokenControllerPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;

class AppBundle extends Bundle
{
  public function build(ContainerBuilder $container)
  {
    parent::build($container);
    $container->addCompilerPass(new OverrideFOSOAuthServerTokenControllerPass());
  }
}

You may have noticed we added an extra property called “scope” to the client credentials request in our token controller. The default behavior of the FOSOAuthServerBundle is to treat OAuth scopes as Symfony security roles (see “Dealing With Scopes” for more information), which can be useful when securing API endpoints. To add support for our new “widget” scope, we’ll need to update the application config. We’ll also add a “client” scope, which we can treat as having full access by not doing anything special.

# config.yml

fos_oauth_server:
    db_driver: orm
    client_class:        AppBundle\Entity\OAuthClient
    access_token_class:  AppBundle\Entity\OAuthAccessToken
    refresh_token_class: AppBundle\Entity\OAuthRefreshToken
    auth_code_class:     AppBundle\Entity\OAuthAuthCode
    service:
        client_manager: app.manager.oauth_client
        options:
            supported_scopes: client widget

Finally, if we make an authentication request to /oauth/v2/token using our new restaurant_id parameter, we should get an access token back!

{
    "access_token": "MmM3NmEwMDhhMDI1ZmI3YzZiMDRlNjRhZjhhN2I0NWFiZDMxZjE5MWJhNTEzMTUwZjJiZjUyMzIwMzI3ZWI3Mw",
    "expires_in": 3600,
    "token_type": "bearer",
    "scope": "widget"
}

Scoping API Requests

Now that our application is handing out public access tokens for our hypothetical booking widget to use, it’s important that we adhere to the correct scope in our API controller actions. Thanks to the groundwork we did earlier, we can get two crucial pieces of information: the scope of the token (which handily translates to ROLE_WIDGET in the Symfony role hierarchy), and the restaurant that the token grants access to.

Let’s use FOSRestBundle and JMSSerializerBundle to quickly develop an endpoint to demonstrate how this can be achieved. To start with, require both bundles and configure FOSRestBundle to just use JSON.

# config.yml

fos_rest:
    param_fetcher_listener: true
    body_listener: true
    format_listener:
        rules:
            - { path: '^/api', priorities: ['json'], fallback_format: json, prefer_extension: false }
            - { path: '^/', stop: true }
    view:
        view_response_listener: true
    access_denied_listener:
        json: true

Then we’ll create an API controller with a simple action to get a restaurant, updating our routing config accordingly.

<?php
// AppBundle\Controller\Api\RestaurantApiController.php
namespace AppBundle\Controller\Api;

use AppBundle\Entity\OAuthAccessToken;
use AppBundle\Entity\Restaurant;
use FOS\RestBundle\Context\Context;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\View\View;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;

class RestaurantApiController extends FOSRestController
{
  /**
   * @Rest\Get("/restaurant/{hashid}", name="api.restaurant")
   */
  public function getRestaurantAction(Restaurant $restaurant)
  {
    $view = View::create();

    // check if the auth token is scoped
    if ($this->isGranted('ROLE_WIDGET')) {
      $token = $this->get('fos_oauth_server.access_token_manager')->findTokenByToken($this->get('security.token_storage')->getToken()->getToken());

      // prevent access to all other restaurants
      if ($token->getRestaurant()->getId() !== $restaurant->getId()) {
        throw new AccessDeniedException();
      }

      // only serialise certain fields
      $context = new Context();
      $context->setGroups(array('widget'));
      $view->setContext($context);
    }

    $view->setData(array(
      'restaurant' => $restaurant,
    ));

    return $view;
  }
}
# routing.yml

app_api:
    resource: "@AppBundle/Controller/Api"
    type:     annotation
    prefix:   /api

We’ve declared the API endpoint with FOSRestBundle’s @Get annotation, and so behind the scenes, Symfony automatically tries to find a restaurant by a hashid equal to the value in the route’s placeholder. In the controller action itself, we check if the authenticated token is scoped to “widget” because we then want to ensure that the token can only access its own restaurant’s data.

Finally, we’ll need to annotate our restaurant entity with serializer groups so the widget only has access to certain properties. My preferred method of doing this is to exclude all object properties by default, and then choose which ones I want to expose.

<?php
// AppBundle\Entity\Restaurant.php
use JMS\Serializer\Annotation as JMS;

/**
 * @ORM\Table(name="restaurant", indexes={@ORM\Index(name="hashid_idx", columns={"hashid"})})
 * @ORM\Entity()
 * @JMS\ExclusionPolicy("all")
 */
class Restaurant {
  /**
   * @var int
   *
   * @ORM\Column(name="id", type="integer")
   * @ORM\Id
   * @ORM\GeneratedValue(strategy="AUTO")
   *
   * @JMS\Expose
   * @JMS\Groups({"Default"})
   */
  private $id;

  /**
   * @var string
   *
   * @ORM\Column(name="name", type="string", length=255)
   *
   * @JMS\Expose
   * @JMS\Groups({"Default", "widget"})
   */
  private $name;
}

Calling the API

Now we can see the result of our hard work. Let’s first authenticate with a client credentials grant flow and call our endpoint (note that I’ve now had to add a scope parameter with the value “client”).

curl 'http://127.0.0.1:8000/oauth/v2/token?grant_type=client_credentials&client_id=2_6769acwdwoowwwkw8k8skcok84wwowo40scks8w44gok4gg0o0&client_secret=5iyyk14lywkcoww4k84k0s8owcsg40os8o4sk0g0c0skk8ksog&scope=client'

{
    "access_token": "YzBkN2NkNDY3NzkwMGMxNDdjODVhYzYzYWVkMDhkNjFkMWUwZTI0YzRiYzEzNzljMDY0NTQzZTAyYzU2Y2Q2NA",
    "expires_in": 3600,
    "token_type": "bearer",
    "scope": "client"
}

curl 'http://127.0.0.1:8000/api/restaurant/JxMZdw' -H 'Authorization: Bearer YzBkN2NkNDY3NzkwMGMxNDdjODVhYzYzYWVkMDhkNjFkMWUwZTI0YzRiYzEzNzljMDY0NTQzZTAyYzU2Y2Q2NA'

{
    "restaurant": {
        "id": 1,
        "name": "test restaurant"
    }
}

Now we’ll try calling the same endpoint with an access token that has the “widget” scope, obtained using the same method our widget will use to authenticate with the API.

curl 'http://127.0.0.1:8000/oauth/v2/token?restaurant_id=JxMZdw'

{
    "access_token": "NGYyOGVkYTZmZjU0ZmU0NWM2ODUxYzk5ZWVkMjkxMWQxMTgyNzExNGI4NWUzNzBhNWY4OWM0MGNiYjExYjUxOA",
    "expires_in": 3600,
    "token_type": "bearer",
    "scope": "widget"
}

curl 'http://127.0.0.1:8000/api/restaurant/JxMZdw' -H 'Authorization: Bearer NGYyOGVkYTZmZjU0ZmU0NWM2ODUxYzk5ZWVkMjkxMWQxMTgyNzExNGI4NWUzNzBhNWY4OWM0MGNiYjExYjUxOA'

{
    "restaurant": {
        "name": "test restaurant"
    }
}

Can you spot the difference? When using our widget token, the restaurant’s ID is no longer returned in the response!

Wrapping Up

So, what have we achieved? Looking at a slightly different response may not seem like much, but we’ve in fact laid the groundwork for the widget to consume the same API as our restaurant booking management backend, but with a reduced set of permissions. By adding a layer on top of the typical client credentials grant flow to protect the client secret and setting up token scopes, we’ll be able to take advantage of Symfony’s security system and serialization groups to cleanly segregate access to our API.

Rapidly detect security vulnerabilities in your web, mobile and desktop applications with IBM Application Security on Cloud. Register Now

Topics:
symfony ,oauth 2 ,api development ,api security ,security

Published at DZone with permission of

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}