DevLab / security / saml-sp-metadata-generator
SECURITY PYTHON ADVANCED

SAML SP Metadata Generator

When integrating an application as a SAML Service Provider (SP) with an Identity Provider (IdP) such as Okta, Azure AD, or Keycloak, you must hand the IdP

Python3.10+
OSLinux / macOS
Lines~226
Packages1 deps
Install Dependencies
$ pip install pysaml2
Usage
$ python3 saml_meta.py --entity-id https://app.example.com --acs-url https://app.example.com/acs --cert cert.pem
#security#python#advanced#pysaml2

SAML SP Metadata Generator

Problem Statement

When integrating an application as a SAML Service Provider (SP) with an Identity Provider (IdP) such as Okta, Azure AD, or Keycloak, you must hand the IdP a metadata XML document describing your SP. This document contains your entity ID, Assertion Consumer Service (ACS) URL, SLO URL, signing certificate, and supported bindings. Hand-crafting this XML is error-prone: a single malformed attribute or missing namespace breaks the entire SSO handshake, and debugging IdP-side errors is painful.

This script generates a standards-compliant SP metadata XML file using pysaml2, validates the inputs, and optionally signs the metadata itself so the IdP can verify authenticity. It's designed for CI/CD pipelines — run it once per environment, drop the output into your IdP's metadata upload form, and move on.

How It Works

The script loads a PEM-encoded X.509 certificate (and optionally its private key), extracts the certificate's SHA-256 fingerprint, and builds a SamlBase metadata tree using saml2.md.EntityDescriptor and its children (SPSSODescriptor, AssertionConsumerService, KeyDescriptor). It then serializes the tree to XML via saml2.mdstore.MetadataStore and writes the result to disk. If a private key is supplied, the metadata is wrapped in a ds:Signature element using signxml (or pysaml2's built-in signer) so the IdP can verify the document's provenance.

Key design decisions: (1) all inputs are validated before any XML is emitted, so partial writes never occur; (2) the certificate is read from disk rather than embedded as a string, matching how most orgs manage cert rotation; (3) output goes to stdout by default so it composes cleanly with shell pipelines, with --output for file writes; (4) the entity ID defaults to the ACS URL's origin, which is what most IdPs expect.

Full Script

#!/usr/bin/env python3
"""
SAML SP Metadata Generator

Generates a SAML 2.0 Service Provider metadata XML document from a PEM
certificate and a handful of CLI parameters. Optionally signs the metadata
using a private key so IdPs can verify authenticity.

Usage:
    python3 saml_meta.py --entity-id https://app.example.com \
        --acs-url https://app.example.com/acs --cert cert.pem
"""

from __future__ import annotations

import argparse
import os
import sys
import logging
from pathlib import Path
from typing import Optional

# pysaml2 metadata classes
from saml2.md import (
    EntityDescriptor,
    SPSSODescriptor,
    AssertionConsumerService,
    SingleLogoutService,
    KeyDescriptor,
    KeyInfo,
    X509Data,
    X509Certificate,
    Organization,
    OrganizationName,
    OrganizationDisplayName,
    OrganizationURL,
    ContactPerson,
    EmailAddress,
)
from saml2 import BINDING_HTTP_POST, BINDING_HTTP_REDIRECT
from saml2.sigver import security_context

# ---------------------------------------------------------------------------
# Configuration (environment variables with sensible defaults)
# ---------------------------------------------------------------------------
DEFAULT_ENTITY_ID = os.environ.get("SAML_ENTITY_ID")
DEFAULT_ACS_URL = os.environ.get("SAML_ACS_URL")
DEFAULT_SLO_URL = os.environ.get("SAML_SLO_URL")
DEFAULT_CERT_PATH = os.environ.get("SAML_CERT_PATH", "./cert.pem")
DEFAULT_KEY_PATH = os.environ.get("SAML_KEY_PATH")
DEFAULT_ORG_NAME = os.environ.get("SAML_ORG_NAME", "Example Org")
DEFAULT_ORG_DISPLAY = os.environ.get("SAML_ORG_DISPLAY", "Example Organization")
DEFAULT_ORG_URL = os.environ.get("SAML_ORG_URL", "https://example.com")
DEFAULT_CONTACT_EMAIL = os.environ.get("SAML_CONTACT_EMAIL", "ops@example.com")

LOG = logging.getLogger("saml_meta")


class MetadataError(Exception):
    """Raised when metadata generation cannot proceed."""


def _read_pem_certificate(path: Path) -> str:
    """Read a PEM cert and return the base64 body (no headers/whitespace)."""
    if not path.is_file():
        raise MetadataError(f"Certificate file not found: {path}")
    raw = path.read_text(encoding="utf-8")
    if "BEGIN CERTIFICATE" not in raw:
        raise MetadataError(f"File does not look like a PEM certificate: {path}")
    # Strip PEM armor and all whitespace — pysaml2 wants the bare base64 blob.
    body = (
        raw.replace("-----BEGIN CERTIFICATE-----", "")
        .replace("-----END CERTIFICATE-----", "")
    )
    body = "".join(body.split())
    if not body:
        raise MetadataError(f"Certificate body is empty: {path}")
    return body


