OpenID Connect Authentication with Booked Scheduler

In this post I'll describe the steps for implementing a plugin for using AppID authentication with Booked Scheduler deployed as a Cloud Foundry application at the IBM Cloud.

It looks like PHP simply isn't going to die, especially because there are some fine PHP applications such as Booked Scheduler. It looks good and works really well, and that's what we've been using to schedule meeting rooms at the Brazil Research Lab for the last 5 years. On top of that, booked has a plugin system that's really easy to use. Also, notice that although we're using IBM Cloud here, one can easily customize this to any other environment.

Anatomy of a Booked authentication plugin

Booked authentication plugins must import the Authentication namespace and implement the Authentication interface. On top of that, they must implement the Validate and Login methods. A minimal OpenID Connect plugin could be created with something like (assuming we're at the top-level of the Booked directory structure):

$ mkdir plugins/Authentication/OpenID
$ cat << EOF > plugins/Authentication/OpenID/OpenID.php
<?php

require_once(ROOT_DIR . 'lib/Application/Authentication/namespace.php');

class OpenID extends Authentication implements IAuthentication {
    public function Validate($username, $password) {
    // ...
    }

    public function Login($username, $loginContext) {
    // ...
    }
}
?>

Simple as that! So, how to we add support for OpenID Connect in PHP?

Composer, OpenID Connect PHP, and Cloud Foundry

The easiest library for supporting OpenID Connect in PHP seems to be jumbojett's openid-connect-php, which prefers to be imported as a library with the composer dependency manager. The PHP build pack already supports composer, so you don't necessarily have to install composer locally if you don't want to. Anyway, as of the time of writing this post, the latest openid-connect-php version is 0.8.0. So the following composer.json would be appropriate, with PHP 5.6 being a dependency of booked itself:

{
     "require": {
         "php": "5.6.*",
         "jumbojett/openid-connect-php": "^0.8.0"
     }
}

While we're at it, the php buildpack, by default, considers the lib directory at the top level of the application as a special library directory and moves it to another location upon deployment. You can fix this by defining the options.json file at the .bp-config directory.

{
   "PHP_DEFAULT" : "{PHP_56_LATEST}",
   "LIBDIR" : "deps",
   "PHP_EXTENSIONS" : [
      "bz2",
      "zlib",
      "curl",
      "mcrypt",
      "mysqli"
   ]
}

PHP 5 support was discontinued on January 10th, 2019. Hence, to run this with cloud foundry you will need a specific version of the build pack, at least until booked officially supports PHP7. Also notice that, for PHP 5, the cflinuxfs2 stack is required. The following entries should probably go into your manifest.yml (alongside other entries, such as route configuration:

applications:
- path: .
  memory: 512M
  instances: 1
  stack: cflinuxfs2
  disk: 1G
  buildpack: https://github.com/cloudfoundry/php-buildpack.git#v4.3.58

With the above code snippets, you should be able to run booked on IBM Cloud and should have all the dependencies to implement an OpenID Connect authentication plugin.

OpenID Connect Authentication

openid-connect-php requires at least a provider URL with client ID and client secret. When one AppID service is bound to a Cloud Foundry application, IBM Cloud populates the VCAP_SERVICES environment variable with the OpenID configuration. This variable contains a JSON-encoded string with all services bound to the application. We're interested in the AppID member. The good thing about using this variable is that it is able to auto-configure itself, and supports running with different AppID instances.

For this particular instance, I had to define the provider, the client id, the secret, the issuer, and the token endpoint. In the end, I implemented initialization of the OpenID Connect client in the constructor of my plugin class:

    public function __construct(Authentication $authentication)
    {
        $vcap_services = json_decode($_ENV["VCAP_SERVICES"]);
        $appid = $vcap_services->{"AppID"}[0]->credentials;
        $issuer = $appid->appidServiceEndpoint;
        $client_id = $appid->clientId;
        $provider = $appid->oauthServerUrl;
        $token_endpoint = $appid->oauthServerUrl . "/token";
        $secret = $appid->secret;

        Log::Debug("Constructing OpenID Client pointing to: %s", $provider);

        $this->oidc = new OpenIDConnectClient($provider, $client_id, $secret, $issuer);
        $this->oidc->providerConfigParam(array('token_endpoint'=>$token_endpoint));
        $this->oidc->setTimeout(5);

        if ($authentication == null) {
            $authentication = new Authentication();
        }

        $this->authToDecorate = $authentication;
    }
Note that the code above supports being called with and without arguments, which is what Booked requires of plugins. Authentication itself is handled in the `Validate` method. If it succeeds, then the method returns true and booked proceeds to call the `Login` method, which should implement logic to keep the user logged into the system. If it fails, the login process is stopped. Note that when this is called, the user is redirected to the identity provider page configured in AppID. In our case, the user is redirected to the SAML 2.0 Single Sign-On service. In the code below, I'm using the user's email as user name. You are free to use any other member you like. You can find the other members in the [OpenID connect documentation](https://connect2id.com/products/server/docs/api/userinfo).
    public function Validate($username, $password)
    {
        Log::Debug("Authenticating user");
        if (!ServiceLocator::GetServer()->GetUserSession()->IsLoggedIn()) {
            if ($this->oidc->authenticate()) {
                $this->userinfo = $this->oidc->requestUserInfo(null);
                $this->username = $this->userinfo->email;
                $this->username = strtolower($this->username);
                return true;
            }
            return false;
        } else {
            return true;
        }
    }
Logging the user in -------------------

Authentication succeeded! Now we need to log the user in. We also use this opportunity to fetch user information from the AppID response, creating (or updating) the user row in the Booked database. In the code block below I show a simplified view of what I ended up implementing. This code has all the required logic by Booked.

public function Login($username, $loginContext)
{
    if ($this->userinfo) {
        $user = $this->Synchronize();
        $username = $this->username;
    } else {
        // Shouldn't happen, probably need to throw exception
        return null;
    }
    return $this->authToDecorate->Login($username, $loginContext);
}

The Synchronize method basically instantiates an AuthenticatedUser object, populates it with user information and persists it to the database. In this specific use case, I always trust what AppID gives me as user information. I also disable the ability of updating user information by implementing the Allow* methods of the interface to return false. More on that later. You will probably have to adjust the code below to your use case, this is not what I implemented, but is valid code assuming all fields in the specification are set.

private function Synchronize($username)
{
    $registration = $this->GetRegistration();
    $user = new AuthenticatedUser(
        $this->username,              # username
        $this->username,              # email
        $this->userinfo->given_name,  # first name
        $this->userinfo->family_name, # last name
        "someStringThatWontBeUsed",   # password
        Configuration::Instance()->GetKey(ConfigKeys::LANGUAGE),
        Configuration::Instance()->GetDefaultTimezone(),
        "",                           # phone number
        "Your Organization",          # organization
        ""                            # title
    );
    $registration->Synchronize($user);

    $userRepository = new UserRepository();
    $user = $userRepository->LoadByUsername($this->username);

    return $user;
}

And that's it! You should have everything needed to implement OpenID authentication.

Additional code

For completeness, at the beginning of your plugin, you will probably want to:

  • Load the composer autoloader,
  • Load the OpenIDConnectClient namespace,
  • Stop execution in case the VCAP_SERVICES environment variable is missing

Which you can achieve with something like

require '/home/vcap/app/deps/vendor/autoload.php';

use Jumbojett\OpenIDConnectClient;

if (!$_ENV["VCAP_SERVICES"]) {
    echo "Error: Not VCAP_SERVICES variable set. Am I running in IBM Cloud?";
    die();
}

To prevent the login screen from being shown (since we're using AppID) and to disable editing of user details, you will probably want the following helper methods:

    public function AreCredentialsKnown()
    {
        return true;
    }

    public function ShowForgotPasswordPrompt()
    {
        return false;
    }

    public function ShowPasswordPrompt()
    {
        return false;
    }

    public function ShowUsernamePrompt()
    {
        return false;
    }

    public function AllowUsernameChange()
    {
        return false;
    }

    public function AllowEmailAddressChange()
    {
        return false;
    }

    public function AllowPasswordChange()
    {
        return false;
    }

    public function AllowNameChange()
    {
        return false;
    }

    public function AllowPhoneChange()
    {
        return false;
    }

    public function AllowOrganizationChange()
    {
        return false;
    }

    public function AllowPositionChange()
    {
        return false;
    }

    public function ShowPersistLoginPrompt()
    {
        return false;
    }
For completeness, the private members of this class and the `GetRegistration` method are shown below:
    private $authToDecorate;
    private $_registration;
    private $username;
    private $userinfo;
    private $oidc;

    private function GetRegistration()
    {
        if ($this->_registration == null)
        {
            $this->_registration = new Registration();
        }

        return $this->_registration;
    }
Avatar
Renato Luiz de Freitas Cunha
Principal Research Software Engineer

My research interests include reinforcement learning and distributed systems