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
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.
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.
#!/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)
| 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.
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
MetadataError with the offending path; exit code 2._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.130, standard SIGINT convention.Path.write_text raises OSError, which bubbles up with a traceback; wrap main() in a broader handler if you want a friendlier message.KeyDescriptor with use="encryption" when the IdP requires encrypted assertions._read_pem_certificate with a call to Vault, AWS Secrets Manager, or Azure Key Vault so certs never touch disk.AssertionConsumerService per entry with distinct index values.xmlschema with the OASIS saml-schema-metadata-2.0.xsd to catch structural errors before shipping to the IdP.