Dzianis Kotau
About Me

Мy name is Dzianis Kotau. I'm Solutions Architect and Zend Certified PHP Engineer. I'm PHP evangelist and loved in it.

IBM App ID Provider for OAuth 2.0 Client

Dzianis Kotau • December 30, 2019

php

Let me introduce you my own IBM App ID Provider for the PHP League's OAuth 2.0 Client. It's not about IBM App ID service, nor PHP League's OAuth 2.0 Client. You can read about those here and here. In brief, IBM App ID is an IBM Cloud service that hides implementation of different OAuth providers (Facebook, Google, SAML, etc.) and allows you to use them in one way. This article is about a provider that you can use to communicate with IBM App ID service using standalone PHP or Symfony.

Table of Contents

Standalone provider

Let's start from standalone PHP. To install the package run

composer require jampire/oauth2-appid

Usage is the same as The League's OAuth client, using \Jampire\OAuth2\Client\Provider\AppIdProvider as the provider.

Use baseAuthUri to specify the IBM App ID base server URL. You can lookup the correct value from the Application settings of your IBM App ID service under oAuthServerUrl without tenantId section, eg. https://us-south.appid.cloud.ibm.com/oauth/v4.

Use tenantId to specify the IBM App ID tenant ID. You can lookup the correct value from the Application settings of your IBM App ID service under tenantId, eg. abcd-efgh-1234-5678-mnop.

All other values you can find in Application settings of your IBM App ID service.

Register your redirect URL in your IBM App ID whitelist. Please, read IBM App ID documentation. Otherwise, you can get the error like this:

Invalid Redirect URI

Authorization Code Flow

<?php

require_once __DIR__ . '/vendor/autoload.php';

use Jampire\OAuth2\Client\Provider\AppIdProvider;
use Jampire\OAuth2\Client\Provider\AppIdException;

session_start();

try {
    $provider = new AppIdProvider([
        'baseAuthUri'   => '{baseAuthUri}',
        'tenantId'      => '{tenantId}',
        'clientId'      => '{clientId}',
        'clientSecret'  => '{clientSecret}',
        'redirectUri'   => '{redirectUri}', // use http://localhost for testing
    ]);
} catch (AppIdException $e) {
    exit('Failed to create provider: ' . $e->getMessage());
}

if (!isset($_GET['code'])) {
    // If we don't have an authorization code then get one

    // Fetch the authorization URL from the provider; this returns the
    // urlAuthorize option and generates and applies any necessary parameters
    // (e.g. state).
    $authorizationUrl = $provider->getAuthorizationUrl();

    // Get the state generated for you and store it to the session.
    $_SESSION['oauth2state'] = $provider->getState();

    // Redirect the user to the authorization URL.
    header('Location: ' . $authorizationUrl);
    exit;
}

if (empty($_GET['state']) || (isset($_SESSION['oauth2state']) && $_GET['state'] !== $_SESSION['oauth2state'])) {
    // Check given state against previously stored one to mitigate CSRF attack
    if (isset($_SESSION['oauth2state'])) {
        unset($_SESSION['oauth2state']);
    }
    exit('Invalid state');

}

try {
    // Try to get an access token using the authorization code grant.
    $accessToken = $provider->getAccessToken('authorization_code', [
        'code' => $_GET['code']
    ]);

    // We have an access token, which we may use in authenticated
    // requests against the service provider's API.
    echo '<b>Access Token:</b> ', $accessToken->getToken(), '<br>';
    echo '<b>Refresh Token:</b> ', $accessToken->getRefreshToken(), '<br>';
    echo '<b>Expired in:</b> ', $accessToken->getExpires(), '<br>';
    echo '<b>Already expired?</b> ', ($accessToken->hasExpired() ? 'expired' : 'not expired'), '<br>';

    // Using the access token, we may look up details about the
    // resource owner.
    $resourceOwner = $provider->getResourceOwner($accessToken);
} catch (Exception $e) {
    // Failed to get the access token or user details.
    exit($e->getMessage());
}

Then run in your console:

php -S localhost:80

If you did everything right, you’ll be redirect to App ID for authentication. Depending on your configuration you’ll be shown with a login widget (either App ID’s or redirected directly to the single configured IdP, but that’s far less important for your use case now).

