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.
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 }
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);
}
}
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 !