The other day I was working with one of our partners, a member based organisation, integrating their portal with our platform.
The use case was simple: allow the partners members to access a Prosple career directory by using their existing credentials.
In order to do this, we need to integrate with their Identity Provider (IdP) and they decided on SAML from the various protocols we support. There was only one catch. They didn’t have a SAML IdP and weren’t exactly sure on how to add that capability to their existing Drupal website.
In order to help our partner with this I started doing some research on SAML implementation for existing login workflows in PHP and was surprised when all I found was solutions that involved replacing the login page (for example using simpleSAMLPHP).
I wanted something simple, that can be easily plugged in to a portal with an existing login page. I did a bit of research, and found LightSAML, which allowed me to quickly whip up a working IdP prototype in around half an hour. I found that pretty amazing and so decided to share my findings for those that find them useful.
Before we get straight into the nitty gritty however, it’s important to at least understand the basics of SAML and how it works.
What is SAML?
Security Assertion Markup Language (SAML, pronounced SAM-el) is an open standard for exchanging authentication and authorization data between parties, in particular, between an identity provider and a service provider. SAML is an XML-based markup language for security assertions (statements that service providers use to make access-control decisions).
Got it? In a nutshell what this means is that an application can delegate authentication to an external system (IdP), via a Service Provider (SP). This is done by exchanging some messages in XML.
In its simplest form, a basic SAML Service Provider Initiated flow looks like this:
A user tries to access the application.
The application checks if the user is logged in via the SP and if not redirects the user to the IdP login page along with a SAML Request.
The user authenticates in the IdP and if successful returns the user to the Assertion Consumer Service Url along with a SAML Response (This is usually signed with the IdP certificate).
The Service Provider decodes the response and provides the user access to the application.
At Prosple we strongly discourage the use of IdP initiated flows. I won’t cover that ground in this article, but check The Dangers of SAML IdP-Initiated SSO by Scott Brady for an overview on the matter if you’re interested.
PHP SAML IdP Demo
Alright, now that we’re clear on the basic concepts, let’s get started.
For the purpose of this demo, we will be making some assumptions:
You have an existing PHP portal (e.g Drupal, Wordpress, etc) with an existing user database, either in the CMS itself or in some sort of external system that your CMS integrates with.
You have a login page where users authenticate against your system.
You may have looked at solutions like simpleSAMLPHP, but you don’t want to create a new login page, you want to integrate the SAML workflow into your existing page.
We’ll be using the LightSAML Core PHP library since, contrary to something like simpleSAMLPHP its main design principles are around working like a pluggable API rather than a standalone application.
In their own words:
LightSAML Core is a PHP library implementing OASIS SAML 2.0 protocol, fully OOP structured with DPI principles, reusable, and embeddable.
Sounds great! Ready? Set? Go!
Housekeeping (PHP, Apache, Docker)
In order to get up and running for our demo we need a server with PHP and Apache.
I’m going to be doing this in Docker, but feel free to use whatever PHP stack you want to run this codebase (XAMP, MAMP, etc).
Let’s create our simple docker-compose file in the root of our project:
version: "3.2"
services:
php:
ports:
- "8080:80"
image: php:apache
volumes:
- .:/var/www/html/
Too easy. Now with a simple docker-compose up
we’re up and running in http://localhost:8080/
Installing LightSAML
The first thing we want to do is install LightSAML.
For this we will use Composer.
Assuming you have Composer installed, from the root of your project run composer require lightsaml/lightsaml
Done!
Now, since we want to be using the classes provided by the library, let’s set up some basic autoloading. We’ll do this by creating an inc.php file in the root of our project with the following code:
<?php
// Autoloading libraries
require __DIR__ . '/vendor/autoload.php';
This merely includes the auto-generated Composer autoload php file and we can then include this in our codebase. Simple stuff.
Login Page
Now, to the use case at hand. The first thing to cover is our login page. We’re going to create a basic dummy login page to simulate the IdP login page.
The code is simple:
<?php
include "inc.php";
// Reading the HTTP Request
$request = \Symfony\Component\HttpFoundation\Request::createFromGlobals();
?>
<h1>IdP Login Page</h1>
<form action="post-saml.php">
<div>
<label>Username:</label>
<input name="username" type="text">
</div>
<div>
<label>Pass:</label>
<input type="password" name="password">
</div>
<input type="submit">
<input type="hidden" name="SAMLRequest"
value="<?php print $request->get("SAMLRequest") ?>">
<input type="hidden" name="RelayState"
value="<?php print $request->get("RelayState") ?>">
</form>
The PHP code includes inc.php file and reads the HTTP request so we can extract some query string parameters. You don’t need to use Symfony for this, basic usage of PHP $_GET suffices, however given I already have the library handy I’ll do that for the purpose of this demo.
Next, we have a basic form simulating your IdP login page: a username input, a password input and a submit button.
Remember the workflow I described above? When users arrives in this login page from a Service Provider, they will arrive with both SAMLRequest and RelayState parameters. We will need these after the authentication is successful so we can respond to the Service Provider.
Depending on your use case and exactly how authentication works in your system, there are many ways to go about this but in this simple demo we are just passing them as hidden fields so they are added to the POST data when the form is submitted and the user taken to post-saml.php (the page where we construct the response and redirect the user back, more on this later).
For the purposes of integrating this in your own login page, just ensure you pass through SAMLRequest and RelayState to that page.
When the user accesses http://localhost:8080/login.php they will see something like this:
IdP Login page
Not very pretty, but it does the job.
When the user submits this form, he will be taken to post-saml.php
Enter SAML
Alright, at this point, you should have validated the user’s credentials and know if the login was successful or not (I won’t write code for that for obvious reasons).
Now it’s time to do the real heavy lifting, where will take some information from the IdP and the authentication result and construct a SAML Response to send to the SP, redirecting the user to the post-back Url (the ACS or Assertion Consumer Service Url provided by the SP where the SAML response should be sent to).
Thankfully as you’ll see this is pretty easy with LightSAML.
Creating some Utilities
First we’re going to create some Utility classes.
We’ll start with an IdpProvider.php class. This is a dummy representation of your system. In a real integration you should instead call whatever APIs are available in your system to obtain the information required.
<?php
class IdpProvider {
}
We’re also going to create a Utility class called IdpTools.php. This will be just a wrapper with some useful functions for things like reading SAML requests and creating SAML responses, just to keep things nice and tidy:
<?php
class IdpTools{
}
We’ll place both of these in the src/Utility folder of our project.
Creating a POST Binding Page
Now we can get started with our post-saml.php file which renders a page with a hidden form to POST data back to the SP.
Let’s start with importing our libraries as well as our Utility classes and instantiating them:
<?php
include "inc.php";
include "src/Utility/IdpProvider.php";
include "src/Utility/IdpTools.php";
// Initiating our IdP Provider dummy connection.
$idpProvider = new IdpProvider();
// Instantiating our Utility class.
$idpTools = new IdpTools();
Great! Now it’s important to reiterate a point here. At this stage in our workflow we’ve already authenticated the user and the login was successful, so now we need to start preparing a response to the SP.
Reading the SAML Request
To do that, we need to read the SAMLRequest first:
// Receive the HTTP Request and extract the SAMLRequest.
$request = \Symfony\Component\HttpFoundation\Request::createFromGlobals();
$saml_request = $idpTools->readSAMLRequest($request);
We’re using Symfony to read the HTTP request and pass it to our Utility function readSAMLRequest() which we’ll implement in our IdpTools class.
Let’s do that now:
/**
* Reads a SAMLRequest from the HTTP request and returns a messageContext.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The HTTP request.
*
* @return \LightSaml\Context\Profile\MessageContext
* The MessageContext that contains the SAML message.
*/
public function readSAMLRequest($request){
// We use the Binding Factory to construct a new SAML Binding based on the
// request.
$bindingFactory = new \LightSaml\Binding\BindingFactory();
$binding = $bindingFactory->getBindingByRequest($request);
// We prepare a message context to receive our SAML Request message.
$messageContext = new \LightSaml\Context\Profile\MessageContext();
// The receive method fills in the messageContext with the SAML Request data.
/** @var \LightSaml\Model\Protocol\Response $response */
$binding->receive($request, $messageContext);
return $messageContext;
}
This implementation comes straight from LightSAML’s cookbook in ‘How to read a SAML message’.
Receiving a SAML message from the HTTP request with the SAML HTTP POST or Redirect binding, in LightSAML is done with the Binding set of classes. The
BindingFactory
can detect the binding type for the given HTTP request and instantiate corresponding Binding class,HttpPostBinding
orHttpRedirectBinding
, capable of receiving the SAML message (AuthnRequest, Response…).First you create Symfony’s HttpFoundation Request, instantiate
BindingFactory
with that request and get the actual binding, and finally call the bindingreceive()
method, that will return deserialized SAML document from the HTTP Request.
The return of this method contains the SAML message itself, in this case the SAMLRequest.
Getting some data from IdP
Continuing in our post-saml.php file, now that we have the SAMLRequest we need to get some data from it. Specifically, we want the ID of the request message (which we’ll use later) and the Issuer which is the identifier of the Service Provider.
// Getting a few details from the message like ID and Issuer.
$issuer = $saml_request->getMessage()->getIssuer()->getValue();
$id = $saml_request->getMessage()->getID();
Note: If your login page can only process authentication from SAML Service Providers you can check the Issuer in the SAMLRequest in the login page and if it’s not a trusted SP, deny access. In our case this login page is used for other purposes so we won’t be doing that.
Now, we also want to get some information from the IdP related to the authenticated user:
// Simulate user information from IdP
$user_id = $request->get("username");
$user_email = $idpProvider->getUserEmail();
In our use case, I’m interested in the user_id and user_email, but this may vary case by case. You would get this from your own system, but for the purposes of this demo we get the username from the login form and the email from our IdpProvider class:
/**
* Returns a dummy user email.
*
* @return string
*/
public function getUserEmail(){
return "duarte.garin@samltuts.com";
}
Constructing the SAML Response
Now we are ready to construct our SAML Response.
We’ll add this call to our post-saml.php file:
// Construct a SAML Response.
$response = $idpTools->createSAMLResponse($idpProvider, $user_id, $user_email, $issuer, $id);
Let’s see the implementation for this:
/**
* Constructs a SAML Response.
*
* @param \IdpProvider $idpProvider
* @param $user_id
* @param $user_email
* @param $issuer
* @param $id
*/
public function createSAMLResponse($idpProvider, $user_id, $user_email, $issuer, $id){
$acsUrl = $idpProvider->getServiceProviderAcs($issuer);
// Preparing the response XML
$serializationContext = new \LightSaml\Model\Context\SerializationContext();
// We now start constructing the SAML Response using LightSAML.
$response = new \LightSaml\Model\Protocol\Response();
$response
->addAssertion($assertion = new \LightSaml\Model\Assertion\Assertion())
->setStatus(new \LightSaml\Model\Protocol\Status(
new \LightSaml\Model\Protocol\StatusCode(
\LightSaml\SamlConstants::STATUS_SUCCESS)
)
)
->setID(\LightSaml\Helper::generateID())
->setIssueInstant(new \DateTime())
->setDestination($acsUrl)
// We obtain the Entity ID from the Idp.
->setIssuer(new \LightSaml\Model\Assertion\Issuer($idpProvider->getIdPId()))
;
$assertion
->setId(\LightSaml\Helper::generateID())
->setIssueInstant(new \DateTime())
// We obtain the Entity ID from the Idp.
->setIssuer(new \LightSaml\Model\Assertion\Issuer($idpProvider->getIdPId()))
->setSubject(
(new \LightSaml\Model\Assertion\Subject())
// Here we set the NameID that identifies the name of the user.
->setNameID(new \LightSaml\Model\Assertion\NameID(
$user_id,
\LightSaml\SamlConstants::NAME_ID_FORMAT_UNSPECIFIED
))
->addSubjectConfirmation(
(new \LightSaml\Model\Assertion\SubjectConfirmation())
->setMethod(\LightSaml\SamlConstants::CONFIRMATION_METHOD_BEARER)
->setSubjectConfirmationData(
(new \LightSaml\Model\Assertion\SubjectConfirmationData())
// We set the ResponseTo to be the id of the SAMLRequest.
->setInResponseTo($id)
->setNotOnOrAfter(new \DateTime('+1 MINUTE'))
// The recipient is set to the Service Provider ACS.
->setRecipient($acsUrl)
)
)
)
->setConditions(
(new \LightSaml\Model\Assertion\Conditions())
->setNotBefore(new \DateTime())
->setNotOnOrAfter(new \DateTime('+1 MINUTE'))
->addItem(
// Use the Service Provider Entity ID as AudienceRestriction.
new \LightSaml\Model\Assertion\AudienceRestriction([$issuer])
)
)
->addItem(
(new \LightSaml\Model\Assertion\AttributeStatement())
->addAttribute(new \LightSaml\Model\Assertion\Attribute(
\LightSaml\ClaimTypes::EMAIL_ADDRESS,
// Setting the user email address.
$user_email
))
)
->addItem(
(new \LightSaml\Model\Assertion\AuthnStatement())
->setAuthnInstant(new \DateTime('-10 MINUTE'))
->setSessionIndex($assertion->getId())
->setAuthnContext(
(new \LightSaml\Model\Assertion\AuthnContext())
->setAuthnContextClassRef(\LightSaml\SamlConstants::AUTHN_CONTEXT_PASSWORD_PROTECTED_TRANSPORT)
)
)
;
// Sign the response.
$response->setSignature(new \LightSaml\Model\XmlDSig\SignatureWriter($idpProvider->getCertificate(), $idpProvider->getPrivateKey()));
// Serialize to XML.
$response->serialize($serializationContext->getDocument(), $serializationContext);
// Set the postback url obtained from the trusted SPs as the destination.
$response->setDestination($acsUrl);
return $response;
}
This looks like a lot but don’t worry as 90% of this is templated code. You can reuse this function in your own project so long as you replace the areas where we are passing variables.
You might also want to check LightSAML cookbook on how to prepare a SAML Response, which is basically where all this code comes from.
Let’s cover the important parts by commenting on sections of the code above.
Obtaining the Assertion Consumer Service Url
It’s important to know where we send this response to right?
The standard name for this endpoint is Assertion Consumer Service or ACS for short. Depending on how you want to implement this and the capabilities of your SP you can either store a mapping in your IdP between the trusted SP Ids and their ACS urls, or between their Ids and their metadata endpoints.
Metadata endpoints in the SP expose information about them so that you can dynamically fetch them when needed (which is what we do at Prosple). For the purposes of this example however, we’ll do the former.
Let’s add that to our IdpProvider class as an attribute:
// Defining some trusted Service Providers.
private $trusted_sps = [
'urn:service:provider:id' => 'https://service-provider.com/login/callback'
];
And expose a method of retrieving this:
/**
* Retrieves the Assertion Consumer Service.
*
* @param string
* The Service Provider Entity Id
* @return
* The Assertion Consumer Service Url.
*/
public function getServiceProviderAcs($entityId){
return $this->trusted_sps[$entityId];
}
And now call it in our SAML Response constructor function:
$acsUrl = $idpProvider->getServiceProviderAcs($issuer);
Great! Now we know where to send the response to!
Issuer or IdP Id
// We obtain the Entity ID from the Idp.
->setIssuer(new \LightSaml\Model\Assertion\Issuer($idpProvider->getIdPId()))
This is where we set our Issuer, which in this case (contrary to the request) is the identifier of our IdP:
/**
* Returning a dummy IdP identifier.
*
* @return string
*/
public function getIdPId(){
return "https://www.idp.com";
}
Obviously in your use case you need to obtain this from your system.
This needs to be done in two places, both on the Response message and on the assertion node of the response.
Setting the NameID
This is a very important part of the response, and it’s in the subject section of the response message.
There are various formats for this. Because this is a member organisation, let’s assume we have numerical uids like 2349
and so we’ll be using the Unspecified NameID Format. You can see the various formats in the SAML spec.
Here is how it looks in our code (notice we are passing the user_id):
->setSubject(
(new \LightSaml\Model\Assertion\Subject())
// Here we set the NameID that identifies the name of the user.
->setNameID(new \LightSaml\Model\Assertion\NameID(
$user_id,
\LightSaml\SamlConstants::NAME_ID_FORMAT_UNSPECIFIED
))
Setting ResponseTo
We also need to somehow define that this SAML Response is related to the SAML Request we received. This is done in the ResponseTo field.
We do this by setting the field to be the ID of the SAMLRequest:
// We set the ResponseTo to be the id of the SAMLRequest.
->setInResponseTo($id)
Setting the Recipient
This is where we define the recipient of our message. Remember when we obtained the ACS endpoint above? This is where we’ll be using it:
// The recipient is set to the Service Provider ACS.
->setRecipient($acsUrl)
Setting Audience Restriction
This defines the audience for our response message.
Since we’re replying to our SP, it makes sense that the SP is the audience and so we pass here its ID:
// Use the Service Provider Entity ID as AudienceRestriction.
new \LightSaml\Model\Assertion\AudienceRestriction([$issuer])
Setting the Email address
For those using the email address, that is an important field to pass in the response. We’ll be using the email we obtained from the IdP authentication here:
->addItem(
(new \LightSaml\Model\Assertion\AttributeStatement())
->addAttribute(new \LightSaml\Model\Assertion\Attribute(
\LightSaml\ClaimTypes::EMAIL_ADDRESS,
// Setting the user email address.
$user_email
))
)
Almost done, we now have a fully compliant SAML Response message ready to be sent! However, there are a few important things still missing.
Signing the SAML Response Message
It’s normally good practice for the IdP to sign the responses with a self signed certificate. This can then be given to the SP to decrypt the message received on their end.
Let’s add that to your post-saml.php file:
// Sign the response.
$response->setSignature(new \LightSaml\Model\XmlDSig\SignatureWriter($idpProvider->getCertificate(), $idpProvider->getPrivateKey()));
See the LightSAML Cookbook on how to sign a SAML message.
We’re getting both the certificate (which is shared with the SP) and the private key from the IdpProvider class. Here is the implementation for both:
/**
* Retrieves the certificate from the IdP.
*
* @return \LightSaml\Credential\X509Certificate
*/
public function getCertificate(){
return \LightSaml\Credential\X509Certificate::fromFile('cert/saml_test_certificate.crt');
}
/**
* Retrieves the private key from the Idp.
*
* @return \RobRichards\XMLSecLibs\XMLSecurityKey
*/
public function getPrivateKey(){
return \LightSaml\Credential\KeyHelper::createPrivateKey('cert/saml_test_certificate.key', '', true);
}
I won’t cover in this article how to generate self signed certificates as that’s a well covered topic on the web.
Preparing the POST Binding
Alright, at this point we have everything we need. Now we just need to render the POST binding, which is a hidden form that gets autosubmitted with the SAMLResponse and RelayState. Let’s add this to our post-saml.php class:
// Prepare the POST binding (form).
$bindingFactory = new \LightSaml\Binding\BindingFactory();
$postBinding = $bindingFactory->create(\LightSaml\SamlConstants::BINDING_SAML2_HTTP_POST);
$messageContext = new \LightSaml\Context\Profile\MessageContext();
$messageContext->setMessage($response);
// Ensure we include the RelayState.
$message = $messageContext->getMessage();
$message->setRelayState($request->get('RelayState'));
$messageContext->setMessage($message);
// Return the Response.
/** @var \Symfony\Component\HttpFoundation\Response $httpResponse */
$httpResponse = $postBinding->send($messageContext);
print $httpResponse->getContent();
Again, refer to the useful LightSAML cookbook on how to send a SAML message for more info.
That’s it!
Lights, Camera, Action!
Everything is now in place, so it’s time to test this out.
For the purpose of this test I’m going to use Auth0 as the Service Provider.
Here is how it will work:
I will trigger a flow from Auth0 (SP) which will take the user to the IdP login page containing the SAMLRequest and RelayState parameters.
I will fill in my (dummy) credentials in the login page and make pretend that authentication was done in the IdP.
The form will then take me to the POST Binding page creating a hidden form with the signed SAML Response and RelayState which will autosubmit and take me back to the SP.
The SP will decrypt the SAML Response and grant me access.
Let’s see it in action in this video:
That’s all folks!
I hope you found this useful, and that it saves someone the time of researching and fiddling with code snippets in order to get a basic understanding on how to do this.
The entire code for this little demo is available here: