When I wired Mautic behind Keycloak for my self-hosted portal, the SSO flow did what every broken SSO flow does: it completed the round-trip, landed on /s/saml/login_check, and quietly redirected to /saml/login_retry - Mautic's universal "something went wrong, please log in again" fallback. No exception in the prod log. No profiler entry (because prod). Just a 302 and a shrug.
This post is the full causal chain. Three errors, stacked.
Error 0: APP_ENV=dev but WebProfilerBundle isn't installed
Before I could see the SAML errors at all, I had to fix the container. Apache was pinning:
# /etc/apache2/conf-enabled/zz-mautic-env.conf
SetEnv APP_ENV dev
SetEnv MAUTIC_ENV dev ...but the composer install had been done with --no-dev, so symfony/web-profiler-bundle wasn't in vendor/. Every request exploded in AppKernel.php:189:
Symfony\Component\ErrorHandler\Error\FatalError: Class
"Symfony\Bundle\WebProfilerBundle\WebProfilerBundle" not found Dev mode returned 500s on every request. rm -rf var/cache/* didn't help (wrong problem). The fix was a one-liner: flip Apache back to prod and clear the compiled container:
cat > /etc/apache2/conf-enabled/zz-mautic-env.conf <<EOF
SetEnv APP_ENV prod
SetEnv MAUTIC_ENV prod
SetEnv APP_DEBUG 0
EOF
rm -rf var/cache/prod/*
apache2ctl graceful Takeaway: if your container claims dev but half the bundles aren't installed, that's a deployment inconsistency, not a Symfony bug.
Error 1: Invalid inbound message destination
With prod serving again, I instrumented the authenticator. I patched vendor/javer/sp-bundle/.../LightSamlSpAuthenticator.php to log exceptions:
public function authenticate(Request $request): Passport
{
file_put_contents("/tmp/saml-debug.log",
"[Authenticator] authenticate() called\n", FILE_APPEND);
try {
return $this->doAuthenticate($request);
} catch (\Throwable $e) {
file_put_contents("/tmp/saml-debug.log",
"[Authenticator] EXCEPTION: ".get_class($e).": ".$e->getMessage()."\n",
FILE_APPEND);
throw $e;
}
} Restore from backup after the session - don't leave debug writes in vendor code.
First real error surfaced:
LightSaml\Error\LightSamlContextException: Invalid inbound message destination
"https://mautic.calegix.com/s/saml/login_check"
at AbstractDestinationValidatorAction.php:60 The destination in Keycloak's <Response> was correct. LightSaml rejected it because when it looked up the SP's own entity descriptor to find matching endpoints, the published ACS URL didn't match. I fetched /saml/metadata.xml and found this:
<AssertionConsumerService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="mautic/s/saml/login_check"/> No scheme, no host. Just the literal string 'mautic/s/saml/login_check'.
The culprit is in Mautic's core:
// docroot/app/bundles/UserBundle/Security/SAML/EntityDescriptorProviderFactory.php
$route = $routeName
? $router->generate($routeName, [], RouterInterface::ABSOLUTE_PATH)
: '';
return new SimpleEntityDescriptorBuilder(
$ownEntityId,
$route ? sprintf('%s%s', $ownEntityId, $route) : '', // <-- here
'',
$arrOwnCredentials[0]->getCertificate()
); It concatenates $ownEntityId + '/s/saml/login_check'. This only works if the entity ID is a URL. My local.php had 'saml_idp_entity_id' => 'mautic' - a bare string - so the ACS was built as mautic/s/saml/login_check.
Error 2: AudienceRestriction MUST BE a wellformed uri
I changed the entity ID to a URL:
'saml_idp_entity_id' => 'https://mautic.calegix.com', Cleared cache, retested. Destination validator passed. Next error, one level deeper:
LightSaml\Error\LightSamlValidationException:
AudienceRestriction MUST BE a wellformed uri
at AssertionValidator.php:173 Keycloak populates <Audience> in the assertion from the client ID. If your KC client is called mautic, you get <Audience>mautic</Audience>. LightSaml's AssertionValidator requires URI-form per SAML 2.0 spec. 'mautic' is not a URI.
Fix: rename the Keycloak client ID to match the entity ID.
podman exec keycloak /opt/keycloak/bin/kcadm.sh update \
clients/<client-uuid> -r primary \
-s 'clientId=https://mautic.calegix.com' One kcadm call. Saved the realm state. Cleared Mautic cache again. Third attempt:
[Authenticator] authenticate() called
[UserMapper] attrs: {"email":"natej@...", ...}
[Authenticator] passport built OK Green.
The generalizable rule
In SAML 2.0, entity IDs are URIs, not names. Every SAML stack that rigorously validates will trip over this. The cascade I hit wasn't one bug - it was three validators all correctly enforcing the same underlying contract:
1. The destination-endpoint resolver expects the ACS URL in SP metadata to match incoming Destination. Malformed ACS, no valid incoming response.
2. The audience validator expects <Audience> to be a URI. A KC client ID of 'mautic' silently violates this.
3. Mautic's EntityDescriptorProviderFactory assumes your entity ID is a base URL and concatenates paths onto it. Bare name breaks ACS construction.
The single fix: entity ID = https://<sp-host>, both in the SP config and the IdP's client record. Full stop. Treating entity IDs as opaque names is a pre-SAML-2.0 habit that keeps costing people afternoons.
Ops notes for the next person
If you run fatal=True on any dev bundle and get a silent 500, check which bundles your production composer actually installed before blaming the kernel.
Temporarily patching vendor/ for debug logs is fine; restore from backups before you leave town.
Debug onAuthenticationFailure at the LightSamlSpAuthenticator level, not in Mautic's UserCreator - the auth passport failures short-circuit before user creation.
No comments yet