# SAML Integration `wcs.backend` integrates SAML 2.0 single sign-on so a Webcloud7 CMS site can act as a SAML Service Provider (SP) against an external Identity Provider (IdP) such as Keycloak, ADFS or OneLogin. Users authenticate at the IdP and are transparently created and logged in on the Plone side. The SAML functionality itself is provided by the external **`wcs.samlauth`** package (a hard dependency declared in `setup.py`). `wcs.backend` pulls it in and wires the overall login experience around it — the shared login form, the force-login behavior that protects the site from anonymous access, and the user-area provisioning that runs after a successful login. ## Architecture Overview The SAML login is implemented as a PAS (Pluggable Authentication Service) plugin installed in the site's `acl_users`. The plugin exposes a set of browser views that speak the SAML protocol with the IdP, and acts as a `IChallengePlugin` so unauthorized requests are redirected into the SAML flow. ```text ┌──────────────┐ AuthnRequest ┌──────────────┐ │ Browser │ ────────────────▶ │ IdP │ │ │ ◀──────────────── │ (Keycloak, │ └──────────────┘ SAML Response │ ADFS, …) │ │ └──────────────┘ │ POST assertion (/acs) ▼ ┌─────────────────────┐ ┌─────────────────┐ │ SamlAuthPlugin │────▶│ Plone PAS │ │ (acs / sls / slo) │ │ (acl_users) │ └─────────────────────┘ └─────────────────┘ │ remember_identity ▼ ┌─────────────────────┐ │ User created / │ │ session + JWT set │ └─────────────────────┘ ``` Main moving parts: 1. **`SamlAuthPlugin` (PAS)** — holds all SP/IdP configuration as plugin properties, implements `IChallengePlugin`, and turns a validated SAML assertion into a Plone user and session (`remember_identity`). 2. **Protocol views** — `sls` (start login), `acs` (assertion consumer), `slo` / `logout` (single logout), `metadata` (SP metadata), and the `idp_metadata` admin form for importing IdP metadata. 3. **Force-login behavior** (`wcs.backend`) — rejects anonymous traversal so visitors are funnelled into the SAML challenge. ## Configuration All SAML settings live as **plugin properties** on the `SamlAuthPlugin` in `acl_users`. After adding the plugin via the ZMI (`/acl_users/manage_main`), open its properties form to edit them. ### Behavior Settings | Property | Default | Description | |---|---|---| | `create_session` | `True` | Create a Plone session (`__ac` cookie) on successful login. | | `create_api_session` | `False` | Issue a JWT and set the `auth_token` cookie for REST API access. | | `include_api_token_in_redirect` | `False` | Append the JWT as an `auth_token` query parameter on the post-login redirect (requires `create_api_session`). | | `create_user` | `True` | Create a matching Plone user on first login if one does not already exist. | | `validate_authn_request` | `False` | Store the outgoing `AuthnRequest` id in the `__saml` cookie and validate it on the response (`InResponseTo`). | | `allowed_redirect_hosts` | `()` | Extra hostnames (beyond the site's own host) that `RelayState` is allowed to redirect to after login. | | `adfs_as_idp` | `False` | Enable ADFS-compatible lowercase URL-encoding of SAML messages. | ### SAML Settings Documents The SAML toolkit configuration is split across three text properties, each holding a JSON document: | Property | Description | |---|---| | `settings_sp` | Service Provider block (`sp`). `entityId`, the `acs`/`slo` URLs and the SP certificate. The endpoint URLs are filled in automatically from the plugin URL at request time. | | `settings_idp` | Identity Provider block (`idp`). `entityId`, single sign-on / single logout endpoints and the IdP signing certificate. Usually populated by importing IdP metadata rather than edited by hand. | | `advanced` | Security and metadata options (`strict`, `debug`, signing/encryption requirements, signature/digest algorithms, contact and organization info). | At request time the plugin merges these three documents (`advanced` first, then `sp`, then `idp`) into the settings object handed to the underlying `python3-saml` toolkit. ### Importing IdP Metadata Instead of editing `settings_idp` by hand, use the **`idp_metadata`** admin form on the plugin (`/acl_users//idp_metadata`). Provide the IdP metadata URL and either: - **Get Metadata** — preview the parsed IdP settings, or - **Get and store metadata** — merge the parsed IdP `entityId`, SSO/SLO endpoints and certificate into `settings_idp`. ### Identity Provider Setup (Keycloak example) When using Keycloak as the IdP, create a SAML client in the realm: 1. **Create Client** - Clients → Create → Client type: `SAML` - Client ID: the SP `entityId`, i.e. `https://your-site.com/acl_users//metadata` 2. **Configure endpoints** - Valid redirect URIs / Assertion Consumer Service POST URL: `https://your-site.com/acl_users//acs` - Logout Service Redirect Binding URL: `https://your-site.com/acl_users//slo` 3. **Signing** - Sign assertions / responses as required, and make the IdP signing certificate available so it can be imported into `settings_idp` (see *Importing IdP Metadata*). 4. **Attribute / NameID mapping** - Map the user identifier to the SAML `NameID`, and add attribute mappers for the user properties you want propagated (e.g. email, full name). Friendly-name attributes are read preferentially; otherwise raw attribute names are used. ## Login Flow From an integrator's perspective the flow is: 1. An anonymous visitor requests a protected page. The force-login behavior raises `Unauthorized`, which the plugin's `challenge` turns into a redirect to `…//require_login?came_from=`. 2. `require_login` redirects to the plugin's **`sls`** view (the login entry point), which builds a SAML `AuthnRequest` and redirects the browser to the IdP. The original URL is carried as `RelayState`. 3. The user authenticates at the IdP. 4. The IdP POSTs the signed SAML response to the **`acs`** (Assertion Consumer Service) view. The plugin validates the assertion, then calls `remember_identity`: - if `create_user` is enabled and the user is unknown, a Plone user is created and a home folder is provisioned; - user properties are updated from the assertion attributes; - a Plone session and (optionally) a JWT token are established. 5. The visitor is redirected back. The target is the `RelayState` URL when its host is the site's own host or appears in `allowed_redirect_hosts`; otherwise the site root. When `include_api_token_in_redirect` and `create_api_session` are both enabled, the JWT is appended as `?auth_token=…`. ### Endpoints All endpoints are views on the plugin object (`/acl_users//`): | View | Purpose | |---|---| | `sls` | Start login — builds the `AuthnRequest` and redirects to the IdP. | | `acs` | Assertion Consumer Service — receives and validates the SAML response. | | `slo` | Single Logout — handles IdP-initiated logout requests. | | `logout` | SP-initiated logout — builds a `LogoutRequest` to the IdP. | | `metadata` | Returns the SP metadata XML for registration at the IdP. | | `require_login` | Redirect target of the challenge plugin; sends the user to `sls`. | | `idp_metadata` | Admin form to import IdP metadata into the plugin. | ### Single Logout - **`logout`** (SP-initiated) builds a SAML `LogoutRequest` and redirects to the IdP's single logout endpoint. - **`slo`** (IdP-initiated) processes an incoming `LogoutRequest`, clears the Plone session, and finally redirects to `…/logged-out`. ## Force-Login Behavior `wcs.backend` registers a `IBeforeTraverseEvent` subscriber on the site root (`login/force_login.py`) that rejects anonymous access to most of the site. This is what forces visitors into the SAML challenge. For each traversal the handler raises `Unauthorized` for anonymous users **unless** one of the following applies: - the request `Accept` header is `application/json` (REST API requests pass through); - the request method is `OPTIONS` (CORS preflight); - the URL contains an allowed sub-path — `++resource++`, `++theme++`, `++plone++static`, `++unique++`, `++api++`, `@@site-logo`, `@@images`, `@@download`, `@@display-file`, `passwordreset`, `adminauth`, or `acl_users` (the SAML protocol views live under `acl_users`, so the flow is never blocked); - the requested item is an allowed endpoint — `favicon.ico`, `plonejsi18n`, `login`, `login_form`, `require_login`, `@@login-help`, `sitemap.xml`, `sitemap.xml.zg`, `ok`, or `@@register`. Because the protocol views and the login form are explicitly allow-listed, force-login and SAML work together: the public site is locked down, while the SAML handshake and the REST API remain reachable. ## Testing SAML integration tests run against a real Keycloak instance configured as a SAML IdP (realm imported from `tests/assets/saml-test-realm.json` by the Keycloak Docker layer). The test case combines the `wcs.backend` and `wcs.samlauth` functional testing layers. ```bash # Run the SAML login tests bin/test -t test_saml_login ``` `test_saml_login.py` covers the end-to-end login (assertion → `__ac` session → redirect back to the portal) and verifies that a user task container is provisioned in the new user's home folder after the first SAML login.