My login page is IBM SSO one

IBM SSO

In case, you are using Facebook or Google, you will see their own login pages.

Final result

After successful authorization, you will be redirected to your registered URL (http://localhost in our test case). And you will see something like this (note, that my IDP is SAML):

Authorization Result

You are now authorized in the system and can perform different tasks as authorized user.

Symfony integration

Note: I'm using knpuniversity/oauth2-client-bundle for Symfony integration. My provider was included to the bundle since version 1.33.0.

Note: Full documentation for adding providers is available at KnpUOAuth2ClientBundle.
This example is based on Symfony v4.3.

I assume that you already have Symfony installation, but without OAuth2 functionality. So, to start using it install knpuniversity/oauth2-client-bundle and jampire/oauth2-appid:

composer require knpuniversity/oauth2-client-bundle jampire/oauth2-appid

Step 1 - Configuring Security

Configure your oauth security.

# config/packages/security.yaml
security:
    providers:
        oauth:
            id: App\Security\UserProvider
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            guard:
                authenticators:
                    - App\Security\AppIdAuthenticator
# config/packages/knpu_oauth2_client.yaml
knpu_oauth2_client:
    # can be set to the service id of a service that implements Guzzle\ClientInterface
    # http_client: null

    # options to configure the default http client
    # http_client_options:
    #     timeout: 0
    #     proxy: null
    #     Use only with proxy option set
    #     verify: false

    clients:
      # will create service: "knpu.oauth2.client.appid"
      # an instance of: KnpU\OAuth2ClientBundle\Client\Provider\AppIdClient
      # composer require jampire/oauth2-appid
      appid:
          # must be "appid" - it activates that type!
          type: appid
          # add and configure client_id and client_secret in .env* file
          client_id: '%env(OAUTH_APPID_CLIENT_ID)%'
          client_secret: '%env(OAUTH_APPID_CLIENT_SECRET)%'
          # a route name you'll create
          redirect_route: '%env(OAUTH_APPID_REDIRECT_ROUTE)%'
          redirect_params: {}
          # IBM App ID base URL. For example, "https://us-south.appid.cloud.ibm.com/oauth/v4". More details at https://cloud.ibm.com/docs/services/appid?topic=appid-getting-started
          base_auth_uri: '%env(OAUTH_APPID_BASE_AUTH_URI)%'
          # IBM App ID service tenant ID. For example, "1234-5678-abcd-efgh". More details at https://cloud.ibm.com/docs/services/appid?topic=appid-getting-started
          tenant_id: '%env(OAUTH_APPID_TENANT_ID)%'
          # Identity Provider code. Defaults to "saml". More details at https://cloud.ibm.com/docs/services/appid?topic=appid-getting-started
          # idp: '%env(OAUTH_APPID_IDP)%'
          # whether to check OAuth2 "state": defaults to true
          # use_state: true

Add your credentials in env. My IDP is SAML here.

OAUTH_APPID_BASE_AUTH_URI=https://us-south.appid.cloud.ibm.com/oauth/v4
OAUTH_APPID_REDIRECT_ROUTE=connect_appid_check
OAUTH_APPID_TENANT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxx
OAUTH_APPID_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxx
OAUTH_APPID_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxx

Step 2 - Add the client controller

Create IBM App ID authenticator controller

<?php

// src/Controller/AppIdController.php

namespace App\Controller;

use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * Class AppIdController
 *
 * @author  Dzianis Kotau <jampire.blr@gmail.com>
 * @package App\Controller
 */
class AppIdController extends AbstractController
{
    /**
     * @Route("/connect", name="connect_appid")
     *
     * Authorization route
     *
     * @param ClientRegistry $clientRegistry
     *
     * @return RedirectResponse
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     */
    public function connect(ClientRegistry $clientRegistry): RedirectResponse
    {
        return $clientRegistry->getClient('appid')->redirect();
    }

    /**
     * @Route("/connect/check", name="connect_appid_check")
     *
     * Callback route
     *
     * @return Response
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     */
    public function check(): Response
    {
        if (!$this->getUser()) {
            return new JsonResponse([
                'status' => false,
                'message' => 'User not found!',
            ]);
        }

        return $this->redirectToRoute('home');
    }
}

Create HomeController

<?php

// src/Controller/HomeController.php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

/**
 * Class HomeController
 *
 * @author  Dzianis Kotau <jampire.blr@gmail.com>
 * @package App\Controller
 */
class HomeController extends AbstractController
{
    /**
     * @Route("/", name="home")
     * @return JsonResponse
     * @author  Dzianis Kotau <jampire.blr@gmail.com>
     */
    public function home(): JsonResponse
    {
        $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');

        return new JsonResponse([
            'name' => $this->getUser()->getFullName(),
            'email' => $this->getUser()->getEmail(),
        ]);
    }
}

Step 3 - Add the guard authenticator

Create IBM App ID authenticator guard. Below code block is published under MIT license. Please see License File for more information.

<?php

// src/Security/AppIdAuthenticator.php

namespace App\Security;

use KnpU\OAuth2ClientBundle\Client\OAuth2ClientInterface;
use KnpU\OAuth2ClientBundle\Security\Authenticator\SocialAuthenticator;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use League\OAuth2\Client\Token\AccessToken;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;

/**
 * Class AppIdAuthenticator
 *
 * @author  Dzianis Kotau <jampire.blr@gmail.com>
 * @package App\Security
 */
class AppIdAuthenticator extends SocialAuthenticator
{
    /** @var ClientRegistry  */
    private $clientRegistry;

    /** @var RouterInterface */
    private $router;

    public function __construct(ClientRegistry $clientRegistry, RouterInterface $router)
    {
        $this->clientRegistry = $clientRegistry;
        $this->router = $router;
    }

    /**
     * @param Request $request
     *
     * @return bool
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     */
    public function supports(Request $request): bool
    {
        $provider = $this->getClient()->getOAuth2Provider();

        return ($request->isMethod('GET') &&
                $request->getPathInfo() === $this->router->generate($provider->getRedirectRoute()));
    }

    /**
     * @param Request $request
     *
     * @return AccessToken|mixed
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     */
    public function getCredentials(Request $request): AccessToken
    {
        return $this->fetchAccessToken($this->getClient());
    }

    /**
     * @param mixed                 $credentials
     * @param UserProviderInterface|UserProvider $userProvider
     *
     * @return UserInterface
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     */
    public function getUser($credentials, UserProviderInterface $userProvider): UserInterface
    {
        return $userProvider->loadUserByUsername($this->getClient()->fetchUserFromToken($credentials));
    }

    /**
     * @param mixed         $credentials
     * @param UserInterface $user
     *
     * @return bool
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     */
    public function checkCredentials($credentials, UserInterface $user): bool
    {
        $provider = $this->getClient()->getOAuth2Provider();

        return $provider->validateAccessToken($credentials);
    }

    /**
     * @return OAuth2ClientInterface|\KnpU\OAuth2ClientBundle\Client\Provider\AppIdClient
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     */
    private function getClient(): OAuth2ClientInterface
    {
        return $this->clientRegistry->getClient('appid');
    }

    /**
     * @param Request        $request
     * @param TokenInterface $token
     * @param string         $providerKey
     *
     * @return Response|null
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     */
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey): ?Response
    {
        // let the request continue to be handled by the controller
        return null;
    }

    /**
     * @param Request                 $request
     * @param AuthenticationException $exception
     *
     * @return Response
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     */
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
    {
        $message = strtr($exception->getMessageKey(), $exception->getMessageData());

        return new Response($message, Response::HTTP_FORBIDDEN);
    }

    /**
     * Called when authentication is needed, but it's not sent.
     * This redirects to the App ID authorization.
     * @param Request                      $request
     * @param AuthenticationException|null $authException
     *
     * @return RedirectResponse
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     */
    public function start(Request $request, AuthenticationException $authException = null): RedirectResponse
    {
        return $this->getClient()->redirect();
    }
}

