Worked example: a Service Provider login profile

Writing a new SAML profile lays out the tiers and primitives. This chapter is the companion tutorial: it builds one complete, real profile - the Service Provider side of Web Browser SSO - end to end, in pure Python (tier 1-2), explaining each decision. It is the distilled core of the runnable examples/django-sp project; pair it with Service Provider integration for the API reference of each call.

The profile we are building does four things:

  1. resolve the chosen Identity Provider’s metadata,

  2. build and send an AuthnRequest,

  3. receive the Response at the Assertion Consumer Service (ACS), verify it, and validate it,

  4. hand the application a clean identity (NameID + attributes).

A reusable profile object

A profile is just a small object that holds configuration and owns the primitives. Everything below is framework-agnostic; the only inputs are the SP’s own identity (entityID, ACS URL, key/cert) and a way to resolve IdP metadata.

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

class SpLoginProfile:
    def __init__(self, *, entity_id, acs_url, idp_metadata):
        self.entity_id = entity_id
        self.acs_url = acs_url
        self.idp = idp_metadata          # a pygamlastan.metadata.EntityDescriptor
        # State the profile must keep across the two legs of the flow. In a web
        # app these live in the user's session, not on the instance.
        self.replay_cache = security.InMemoryReplayCache()
        self.id_store = InMemoryPersistentIdStore()   # defined below

Step 1 - resolve the IdP

The profile needs the IdP’s SSO endpoint (where to send the request) and its signing certificate (to verify the response). Both come from the IdP’s metadata, resolved however your federation works - a trusted local file, or an MDQ lookup that you signature-verify (see Service Provider integration for both). Here we just read what we need from an already-resolved EntityDescriptor:

def sso_endpoint(self) -> str:
    for ep in self.idp.single_sign_on_services():
        if ep.binding == core.BINDING_HTTP_REDIRECT:
            return ep.location
    raise LookupError("IdP advertises no HTTP-Redirect SSO endpoint")

def idp_signing_cert(self) -> bytes:
    certs = self.idp.signing_certificates(role="idp")
    if not certs:
        raise LookupError("IdP metadata has no signing certificate")
    return certs[0]

Step 2 - build and send the AuthnRequest

Shape the request with AuthnRequestOptions, ask the IdP to reply over HTTP-POST, and encode the request for the HTTP-Redirect binding. Keep the returned request.id - it binds the response to this request in step 3.

def begin_login(self) -> tuple[str, str]:
    options = profiles.AuthnRequestOptions(
        sp_entity_id=self.entity_id,
        acs_url=self.acs_url,
        destination=self.sso_endpoint(),
        protocol_binding=core.BINDING_HTTP_POST,
        name_id_format=core.NAMEID_PERSISTENT,
    )
    request = profiles.create_authn_request(options)
    redirect_url = bindings.redirect_encode(
        request.to_xml().encode(), is_request=True,
        destination=self.sso_endpoint(),
    )
    return redirect_url, request.id        # redirect the browser; stash request.id

To sign the redirect (some IdPs set WantAuthnRequestsSigned), pass a SamlSigner and sig_alg to redirect_encode.

Step 3 - receive, verify, and validate the Response

This is where the security lives. The IdP POSTs the Response to the ACS. Decode it from the raw form pairs, then hand it to process_response_verified(), which verifies the XML-DSig over the exact bytes with the IdP’s signing cert and validates the result in one call.

def complete_login(self, form_pairs, expected_request_id):
    decoded = bindings.post_decode(form_pairs)   # list[(name, value)]
    verifier = crypto.SamlVerifier.from_cert(self.idp_signing_cert())
    result = profiles.process_response_verified(
        decoded.saml_text,
        verifier,
        security.SecurityConfig(),               # production-safe defaults
        sp_entity_id=self.entity_id,
        acs_url=self.acs_url,
        expected_idp_entity_id=self.idp.entity_id,
        expected_request_id=expected_request_id,
        replay_cache=self.replay_cache,
        persistent_id_store=self.id_store,
    )
    return result                                 # a profiles.AuthnResult

Three deliberate choices, each a profile rule:

  • Pass the raw form pairs, not a collapsed dict. post_decode rejects a plain mapping by default because a collapsed map can hide a second smuggled SAMLResponse. From Django that is [(k, v) for k, vs in request.POST.lists() for v in vs].

  • Use process_response_verified(), not process_response with a hand-passed verified_signed_ids - the former cannot be tricked into “trusting” an unverified response.

  • Supply a persistent_id_store. Because the request asked for a persistent NameID, validation requires a store so a persistent identifier cannot be silently re-bound to a different principal.

The persistent-ID store is the one piece of state you must implement. Any backend works; it fails closed, so a raised exception is treated as a conflict:

class InMemoryPersistentIdStore:
    def __init__(self):
        self._seen = {}                  # (name_id, sp) -> principal

    def check_and_record(self, name_id, sp_entity_id, principal) -> bool:
        key = (name_id, sp_entity_id)
        existing = self._seen.get(key)
        if existing is None:
            self._seen[key] = principal
            return True
        return existing == principal     # False => reassignment, rejected

In production back this with a database row carrying a unique constraint (see Writing a new SAML profile for the SQL version), so the binding survives restarts and multiple workers.

Step 4 - use the identity

AuthnResult is the clean output - no XML, no borrowing from the parsed document:

result = profile.complete_login(form_pairs, expected_request_id)
user_key = result.name_id                       # stable per-SP identifier
issuer = result.idp_entity_id
attrs = result.attributes_dict()                # {name: [values]}
# e.g. attrs["urn:oid:0.9.2342.19200300.100.1.3"] -> ["alice@example.org"]

Map the wire attribute names to friendly local names with pygamlastan.attribute_map if you prefer mail over the OID.

Adding a profile rule

The 32-check suite ran inside process_response_verified. To enforce a 33rd rule specific to your profile, inspect the typed AuthnResult you already hold - no need to re-parse. For example, require a step-up authentication context:

REQUIRED = core.AUTHN_CONTEXT_PASSWORD_PROTECTED_TRANSPORT
if result.authn_context_class_ref != REQUIRED:
    raise PermissionError("insufficient authentication context")

For policy that the validator already understands (tighter windows, encrypted assertions, signed responses), set the matching SecurityConfig field instead of writing a check - see Writing a new SAML profile (tier 2) and Validation and replay protection.

Where to go from here

  • The full, runnable version of this profile - with IdP discovery, MDQ resolution, and a Django ACS view - is examples/django-sp.

  • The IdP side of the same flow is in Identity Provider integration and examples/django-idp.

  • For profiles that need messages this binding cannot yet build (Single Logout initiation, ECP, artifact resolution), see tier 3 of Writing a new SAML profile.