Multiple SSO with Symfony and  OneLogin SAML Bundle (2024)

Cyril Pereira

·

Follow

8 min read

·

May 26, 2024

--

You want to implement SSO on your application, you have a SaaS used by different company and you need to have for them their own configuration to integrate their own SSO.

For my SaaS application i want to configure a configuration SAML by customer.

Multiple SSO with Symfony andOneLogin SAML Bundle (2)

I want to be able to change the configuration on the fly.

UPDATE : i rewrite this post because i removed the bundle https://github.com/nbgrp/onelogin-saml-bundle

I think it’s a great bundle, but it was overkill to use it at the end.

composer require onelogin/php-saml

Configure Symfony like this

Edit the file config/packages/framwork.yaml like this

# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
...
trusted_headers: [ 'x-forwarded-for', 'x-forwarded-proto'
trusted_proxies: '127.0.0.1,::1'
...

Update the config/packages/security.yaml also

security:
providers:
saml_provider:
id: App\Security\SamlUserProvider

firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
saml:
pattern: ^/saml
stateless: true
custom_authenticator: App\Security\SamlAuthenticator
main:
lazy: true
provider: saml_provider

access_control:
- { path: ^/saml/(metadata|login|acs|logout|sls), roles: PUBLIC_ACCESS }

Multiple SSO with Symfony andOneLogin SAML Bundle (3)

Create an Entity for the configuration.

<?php

// src/Entity/SamlConfig.php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
* @ORM\Entity(repositoryClass="App\Repository\SamlConfigRepository")
*/
class SamlConfig
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;

/**
* @ORM\Column(type="string", length=255)
*/
private $client;

/**
* @ORM\Column(type="text")
*/
private $idpEntityId;

/**
* @ORM\Column(type="text")
*/
private $idpSsoUrl;

/**
* @ORM\Column(type="text")
*/
private $idpSloUrl;

/**
* @ORM\Column(type="text")
*/
private $idpCert;

/**
* @ORM\Column(type="text")
*/
private $spEntityId;

/**
* @ORM\Column(type="text")
*/
private $spAcsUrl;

/**
* @ORM\Column(type="text")
*/
private $spSloUrl;

/**
* @ORM\Column(type="text")
*/
private $spPrivateKey;

/**
* @ORM\Column(type="string", length=255)
*/
private $identifierAttribute;

/**
* @ORM\Column(type="boolean")
*/
private $autoCreate;

/**
* @ORM\Column(type="json")
*/
private $attributeMapping;

// Getters and setters

public function getId(): ?int
{
return $this->id;
}

public function getClient(): ?string
{
return $this->client;
}

public function setClient(string $client): self
{
$this->client = $client;

return $this;
}

public function getIdpEntityId(): ?string
{
return $this->idpEntityId;
}

public function setIdpEntityId(string $idpEntityId): self
{
$this->idpEntityId = $idpEntityId;

return $this;
}

public function getIdpSsoUrl(): ?string
{
return $this->idpSsoUrl;
}

public function setIdpSsoUrl(string $idpSsoUrl): self
{
$this->idpSsoUrl = $idpSsoUrl;

return $this;
}

public function getIdpSloUrl(): ?string
{
return $this->idpSloUrl;
}

public function setIdpSloUrl(string $idpSloUrl): self
{
$this->idpSloUrl = $idpSloUrl;

return $this;
}

public function getIdpCert(): ?string
{
return $this->idpCert;
}

public function setIdpCert(string $idpCert): self
{
$this->idpCert = $idpCert;

return $this;
}

public function getSpEntityId(): ?string
{
return $this->spEntityId;
}

public function setSpEntityId(string $spEntityId): self
{
$this->spEntityId = $spEntityId;

return $this;
}

public function getSpAcsUrl(): ?string
{
return $this->spAcsUrl;
}

public function setSpAcsUrl(string $spAcsUrl): self
{
$this->spAcsUrl = $spAcsUrl;

return $this;
}

public function getSpSloUrl(): ?string
{
return $this->spSloUrl;
}

public function setSpSloUrl(string $spSloUrl): self
{
$this->spSloUrl = $spSloUrl;

return $this;
}

public function getSpPrivateKey(): ?string
{
return $this->spPrivateKey;
}

public function setSpPrivateKey(string $spPrivateKey): self
{
$this->spPrivateKey = $spPrivateKey;

return $this;
}

public function getIdentifierAttribute(): ?string
{
return $this->identifierAttribute;
}

public function setIdentifierAttribute(string $identifierAttribute): self
{
$this->identifierAttribute = $identifierAttribute;

return $this;
}

public function getAutoCreate(): ?bool
{
return $this->autoCreate;
}

public function setAutoCreate(bool $autoCreate): self
{
$this->autoCreate = $autoCreate;

return $this;
}

public function getAttributeMapping(): ?array
{
return $this->attributeMapping;
}

public function setAttributeMapping(array $attributeMapping): self
{
$this->attributeMapping = $attributeMapping;

return $this;
}
}

