Skip to content

✨ Feature / SAML metadata validation #17

@docJerem

Description

@docJerem

Add SAML metadata validation with optional strict mode

Context

ex_saml should provide a first-class validation function for SAML metadata documents (both SP and IdP), usable before a document is exported to a partner or imported from one.

The goal is to detect:

  1. XML / SAML structural issues (spec violations per OASIS).
  2. Interoperability issues that pass with tolerant IdPs (Okta, Auth0, Google Workspace, Keycloak default) but fail with stricter ones (Entra ID, ADFS, Shibboleth strict, PingFederate strict, SAP IAS).

Real-world cases this would catch:

  • AssertionConsumerService declared with HTTP-Redirect binding.
  • entityID not formatted as an absolute URI.
  • Missing NameIDFormat.
  • Root CA certificate used as an operational SAML signing certificate.
  • Shared certificate for both signing and encryption KeyDescriptors.
  • Signature using RSA-SHA1 or other deprecated algorithms.

Why validate IdP metadata from an SP library

Even though ex_saml is an SP-focused library, a very common SP deployment pattern is to let an administrator upload IdP metadata through a web form (or paste it in) before it is persisted and used at runtime. Validating that payload at the boundary — before it is stored or wired into the provider loader — is a first-class SP concern:

  • Reject malformed documents early with an actionable error list, rather than failing later during an SSO attempt.
  • Surface interop warnings (deprecated algorithms, expired certs, missing NameIDFormat, non-HTTPS endpoints) to the admin while they still have the source document in hand.
  • Keep the runtime path (ExSaml.IdpData.load_provider/2) focused on loading valid configuration, not on producing user-facing diagnostics.

This use case must be explicitly called out in the public documentation (see PLAN → PR 5).

Proposed API

@spec validate(binary(), keyword()) ::
        {:ok, ValidationResult.t()}
        | {:error, ValidationResult.t()}

ExSaml.Metadata.validate(xml, opts \\ [])

Options:

strict: false                  # default — spec conformance only
ignore: []                     # list of violation codes to silence
allow_http_endpoints: false    # allow non-HTTPS endpoint locations
cert_min_remaining_days: 30    # warning threshold for near-expiry certs
entity_id_domain_check: false  # opt in to :entity_id_endpoint_domain_mismatch

Return type:

defmodule ExSaml.Metadata.ValidationResult do
  defstruct errors: [], warnings: []

  @type violation :: %{
          code: atom(),
          severity: :error | :warning,
          message: String.t(),
          path: String.t() | nil,        # nil for document-level violations (e.g. :invalid_xml, :invalid_root_element)
          spec_reference: String.t() | nil
        }

  @type t :: %__MODULE__{
          errors: [violation()],
          warnings: [violation()]
        }
end

Return semantics:

  • {:ok, %ValidationResult{errors: [], warnings: warnings}} when no error-level violation is found.
  • {:error, %ValidationResult{errors: errors, warnings: warnings}} when at least one error-level violation is found.
  • Ignored violation codes are removed from both errors and warnings.

Validation rules

Rules are split into specification conformance and best practices.

  • Specification conformance rules are always errors.
  • Best-practice rules are warnings by default.
  • Best-practice rules become errors when strict: true.
  • Any rule can be suppressed with ignore: [:some_code].

Always enforced: SAML spec conformance

These rules map to MUST / MUST NOT expectations in SAML metadata and related OASIS specifications.

  • XML is well-formed.

  • Root element is <md:EntityDescriptor>.

  • <md:EntitiesDescriptor> is not supported in v1 and should return a clear :entities_descriptor_not_supported error.

  • entityID is present.

  • entityID length is ≤ 1024 characters.

  • At least one <md:SPSSODescriptor> or <md:IDPSSODescriptor> is present.

  • protocolSupportEnumeration contains urn:oasis:names:tc:SAML:2.0:protocol.

  • For SP metadata: at least one <md:AssertionConsumerService> is declared.

  • For IdP metadata: at least one <md:SingleSignOnService> is declared.

  • Every <md:AssertionConsumerService> uses a permitted binding:

    • urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST
    • urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact
    • urn:oasis:names:tc:SAML:2.0:bindings:PAOS
  • <md:AssertionConsumerService> must not use HTTP-Redirect.

  • At least one <md:AssertionConsumerService> uses HTTP-POST, as required by the Web Browser SSO Profile (Profiles §4.1.3.5, Conformance §3.1).

    • Error code: :missing_http_post_acs.
    • Can be silenced via ignore: [:missing_http_post_acs] for SP metadata exclusively targeting ECP (PAOS) or Artifact profiles, which is a rare deployment.
  • All <md:AssertionConsumerService> entries have unique index values.

  • At most one <md:AssertionConsumerService> has isDefault="true".

  • <md:SingleLogoutService> bindings, when present, are in the allowed set:

    • HTTP-Redirect
    • HTTP-POST
    • HTTP-Artifact
    • SOAP
  • If a <ds:Signature> is present, validate its structure only:

    • expected nodes are present (SignedInfo, SignatureValue, Reference, etc.)
    • declared digest and signature algorithms are recognized
    • cryptographic verification against a trust anchor is out of scope
  • Every <md:KeyDescriptor> contains a parseable X.509 certificate.

  • Every X.509 certificate is not expired as of DateTime.utc_now/0.

    • This is treated as an error by default because expired certificates are not usable in practice for SAML exchanges.
    • It can be ignored for legacy scenarios with ignore: [:certificate_expired].