def _validate_url(url: str, label: str) -> str:
    """Basic URL sanity check — must be absolute https (or http for dev)."""
    if not url.startswith(("https://", "http://")):
        raise MetadataError(f"{label} must be an absolute URL, got: {url!r}")
    return url


def build_metadata(
    entity_id: str,
    acs_url: str,
    cert_body: str,
    slo_url: Optional[str] = None,
    org_name: str = DEFAULT_ORG_NAME,
    org_display: str = DEFAULT_ORG_DISPLAY,
    org_url: str = DEFAULT_ORG_URL,
    contact_email: str = DEFAULT_CONTACT_EMAIL,
) -> EntityDescriptor:
    """Construct the SP EntityDescriptor tree."""
    key_info = KeyInfo()
    x509_data = X509Data()
    x509_cert = X509Certificate()
    x509_cert.text = cert_body
    x509_data.x509_certificate.append(x509_cert)
    key_info.x509_data.append(x509_data)

    key_descriptor = KeyDescriptor()
    key_descriptor.use = "signing"  # also valid: "encryption"
    key_descriptor.key_info = key_info

    sp = SPSSODescriptor()
    sp.protocol_support_enumeration = "urn:oasis:names:tc:SAML:2.0:protocol"
    sp.authn_requests_signed = "true"
    sp.want_assertions_signed = "true"
    sp.key_descriptor.append(key_descriptor)

    # ACS endpoint — POST binding is the modern default.
    acs = AssertionConsumerService()
    acs.binding = BINDING_HTTP_POST
    acs.location = acs_url
    acs.index = "0"
    acs.is_default = "true"
    sp.assertion_consumer_service.append(acs)

    # Optional SLO endpoint.
    if slo_url:
        slo = SingleLogoutService()
        slo.binding = BINDING_HTTP_REDIRECT
        slo.location = slo_url
        sp.single_logout_service.append(slo)

    org = Organization()
    on = OrganizationName(); on.text = org_name
    odn = OrganizationDisplayName(); odn.text = org_display
    ou = OrganizationURL(); ou.text = org_url
    org.organization_name.append(on)
    org.organization_display_name.append(odn)
    org.organization_url.append(ou)
    sp.organization = org

    contact = ContactPerson()
    contact.contact_type = "technical"
    email = EmailAddress(); email.text = contact_email
    contact.email_address.append(email)
    sp.contact_person.append(contact)

    ed = EntityDescriptor()
    ed.entity_id = entity_id
    ed.spsso_descriptor.append(sp)
    return ed


def serialize_metadata(ed: EntityDescriptor) -> str:
    """Serialize the EntityDescriptor to a pretty-printed XML string."""
    from saml2 import entity
    # pysaml2's to_string is the canonical serializer for SamlBase trees.
    xml_str = ed.to_string()
    if isinstance(xml_str, bytes):
        xml_str = xml_str.decode("utf-8")
    return xml_str


def sign_metadata(xml_str: str, key_path: Path, cert_path: Path) -> str:
    """Sign the metadata XML using XMLDSig (wraps signxml if available)."""
    try:
        from signxml import XMLSigner, methods
    except ImportError as exc:  # pragma: no cover
        raise MetadataError(
            "signxml is required for --sign; install with: pip install signxml"
        ) from exc

    from lxml import etree

    if not key_path.is_file():
        raise MetadataError(f"Private key not found: {key_path}")

    root = etree.fromstring(xml_str.encode("utf-8"))
    signer = XMLSigner(
        method=methods.enveloped,
        signature_algorithm="rsa-sha256",
        digest_algorithm="sha256",
        c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#",
    )
    signed_root = signer.sign(
        root,
        key=key_path.read_bytes(),
        cert=cert_path.read_bytes(),
    )
    return etree.tostring(signed_root, pretty_print=True, xml_declaration=True).decode()


def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Generate SAML 2.0 SP metadata XML.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--entity-id", default=DEFAULT_ENTITY_ID,
                        help="SP entity ID (URI). Defaults to ACS origin.")
    parser.add_argument("--acs-url", default=DEFAULT_ACS_URL,
                        help="Assertion Consumer Service URL.")
    parser.add_argument("--slo-url", default=DEFAULT_SLO_URL,
                        help="Optional Single Logout URL.")
    parser.add_argument("--cert", default=DEFAULT_CERT_PATH,
                        help="Path to PEM-encoded X.509 certificate.")
    parser.add_argument("--key", default=DEFAULT_KEY_PATH,
                        help="Path to PEM private key (enables signing).")
    parser.add_argument("--sign", action="store_true",
                        help="Sign the metadata XML with --key.")
    parser.add_argument("--output", "-o", default="-",
                        help="Output file path, or '-' for stdout.")
    parser.add_argument("--verbose", "-v", action="store_true",
                        help="Enable debug logging.")
    return parser.parse_args(argv)


