A puzzle-piece grid representing stacked SAML validators

Mautic SAML Entity IDs Must Be URIs: A Debugging Story in Three Errors

Three stacked validators, one bare-string config, and an afternoon lost to SAML 2.0's dislike of non-URI identifiers.

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.

Related posts

No comments yet