Best practices

These rules are warnings by default and errors when strict: true.

  • entityID should be an absolute URI according to RFC 3986.

  • <md:EntityDescriptor> declares either validUntil or cacheDuration.

  • All endpoint Location attributes are absolute HTTPS URLs.

    • Can be relaxed with allow_http_endpoints: true.
  • Signing certificate is not a CA certificate.

    • BasicConstraints should be CA:FALSE or absent.
  • Signing certificate KeyUsage includes digitalSignature.

  • Signing certificate KeyUsage does not include keyCertSign.

  • Separate certificates are used for use="signing" and use="encryption" KeyDescriptors.

  • At least one <md:NameIDFormat> is declared in the SP descriptor.

  • AuthnRequestsSigned="true" is set on SPSSODescriptor.

  • WantAssertionsSigned="true" is set on SPSSODescriptor.

  • Signature algorithms are in a modern allow-list:

    • RSA-SHA256
    • RSA-SHA384
    • RSA-SHA512
    • ECDSA-SHA256+
  • Deprecated algorithms such as RSA-SHA1, DSA-SHA1, or MD5 are rejected in strict mode.

  • Certificate validity window has more than cert_min_remaining_days days remaining.

  • <md:Organization> is present.

  • At least one <md:ContactPerson contactType="technical"> is present.

  • Opt-in lint: entityID domain is consistent with endpoint domains when entityID is an HTTPS URI.

    • This rule stays as :warning even in strict mode, and requires explicit opt-in via entity_id_domain_check: true to be surfaced.
    • Rationale: multi-tenant SAML-as-a-service providers (Cryptr, WorkOS, FusionAuth hosted, etc.) legitimately issue metadata where the entityID lives under the tenant's own domain while endpoints live under the provider's domain. This is not a misconfiguration.
    • The rule exists to catch accidental copy-paste or provisioning mismatches in single-tenant deployments, where this alignment is expected.

The entityID URI rule is intentionally stricter than what many permissive IdPs accept. The goal is interoperability with enterprise IdPs and predictable metadata exchange.

Example

# Default mode — tolerates non-URI entityID but catches ACS Redirect
iex> ExSaml.Metadata.validate(xml)
{:error,
 %ExSaml.Metadata.ValidationResult{
   errors: [
     %{
       code: :invalid_acs_binding,
       severity: :error,
       message: "HTTP-Redirect binding is not valid for AssertionConsumerService",
       path: "/EntityDescriptor/SPSSODescriptor/AssertionConsumerService[2]",
       spec_reference: "SAML 2.0 Bindings §3.4.3, Profiles §4.1.3.5"
     }
   ],
   warnings: [
     %{
       code: :entity_id_not_uri,
       severity: :warning,
       message: "entityID should be an absolute URI for interoperability",
       path: "/EntityDescriptor/@entityID",
       spec_reference: "SAML 2.0 Core §8.3.6, RFC 3986"
     }
   ]
 }}
# Strict mode — promotes best-practice warnings to errors
iex> ExSaml.Metadata.validate(xml, strict: true)
{:error,
 %ExSaml.Metadata.ValidationResult{
   errors: [
     %{
       code: :invalid_acs_binding,
       severity: :error,
       message: "HTTP-Redirect binding is not valid for AssertionConsumerService",
       path: "/EntityDescriptor/SPSSODescriptor/AssertionConsumerService[2]",
       spec_reference: "SAML 2.0 Bindings §3.4.3, Profiles §4.1.3.5"
     },
     %{
       code: :entity_id_not_uri,
       severity: :error,
       message: "entityID should be an absolute URI for interoperability",
       path: "/EntityDescriptor/@entityID",
       spec_reference: "SAML 2.0 Core §8.3.6, RFC 3986"
     }
   ],
   warnings: []
 }}
