nbgrp/onelogin-saml-bundle
Symfony bundle integrating the OneLogin PHP SAML toolkit for easy SAML service provider setup, SSO authentication, and wiring SAML logins into Symfony’s security system.
What it is. A thin, Symfony-native wrapper around onelogin/php-saml: it turns per-provider YAML into OneLogin Settings, registers SamlAuth/SamlAuthFactory services, exposes an SP metadata route, and plugs into the Security component via a user provider and a custom authenticator. Multi-provider (multiple IdPs) is first-class — most "simple" SAML bundles are not.
Install.
composer require nbgrp/onelogin-saml-bundle
There is no Flex recipe, so register it manually in config/bundles.php (Nbgrp\OneloginSamlBundle\NbgrpOneloginSamlBundle::class => ['all' => true]) and create config/packages/nbgrp_onelogin_saml.yaml.
Version note (v3.0.0). v3.0.0 adds Symfony 8.0 support behind a major version bump. The bundle tracks Symfony's own support window, so a major version almost always means older Symfony minors were dropped, not just that 8.0 was added — read composer.json (require for symfony/* and php) before upgrading. Practically: if you are on Symfony 5.4/6.x, stay on the 2.x line; if you are planning a Symfony 7 → 8 upgrade, wait until the app itself is on 8 and then take v3 in the same change, so you only debug the bundle once. Pin the constraint (^3.0) rather than floating, and re-run php bin/console debug:container nbgrp_onelogin_saml after the upgrade to confirm the service IDs you autowire are unchanged.
Where to look first.
config/packages/nbgrp_onelogin_saml.yaml — one providers entry per IdP, default_provider picks the one the firewall uses.security.yaml — a user provider + custom_authenticator.User entity — and either the built-in attribute mapper or your own SamlUserFactoryInterface.php bin/console debug:config nbgrp_onelogin_saml and debug:router | grep saml to see the merged config and the metadata/ACS routes.Minimal config.
# config/packages/nbgrp_onelogin_saml.yaml
nbgrp_onelogin_saml:
default_provider: corporate
providers:
corporate:
idp:
entityId: '%env(SAML_IDP_ENTITY_ID)%'
singleSignOnService:
url: '%env(SAML_IDP_SSO_URL)%'
singleLogoutService:
url: '%env(SAML_IDP_SLO_URL)%'
x509cert: '%env(base64:SAML_IDP_CERT)%'
sp:
entityId: '%env(SAML_SP_ENTITY_ID)%'
assertionConsumerService:
url: '%env(SAML_SP_ACS_URL)%'
security:
authnRequestsSigned: false
wantAssertionsSigned: true
wantMessagesSigned: true
# config/packages/security.yaml
security:
providers:
saml_users:
id: nbgrp_onelogin_saml.user_provider
firewalls:
saml:
pattern: ^/(saml|login)
provider: saml_users
custom_authenticators:
- nbgrp_onelogin_saml.authenticator
entry_point: App\Security\SamlEntryPoint # or your login controller
logout:
path: app_logout
access_control:
- { path: ^/saml/(metadata|acs), roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER }
First use case: SP-initiated SSO. Point your "Log in with SSO" button at a controller that calls SamlAuth::login(). php-saml builds the AuthnRequest, redirects the browser to the IdP, and the IdP POSTs the assertion back to your ACS URL; the bundle's authenticator validates it, loads/creates the user, and fires the normal Symfony login events. Give the IdP your metadata URL (nbgrp_onelogin_saml_metadata route) so it knows your entity ID, ACS URL, and signing certificate. Nothing works until those three values match on both sides exactly — do that handshake before debugging anything else.
Login controller. login() performs the HTTP redirect internally (php-saml calls exit), so don't expect a return value to wrap:
#[Route('/saml/login', name: 'saml_login')]
public function login(#[Autowire(service: 'nbgrp_onelogin_saml.auth')] SamlAuthInterface $auth): Response
{
$auth->login($this->getParameter('saml_return_to')); // RelayState
return new Response(status: 204); // unreachable in practice
}
Autowire by service ID (nbgrp_onelogin_saml.auth, ...auth_factory, ...user_provider, ...authenticator) rather than FQCN so minor namespace moves between versions don't break you. This matters more than usual across the 2.x → 3.x boundary: service IDs are stable, class namespaces are not guaranteed.
Deep links / target path. Unauthenticated requests hit your AuthenticationEntryPoint; have it redirect to /saml/login?_target_path=..., store the target in the session, and after the authenticator succeeds redirect there. If you prefer RelayState, pass the URL to login($returnTo) and check that your version's authenticator honors it as the target — some do, some leave it to you.
Attribute mapping. SAML attributes arrive as arrays keyed by whatever the IdP sends — friendly names (mail, memberOf) or OIDs (urn:oid:0.9.2342.19200300.100.1.3). Two clean options:
AttributeMapper (configure the user entity class plus a username/email/roles map) and the shipped SamlUserProvider.SamlUserFactoryInterface::createUser(string $identifier, array $attributes): SamlUserInterface when you need real hydration logic, and inject it into nbgrp_onelogin_saml.user_provider.In both cases, normalise: is_array($v) ? $v[0] : $v, strip group prefixes (CN=...), and always apply default roles.
Event listener for post-login logic. Subscribe to SamlUserEvent to tweak the user or capture data the mapper doesn't handle. This is also the natural place to persist getSessionIndex() (e.g., on the user row or in the session) — you need it later for single logout.
Multiple IdPs. Define each provider, then use nbgrp_onelogin_saml.auth_factory to get an auth instance for a specific name:
$auth = $authFactory->createAuth($request->attributes->get('idp')); // 'corporate', 'partner', ...
$auth->login();
If the built-in authenticator resolves only the default provider on ACS, the reliable pattern for genuinely multi-tenant SSO is one firewall per IdP (or a thin custom authenticator delegating to the right SamlAuth). Verify against the installed version before designing around it.
Single logout. Symfony's default logout just kills the local session; the IdP is never told. Add a LogoutSuccessHandler (or dedicated controller) that calls SamlAuth::logout($returnUrl) with the stored session index, and expose a public route for the IdP-initiated SLO callback. Order matters: read sessionIndex/NameID before invalidate_session.
Testing. Functional tests should use $client->loginUser($user) and skip SAML entirely. Unit-test the attribute mapper/factory with fixture attribute arrays — that's where the real logic lives. For the ACS path itself, mock the SamlAuthInterface service rather than trying to forge signed assertions.
composer.json on the v3.0.0 tag, run composer why-not if resolution fails, and diff your merged config (debug:config) before and after — YAML keys that were silently ignored on 2.x can start failing on 3.x. Do the bundle bump in the same commit as the framework bump, never ahead of it.nbgrp_onelogin_saml.* service IDs directly (you should), a constraint of ^2.1 || ^3.0 lets Composer pick 3.x on a Symfony 7 app during an unrelated composer update. Pin to the line that matches your Symfony version.http vs https, port, and hostname all count. Behind a load balancer, configure trusted_proxies/X-Forwarded-* or the generated ACS URL and OneLogin's currentURL check will disagree.%env(base64:SAML_IDP_CERT)% or file:. OneLogin accepts raw base64 or full PEM.SameSite. The IdP's POST to your ACS is a cross-site POST; SameSite=Lax cookies are not sent, except in Chrome's 2-minute "Lax+POST" grace window. That makes logins work intermittently. Use SameSite=None; Secure (or ensure a fresh session) for the SAML firewall. Newer Symfony versions default samesite to lax, so this bites on fresh installs as well as upgrades.InResponseTo. Stateless firewalls, a wiped session, or a cookie set after the ACS POST produce "Invalid SAML Response" with no obvious cause. Keep stateless: false.debug: true in production leaks assertions into logs/errors. Gate it on the environment.uid, mail) is the least surprising.NotBefore/NotOnOrAfter failures are almost always a drifting container clock, not a config bug. Ensure NTP and check getLastErrorReason().sp.privateKey and your SP certificate registered at the IdP. Mismatched pairs fail as "Unable to decrypt assertion".supports() returns false and you get a 404 or a form-login fallback instead of a SAML error.entry_point = 401, not a redirect. With only a custom_authenticator, unauthenticated users get an unauthorized response rather than being sent to the IdP. Provide an entry point./saml/acs or /saml/metadata. The assertion is the credential; CSRF or firewall rules on those paths break the flow.getLastResponse(), getLastRequestXML(), getLastMessageId() and getLastErrorReason() are your friends — log them in dev. php bin/console debug:container nbgrp_onelogin_saml confirms wiring.SamlUserEvent, a custom authenticator, and overriding the metadata controller. Prefer these over forking; the bundle is small and actively released.How can I help you explore Laravel packages today?