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