Step 4 - Add User security

Full documentation for adding providers is available at Symfony Security Documentation.

Create User provider class. Below code block is published under MIT license. Please see License File for more information.

<?php

// src/Security/UserProvider.php

namespace App\Security;

use KnpU\OAuth2ClientBundle\Security\User\OAuthUserProvider;
use League\OAuth2\Client\Provider\ResourceOwnerInterface;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\UserInterface;
use function get_class;

/**
 * Class UserProvider
 *
 * @author  Dzianis Kotau <jampire.blr@gmail.com>
 * @package App\Security
 */
class UserProvider extends OAuthUserProvider
{
    private $roles;

    public function __construct(array $roles = ['ROLE_USER', 'ROLE_OAUTH_USER'])
    {
        $this->roles = $roles;
        parent::__construct($roles);
    }

    /**
     * @param ResourceOwnerInterface $resourceOwner
     *
     * @return UserInterface
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     */
    public function loadUserByUsername($resourceOwner): UserInterface
    {
        return new User($resourceOwner, $this->roles);
    }

    public function supportsClass($class): bool
    {
        return User::class === $class;
    }

    /**
     * @param UserInterface $user
     *
     * @return UserInterface
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     */
    public function refreshUser(UserInterface $user): UserInterface
    {
        if (!$user instanceof User) {
            throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user)));
        }

        return $this->loadUserByUsername($user->getResourceOwner());
    }
}

