Writing a new SAML profile

A SAML profile (Web Browser SSO, Single Logout, ECP, an eIDAS/Sweden-Connect national profile, or your own house profile) is rarely a brand-new protocol. It is almost always a specific recipe over a small set of primitives:

  • shape one or more messages (AuthnRequest, Response, LogoutRequest …),

  • move them over a binding (Redirect / POST / Artifact),

  • sign and/or encrypt them,

  • on receipt: parse, verify the signature, run the validation suite, and enforce any extra rules the profile mandates,

  • map attributes between the wire and your application.

pygamlastan exposes each of those steps as a primitive, so most profiles are written in pure Python by composing them. This guide shows how, and is honest about the three cases you may hit - from “trivial” to “needs a small Rust binding addition”. The design rationale is recorded in the project’s Architecture Decision Records (adr/0001-profile-extension-surface.md).

For a step-by-step build of one complete profile, see the companion tutorial Worked example: a Service Provider login profile.

The three tiers

Tier

Your profile is…

Effort

1

a recomposition of existing primitives (different message shaping, attribute policy, NameID strategy, orchestration)

Easy, pure Python

2

tier 1 plus custom security policy or extra per-response checks

Moderate, pure Python

3

built on machinery gamlastan implements but pygamlastan does not yet bind (SLO flow, ECP/PAOS, artifact resolution, NameID management, …)

Add a thin Rust binding module

Tier 1 - compose the primitives

Everything you need to drive a request/response profile is already bound. The example below is a complete, minimal SP-side Web-SSO profile written entirely in Python: build the request, send it signed over HTTP-Redirect, then verify and validate the response.

from pygamlastan import core, crypto, bindings, profiles, security, xml

IDP = "https://idp.example.org"
SP = "https://sp.example.org/sp"
ACS = "https://sp.example.org/acs"

# --- Outbound: build and sign an AuthnRequest, encode it for redirect ---
def begin_login(signer: crypto.SamlSigner, destination: str) -> str:
    opts = profiles.AuthnRequestOptions(SP, acs_url=ACS, destination=destination)
    request = profiles.create_authn_request(opts)
    # Detached HTTP-Redirect signature over the raw query parameters.
    return bindings.redirect_encode(
        request.to_xml().encode(), is_request=True, destination=destination,
        relay_state="opaque-state", signer=signer, sig_alg="rsa-sha256",
    )

# --- Inbound: verify the signature, then validate, in one safe call ---
def finish_login(response_xml: str, verifier: crypto.SamlVerifier) -> dict:
    cfg = security.SecurityConfig()              # production-safe defaults
    cfg.require_signed_assertions = True
    result = profiles.process_response_verified(  # verifies internally
        response_xml, verifier, cfg, SP, ACS, IDP,
        expected_request_id="_req123",
        replay_cache=security.InMemoryReplayCache(),
    )
    return result.attributes_dict()

Important

Always bind validation to real crypto. process_response_verified verifies the XML-DSig over the exact bytes itself and refuses to proceed if the signature is missing or invalid - prefer it over hand-passing verified_signed_ids to pygamlastan.profiles.process_response(). See Validation and replay protection for the lower-level path.

The primitives a profile typically reaches for:

Need

Use

Build / parse any message

pygamlastan.core constructors, pygamlastan.xml parse_*

Sign / verify / encrypt / decrypt, canonicalize, HSM

pygamlastan.crypto

Redirect / POST / Artifact transport

pygamlastan.bindings

Validation suite + replay cache

pygamlastan.security

Metadata endpoints / certificates

pygamlastan.metadata

Attribute wire ⇄ local conversion, eduPersonTargetedID

pygamlastan.attribute_map, pygamlastan.idp

Tier 2 - custom security policy and extra checks

Tune the full policy

Every gamlastan SecurityConfig knob is individually settable, so a profile can express its exact policy without depending on the strict() / permissive() presets:

cfg = security.SecurityConfig()
# Profile mandates encrypted, signed assertions and a tight window:
cfg.require_signed_assertions = True
cfg.require_encrypted_assertions = True       # e.g. a PEFIM-style profile
cfg.max_assertion_age_seconds = 120
cfg.clock_skew_seconds = 60
# Bind the assertion to the client's source address:
cfg.check_client_address = True
# Errata toggles (all default-on; shown for completeness):
cfg.reject_signatures_with_ds_object = True   # E91
cfg.sanitize_relay_state = True               # E90
cfg.require_integrity_with_cbc = True         # E93

Enforce persistent-ID uniqueness (E78)

A profile that issues or consumes persistent NameIDs should ensure an identifier is never silently re-bound to a different principal. Enable the check and pass a store implementing check_and_record(name_id, sp_entity_id, principal) -> bool (return False to signal a conflict). Any backend works - here a dict, in production a database row with a unique constraint:

class DbPersistentIdStore:
    def __init__(self, conn):
        self.conn = conn

    def check_and_record(self, name_id, sp_entity_id, principal):
        row = self.conn.fetchone(
            "SELECT principal FROM persistent_ids WHERE nid=? AND sp=?",
            (name_id, sp_entity_id),
        )
        if row is None:
            self.conn.execute(
                "INSERT INTO persistent_ids(nid, sp, principal) VALUES (?,?,?)",
                (name_id, sp_entity_id, principal),
            )
            return True
        return row[0] == principal   # False => reassignment, rejected

cfg = security.SecurityConfig()
cfg.enforce_persistent_id_uniqueness = True   # default True; explicit here
result = security.validate_response(
    response, cfg,
    received_url=ACS, expected_idp_entity_id=IDP,
    sp_entity_id=SP, acs_url=ACS, expected_request_id="_req1",
    replay_cache=security.InMemoryReplayCache(),
    persistent_id_store=DbPersistentIdStore(conn),
)

Note

The store fails closed: if your check_and_record raises, the adapter treats it as a conflict and the uniqueness check fails.

Layer profile-specific checks

gamlastan runs its 32-check suite as a unit. To add a 33rd, profile-specific rule, run the suite and then apply your own check on the parsed response - you do not have to re-walk the document, because every built-in outcome is addressable and you already hold the typed objects:

result = security.validate_response(
    response, cfg, received_url=ACS, expected_idp_entity_id=IDP,
    sp_entity_id=SP, acs_url=ACS, expected_request_id="_req1",
    replay_cache=security.InMemoryReplayCache(),
)

# Pull one built-in outcome out of the run by number or name:
age_check = result.get(0)                       # checklist #0
audience = result.by_name("Audience restriction")

# A profile rule: require a specific Authentication Context class.
REQUIRED_ACR = core.AUTHN_CONTEXT_PASSWORD_PROTECTED_TRANSPORT
def profile_ok(response) -> bool:
    for assertion in response.assertions:
        for st in assertion.authn_statements:
            if st.authn_context.authn_context_class_ref != REQUIRED_ACR:
                return False
    return True

accepted = result.is_valid() and profile_ok(response)

For freshness-only gating you can also run the one check gamlastan exposes standalone, without a full validation pass:

check = security.check_assertion_age(cfg, assertion.issue_instant, now)
if not check.passed:
    reject(check.detail)

Tier 3 - profiles that need unbound machinery

gamlastan already implements Single Logout, ECP/PAOS, Artifact Resolution, Assertion Query, NameID Mapping/Management, PEFIM and the Sweden-Connect profile - but pygamlastan does not bind all of them yet. If your profile builds on one of these, the hard cryptographic and protocol work is already done in Rust; what remains is to surface it.

Adding a binding module follows the established pattern (see any file under src/): wrap the gamlastan type in a #[pyclass], expose its methods as #[pymethods], convert errors with the helpers in src/errors.rs, and register the submodule in src/lib.rs. Each existing module is ~150-450 lines of mechanical wrapping with no new security logic. The Logout message types are already bound (pygamlastan.xml.parse_logout_request() and friends), so a Single-Logout profile is mostly a matter of binding the SLO flow helpers.

If a profile needs genuinely new SAML semantics that gamlastan does not implement, that belongs upstream in gamlastan rather than in the binding.

A checklist for a new profile

  1. Messages - can you build/parse them with pygamlastan.core / pygamlastan.xml? (Logout, AuthnRequest, Response, Assertion: yes.)

  2. Transport - Redirect / POST / Artifact via pygamlastan.bindings?

  3. Crypto - signing/verification/encryption rules expressible with pygamlastan.crypto (algorithms, HSM, c14n)?

  4. Policy - all rules covered by SecurityConfig fields, or a small Python check layered on the result?

  5. Identity - NameID strategy, persistent-ID uniqueness, attribute mapping?

  6. Gap? - if a step needs an unbound gamlastan profile (tier 3), add a binding module following the existing pattern.

Tiers 1-2 cover the large majority of real-world profiles and stay in Python.