And of course his repository

<?php

// api/src/Repository/SamlConfigRepository.php

namespace App\Repository;

use App\Entity\SamlConfig;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class SamlConfigRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SamlConfig::class);
}

public function findByClient(string $client): ?SamlConfig
{
return $this->findOneBy(['client' => $client]);
}
}

<?php

// src/Service/SamlConfigProvider.php

namespace App\Service;

use App\Repository\SamlConfigRepository;

use OneLogin\Saml2\Settings;
use Symfony\Component\HttpFoundation\RequestStack;

class SamlConfigProvider
{
public function __construct(
private SamlConfigRepository $samlConfigRepository,
private RequestStack $requestStack
) {
}

public function getConfig(string $client): array
{
$config = $this->samlConfigRepository->findByClient($client);

if (!$config) {
throw new \Exception('SAML configuration not found for client ' . $client);
}

list($scheme, $host) = $this->getSPEntityId();

$schemeAndHost = sprintf('%s://%s', $scheme, $host);

return [
'settings' => new Settings([
'idp' => [
'entityId' => $schemeAndHost."/saml/metadata/".$client,
'singleSignOnService' => ['url' => $config->getIdpSsoUrl()],
'singleLogoutService' => ['url' => $config->getIdpSloUrl()],
'x509cert' => $config->getIdpCert(),
],
'sp' => [
'entityId' => $schemeAndHost,
'assertionConsumerService' => [
'url' => $schemeAndHost."/saml/acs/".$client,
],
'singleLogoutService' => [
'url' => $schemeAndHost."/saml/logout/".$client,
],
'privateKey' => $config->getSpPrivateKey(),
],
]),
'identifier' => $config->getIdentifierAttribute(),
'autoCreate' => $config->getAutoCreate(),
'attributeMapping' => $config->getAttributeMapping(),
'CustomerUrl' => $config->getSpEntityId(),
];
}

private function getSPEntityId()
{
$scheme = $this->requestStack->getCurrentRequest()->getScheme();
if(isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
$scheme = $_SERVER['HTTP_X_FORWARDED_PROTO'];
}

$host = $this->requestStack->getCurrentRequest()->getHost();
if(isset($_SERVER['HTTP_X_FORWARDED_HOST'])) {
$host = $_SERVER['HTTP_X_FORWARDED_HOST'];
}

return [$scheme, $host];
}

}

We are creating 4 routes

  • saml_login : to log in
  • saml_acs : to manage the authentication from the sso
  • saml_logout : to manage the logout from the sso
  • saml_metadata : to get the metadata depending of the configuration

We are going to use /client in the routes to find which configuration we want to use.

  • /saml/login/my-customer-config
<?php

// src/Controller/SamlController.php

namespace App\Controller;

use App\Security\SamlUserProvider;
use App\Service\SamlConfigProvider;
use OneLogin\Saml2\Auth;
use Psr\Log\LoggerInterface;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use OneLogin\Saml2\Settings;
use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface;
use App\Security\SamlAuthenticator;