Create User class. Below code block is published under MIT license. Please see License File for more information.

<?php

// src/Security/User.php

namespace App\Security;

use KnpU\OAuth2ClientBundle\Security\User\OAuthUser;
use League\OAuth2\Client\Provider\ResourceOwnerInterface;

/**
 * Class User
 *
 * @author  Dzianis Kotau <jampire.blr@gmail.com>
 * @package App\Security
 */
class User extends OAuthUser
{
    /** @var string */
    private $fullName;

    /** @var string */
    private $cNum;

    /** @var string */
    private $lotusNotesId;

    /** @var array */
    private $ibmInfo = [];

    /** @var string */
    private $location;

    /** @var ResourceOwnerInterface  */
    private $resourceOwner;

    /**
     * User constructor.
     *
     * @param array $roles
     * @param ResourceOwnerInterface $resourceOwner
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     */
    public function __construct(ResourceOwnerInterface $resourceOwner, array $roles)
    {
        $this->resourceOwner = $resourceOwner;
        $this->setFullName($this->resourceOwner->getFullName());
        $this->setCnum($this->resourceOwner->getCnum());
        $this->setLotusNotesId($this->resourceOwner->getLotusNotesId());
        $this->setIbmInfo($this->resourceOwner->getIbmInfo());
        $this->setLocation($this->resourceOwner->getLocation());

        parent::__construct($resourceOwner->getEmail(), $roles);
    }

    /**
     * @return string|null
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     */
    public function getEmail(): ?string
    {
        return $this->getUsername();
    }

    /**
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     * @return string
     */
    public function getFullName(): ?string
    {
        return $this->fullName;
    }

    /**
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     * @param string $fullName
     *
     * @return self
     */
    public function setFullName(string $fullName): self
    {
        $this->fullName = $fullName;

        return $this;
    }

    /**
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     * @return string
     */
    public function getCnum(): ?string
    {
        return $this->cNum;
    }

    /**
     * @param string $cNum
     *
     * @return self
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     */
    public function setCnum(string $cNum): self
    {
        $this->cNum = $cNum;

        return $this;
    }

    /**
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     * @return string
     */
    public function getLotusNotesId(): ?string
    {
        return $this->lotusNotesId;
    }

    /**
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     * @param string $lotusNotesId
     *
     * @return self
     */
    public function setLotusNotesId(string $lotusNotesId): self
    {
        $this->lotusNotesId = $lotusNotesId;

        return $this;
    }

    /**
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     * @return array
     */
    public function getIbmInfo(): array
    {
        return $this->ibmInfo;
    }

    /**
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     * @param array $ibmInfo
     *
     * @return self
     */
    public function setIbmInfo(array $ibmInfo = []): self
    {
        $this->ibmInfo = $ibmInfo;

        return $this;
    }

    /**
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     * @return string
     */
    public function getLocation(): ?string
    {
        return $this->location;
    }

    /**
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     * @param string $location
     *
     * @return self
     */
    public function setLocation(string $location): self
    {
        $this->location = $location;

        return $this;
    }

    /**
     * @author Dzianis Kotau <jampire.blr@gmail.com>
     * @return ResourceOwnerInterface
     */
    public function getResourceOwner(): ResourceOwnerInterface
    {
        return $this->resourceOwner;
    }
}

Then run your application

symfony server:start --no-tls

and try to login into it in the browser.