Files
kevinveenbirkenbachandClaude Opus 5 6b6a0ce27e feat(keycloak): import a SAML client for every app that asks for one
The realm carried exactly one client, the shared OIDC one, and no SAML client
existed anywhere in the repo - `flavor: saml` was in the resolver's enum with
zero consumers. Apps that cannot speak OIDC therefore had no way to
authenticate against Keycloak at all; they could only be fronted by the
oauth2-proxy, which authenticates at the edge and leaves the app itself without
a session.

KEYCLOAK_SAML_APPS collects every deployed app whose services.sso.flavor is
saml. It filters stepwise - value.services, then .sso, then .flavor, each
guarded by `defined` - because Ansible's undefined is a Marker(StrictUndefined,
Tripwire): resolving that chain in one selectattr raises for every app without
an sso block instead of yielding undefined, which would have broken the realm
render for every deployment in the repo.

The include is unconditional and the loop emits a leading comma per client, so
the realm needs no branch on the list being empty. That matters: an Ansible
`>-` value that failed to convert would be a non-empty string and a truthiness
guard would then splice a broken clients array.

The client's shape is not guesswork - every attribute comes from a failure
reproduced against a live Keycloak 26.7.1 until a SuiteCRM SAML login completed:
defaultClientScopes is emptied because the built-in role_list scope emits one
Role attribute per realm role and the SP rejects the assertion with "Found an
Attribute element with duplicated Name"; saml.client.signature is false because
the SP does not sign its AuthnRequests; the username mapper's attribute name
comes from OIDC.ATTRIBUTES.USERNAME rather than a hardcoded claim.

The new test renders the complete realm - every include, the role's own
ldap_groups_filter, StrictUndefined - and parses it as JSON for zero, one and
several SAML apps. The file had no test before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:29 +02:00

66 lines
2.1 KiB
Django/Jinja

{% for app in KEYCLOAK_SAML_APPS %}
{% set app_url = lookup('tls', app, 'url.base') %}
,{
"clientId": "{{ [app_url, 'saml/metadata'] | path_join }}",
"name": "{{ app }}",
"enabled": true,
"protocol": "saml",
"frontchannelLogout": true,
"redirectUris": ["{{ [app_url, 'saml/acs'] | path_join }}"],
"defaultClientScopes": [],
"optionalClientScopes": [],
"attributes": {
"saml.assertion.signature": "true",
"saml.server.signature": "true",
"saml.client.signature": "false",
"saml.signature.algorithm": "RSA_SHA256",
"saml_name_id_format": "username",
"saml_force_name_id_format": "true",
"saml_assertion_consumer_url_post": "{{ [app_url, 'saml/acs'] | path_join }}",
"saml_single_logout_service_url_redirect": "{{ [app_url, 'saml/logout'] | path_join }}"
},
"protocolMappers": [
{
"name": "username",
"protocol": "saml",
"protocolMapper": "saml-user-property-mapper",
"config": {
"user.attribute": "username",
"attribute.name": "{{ OIDC.ATTRIBUTES.USERNAME }}",
"attribute.nameformat": "Basic"
}
},
{
"name": "email",
"protocol": "saml",
"protocolMapper": "saml-user-property-mapper",
"config": {
"user.attribute": "email",
"attribute.name": "email",
"attribute.nameformat": "Basic"
}
},
{
"name": "firstName",
"protocol": "saml",
"protocolMapper": "saml-user-property-mapper",
"config": {
"user.attribute": "firstName",
"attribute.name": "firstName",
"attribute.nameformat": "Basic"
}
},
{
"name": "lastName",
"protocol": "saml",
"protocolMapper": "saml-user-property-mapper",
"config": {
"user.attribute": "lastName",
"attribute.name": "lastName",
"attribute.nameformat": "Basic"
}
}
]
}
{% endfor %}