SAML 2.0

FerrisKey speaks SAML 2.0 as an identity provider. A service provider sends it an AuthnRequest, the user signs in through the normal FerrisKey login, and FerrisKey posts back a signed assertion.

This is what gets an application that only supports SAML, and there are plenty of them, onto the same realm, the same users, and the same audit trail as everything else.

Identity provider, not broker

This module makes FerrisKey the IdP that other applications trust. Signing in through an external SAML provider is a different problem, handled by federation in Abyss, and it is not shipped yet.

Endpoints

Every endpoint is realm-scoped, under the realm’s protocol path.

MethodEndpointDescription
GET/realms/{realm_name}/protocol/samlSSO through the HTTP-Redirect binding
POST/realms/{realm_name}/protocol/samlSSO through the HTTP-POST binding
GET/realms/{realm_name}/protocol/saml/continueResume the flow after the user has authenticated
GET/realms/{realm_name}/protocol/saml/descriptorIdP metadata, for handing to a service provider

The descriptor is what most service providers want: point them at that URL, or download it and upload the XML, and they learn the entity id, the SSO endpoints, and the signing certificate on their own.

The entity id and SERVER_PUBLIC_URL

The IdP entity id is derived from the public base URL and the realm name:

https://sso.example.com/realms/home

It is signed into every assertion, which means it cannot move. If it is derived from the request’s Host header and a request arrives through a different hostname, the entity id changes and service providers reject the assertion.

Set SERVER_PUBLIC_URL before using SAML

Leave SERVER_PUBLIC_URL unset and FerrisKey falls back to the Host header, which is fine for OIDC and wrong for SAML. Set it to the origin browsers and service providers actually reach, as scheme://host[:port]. See Configuration.

Registering a service provider

A SAML service provider is a FerrisKey client carrying a SAML configuration.

MethodEndpointDescription
GET/realms/{realm_name}/clients/{client_id}/saml-configRead the client’s SAML configuration
PUT/realms/{realm_name}/clients/{client_id}/saml-configCreate or replace it
FieldDefaultDescription
sp_entity_idrequiredThe service provider’s entity id. FerrisKey resolves incoming requests by their issuer, so this must match exactly
acs_urlrequiredAssertion Consumer Service URL, where the signed response is posted
name_id_formatemailAddressFormat of the subject identifier
sign_assertionsSign the assertion element
sign_documentsSign the response document
want_authn_requests_signedExpect the service provider to sign its AuthnRequest

If an AuthnRequest names an ACS URL that does not match the registered one, the request is rejected rather than redirected. That is deliberate: an unchecked ACS URL in a request is an open redirect with an assertion attached.

Name ID formats

ValueURN
emailAddressurn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
persistenturn:oasis:names:tc:SAML:2.0:nameid-format:persistent
transienturn:oasis:names:tc:SAML:2.0:nameid-format:transient
unspecifiedurn:oasis:names:tc:SAML:1.1:nameid-format:unspecified

The short name and the full URN are both accepted when writing the config. A format the user cannot satisfy fails the flow: asking for emailAddress on a user with no email address produces an error rather than an empty subject.

Attribute mappers

Beyond the subject, a service provider usually wants attributes. Mappers say which user field goes out under which SAML attribute name.

MethodEndpointDescription
GET/realms/{realm_name}/clients/{client_id}/saml-attribute-mappersList the client’s mappers
POST/realms/{realm_name}/clients/{client_id}/saml-attribute-mappersCreate a mapper
DELETE/realms/{realm_name}/clients/{client_id}/saml-attribute-mappers/{mapper_id}Delete a mapper

A mapper has a name, a name_format, and a source.

Sources are written as prefixed strings:

SourceValue
User iduser:id
Usernameuser:username
Emailuser:email
First nameuser:first_name
Last nameuser:last_name
A custom user attributeattribute:<key>

Name formats:

ValueURN
basicurn:oasis:names:tc:SAML:2.0:attrname-format:basic
uriurn:oasis:names:tc:SAML:2.0:attrname-format:uri
unspecifiedurn:oasis:names:tc:SAML:2.0:attrname-format:unspecified
{
  "name": "email",
  "name_format": "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
  "source": "user:email"
}

The flow

sequenceDiagram
    participant SP as Service Provider
    participant B as Browser
    participant FK as FerrisKey

    SP->>B: Redirect with AuthnRequest
    B->>FK: GET/POST /realms/{realm}/protocol/saml
    FK->>FK: Parse request, resolve SP by issuer, check ACS URL
    FK->>B: Login portal
    B->>FK: Credentials, MFA if required
    FK->>B: Redirect to /protocol/saml/continue
    B->>FK: GET /protocol/saml/continue
    FK->>FK: Build and sign the assertion
    FK->>B: Auto-submitting form to the ACS URL
    B->>SP: POST SAMLResponse (+ RelayState)

The RelayState the service provider sent is carried through and returned untouched, which is how it recovers the page the user was heading to.

Assertion validity

ConstantValue
Assertion lifetime300 seconds
Allowed clock skew60 seconds

The skew window exists because the service provider’s clock is not yours. If assertions are being rejected as not-yet-valid or expired, check NTP on both sides before anything else.