# Tolerate a known legacy issue
iex> ExSaml.Metadata.validate(xml, strict: true, ignore: [:entity_id_not_uri])
{:error,
 %ExSaml.Metadata.ValidationResult{
   errors: [
     %{
       code: :invalid_acs_binding,
       severity: :error,
       message: "HTTP-Redirect binding is not valid for AssertionConsumerService",
       path: "/EntityDescriptor/SPSSODescriptor/AssertionConsumerService[2]",
       spec_reference: "SAML 2.0 Bindings §3.4.3, Profiles §4.1.3.5"
     }
   ],
   warnings: []
 }}

Suggested violation codes

:invalid_xml
:invalid_root_element
:entities_descriptor_not_supported
:missing_entity_id
:entity_id_too_long
:entity_id_not_uri
:missing_role_descriptor
:missing_saml2_protocol_support
:missing_acs
:missing_sso_service
:invalid_acs_binding
:missing_http_post_acs
:duplicate_acs_index
:multiple_default_acs
:invalid_slo_binding
:invalid_signature_structure
:unknown_signature_algorithm
:unknown_digest_algorithm
:missing_x509_certificate
:invalid_x509_certificate
:certificate_expired
:certificate_near_expiry
:ca_certificate_used_for_signing
:invalid_signing_key_usage
:shared_signing_and_encryption_certificate
:missing_name_id_format
:missing_valid_until_or_cache_duration
:non_https_endpoint
:missing_authn_requests_signed
:missing_want_assertions_signed
:deprecated_signature_algorithm
:missing_organization
:missing_technical_contact
:entity_id_endpoint_domain_mismatch

Acceptance criteria

  • Public functions ExSaml.Metadata.validate/1 and ExSaml.Metadata.validate/2.

  • Public struct ExSaml.Metadata.ValidationResult.

  • violation type documented via @type annotation with accompanying ExDoc @typedoc explaining each field and when path / spec_reference may be nil.

  • Every violation includes:

    • stable code
    • severity
    • human-readable message
    • optional path
    • optional spec_reference
  • Options:

    • :strict
    • :ignore
    • :allow_http_endpoints
    • :cert_min_remaining_days
    • :entity_id_domain_check
  • Test fixtures under test/fixtures/metadata/ covering:

    • spec-clean SP metadata
    • spec-clean IdP metadata
    • <md:EntitiesDescriptor> explicitly unsupported in v1
    • ACS with HTTP-Redirect binding
    • ACS missing HTTP-POST binding
    • ACS missing HTTP-POST binding + ignore: [:missing_http_post_acs] (ECP/Artifact-only case)
    • duplicate ACS index
    • multiple ACS with isDefault="true"
    • non-URI entityID
    • missing NameIDFormat
    • Root CA used as signing cert
    • shared cert for signing and encryption
    • expired certificate
    • near-expiry certificate
    • RSA-SHA1 signature algorithm
    • HTTP endpoint Location
    • allow_http_endpoints: true
    • entity_id_domain_check: true with matching and mismatching domains
    • ignore: option suppressing specified codes
  • ExDoc documentation listing every violation code, severity by mode, and spec reference.

  • README section with before/after usage example.

  • README or ExDoc section explaining the difference between:

    • metadata structure validation
    • certificate linting
    • cryptographic XML signature verification

Non-goals

Out of scope for this issue:

  • Cryptographic verification of the XML signature against a trust anchor.

    • This should be handled separately by something like ExSaml.Metadata.verify_signature/2.
  • Full PKIX chain validation.

    • No trust store handling.
    • No root / intermediate chain building.
    • No CRL / OCSP revocation checks.
  • Fetching remote metadata over HTTP(S).

  • Auto-fixing or rewriting non-conformant metadata.

  • <md:EntitiesDescriptor> federation bundles with multiple entities.

  • Validation of <md:AttributeConsumingService>.

  • Validation of <mdui:UIInfo> extensions.


PLAN