class SamlController extends AbstractController
{
public function __construct(
private SamlConfigProvider $samlConfigProvider,
private UserAuthenticatorInterface $userAuthenticator,
private SamlAuthenticator $authenticator,
private SamlUserProvider $samlUserProvider,
private LoggerInterface $logger
) {
}

/**
* @Route("/saml/login/{client}", name="saml_login", requirements={"client"=".+"})
*/
public function login(Request $request, $client): Response
{
$this->logger->info("Starting SAML login for client: $client");

$config = $this->samlConfigProvider->getConfig($client);
$auth = new Auth($config['settings']);
$auth->login();

// The login method does a redirect, so we won't reach this line
return new Response('Redirecting to IdP...', 302);
}

/**
* @Route("/saml/acs/{client}", name="saml_acs", requirements={"client"=".+"})
*/
public function acs(Request $request, $client): Response
{
$this->logger->info("Processing SAML ACS for client: $client");

$config = $this->samlConfigProvider->getConfig($client);
$auth = new Auth($config['settings']);
$auth->processResponse();

if (!$auth->isAuthenticated()) {
$this->logger->error("SAML authentication failed for client: $client");
return new Response('SAML authentication failed.', Response::HTTP_UNAUTHORIZED);
}

$attributes = $auth->getAttributes();
$identifier = $attributes[$config['identifier']][0];

try {
$user = $this->samlUserProvider->loadUserByIdentifier($identifier);
return $this->userAuthenticator->authenticateUser(
$user,
$this->authenticator,
$request
);
} catch (\Exception $e) {
$this->logger->error("Error during SAML authentication for client: $client, error: " . $e->getMessage());
return new Response('Authentication exception occurred.', Response::HTTP_UNAUTHORIZED);
}
}

/**
* @Route("/saml/logout/{client}", name="saml_logout", requirements={"client"=".+"})
*/
public function logout(Request $request, string $client): Response
{
$this->logger->info("Starting SAML logout for client: $client");
$config = $this->samlConfigProvider->getConfig($client);
try {
$auth = new Auth($config['settings']);
$auth->logout();

// The logout method does a redirect, so we won't reach this line
return new Response('Redirecting to IdP for logout...', 302);
} catch (Error $e) {
$this->logger->critical(sprintf('Unable to logout client with message: "%s"', $e->getMessage()));
throw new UnprocessableEntityHttpException('Error while trying to logout');
}
}

/**
* @Route("/saml/sls/{client}", name="saml_sls", requirements={"client"=".+"})
*/
public function sls(Request $request, string $client): Response
{
$this->logger->info("Processing SAML Logout for client: $client");

$config = $this->samlConfigProvider->getConfig($client);
$auth = new Auth($config['settings']);

$auth->processSLO();

$errors = $auth->getErrors();
if (!empty($errors)) {
return new Response('SAML Logout failed: ' . implode(', ', $errors), Response::HTTP_INTERNAL_SERVER_ERROR);
}

// Redirection après une déconnexion réussie
return $this->redirect(sprintf('https://%s/sign-in?nosso=1', $config['CustomerUrl']));
}

/**
* @Route("/saml/metadata/{client}", name="saml_metadata", requirements={"client"=".+"})
*/
public function metadata(string $client): Response
{
$config = $this->samlConfigProvider->getConfig($client);
$metadata = (new Settings($config['settings']))->getSPMetadata();
return new Response($metadata, 200, ['Content-Type' => 'text/xml']);
}
}

The user provider will have the role to get a user and create one if it dosen’t exist.

<?php

// src/Security/SamlUserProvider.php

namespace App\Security;

use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;

class SamlUserProvider implements UserProviderInterface
{
private $identifierField;

public function __construct(private EntityManagerInterface $entityManager)
{
}

public function setIdentifierField(string $identifierField)
{
$this->identifierField = $identifierField;
}

public function loadUserByIdentifier(string $identifier): User
{
if (!$this->identifierField) {
throw new \LogicException('Identifier field must be set before calling loadUserByIdentifier.');
}

return $this->entityManager->getRepository(User::class)
->findOneBy([$this->identifierField => $identifier]);
}

public function loadUserByUsername(string $username): UserInterface
{
return $this->loadUserByIdentifier($username);
}

public function refreshUser(UserInterface $user): ?UserInterface
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user)));
}

$getter = 'get' . ucfirst($this->identifierField);
$value = "";
if (method_exists($user, $getter)) {
$value = $user->$getter();
if($value !== "") {
return $this->loadUserByIdentifier($value);
}
}
}

