feat(suitecrm): authenticate against Keycloak via SAML instead of a proxy

The role sat behind the oauth2-proxy, which authenticates at the edge and hands
the app nothing: SuiteCRM kept serving its own Angular login after the Keycloak
round trip, so no session ever existed and the app had no logout control. CI run
31927586060 is the only run in which the role's Playwright spec ever executed,
and it failed exactly there - "no in-app logout control reachable on the current
authenticated surface" at personas/utils/logout.js.

SuiteCRM 8.9.1 ships nbgrp/onelogin-saml-bundle and treats AUTH_TYPE=saml as a
first-class firewall in config/packages/security.php:249, so the app can own its
session without a line of PHP from us. flavor: saml turns the proxy off by
itself - is_proxy_gated is `enabled AND flavor == 'oauth2'` - which also drops
the redis session store and the auth_request clamp in the vhost. The role
declares no allowed_groups, so no edge ACL is lost.

Four settings are not cosmetic; each is a failure reproduced against a live
Keycloak 26.7.1 until the login completed:

- SAML_COMPRESS_REQUESTS: the HTTP-Redirect binding requires DEFLATE, and
  Keycloak rejects an uncompressed AuthnRequest with a ZipException.
- SAML_USE_ATTRIBUTE_FRIENDLY_NAME=false: the bundle defaults to matching
  attributes by friendlyName, which Keycloak's mappers do not set, so not a
  single attribute arrives.
- APP_DEBUG is forced to 0 whenever SAML is on: public/legacy/modules/Users/
  User.php:944 reads $_POST['ut'] unguarded while creating the user, and with
  debug on Symfony turns that warning into an exception the authenticator
  reports as a failed login - the account is created and the FIRST login of
  every new user fails anyway.
- SAML_SP_ENTITY_ID must equal the Keycloak client's clientId.

The IdP signing certificate is read from the realm descriptor at boot rather
than templated in, so a key rotation needs no redeploy. It uses the same public
issuer URL the browser is redirected to - one address, not two - with the SOCKS
proxy espocrm established for onion issuers. An unreachable IdP or a descriptor
without a certificate aborts the start; both paths are verified, as is the skip
when the variable is absent on LDAP/native deployments.

Verified end to end locally against Keycloak 26.7.1, MariaDB 12.3 and SuiteCRM
8.9.1 from the upstream tarball: session-status reports active:true for the
Keycloak user, auto-created on the first attempt against an empty users table.

web-app-keycloak/TODO.md listed this role for the trusted-header bridge. Nothing
of it was implemented, and SuiteCRM exposes no header entry point at all
(REMOTE_USER / X-Forwarded-User / X-Auth-Request appear nowhere in core/ or
public/legacy), so it moves to the same exclusion as jitsi: use the native
mechanism the app already ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-17 18:02:30 +02:00
1 parent 6b6a0ce27e
commit d15716643a
7 files changed
+56 -17

No files matched your search

+1 -2
View File
@@ -19,6 +19,5 @@ Roles still to migrate to the trusted-header SSO bridge (pattern: `web-app-baser
- `web-app-friendica` — addon/hook: header → session
- `web-app-akaunting` — Laravel controller shim (stage shim into the named volume on every deploy)
- `web-app-magento` — custom `Infinito_HeaderSso` module (bypass FPC/Varnish on the handoff route)
- `web-app-suitecrm` — header → session shim
- `web-app-funkwhale` — django header / remote-user auth backend
- Excluded: `web-app-flowise` (session-minting code is commercial-licensed), `web-app-jitsi` (no HTTP session — use native `AUTH_TYPE=jwt`)
- Excluded: `web-app-flowise` (session-minting code is commercial-licensed), `web-app-jitsi` (no HTTP session — use native `AUTH_TYPE=jwt`), `web-app-suitecrm` (upstream SAML — use native `AUTH_TYPE=saml`)
+10 -5
View File
@@ -127,11 +127,16 @@ docker run --rm -it \
`extensions/<software>/config/services/ldap/`, which maps the LDAP attributes onto
SuiteCRM's user fields for auto-created accounts.
- **SSO** reaches the app through the oauth2-proxy declared in
[`meta/services.yml`](./meta/services.yml) (`sso.flavor: oauth2`), which terminates the
Keycloak OIDC flow in front of SuiteCRM. SuiteCRM itself is not OIDC-aware; its own
SAML and OAuth provider settings stay untouched by this role and remain available in
the Administration panel.
- **SSO** is SuiteCRM's own SAML login against Keycloak (`sso.flavor: saml` in
[`meta/services.yml`](./meta/services.yml)), not a proxy in front of it. Unauthenticated
requests hit the Symfony firewall's SAML entry point, which redirects to Keycloak;
the assertion comes back to `/saml/acs` and establishes a real SuiteCRM session, so the
app renders its own authenticated UI including its logout control. Accounts are created
on first login from the assertion (`SAML_AUTO_CREATE`), and the IdP signing certificate
is read from Keycloak's realm descriptor by
[`files/docker-entrypoint-suitecrm.sh`](./files/docker-entrypoint-suitecrm.sh) at boot,
container to container, because the canonical Keycloak URL is unresolvable from inside
the app container under Tor. `/auth` stays reachable as the local break-glass login.
## Persona contract opt-outs
@@ -16,6 +16,18 @@ if [ ! -d "$APP_DIR" ]; then
exit 1
fi
if [ -n "${SUITECRM_SAML_IDP_DESCRIPTOR_URL:-}" ]; then
log "Fetching the IdP signing certificate from ${SUITECRM_SAML_IDP_DESCRIPTOR_URL}..."
_descriptor=$(wget -qO- --tries=30 --waitretry=5 --retry-connrefused "$SUITECRM_SAML_IDP_DESCRIPTOR_URL")
SAML_IDP_X509CERT=$(printf '%s' "$_descriptor" | sed -n 's:.*X509Certificate>\([^<]*\)</[^>]*X509Certificate>.*:\1:p' | head -n 1)
if [ -z "$SAML_IDP_X509CERT" ]; then
log "ERROR: no X509Certificate in the IdP descriptor."
exit 1
fi
export SAML_IDP_X509CERT
log "IdP certificate loaded."
fi
TMPDIR="${APP_DIR}/tmp"
export TMPDIR
mkdir -p "$TMPDIR"
+1 -6
View File
@@ -4,11 +4,7 @@ sso:
bond: 1
enabled: "{{ 'web-app-keycloak' in group_names }}"
shared: "{{ 'web-app-keycloak' in group_names }}"
flavor: oauth2
oauth2:
origin:
host: suitecrm
port: "80"
flavor: saml
logout:
bond: 1
enabled: "{{ 'web-svc-logout' in group_names }}"
@@ -62,7 +58,6 @@ suitecrm:
from_name: Customer Relationship Management (SuiteCRM, {{ DOMAIN_PRIMARY }})
ports:
local:
sso: 16490
http: 8062
internal:
http: 80
+24 -2
View File
@@ -1,7 +1,7 @@
{% include 'roles/sys-svc-container/templates/env.j2' %}
APP_ENV=prod
APP_DEBUG={{ MODE_DEBUG | bool | ternary(1, 0) }}
APP_DEBUG={{ (MODE_DEBUG | bool and not (SUITECRM_SSO_ENABLED | bool)) | ternary(1, 0) }}
SERVER_SCHEME={{ lookup('tls', application_id, 'protocols.web') }}
TRUSTED_PROXIES={{ NETWORK_TRUSTED_PROXY_CIDRS | join(',') }}
@@ -21,7 +21,29 @@ SUITECRM_ADMIN_EMAIL={{ lookup('users', 'administrator').email }}
SUITECRM_URL={{ SUITECRM_URL }}
{% if SUITECRM_LDAP_ENABLED | bool %}
{% if SUITECRM_SSO_ENABLED | bool %}
AUTH_TYPE=saml
SAML_IDP_ENTITY_ID={{ OIDC.CLIENT.ISSUER_URL }}
SAML_IDP_SSO_URL={{ [OIDC.CLIENT.ISSUER_URL, 'protocol/saml'] | path_join }}
SAML_IDP_SLO_URL={{ [OIDC.CLIENT.ISSUER_URL, 'protocol/saml'] | path_join }}
SAML_SP_ENTITY_ID={{ SUITECRM_SAML_SP_ENTITY_ID }}
SAML_COMPRESS_REQUESTS=true
SAML_USE_ATTRIBUTE_FRIENDLY_NAME=false
SAML_USERNAME_ATTRIBUTE={{ OIDC.ATTRIBUTES.USERNAME }}
SAML_AUTO_CREATE=enabled
SAML_AUTOCREATE_ATTRIBUTES_MAP={"{{ OIDC.ATTRIBUTES.USERNAME }}":"user_name","email":"email1","firstName":"first_name","lastName":"last_name"}
SUITECRM_SAML_IDP_DESCRIPTOR_URL={{ SUITECRM_SAML_IDP_DESCRIPTOR_URL }}
{% if SUITECRM_OIDC_SOCKS_PROXY | length > 0 %}
http_proxy={{ SUITECRM_OIDC_SOCKS_PROXY }}
https_proxy={{ SUITECRM_OIDC_SOCKS_PROXY }}
no_proxy=localhost,127.0.0.1
{% endif %}
{% elif SUITECRM_LDAP_ENABLED | bool %}
AUTH_TYPE=ldap
LDAP_AUTO_CREATE=enabled
@@ -20,7 +20,7 @@ CSS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.css.enabled')
# nocheck: playwright-service-gate — the role wires no SMTP at all: templates/env.j2 renders no mail key and services.suitecrm.email.from_name is read by nothing, so the flag has no surface to gate
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
# nocheck: playwright-service-gate — only swaps SuiteCRM's own AUTH_TYPE to ldap; the personas enter through the oauth2-proxy OIDC chain, which never binds
# nocheck: playwright-service-gate — only swaps SuiteCRM's own AUTH_TYPE to ldap, and only when SSO is off; with SSO on, templates/env.j2 selects saml and the LDAP block is never rendered
LDAP_SERVICE_ENABLED={{ lookup('config', application_id, 'services.ldap.enabled') | string | lower }}
LOGOUT_SERVICE_ENABLED={{ lookup('config', application_id, 'services.logout.enabled') | string | lower }}
@@ -32,7 +32,7 @@ MATOMO_BASE_URL={{ lookup('tls', 'web-app-matomo', 'url.base') | dotenv_quote }}
MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }}
# nocheck: persona-block-rationale — templates/env.j2:20-22 seeds only the administrator into SuiteCRM and biber reaches the app solely through the oauth2-proxy (meta/services.yml:7), so the sso: false variants leave biber without a native account (see README "Persona contract opt-outs").
# nocheck: persona-block-rationale — with SSO on, SuiteCRM authenticates through SAML against Keycloak and auto-creates the account on first login; with SSO off, templates/env.j2 seeds only the administrator, so biber has no account at all (see README "Persona contract opt-outs").
PERSONA_BIBER_BLOCKED={{ 'false' if (lookup('config', application_id, 'services.sso.enabled') | bool) else 'true' }}
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
+6
View File
@@ -22,6 +22,12 @@ SUITECRM_SEED_DIR: /usr/src/suitecrm
SUITECRM_WEB_USER: www-data
SUITECRM_WEB_GROUP: www-data
SUITECRM_SSO_ENABLED: "{{ lookup('sso', application_id, 'is_saml') }}"
SUITECRM_SAML_IDP_DESCRIPTOR_URL: "{{ [OIDC.CLIENT.ISSUER_URL, 'protocol/saml/descriptor'] | path_join }}"
SUITECRM_SAML_SP_ENTITY_ID: "{{ [SUITECRM_URL, 'saml/metadata'] | path_join }}"
SUITECRM_OIDC_ONION_ISSUER: "{{ lookup('tls', 'web-app-keycloak', 'domain').endswith('.onion') }}"
SUITECRM_OIDC_SOCKS_PROXY: "{{ lookup('tor_socks_proxy') if SUITECRM_OIDC_ONION_ISSUER | bool else '' }}"
SUITECRM_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enabled') }}"
SUITECRM_LDAP_CONFIG_HOST: "{{ [ lookup('container', application_id, 'directories.config'), 'ldap.yaml'] | path_join }}"
SUITECRM_LDAP_EXTENSION_DIR: "{{ [SUITECRM_APP_DIR, 'extensions', SUITECRM_WELL_SOFTWARE_NAME, 'config/services/ldap'] | path_join }}"