diff --git a/README.md b/README.md index 01b588d..8d1a8c0 100644 --- a/README.md +++ b/README.md @@ -38,64 +38,6 @@ php artisan vendor:publish --tag="saml_config" will publish the config/saml.php file. -#### SAML SP entries - -Within the saml.php config file the SAML Service Provider array needs to be filled. Subsequently an example from the config/saml.php file: - -``` -'sp' => [ - - /** - * Sample SP entry - * The entry is identified by the base64 encoded URL. This example shows a possible entry for - * a SimpleSamlPhp service provider running on localhost: - * - * Sample URL: https://localhost/samlsp/module.php/saml/sp/saml2-acs.php/default-sp - * Base64 encoded URL: aHR0cHM6Ly9sb2NhbGhvc3Qvc2FtbHNwL21vZHVsZS5waHAvc2FtbC9zcC9zYW1sMi1hY3MucGhwL2RlZmF1bHQtc3A= - */ - 'aHR0cHM6Ly9sb2NhbGhvc3Qvc2FtbHNwL21vZHVsZS5waHAvc2FtbC9zcC9zYW1sMi1hY3MucGhwL2RlZmF1bHQtc3A=' => [ - - // The destination is the consuming SAML URL. This might be a SamlAuthController receiving the SAML response. - 'destination' => 'https://localhost/samlsp/module.php/saml/sp/saml2-acs.php/default-sp', - // Issuer could be anything, mostly it makes sense to pass the metadata URL - 'issuer' => 'https://localhost', - - // OPTIONAL: Use a specific audience restriction value when creating the SAMLRequest object. - // Default value is the assertion consumer service URL (the base64 encoded SP url). - // This is a bugfix for Nextcloud as SP and can be removed for normal SPs. - 'audience_restriction' => 'http://localhost', - ], - -], -``` - -You can generate the base_64 encoded AssertionURL by using the following artisan command. - -```bash - $ php artisan laravel-saml:encodeurl https://sp.webapp.com/saml/login - -- - URL Given: https://sp.webapp.com/saml/login - Encoded AssertionURL:aHR0cHM6Ly9zcC53ZWJhcHAuY29tL3NhbWwvbG9naW4= -``` - -config/saml.php: -``` -'sp' => [ - - ... - - /** - * New entry - * - * Sample URL: https://sp.webapp.com/saml/login - * Base64 encoded URL: aHR0cHM6Ly9zcC53ZWJhcHAuY29tL3NhbWwvY29uc3VtZQ== - */ - 'aHR0cHM6Ly9zcC53ZWJhcHAuY29tL3NhbWwvY29uc3VtZQ==' => [ - 'destination' => 'https://sp.webapp.com/saml/consume', - 'issuer' => 'https://sp.webapp.com', - ], -], -``` #### FileSystem configuration @@ -113,18 +55,55 @@ Within ```config/filesystem.php``` following entry needs to be added: ], ``` -The package controllers are using the ```storage/saml``` path for retrieving both certificates and the metadata file. Create first the storage path, then either add or link the certificates. Add also a metadata file for the SAML IDP. For help generating an IDP metadata.xml file, see https://www.samltool.com/idp_metadata.php. +#### Setting the entity id + +In config/saml.php set the field idp.entity-id to your entity id. This is normally a uri, the uri doesn't need to exist, it just needs to be unique + + 'idp' => [ + ..... + 'entityId' => 'http://idp.wherever.com' + ], + +#### Generating metadata and certificates + +There is a sample metadata template in storage/saml/idp/metadata.blade.php, This was generated using https://www.samltool.com/idp_metadata.php + +Edit this template to customize it for your site. + +When you're finished run the following command to generate certificates and the metadata file ``` -mkdir -p storage/saml/idp -touch storage/saml/idp/{metadata.xml,cert.pem,key.pem} +php artisan laravel-saml:generate-meta --cert ``` -Add the contents to the metadata.xml, cert.pem and key.pem files for the IDP. +To use exisiting certificates just make sure they're present in the saml drive then run the command without the --cert option + +#### SAML SP entries + +Within the saml.php config file the SAML Service Provider array needs to be filled. + +``` + 'sp' => [ + + //Tableau + 'https://sso.online.tableau.com/public/sp/SSO?alias=xxxx-xxxx-xxxx-xxxx-xxxxxxxx' => [ + 'entity-id' => 'https://sso.online.tableau.com/public/sp/metadata?alias=xxxx-xxxx-xxxx-xxxx-xxxxxxxx', + 'certificate' => 'MIICozC........dUvTnGP18g==' + ], + + //A nifty testing service provider + 'https://sptest.iamshowcase.com/acs' => [ + + ] + + ], +``` ### Using the SAML package -To use the SAML package, some files need to be modified. Within your login view, problably ```resources/views/auth/login.blade.php``` add a SAMLRequest field beneath the CSRF field (this is actually a good place for it): +To use the SAML package, some files need to be modified. +Within your login view, problably ```resources/views/auth/login.blade.php``` add a SAMLRequest field beneath the CSRF field +(this is actually a good place for it): ``` {{-- The hidden CSRF field for secure authentication --}} {{ csrf_field() }} @@ -134,37 +113,24 @@ To use the SAML package, some files need to be modified. Within your login view, @endif ``` -The SAMLRequest field will be filled automatically when a SAMLRequest is sent by a http request and therefore initiate a SAML authentication attempt. To initiate the SAML auth, the login and redirect functions need to be modified. Within ```app/Http/Middleware/AuthenticatesUsers.php``` add following lines to both the top and the authenticated function: -(NOTE: you might need to copy it out from vendor/laravel/framework/src/Illuminate/Foundation/Auth/ to your Middleware directory) - +The SAMLRequest field will be filled automatically when a SAMLRequest is sent by a http request and therefore initiate a SAML authentication attempt. + To initiate the SAML auth, the login and redirect functions need to be modified. + Within ```app/Http/Controllers/Auth/LoginController.php``` change ```use AuthenticatesUsers``` to ```use SamlAuthenticatesUsers``` + ``` -handleSamlLoginRequest($request); - } - } - - ... + use SamlAuthenticatesUsers; + +..... ``` + To allow later direct redirection when somebody is already logged in, we need to add also some lines to ```app/Http/Middleware/RedirectIfAuthenticated.php```: ``` true, ``` -Make sure that the environmental logging variable ```APP_LOG_LEVEL``` is set to debug within your ```.env``` file. - +Make sure that the environmental logging variable ```APP_LOG_LEVEL``` is set to debug within your ```.env``` file. It will log to ```storage/logs/laravel.log``` diff --git a/src/Console/EncodeAssertionUrlCommand.php b/src/Console/EncodeAssertionUrlCommand.php deleted file mode 100644 index fe44e21..0000000 --- a/src/Console/EncodeAssertionUrlCommand.php +++ /dev/null @@ -1,71 +0,0 @@ -argument('url'); - - if(!empty($url)){ - $this->info("URL Given: $url"); - $this->info("Encoded AssertionURL:". base64_encode($url)); - return; - } - - // Show Usage - $this->showUsage(); - } - - /** - * - */ - protected function showUsage() - { - $this->info($this->getDescription()); - $this->warn('Usage: '); - $this->line(' laravel-saml:encodeurl url'); - $this->line(''); - $this->warn('Arguments:'); - $this->line(' url - The URL to encode'); - } - -} \ No newline at end of file diff --git a/src/Console/SamlSetupCommand.php b/src/Console/SamlSetupCommand.php new file mode 100644 index 0000000..e756170 --- /dev/null +++ b/src/Console/SamlSetupCommand.php @@ -0,0 +1,127 @@ +generateCertificate(); + $this->generateMetadata(); + } + + protected function generateCertificate() + { + $keyfile = Storage::disk('saml')->path(config('saml.idp.key')); + $certfile = Storage::disk('saml')->path(config('saml.idp.cert')); + $days = $this->options()['cert-days']; + + if($this->options()['cert'] || !file_exists($keyfile) || !file_exists($certfile)){ + $output = []; + $ret = 0; + exec("openssl req -newkey rsa:2048 -nodes -x509 -days $days -out $certfile -keyout $keyfile ", + $output,$ret ); + if($ret == 0){ + $this->info("Certificate Generated"); + }else{ + $this->error("Failed to generate certificate\n". implode("\n", $output)); + } + } + + } + + protected function generateMetadata() + { + $metadataFile = Storage::disk('saml')->path(config('saml.idp.metadata')); + $file = pathinfo($metadataFile); + $blade = $file['dirname'].'/'.$file['filename'].'.blade.php'; + + $certificate = file_get_contents( Storage::disk('saml')->path(config('saml.idp.cert'))); + $certificate = preg_replace('/-----.*CERTIFICATE-----/', '', $certificate); + $certificate = str_replace("\n","", $certificate); + + if(file_exists($blade)){ + $contents = $this->bladeRender(file_get_contents($blade), [ + 'certificate' => $certificate + ]); + file_put_contents($metadataFile, $contents); + }else{ + throw new \Exception("File $blade doesn't exist"); + } + + $this->info("$metadataFile generated"); + } + + public function bladeRender($value, array $args = array()) + { + $generated = \Blade::compileString($value); + + ob_start() and extract($args, EXTR_SKIP); + + // We'll include the view contents for parsing within a catcher + // so we can avoid any WSOD errors. If an exception occurs we + // will throw it out to the exception handler. + try + { + eval('?>'.$generated); + } + catch (\Exception $e) + { + // If we caught an exception, we'll silently flush the output + // buffer so that no partially rendered views get thrown out + // to the client and confuse the user with junk. + ob_get_clean(); throw $e; + } + + $content = ob_get_clean(); + + return $content; + } + +} \ No newline at end of file diff --git a/src/Http/Controllers/SamlIdpController.php b/src/Http/Controllers/SamlIdpController.php index 91fb06a..3489ea2 100644 --- a/src/Http/Controllers/SamlIdpController.php +++ b/src/Http/Controllers/SamlIdpController.php @@ -9,9 +9,13 @@ class SamlIdpController extends Controller { use SamlAuth; - - // This includes the controller routing points for - // - metadata - // - certfile - // - keyfile (this one should be used only for authenticated users) + + protected function metadata() { + return response( + $this->getSamlFile(config('saml.idp.metadata'), false), + 200, [ + 'Content-Type' => 'application/xml' + ] + ); + } } \ No newline at end of file diff --git a/src/Http/Traits/SamlAuth.php b/src/Http/Traits/SamlAuth.php index f09f9ac..57b235e 100644 --- a/src/Http/Traits/SamlAuth.php +++ b/src/Http/Traits/SamlAuth.php @@ -2,6 +2,7 @@ namespace KingStarter\LaravelSaml\Http\Traits; +use LightSaml\Credential\KeyHelper; use Storage; use Illuminate\Http\Request; use LightSaml\Model\Protocol\Response as Response; @@ -84,7 +85,7 @@ public function handleSamlLoginRequest($request) { // Get and decode the SAML request $SAML = $request->SAMLRequest; $decoded = base64_decode($SAML); - $xml = gzinflate($decoded); + $xml = $decoded[0] == '<' ? $decoded : gzinflate($decoded); // Initiate context and authentication request object $deserializationContext = new \LightSaml\Model\Context\DeserializationContext(); $deserializationContext->getDocument()->loadXML($xml); @@ -107,8 +108,21 @@ protected function buildSamlResponse($authnRequest, $request) { // Get corresponding destination and issuer configuration from SAML config file for assertion URL // Note: Simplest way to determine the correct assertion URL is a short debug output on first run - $destination = config('saml.sp.'.base64_encode($authnRequest->getAssertionConsumerServiceURL()).'.destination'); - $issuer = config('saml.sp.'.base64_encode($authnRequest->getAssertionConsumerServiceURL()).'.issuer'); + // : The old code base64 encoded the url, the new config file format doesn't require this, + // It also makes some changes to the fields + $url = $authnRequest->getAssertionConsumerServiceURL(); + $sp = config('saml.sp')[$url]; + if(!$sp){ + $sp = config('saml.sp.' . base64_encode($url)); + if(!$sp){ + throw new \Exception("Invalid SAML Consumer $url"); + } + } + $destination = isset($sp['destination']) ? $sp['destination'] : $url; + $issuer = isset($sp['issuer']) ? $sp['issuer'] : config('saml.idp.entityId'); + $audienceRestriction = $url; + if(isset($sp['entity-id'])) $audienceRestriction = $sp['entity-id']; + if(isset($sp['audience_restriction'])) $audienceRestriction = $sp['audience_restriction']; // Load in both certificate and keyfile // The files are stored within a private storage path, this prevents from @@ -120,17 +134,32 @@ protected function buildSamlResponse($authnRequest, $request) if (config('saml.debug_saml_request')) { Log::debug(''); - Log::debug('Assertion URL: ' . $authnRequest->getAssertionConsumerServiceURL()); - Log::debug('Assertion URL: ' . base64_encode($authnRequest->getAssertionConsumerServiceURL())); + Log::debug('Assertion URL: ' . $url); + Log::debug('Assertion URL: ' . base64_encode($url)); Log::debug('Destination: ' . $destination); Log::debug('Issuer: ' . $issuer); Log::debug('Certificate: ' . $this->certfile()); + Log::debug('SAMLRequest:' . $request->get('SAMLRequest')); + } + + //Validate sp certifcate + $spCert = null; + if(isset($sp['certificate-file'])){ + $spCert = X509Certificate::fromFile(Storage::disk('saml')->path($sp['certificate-file'])); + } + if(isset($sp['certificate'])){ + $x509 = new X509Certificate(); + $spCert = $x509->setData($sp['certificate']); + } + if($spCert && !$authnRequest->getSignature()->validate(KeyHelper::createPublicKey($spCert))){ + Log::error("Invalid signature for URL $url. SAMLRequest=" . $request->get('SAMLRequest')); + throw new \Exception("Invalid signature for URL $url."); } // Generate the response object $response = new \LightSaml\Model\Protocol\Response(); $response - ->addAssertion($assertion = new \LightSaml\Model\Assertion\Assertion()) + ->addAssertion($assertion = new \LightSaml\Model\Assertion\Assertion()) ->setID(\LightSaml\Helper::generateID()) ->setIssueInstant(new \DateTime()) ->setDestination($destination) @@ -162,60 +191,60 @@ protected function buildSamlResponse($authnRequest, $request) ->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 - )) + (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([ - config('saml.sp.'.base64_encode($authnRequest->getAssertionConsumerServiceURL()).'.audience_restriction', - $authnRequest->getAssertionConsumerServiceURL())]) - ) - ) + (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([ + $audienceRestriction + ]) + ) + ) ->addItem( - (new \LightSaml\Model\Assertion\AttributeStatement()) + (new \LightSaml\Model\Assertion\AttributeStatement()) ->addAttribute(new \LightSaml\Model\Assertion\Attribute( - \LightSaml\ClaimTypes::EMAIL_ADDRESS, - $email - )) + \LightSaml\ClaimTypes::EMAIL_ADDRESS, + $email + )) ->addAttribute(new \LightSaml\Model\Assertion\Attribute( - \LightSaml\ClaimTypes::COMMON_NAME, - $name - )) + \LightSaml\ClaimTypes::COMMON_NAME, + $name + )) ->addAttribute(new \LightSaml\Model\Assertion\Attribute( - \LightSaml\ClaimTypes::ROLE, - $roles - )) - ) + \LightSaml\ClaimTypes::ROLE, + $roles + )) + ) ->addItem( - (new \LightSaml\Model\Assertion\AuthnStatement()) + (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) - ) - ) - ; - - // Send out the saml response - $this->sendSamlResponse($response); + (new \LightSaml\Model\Assertion\AuthnContext()) + ->setAuthnContextClassRef(\LightSaml\SamlConstants::AUTHN_CONTEXT_PASSWORD_PROTECTED_TRANSPORT) + ) + ) + ; + + // Send out the saml response + $this->sendSamlResponse($response); } /** @@ -232,6 +261,17 @@ protected function sendSamlResponse(Response $response) $messageContext->setMessage($response)->asResponse(); /** @var \Symfony\Component\HttpFoundation\Response $httpResponse */ $httpResponse = $postBinding->send($messageContext); + + if (config('saml.debug_saml_request')) { + $matches = []; + preg_match('/name="SAMLResponse" value="([^"]*)"/', $httpResponse->getContent(), $matches); + if($matches && $matches[1]){ + Log::debug('SAMLResponse: '. $matches[1]); + }else{ + Log::debug("SAMLResponse: Couldn't extract the response"); + } + } + print $httpResponse->getContent()."\n\n"; } diff --git a/src/Http/Traits/SamlAuthenticatesUsers.php b/src/Http/Traits/SamlAuthenticatesUsers.php new file mode 100644 index 0000000..3bf45a6 --- /dev/null +++ b/src/Http/Traits/SamlAuthenticatesUsers.php @@ -0,0 +1,32 @@ +handleSamlLoginRequest($request); + } + } + +} diff --git a/src/LaravelSamlServiceProvider.php b/src/LaravelSamlServiceProvider.php index f059de1..7b7d702 100644 --- a/src/LaravelSamlServiceProvider.php +++ b/src/LaravelSamlServiceProvider.php @@ -6,6 +6,7 @@ use Illuminate\Foundation\Application as LaravelApplication; use Config; use KingStarter\LaravelSaml\Console\EncodeAssertionUrlCommand; +use KingStarter\LaravelSaml\Console\SamlSetupCommand; class LaravelSamlServiceProvider extends ServiceProvider { @@ -39,15 +40,18 @@ public function register() protected function bootInConsole() { if ($this->app instanceof LaravelApplication && $this->app->runningInConsole()) { - // Publishing configurations - $this->publishes([ - __DIR__ . '/config/saml.php' => config_path('saml.php'), - ], 'saml_config'); - + // Create storage/saml directory if (!file_exists(storage_path() . "/saml/idp")) { mkdir(storage_path() . "/saml/idp", 0755, true); } + + // Publishing configurations + $this->publishes([ + __DIR__ . '/config/saml.php' => config_path('saml.php'), + __DIR__ . '/resources/metadata.blade.php' => storage_path('saml/idp/metadata.blade.php') + ], 'saml_config'); + } $this->registerCommands(); @@ -71,7 +75,7 @@ protected function registerCommands() protected function registerModuleMakeCommand() { $this->commands([ - EncodeAssertionUrlCommand::class + SamlSetupCommand::class, ]); } diff --git a/src/config/saml.php b/src/config/saml.php index b27b22f..12439c6 100644 --- a/src/config/saml.php +++ b/src/config/saml.php @@ -46,9 +46,10 @@ */ 'idp' => [ - 'metadata' => 'idp/metadata.xml', - 'cert' => 'idp/cert.pem', - 'key' => 'idp/key.pem', + 'metadata' => 'idp/metadata.xml', + 'cert' => 'idp/cert.pem', + 'key' => 'idp/key.pem', + 'entityId' => 'http://idp.mysite.com' ], /* @@ -58,44 +59,26 @@ | | Array of service provider data. Add your list of SPs here. | - | An SP is defined by its consumer service URL which is base64 encoded. - | It contains the destination, issuer, cert and cert-key. - | + | An SP is defined by its consumer service URL + | It contains: + | * entity-id (their entity id) + | * certificate (optional) + | * certificate-file (optional, the path within the 'saml' drive) */ - 'sp' => [ - - /** - * Sample SP entry - * The entry is identified by the base64 encoded URL. This example shows a possible entry for - * a SimpleSamlPhp service provider running on localhost: - * - * Sample URL: https://localhost/samlsp/module.php/saml/sp/saml2-acs.php/default-sp - * Base64 encoded URL: aHR0cHM6Ly9sb2NhbGhvc3Qvc2FtbHNwL21vZHVsZS5waHAvc2FtbC9zcC9zYW1sMi1hY3MucGhwL2RlZmF1bHQtc3A= - * - * Note: To create a new entry, use laravel-saml:encodeurl artisan command to encode your ServiceProvider URL. - * - * php artisan laravel-saml:encodeurl https://localhost/samlsp/module.php/saml/sp/saml2-acs.php/default-sp - * -- - * URL Given: https://sp.webapp.com/saml/login - * Encoded AssertionURL:aHR0cHM6Ly9sb2NhbGhvc3Qvc2FtbHNwL21vZHVsZS5waHAvc2FtbC9zcC9zYW1sMi1hY3MucGhwL2RlZmF1bHQtc3A= - * - * In case of doubt enable debug_saml_request and check the logfile while performing - * a SAML login request from your SP. - */ - 'aHR0cHM6Ly9sb2NhbGhvc3Qvc2FtbHNwL21vZHVsZS5waHAvc2FtbC9zcC9zYW1sMi1hY3MucGhwL2RlZmF1bHQtc3A=' => [ - - // The destination is the consuming SAML URL. This might be a SamlAuthController receiving the SAML response. - 'destination' => 'https://localhost/samlsp/module.php/saml/sp/saml2-acs.php/default-sp', - // Issuer could be anything, mostly it makes sense to pass the metadata URL - 'issuer' => 'http://localhost/saml/idp/metadata', - - // OPTIONAL: Use a specific audience restriction value when creating the SAMLRequest object. - // Default value is the assertion consumer service URL (the base64 encoded SP url). - // This is a bugfix for Nextcloud as SP and can be removed for normal SPs. - 'audience_restriction' => 'http://localhost/saml/idp/metadata', + 'sp' => [ + + //Tableau + 'https://sso.online.tableau.com/public/sp/SSO?alias=xxxx-xxxx-xxxx-xxxx-xxxxxxxx' => [ + 'entity-id' => 'https://sso.online.tableau.com/public/sp/metadata?alias=xxxx-xxxx-xxxx-xxxx-xxxxxxxx', + 'certificate' => 'MIICozC........dUvTnGP18g==' ], - + + //A nifty testing sp + 'https://sptest.iamshowcase.com/acs' => [ + + ] + ], - + ]; diff --git a/src/resources/metadata.blade.php b/src/resources/metadata.blade.php new file mode 100644 index 0000000..5548dd7 --- /dev/null +++ b/src/resources/metadata.blade.php @@ -0,0 +1,26 @@ +{{--GENERATED WITH https://www.samltool.com/idp_metadata.php--}} +{{--http://idp.[MYSITE] is just the Entity id, it doesn't need to exist, just needs to be unique--}} +{{--We have to escape the document definition as well or it doesn't run on certain php implementations --}} +<{{'?'}}xml version="1.0"{{'?'}}> + + + + + + {{$certificate}} + + + + + + + {{$certificate}} + + + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified + + + \ No newline at end of file