public function supportsClass(string $class): bool
{
return User::class === $class || is_subclass_of($class, User::class);
}

public function createUserFromSamlAttributes(string $identifier, array $attributes, array $attributeMapping): User
{
$user = new User();
$setter = 'set' . ucfirst($this->identifierField);
if (method_exists($user, $setter)) {
$user->$setter($identifier);
}

foreach ($attributeMapping as $userField => $samlAttribute) {
if (isset($attributes[$samlAttribute])) {
$setter = 'set' . ucfirst($userField);
if (method_exists($user, $setter)) {
$user->$setter($attributes[$samlAttribute][0]);
}
}
}

// Save new user to the database
$this->entityManager->persist($user);
$this->entityManager->flush();

return $user;
}
}

Create an SAML Authenticator

The authenticator will have the role to validate the user, and generate a jwt.

The CustomerUrl contain the SPEntityId.

<?php

// src/Security/SamlAuthenticator.php

namespace App\Security;

use App\Service\SamlConfigProvider;

use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use OneLogin\Saml2\Auth;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

class SamlAuthenticator extends AbstractAuthenticator
{
public function __construct(
private SamlConfigProvider $samlConfigProvider,
private SamlUserProvider $userProvider,
private JWTTokenManagerInterface $jWTManager
) {
}

public function supports(Request $request): ?bool
{
return $request->attributes->get('_route') === 'saml_acs';
}

public function authenticate(Request $request): Passport
{
$client = $request->attributes->get('client');
$config = $this->samlConfigProvider->getConfig($client);
$auth = new Auth($config['settings']);
$auth->processResponse();
if (!$auth->isAuthenticated()) {
throw new AuthenticationException('SAML authentication failed.');
}

$attributes = $auth->getAttributes();
$identifierAttribute = $config['identifier'];
$identifierValue = $attributes[$identifierAttribute][0];

// Load or create the user
$this->userProvider->setIdentifierField($identifierAttribute);
$user = $this->userProvider->loadUserByIdentifier($identifierValue);

if (!$user && $config['autoCreate']) {
$user = $this->userProvider->createUserFromSamlAttributes($identifierValue, $attributes, $config['attributeMapping']);
}

if (!$user) {
throw new AuthenticationException('User not found and auto-creation is disabled.');
}

return new SelfValidatingPassport(new UserBadge($identifierValue, function () use ($user) {
return $user;
}));
}

public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?RedirectResponse
{
// On success, generate JWT and return it
$user = $token->getUser();
$jwt = $this->generateJwtToken($user);

$client = $request->attributes->get('client');
$config = $this->samlConfigProvider->getConfig($client);

$opw = $config['CustomerUrl'];

$url = sprintf("%s?j=%s", $opw, $jwt);
return new RedirectResponse($url);

}

public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
// On failure, return appropriate response
return new JsonResponse(['error' => $exception->getMessageKey()], Response::HTTP_UNAUTHORIZED);
}

private function generateJwtToken($user)
{
return $this->jWTManager->create($user);
}
}

Multiple SSO with Symfony andOneLogin SAML Bundle (4)

What we got ?

  • The full process to log in with SSO/SAML
  • Dynamic configuration depending of your customer
  • Create user if not exist

You are now ready to test it …

Enjoy !

Multiple SSO with Symfony and 
OneLogin SAML Bundle (2024)
Top Articles
Latest Posts
Recommended Articles
Article information

Author: Catherine Tremblay

Last Updated:

Views: 5558

Rating: 4.7 / 5 (47 voted)

Reviews: 94% of readers found this page helpful

Author information

Name: Catherine Tremblay

Birthday: 1999-09-23

Address: Suite 461 73643 Sherril Loaf, Dickinsonland, AZ 47941-2379

Phone: +2678139151039

Job: International Administration Supervisor

Hobby: Dowsing, Snowboarding, Rowing, Beekeeping, Calligraphy, Shooting, Air sports

Introduction: My name is Catherine Tremblay, I am a precious, perfect, tasty, enthusiastic, inexpensive, vast, kind person who loves writing and wants to share my knowledge and understanding with you.