def main(argv: Optional[list[str]] = None) -> int:
    args = parse_args(argv)
    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.INFO,
        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
    )

    if not args.acs_url:
        raise MetadataError("--acs-url is required (or set SAML_ACS_URL).")

    acs_url = _validate_url(args.acs_url, "ACS URL")
    entity_id = args.entity_id or acs_url.rsplit("/", 1)[0]
    LOG.info("Using entity ID: %s", entity_id)

    cert_path = Path(args.cert).expanduser().resolve()
    cert_body = _read_pem_certificate(cert_path)
    LOG.debug("Loaded certificate (%d base64 chars).", len(cert_body))

    ed = build_metadata(
        entity_id=entity_id,
        acs_url=acs_url,
        cert_body=cert_body,
        slo_url=args.slo_url,
    )
    xml_str = serialize_metadata(ed)

    if args.sign:
        if not args.key:
            raise MetadataError("--sign requires --key.")
        key_path = Path(args.key).expanduser().resolve()
        LOG.info("Signing metadata with %s", key_path)
        xml_str = sign_metadata(xml_str, key_path, cert_path)

    if args.output == "-":
        sys.stdout.write(xml_str)
        if not xml_str.endswith("\n"):
            sys.stdout.write("\n")
    else:
        out_path = Path(args.output).expanduser().resolve()
        out_path.write_text(xml_str, encoding="utf-8")
        LOG.info("Wrote metadata to %s", out_path)

    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except MetadataError as exc:
        LOG.error("Metadata generation failed: %s", exc)
        sys.exit(2)
    except KeyboardInterrupt:
        LOG.warning("Interrupted.")
        sys.exit(130)

Configuration

Variable Purpose Default
SAML_ENTITY_ID SP entity ID URI. Should be a stable identifier, not a URL you'll change. Derived from ACS origin
SAML_ACS_URL Assertion Consumer Service endpoint. Where the IdP POSTs SAML responses. (required)
SAML_SLO_URL Single Logout endpoint. Optional. None
SAML_CERT_PATH Path to PEM-encoded X.509 certificate used to sign/verify assertions. ./cert.pem
SAML_KEY_PATH Path to PEM private key. Required only with --sign. None
SAML_ORG_NAME Organization name embedded in metadata. Example Org
SAML_ORG_DISPLAY Human-readable organization name. Example Organization
SAML_ORG_URL Organization website. https://example.com
SAML_CONTACT_EMAIL Technical contact email shown to IdP admins. ops@example.com

CLI flags override env vars. --sign requires --key; --output - (default) streams to stdout.

Usage Examples

1. Minimal metadata to stdout:

$ python3 saml_meta.py --entity-id https://app.example.com \
    --acs-url https://app.example.com/acs --cert cert.pem
<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
                     entityID="https://app.example.com">
  <md:SPSSODescriptor AuthnRequestsSigned="true" WantAssertionsSigned="true"
                      protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
    <md:KeyDescriptor use="signing">...</md:KeyDescriptor>
    <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
                                 Location="https://app.example.com/acs"
                                 index="0" isDefault="true"/>
  </md:SPSSODescriptor>
</md:EntityDescriptor>

2. Signed metadata written to a file:

$ python3 saml_meta.py \
    --entity-id https://app.example.com/saml \
    --acs-url https://app.example.com/saml/acs \
    --slo-url https://app.example.com/saml/slo \
    --cert cert.pem --key key.pem --sign \
    -o sp-metadata.xml -v
2024-05-01 12:00:00 INFO saml_meta: Using entity ID: https://app.example.com/saml
2024-05-01 12:00:00 INFO saml_meta: Signing metadata with /etc/saml/key.pem
2024-05-01 12:00:00 INFO saml_meta: Wrote metadata to /etc/saml/sp-metadata.xml

3. Environment-driven (CI-friendly):

$ export SAML_ACS_URL=https://staging.example.com/acs
$ export SAML_CERT_PATH=/run/secrets/sp.crt
$ python3 saml_meta.py -o /tmp/staging-metadata.xml

Error Handling

  • Missing certificate or wrong format → MetadataError with the offending path; exit code 2.
  • Relative or malformed ACS URL → _validate_url rejects anything that isn't http(s):// before XML is built, so no half-written files.
  • --sign without --key → explicit MetadataError. Signing without a key is meaningless.
  • signxml not installed → caught ImportError re-raised as MetadataError with the exact pip install command.
  • Interrupted (Ctrl-C) → exit code 130, standard SIGINT convention.
  • Output path unwritable → Path.write_text raises OSError, which bubbles up with a traceback; wrap main() in a broader handler if you want a friendlier message.

Extending Ideas

  • Add encryption key descriptors: emit a second KeyDescriptor with use="encryption" when the IdP requires encrypted assertions.
  • Fetch cert from a secrets manager: replace _read_pem_certificate with a call to Vault, AWS Secrets Manager, or Azure Key Vault so certs never touch disk.
  • Multi-ACS support: accept a JSON config listing multiple ACS URLs (e.g., regional endpoints) and emit one AssertionConsumerService per entry with distinct index values.
  • Validate against the SAML schema: pipe the output through xmlschema with the OASIS saml-schema-metadata-2.0.xsd to catch structural errors before shipping to the IdP.
// RELATED_SCRIPTS
← Back to DevLab valtersit.com