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.58With 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;
    }    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;
        }
    }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_SERVICESenvironment 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;
    }    private $authToDecorate;
    private $_registration;
    private $username;
    private $userinfo;
    private $oidc;
    private function GetRegistration()
    {
        if ($this->_registration == null)
        {
            $this->_registration = new Registration();
        }
        return $this->_registration;
    }