Each checkbox below is delivered as its own pull request. Ordering is suggested — a later PR can be started once its predecessor is merged. Rule codes referenced here map to the "Suggested violation codes" list above.

  • PR 1 — Module scaffolding + spec-conformance rules (✨ Feature / Metadata validation — scaffolding + spec-conformance rules #20)
    • Public ExSaml.Metadata.validate/1 and ExSaml.Metadata.validate/2.
    • Public ExSaml.Metadata.ValidationResult struct + violation @type / @typedoc.
    • :ignore option plumbing.
    • Always-error rules: :invalid_xml, :invalid_root_element, :entities_descriptor_not_supported, :missing_entity_id, :entity_id_too_long, :missing_role_descriptor, :missing_saml2_protocol_support, :missing_acs, :missing_sso_service, :invalid_acs_binding, :missing_http_post_acs, :duplicate_acs_index, :multiple_default_acs, :invalid_slo_binding.
    • Fixtures: spec-clean SP, spec-clean IdP, <md:EntitiesDescriptor> unsupported, ACS with HTTP-Redirect, ACS missing HTTP-POST (with and without ignore: [:missing_http_post_acs]), duplicate ACS index, multiple isDefault="true".
  • PR 2 — Certificate and signature-structure rules
    • :missing_x509_certificate, :invalid_x509_certificate, :certificate_expired (error by default, silence-able via ignore).
    • :invalid_signature_structure, :unknown_signature_algorithm, :unknown_digest_algorithm.
    • Always-error half of certificate linting: :ca_certificate_used_for_signing, :invalid_signing_key_usage, :shared_signing_and_encryption_certificate may live here or in PR 3 depending on severity choice — decide during PR 2 review.
    • Fixtures: expired cert, Root CA used as signing cert, shared signing/encryption cert.
  • PR 3 — Best-practice rules + strict mode
    • Options: :strict, :cert_min_remaining_days, :allow_http_endpoints.
    • Warning-by-default / error-in-strict rules: :entity_id_not_uri, :missing_valid_until_or_cache_duration, :non_https_endpoint, :missing_name_id_format, :missing_authn_requests_signed, :missing_want_assertions_signed, :deprecated_signature_algorithm, :certificate_near_expiry, :missing_organization, :missing_technical_contact.
    • Fixtures: non-URI entityID, missing NameIDFormat, HTTP endpoint Location, allow_http_endpoints: true, RSA-SHA1 signature algorithm, near-expiry certificate.
  • PR 4 — Opt-in domain mismatch lint
    • Option: :entity_id_domain_check.
    • Rule: :entity_id_endpoint_domain_mismatch (remains :warning even in strict mode).
    • Fixtures: matching domain, mismatching domain, multi-tenant SaaS layout (should not trigger unless opted in).
  • PR 5 — Documentation
    • README section with a before/after usage example.
    • README or ExDoc section explaining the difference between metadata structure validation, certificate linting, and cryptographic XML signature verification.
    • ExDoc listing every violation code, its severity by mode, and its spec reference.
    • Explicit documentation of the admin-uploaded IdP metadata use case (form upload before persisting), with a code snippet showing how to call validate/2 from a controller or changeset.

Related but out of scope for this issue


Cohérence avec l'existant

Findings from reading the current codebase (lib/ex_saml/core/saml.ex, lib/ex_saml/idp_data.ex, lib/ex_saml/core/sp_metadata.ex, lib/ex_saml/core/idp_metadata.ex, mix.exs).

What exists today

  • IdP metadata is already parsed, but tolerantly: ExSaml.Core.Saml.decode_idp_metadata/1 (lib/ex_saml/core/saml.ex:204) only returns :bad_entity or :missing_sso_location; other structural defects surface as logs in lib/ex_saml/idp_data.ex (see :116, :196) rather than as an actionable diagnostic.
  • SP metadata is generated in lib/ex_saml/core/saml.ex:1188-1282.
  • The internal structs ExSaml.Core.SpMetadata (lib/ex_saml/core/sp_metadata.ex) and ExSaml.Core.IdpMetadata (lib/ex_saml/core/idp_metadata.ex) exist, but no public ExSaml.Metadata module. The proposed API slots in next to them without touching internals.

Natural insertion points

  • Add a new top-level ExSaml.Metadata module. The validator takes a raw XML binary, so it is fully decoupled from the provider runtime (no dependency on service_providers_accessor, IdpData.load_providers/2, or ETS state) — validation is unit-testable without bootstrapping providers.
  • sweet_xml + xmerl are already used throughout the library — reuse them for the XPath selectors that populate the path field of each violation.
  • :public_key and :crypto are already declared in mix.exs:27 extra_applications — certificate inspection (expiration, BasicConstraints, KeyUsage) requires no new dependency.
  • The existing fixtures under test/data/*.xml (Azure, OneLogin, Shibboleth, SimpleSAML, TestShib) provide a ready-made "real-world IdP" corpus; new crafted fixtures will only need to cover the specific violation cases listed per PR above.

Latent non-conformance uncovered during this review

Alignment with the project's recent direction

Recent commits on the library have pushed defensive hardening at parse/verify time:

  • 48b6fca — XXE fix, NotBefore validation, algorithm whitelist.
  • b4d8ee7 / 4761838 — CI security and quality checks.

Structural metadata validation is the complementary input-validation layer: it hardens the boundary where external metadata enters the library, filling the remaining gap between "we parse XML safely" and "we only trust well-formed, spec-conformant SAML documents".


References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions