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:
resolve the chosen Identity Provider’s metadata,
build and send an
AuthnRequest,receive the
Responseat the Assertion Consumer Service (ACS), verify it, and validate it,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_decoderejects a plain mapping by default because a collapsed map can hide a second smuggledSAMLResponse. From Django that is[(k, v) for k, vs in request.POST.lists() for v in vs].Use
process_response_verified(), notprocess_responsewith a hand-passedverified_signed_ids- the former cannot be tricked into “trusting” an unverified response.Supply a
persistent_id_store. Because the request asked for a persistentNameID, 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.