Implementing a SAML IDP with Laravel
Recently, I was tasked with figuring out how to implement Single Sign On (SSO) between a Laravel 5.2 Application and Canvas LMS. Canvas LMS offers a few different options for SSO support, and most are popular third party providers such as Google, Facebook, and Twitter.
However, I needed to make it so when users who had already logged into my Laravel application, went to Canvas, they were already logged in, so they didn't have to login twice. This lead me to a few options, SAML being one of them.
After a fair amount of research, the best options I could find from the Canvas LMS community was a php application called SimpleSAML. While this looked like a fairly clean solution, it didn't quite meet my requirements of being able to integrate with my Laravel Application. And while I could have used it as a central IDP (Identity Provider) and have both Laravel and Canvas LMS behave like two different Service Providers (SP), that seemed overly complex.
So I decided to role my own solution for Laravel. The first thing I needed to do was figure out how SAML Worked. I found a nice article written on Github that gives a simplified overview. I suggest reviewing this if you're new to SAML as well: https://github.com/jch/saml.
The short version is there are three main parts. There is the initial login request at the SP. Then their is the Request sent to the IDP, and then there is a response from the IDP to the SP. The initial login request at the SP creates a unique identification id, and sends that in some xml to the IDP as the SAMLRequest
. The IDP has the user login, and authenticates the user against it's own DB, and uses that initial id from the SAMLRequest
to match the newly authenticated users to the original login request. It then sends the user and POSTS an xml response back to the SP using something refereed to as POST binding. The SP uses the xml response, and matches the ID in the response to the initial login request id, checks the status, and uses the NameID format to match the user from the IDP with a user in the SP system. If all matches up, the user is logged in at the SP.
Creating IDP Meta Data
The first thing we need to do is generate a bunch of information and store it in an xml file so our Service Provider (SP), which will be Canvas LMS can pull in all the configuration settings required to talk to our IDP. I found this great website that has online tools that can help us generate all the information we need for this: https://www.samltool.com/online_tools.php
First we're going to generate some self signed certificates. This tool provides an easy way to do that, just answer the questions on this form: https://www.samltool.com/self_signed_certs.php
Once you have your certificate and your private key make sure you save them and put them in your project files somewhere you can easily access from inside laravel. We'll need these to sign responses, and to generate our IDP metadata.
The next thing we'll need to do is create our metadata, again samltools.com provides an easy way to do just that: https://www.samltool.com/idp_metadata.php
A few questions it asks, that stumped me at first.
- What's an EntityId?
- What NameID Format should I use?
- Do I want to sign AuthnRequests?
An Entity ID is a unique URN that identifies your Identity Provider. That sounds complicated, but it can be what ever you want. So for instance, you could do http://idp.yourmainwebsites.com
And that would be an acceptable Entity ID. One thing I want to point out, while these are urls, they don't actually need to resolve. So you don't for instance need to actually configure an IDP sub domain to resolve if you make that your Entity ID. Also, when defining your Entity ID, make sure you use http, and not https. Https will cause problems, i don't know why, it just does.
For a NameID Format, this is something you can be pretty specific about, but if you're like me, and are new to this, you probably don't really understand all the differences between each type. I was able to get away with using the default option of format:unspecified
, however format:emailAdrress
would have probably been more accurate, since that's the way we're identifying users.
For signed AuthnRequests, I put false. It is probably a perfectly good idea to turn this to true, but it will add one more step of complexity when handling the request. I'll leave this up to you, but it will be out side the scope of this write up.
For Single Sign On Service Endpoint, it's just your login url in your laravel website. Also, the single logout service is just your logout url.
The last two fields, are where we put the private key and certificate we generated earlier in this article.
Once you have this xml file generated, save it to an easily accessible location in your site so you can reference it from your SP via the web.
Processing The Request
The first step was figuring out how to start the SSO process with SAML and Canvas LMS. Login to Canvas with your administrator user, and navigate to the site
that you want to have users authenticate with. Typically in canvas you'll have Site Admin, and then a school name of some sort that students actually work in. Click over to your school name, and then select Authentication from the menu. In here, on the right hand side, there's a drop down for adding Authentication Services. Select SAML. Once you do that, a form with the title SAML will be populated, and you'll have a bunch of fields to fill in. Here's where the metadata we created above comes in handy. Where it says IdP Metadata URI, enter the location of the metadata we generated above, and hit save. This should auto populate all the form fields for your IdP.
Now when navigating to https://yourcanvasurl.com/login/saml
it will redirect the user back to my laravel login with a url parameter of ?SAMLRequest=longuglystring
.
Ok, so from what we saw in the github article referenced above, we know the long ugly string is really xml that's been base64_encoded. So in theory on the login page, all we need to do is capture the parameter, and post it along with the login. Well that's pretty simple:
@if(isset($_GET['SAMLRequest']))
<input type="hidden" id="SAMLRequest" name="SAMLRequest" value="{{ $_GET['SAMLRequest'] }}">
@endif
This code basically checks to see if the value is present in the url parameters, and if so adds it as a hidden field so it is posted with the other login information on submit.
Now that we have that being sent to Laravel's Auth Controller on login, let's find where that code is actually ending up.
All of the login logic is actually done in a trait that the AuthController uses. The trait is called AuthenticatesAndRegistersUsers
. Instead of modifying this file directly, I created a new file with the same name, but with a different namespace. In my AuthController
, I simply swap out which AuthenticateAndRegisterUsers
trait is being loaded with my new trait. Inside this file I added some code to catch the SAMLResponse
in the handleUserWasAuthenticated
method.
if(isset($request['SAMLRequest'])){
new SamlAuth($request);
}
This code is pretty straightforward. If the SAMLRequest
is present in the post data from the login form, we know we need to initiate the SSO process. Here we call a class that manages the SAML Authentication process, and we pass in the $request from the login form.
Before we get into the SamlAuth
Class code, I want to reference a package I used to do a lot of the SAML processing. It's called LightSAML and you can find it here: https://www.lightsaml.com/LightSAML-Core/
To install the above library simply run the following command:
composer require lightsaml/lightsaml
Now that we have that installed, and spent some time looking over the example on the their website, we are ready to tackle the SamlAuth
Class. This class in a few places, basically copied and modified examples provided by LightSAML on their website.
So the first thing we need to do is process the Request, and turn it into XML again so we can actually process that data.
Here's my handlSAMLRequest
method:
protected function handleSAMLRequest($request)
{
$SAML = $request->SAMLRequest;
$decoded = base64_decode($SAML);
$xml = gzinflate($decoded);
$deserializationContext = new \LightSaml\Model\Context\DeserializationContext();
$deserializationContext->getDocument()->loadXML($xml);
$authnRequest = new \LightSaml\Model\Protocol\AuthnRequest();
$authnRequest->deserialize($deserializationContext->getDocument()->firstChild, $deserializationContext);
$this->buildSAMLResponse($authnRequest, $request);
}
As we discussed earlier, from reading the jch/saml overview on github we know the data in the url is base64 encoded. But what wasn't mentioned is the xml is first gzip deflated, then base64_encoded.
I lost an entire day trying to figure out why when this string was decoded, it wasn't xml, but random nonsense. LightSAML also has a method for processing your requests, but for what ever reason it also doesn't realize the request was gzip deflated, and ended up with the same random nonsense I was getting manually. So In this method, we do some work before sending it to our LightSaml library. We decode it, and then inflate it, and what we end up with is xml. This means we can actually process this request.
The LightSaml library is going to take that XML and turn it into an object, this is helpful because now we can actually reference the data sent over in the request, when building our response.
The very last line of code $this->buildSAMLResponse($authnRequest, $request);
is sending the new $authnRequest
object, and our $request
object over to our method for building our the SAML response.
Building The Response
The Response is more complex, since we have a lot of things to do to format this xml properly for our SP to decipher and authenticate a user.
Thankfully again LightSAML does the heavy lifting for us. My method below is mostly the same as the example provided here: https://www.lightsaml.com/LightSAML-Core/Cookbook/How-to-make-Response/. With several modifications.
My buildSAMLResponse
method:
protected function buildSAMLResponse($authnRequest, $request)
{
$destination = config('saml.sp.'.base64_encode($authnRequest->getAssertionConsumerServiceURL()).'.destination');
$issuer = config('saml.sp.'.base64_encode($authnRequest->getAssertionConsumerServiceURL()).'.issuer');
$cert = config('saml.sp.'.base64_encode($authnRequest->getAssertionConsumerServiceURL()).'.cert');
$key = config('saml.sp.'.base64_encode($authnRequest->getAssertionConsumerServiceURL()).'.key');
$certificate = \LightSaml\Credential\X509Certificate::fromFile($cert);
$privateKey = \LightSaml\Credential\KeyHelper::createPrivateKey($key, '', true);
$response = new \LightSaml\Model\Protocol\Response();
$response
->addAssertion($assertion = new \LightSaml\Model\Assertion\Assertion())
->setID(\LightSaml\Helper::generateID())
->setIssueInstant(new \DateTime())
->setDestination($destination)
->setIssuer(new \LightSaml\Model\Assertion\Issuer($issuer))
->setStatus(new \LightSaml\Model\Protocol\Status(new \LightSaml\Model\Protocol\StatusCode('urn:oasis:names:tc:SAML:2.0:status:Success')))
->setSignature(new \LightSaml\Model\XmlDSig\SignatureWriter($certificate, $privateKey));
if(\Auth::check()){
$email= \Auth::user()->email;
$name = \Auth::user()->name;
}else {
$email = $request['email'];
$name = 'Place Holder';
}
$assertion
->setId(\LightSaml\Helper::generateID())
->setIssueInstant(new \DateTime())
->setIssuer(new \LightSaml\Model\Assertion\Issuer($issuer))
->setSubject(
(new \LightSaml\Model\Assertion\Subject())
->setNameID(new \LightSaml\Model\Assertion\NameID(
$email,
\LightSaml\SamlConstants::NAME_ID_FORMAT_EMAIL
))
->addSubjectConfirmation(
(new \LightSaml\Model\Assertion\SubjectConfirmation())
->setMethod(\LightSaml\SamlConstants::CONFIRMATION_METHOD_BEARER)
->setSubjectConfirmationData(
(new \LightSaml\Model\Assertion\SubjectConfirmationData())
->setInResponseTo($authnRequest->getId())
->setNotOnOrAfter(new \DateTime('+1 MINUTE'))
->setRecipient($authnRequest->getAssertionConsumerServiceURL())
)
)
)
->setConditions(
(new \LightSaml\Model\Assertion\Conditions())
->setNotBefore(new \DateTime())
->setNotOnOrAfter(new \DateTime('+1 MINUTE'))
->addItem(
new \LightSaml\Model\Assertion\AudienceRestriction([$authnRequest->getAssertionConsumerServiceURL()])
)
)
->addItem(
(new \LightSaml\Model\Assertion\AttributeStatement())
->addAttribute(new \LightSaml\Model\Assertion\Attribute(
\LightSaml\ClaimTypes::EMAIL_ADDRESS,
$email
))
->addAttribute(new \LightSaml\Model\Assertion\Attribute(
\LightSaml\ClaimTypes::COMMON_NAME,
$name
))
)
->addItem(
(new \LightSaml\Model\Assertion\AuthnStatement())
->setAuthnInstant(new \DateTime('-10 MINUTE'))
->setSessionIndex('_some_session_index')
->setAuthnContext(
(new \LightSaml\Model\Assertion\AuthnContext())
->setAuthnContextClassRef(\LightSaml\SamlConstants::AUTHN_CONTEXT_PASSWORD_PROTECTED_TRANSPORT)
)
)
;
$this->sendSAMLResponse($response);
}
So let's break this up a bit. The first section is defining some SP details, which I'm pulling from a config file.
$destination = config('saml.sp.'.base64_encode($authnRequest->getAssertionConsumerServiceURL()).'.destination');
$issuer = config('saml.sp.'.base64_encode($authnRequest->getAssertionConsumerServiceURL()).'.issuer');
$cert = config('saml.sp.'.base64_encode($authnRequest->getAssertionConsumerServiceURL()).'.cert');
$key = config('saml.sp.'.base64_encode($authnRequest->getAssertionConsumerServiceURL()).'.key');
You'll notice I'm using this object method to receive the Service URL $authnRequest->getAssertionConsumerServiceURL()
. This is returning the url we defined above of https://yourcanvasdomain.com/login/saml
. This is how I can decipher which SP is actually making the request. You'll also notice I'm base64 encoding it. This is because urls break the dot notation for specifying variables in a config array. The config file base64 encodes the url as well, so they end up matching, but we have a readable url in the config.
Here I have defined a few variables, destination, issuer, cert, and key.
The Cert, and key, are the certs and key we generated earlier and I told you to store somewhere you can access easily from inside laravel. The issuer, is your IDP entity ID. The destination, is where we're going to send the response. This took some time to figure out, but it's https://yourcanvasdomain.com/saml_consume
.
The next part in the BuildSAMLResponse
method is preparing our certificate and private key to sign our response.
$certificate = \LightSaml\Credential\X509Certificate::fromFile($cert);
$privateKey = \LightSaml\Credential\KeyHelper::createPrivateKey($key, '', true);
Next we're going to prepare the SAMLResponse.
$response = new \LightSaml\Model\Protocol\Response();
$response
->addAssertion($assertion = new \LightSaml\Model\Assertion\Assertion())
->setID(\LightSaml\Helper::generateID())
->setIssueInstant(new \DateTime())
->setDestination($destination)
->setIssuer(new \LightSaml\Model\Assertion\Issuer($issuer))
->setStatus(new \LightSaml\Model\Protocol\Status(new \LightSaml\Model\Protocol\StatusCode('urn:oasis:names:tc:SAML:2.0:status:Success')))
->setSignature(new \LightSaml\Model\XmlDSig\SignatureWriter($certificate, $privateKey));
First we're going to add an Assertion. We'll get to this part later, as we need to still build out the assertion. But next we need to generate a new ID for this response (this is not the id of the request, we'll use that later in the assertion). We then set an IssueInstant, which is a time stamp, the Destination, which we pulled from our configuration file earlier. We also set our Issuer, which again we pulled from our config earlier, then we're going to set the Status to be a Success, this needs to be wrapped in the classes so the object type matches what the LightSaml Response is looking for. And last but not least, we sign our response with a signature using our certificate and private key.
So far thats pretty easy. LightSaml is taking care of all the specifics we just need to make sure we're passing the correct information through.
Next we need to build out the Assertion. But before we do that we need to prep some user specific information.
If the user is in the process of logging in, they're email will be in the response, however, if the user is already logged in, the email will be in the Authenticated user object. So we need to check if the user is already logged in.
if(\Auth::check()){
$email= \Auth::user()->email;
$name = \Auth::user()->name;
}else {
$email = $request['email'];
$name = 'Place Holder';
}
You'll note that if the user isn't logged in we're filling the name with place holder information. This is because the name isn't needed for SSO, and we only have it if they're already logged in.
Now that we have our users $email
, which is how Canvas is going to find our user and log them in, we can create our Assertion. (The users should already exist inside canvas. If they're not in Canvas there is an option for Just In Time Processing, which basically creates the user if they don't exist.) We also don't need to pass a password to Canvas, so your users accounts in Canvas don't actually need passwords either. They can't login to Canvas directly this way either which maybe a nice feature. In my setup, I will be creating users in Canvas via the OAuth API when a user registers for my laravel application.
$assertion
->setId(\LightSaml\Helper::generateID())
->setIssueInstant(new \DateTime())
->setIssuer(new \LightSaml\Model\Assertion\Issuer($issuer))
->setSubject(
(new \LightSaml\Model\Assertion\Subject())
->setNameID(new \LightSaml\Model\Assertion\NameID(
$email,
\LightSaml\SamlConstants::NAME_ID_FORMAT_EMAIL
))
->addSubjectConfirmation(
(new \LightSaml\Model\Assertion\SubjectConfirmation())
->setMethod(\LightSaml\SamlConstants::CONFIRMATION_METHOD_BEARER)
->setSubjectConfirmationData(
(new \LightSaml\Model\Assertion\SubjectConfirmationData())
->setInResponseTo($authnRequest->getId())
->setNotOnOrAfter(new \DateTime('+1 MINUTE'))
->setRecipient($authnRequest->getAssertionConsumerServiceURL())
)
)
)
->setConditions(
(new \LightSaml\Model\Assertion\Conditions())
->setNotBefore(new \DateTime())
->setNotOnOrAfter(new \DateTime('+1 MINUTE'))
->addItem(
new \LightSaml\Model\Assertion\AudienceRestriction([$authnRequest->getAssertionConsumerServiceURL()])
)
)
->addItem(
(new \LightSaml\Model\Assertion\AttributeStatement())
->addAttribute(new \LightSaml\Model\Assertion\Attribute(
\LightSaml\ClaimTypes::EMAIL_ADDRESS,
$email
))
->addAttribute(new \LightSaml\Model\Assertion\Attribute(
\LightSaml\ClaimTypes::COMMON_NAME,
$name
))
)
->addItem(
(new \LightSaml\Model\Assertion\AuthnStatement())
->setAuthnInstant(new \DateTime('-10 MINUTE'))
->setSessionIndex('_some_session_index')
->setAuthnContext(
(new \LightSaml\Model\Assertion\AuthnContext())
->setAuthnContextClassRef(\LightSaml\SamlConstants::AUTHN_CONTEXT_PASSWORD_PROTECTED_TRANSPORT)
)
);
This is a pretty messy section, but lets see if we can simplify it a bit.
$assertion
->setId(\LightSaml\Helper::generateID())
->setIssueInstant(new \DateTime())
->setIssuer(new \LightSaml\Model\Assertion\Issuer($issuer))
->setSubject(
(new \LightSaml\Model\Assertion\Subject())
->setNameID(new \LightSaml\Model\Assertion\NameID(
$email,
\LightSaml\SamlConstants::NAME_ID_FORMAT_EMAIL
))
->addSubjectConfirmation(
(new \LightSaml\Model\Assertion\SubjectConfirmation())
->setMethod(\LightSaml\SamlConstants::CONFIRMATION_METHOD_BEARER)
->setSubjectConfirmationData(
(new \LightSaml\Model\Assertion\SubjectConfirmationData())
->setInResponseTo($authnRequest->getId())
->setNotOnOrAfter(new \DateTime('+1 MINUTE'))
->setRecipient($authnRequest->getAssertionConsumerServiceURL())
)
)
)
The first section we are setting some basic details for the assertion. The assertion again gets its own unique id, time stamp, the Issuer information (again pulled from the config), and the subject. Finally here in the subject we include the users email we are logging in. We also include in the subject confirmation the actual request id which we are referencing here: $authnRequest->getId()
.
The rest of the assertion is setting some conditions, for instance the response is only intended for the same SP as made the request, the email, and the common name. Mainly we just need to make sure the $email
variable here is set to our users.
now that we have completed building our assertion, our response is also complete. Now we can send our response back to the SP to login in our users.
Sending the Response
We can now send the response off to our method for handling sending the response to the SP. luckily LightSaml has all the information it needs to do this from the $response
object we just built.
$this->sendSAMLResponse($response);
This is done with a process called Post Binding. Basically we're building a form with the post data being sent to our SP destination. And on body load that form is submitted. This redirects our user back to the SP with post data in tow. The SP see's the POST data, and if everything is correct, the user is logged into their account.
Again LightSAML will handle this for us.
My sendSAMLResponse
method:
$bindingFactory = new \LightSaml\Binding\BindingFactory();
$postBinding = $bindingFactory->create(\LightSaml\SamlConstants::BINDING_SAML2_HTTP_POST);
$messageContext = new \LightSaml\Context\Profile\MessageContext();
$messageContext->setMessage($response)->asResponse();
/** @var \Symfony\Component\HttpFoundation\Response $httpResponse */
$httpResponse = $postBinding->send($messageContext);
print $httpResponse->getContent()."\n\n";
This method, builds a form using the $response xml, it then deflates the xml, and base64 encodes it, and sends it back to the SP . When we print the form, it submits on body load and we are sent off to our SP.
If everything was done correctly, we should now be logged into our SP.
Bonus Round
Now this works when a user goes to our SP first, then gets redirected back to our Laravel Application, and logins, and then returns to the SP logged in. However, what if our users is already logged into our Laravel application? Well we need a way of detecting the SAML request when a user is logged in. This can be done by modifying our Guest Middleware, RedirectIfAuthenticated
.
All we need to do is copy the existing middleware, create a new version of it and update the handle
method. Add the below code to the top of the method.
if(isset($request['SAMLRequest'])){
new SamlAuth($request);
}
Now in our Kernal.php
file in our main application, simply swap out the RedirectIfAuthenticated
middleware with our new one we just created.
Now if a user is logged in, and taken to the login page with a SAMLRequest in the url, we will run through the same SSO flow, and the user should be redirected to the SP and be logged in.
I hope this helps someone else trying to implement SSO between Laravel, and Canvas LMS. This should however work for PHP applications in general, and should work with any SAML SP.