SAML
SAML 2.0 (Security Assertion Markup Language) support.
The module implements both roles:
- Service Provider (SP) - accepts logins from external IdPs.
- Identity Provider (IdP) - issues SAML assertions.
Warning
The SAML module is not implemented in jam.Jam / jam.aio.Jam. The reason is listed here: makridenko.ru
Supported features:
- HTTP-POST, HTTP-Redirect and HTTP-Artifact bindings.
- XML-DSig signatures (RSA-SHA256) with embedded certificates.
- Assertion encryption (AES-256-GCM + RSA-OAEP) via
EncryptedAssertion. - Single Logout (SLO).
- Attribute Query.
- NameID Management.
- Metadata generation and parsing.
- Replay protection and clock skew tolerance.
Works with zero extra dependencies (xml.etree + cryptography).
Setup¶
Module: jam.saml.SAML
There is no Jam instance integration yet, so the module is used directly
or through the jam.saml.create_instance factory.
Args:
role:str = "sp"-"sp"(Service Provider) or"idp"(Identity Provider).private_key:str | None- PEM string or path to the private key file (used for signing and decryption).public_key:str | None- PEM string or path to the public key / certificate (used for verification).certificate:str | None- PEM certificate string (included in metadata and signatures).entity_id:str | None- Entity ID of this party.acs_url:str | None- Assertion Consumer Service URL (SP role).sso_url:str | None- Single Sign-On URL (IdP role).idp_public_key:str | None- IdP public key for signature verification (SP role).sp_public_key:str | None- SP public key for signature verification (IdP role).encryption_key:str | None- SP public key for assertion encryption (IdP role).default_exp:int = 300- Default assertion lifetime in seconds.allowed_clock_skew:int = 120- Clock skew tolerance in seconds.want_assertions_signed:bool = True- Require signed assertions (SP role).id_store:dict | None- Dict for replay protection. Auto-created ifNone.replay_ttl:int = 300- Seconds before a consumed ID is eligible for cleanup.
from jam.saml import SAML
idp = SAML(
role="idp",
private_key="path/to/idp_private_key.pem",
certificate="path/to/idp_cert.pem",
entity_id="https://idp.example.com",
sso_url="https://idp.example.com/sso",
)
sp = SAML(
role="sp",
private_key="path/to/sp_private_key.pem",
entity_id="https://sp.example.com",
acs_url="https://sp.example.com/acs",
idp_public_key="path/to/idp_cert.pem",
)
Service Provider¶
Init¶
Module: jam.saml.SAML with role="sp".
from jam.saml import SAML
sp = SAML(
role="sp",
entity_id="https://sp.example.com",
acs_url="https://sp.example.com/acs",
idp_public_key="path/to/idp_cert.pem",
)
Prepare AuthnRequest¶
Method: sp.prepare_authn_request
Args:
idp_sso_url:str- IdP single sign-on endpoint URL.acs_url:str | None- SP assertion consumer service URL (defaults to the instanceacs_url).binding:str = "redirect"-"redirect"or"post".**kwargs-relay_state,issuer,force_authn, etc.
Returns:
str - Redirect: IdP URL with a signed AuthnRequest. POST: Base64-encoded SAMLRequest for an HTML form.
Redirect binding:
url = sp.prepare_authn_request(
idp_sso_url="https://idp.example.com/sso",
acs_url="https://sp.example.com/acs",
binding="redirect",
)
print(url)
>>> https://idp.example.com/sso?SAMLRequest=...&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256&Signature=...
POST binding (embed the result in an HTML form field named SAMLRequest):
saml_request = sp.prepare_authn_request(
idp_sso_url="https://idp.example.com/sso",
acs_url="https://sp.example.com/acs",
binding="post",
)
Parse response¶
Method: sp.parse_response
Args:
saml_response:str- Raw SAMLResponse data (Base64 for POST, query-string for Redirect).binding:str = "post"-"post"or"redirect".**kwargs:audience:str- Expected audience (SP entity ID).issuer:str- Expected issuer (IdP entity ID).acs_url:str- Expected Recipient (ACS URL).verify_signature:bool- Overridewant_assertions_signed.
Returns:
SAMLResponse - Parsed response with the assertion data.
from jam.saml.binding import encode_post
saml_response = sp.parse_response(
encoded_response,
binding="post",
audience="https://sp.example.com",
issuer="https://idp.example.com",
)
print(saml_response.status_code)
>>> urn:oasis:names:tc:SAML:2.0:status:Success
print(saml_response.assertion.subject.name_id)
>>> user@example.com
print(saml_response.assertion.attributes)
>>> {"email": "user@example.com", "role": "admin"}
Encrypted assertions are decrypted automatically when the SP has a private_key:
sp = SAML(
role="sp",
private_key="path/to/sp_private_key.pem",
idp_public_key="path/to/idp_cert.pem",
)
result = sp.parse_response(encoded_response, binding="post")
Identity Provider¶
Init¶
Module: jam.saml.SAML with role="idp".
from jam.saml import SAML
idp = SAML(
role="idp",
private_key="path/to/idp_private_key.pem",
certificate="path/to/idp_cert.pem",
entity_id="https://idp.example.com",
sso_url="https://idp.example.com/sso",
sp_public_key="path/to/sp_cert.pem",
)
Parse AuthnRequest¶
Method: idp.parse_authn_request
Args:
saml_request:str- Raw SAMLRequest data.binding:str = "redirect"-"redirect"or"post".**kwargs-issuer(expected issuer for validation).
Returns:
SAMLRequest - Parsed request.
request = idp.parse_authn_request(
query_string,
binding="redirect",
issuer="https://sp.example.com",
)
print(request.id)
>>> _abc123...
print(request.acs_url)
>>> https://sp.example.com/acs
Build response¶
Method: idp.build_response
Args:
subject:str- The authenticated user identifier.attributes:dict[str, Any]- User attributes (email, roles, etc.).issuer:str- IdP entity ID.audience:str- SP entity ID (the intended audience).**kwargs:in_response_to:str- AuthnRequest ID.name_id_format:str- NameID format.session_index:str- Session index.destination:str- ACS URL (defaults to the instanceacs_url).encrypt:bool = False- Encrypt the assertion withencryption_key.
Returns:
str - Signed (and optionally encrypted) SAML Response XML.
xml_str = idp.build_response(
subject="user@example.com",
attributes={"email": "user@example.com", "role": "admin"},
issuer="https://idp.example.com",
audience="https://sp.example.com",
in_response_to=request.id,
)
Encrypt the assertion (the SP must have the matching private_key):
idp = SAML(
role="idp",
private_key="path/to/idp_private_key.pem",
encryption_key="path/to/sp_cert.pem",
)
xml_str = idp.build_response(
subject="user@example.com",
attributes={"email": "user@example.com"},
issuer="https://idp.example.com",
audience="https://sp.example.com",
encrypt=True,
)
Single Logout¶
SLO is supported in both directions. Both build_* and parse_* methods
accept binding="post" or binding="redirect".
Build LogoutRequest¶
Method: saml.build_logout_request
Args:
name_id:str- User identifier to log out.issuer:str- Entity ID of the sender.destination:str- SLO endpoint of the recipient.session_index:str | None- Session index (optional).**kwargs-binding,relay_state,name_id_format.
Returns:
str - POST: Base64-encoded signed XML. Redirect: signed redirect URL.
result = sp.build_logout_request(
name_id="user@example.com",
issuer="https://sp.example.com",
destination="https://idp.example.com/slo",
session_index="_session_abc",
binding="post",
)
Parse LogoutRequest¶
Method: saml.parse_logout_request
Args:
saml_request:str- Raw LogoutRequest data.binding:str = "redirect"-"redirect"or"post".**kwargs-issuer(expected issuer).
Returns:
SAMLLogoutRequest - Parsed request.
logout_request = idp.parse_logout_request(
encoded,
binding="post",
issuer="https://sp.example.com",
)
print(logout_request.name_id)
>>> user@example.com
print(logout_request.session_index)
>>> _session_abc
Build LogoutResponse¶
Method: saml.build_logout_response
Args:
in_response_to:str- LogoutRequest ID to respond to.issuer:str- Entity ID of the sender.destination:str- SLO endpoint of the recipient.status_code:str = "urn:oasis:names:tc:SAML:2.0:status:Success"- SAML status code.**kwargs-binding,relay_state.
Returns:
str - POST: Base64-encoded signed XML. Redirect: signed redirect URL.
result = idp.build_logout_response(
in_response_to=logout_request.id,
issuer="https://idp.example.com",
destination="https://sp.example.com/slo",
binding="post",
)
Parse LogoutResponse¶
Method: saml.parse_logout_response
Args:
saml_response:str- Raw LogoutResponse data.binding:str = "redirect"-"redirect"or"post".**kwargs-issuer(expected issuer).
Returns:
SAMLLogoutResponse - Parsed response.
logout_response = sp.parse_logout_response(
encoded,
binding="post",
issuer="https://idp.example.com",
)
print(logout_response.status_code)
>>> urn:oasis:names:tc:SAML:2.0:status:Success
Attribute Query¶
Lets an SP request specific user attributes from the IdP.
Build AttributeQuery¶
Method: sp.build_attribute_query
Args:
subject:str- Subject to query attributes for.issuer:str- SP entity ID.destination:str- IdP attribute query endpoint URL.attribute_names:list[str] | None- Specific attributes to request (None= all).binding:str = "post"-"post"or"redirect".
Returns:
str - POST: Base64-encoded signed XML. Redirect: signed redirect URL.
encoded = sp.build_attribute_query(
subject="user@example.com",
issuer="https://sp.example.com",
destination="https://idp.example.com/attr",
attribute_names=["email", "role"],
binding="post",
)
Parse AttributeQuery¶
Method: idp.parse_attribute_query
Args:
saml_request:str- Raw AttributeQuery data.binding:str = "redirect"-"redirect"or"post".**kwargs-issuer(expected issuer).
Returns:
SAMLAttributeQuery - Parsed query.
query = idp.parse_attribute_query(encoded, binding="post")
print(query.subject)
>>> user@example.com
print(query.attribute_names)
>>> ["email", "role"]
Build AttributeQueryResponse¶
Method: idp.build_attribute_query_response
Args:
in_response_to:str- AttributeQuery ID.subject:str- The subject.attributes:dict[str, Any]- User attributes.issuer:str- IdP entity ID.audience:str- SP entity ID.
Returns:
str - Signed SAML Response XML.
xml_str = idp.build_attribute_query_response(
in_response_to=query.id,
subject="user@example.com",
attributes={"email": "user@example.com", "role": "admin"},
issuer="https://idp.example.com",
audience="https://sp.example.com",
)
Parse AttributeQueryResponse¶
Method: sp.parse_attribute_query_response
Same signature and return type as sp.parse_response.
result = sp.parse_attribute_query_response(
encoded,
binding="post",
audience="https://sp.example.com",
issuer="https://idp.example.com",
)
print(result.assertion.attributes)
>>> {"email": "user@example.com", "role": "admin"}
Artifact Binding¶
The SP receives an artifact instead of the actual message, then resolves it through a direct SOAP back-channel request to the IdP.
Create an artifact¶
Method: saml.build_artifact
Args:
source_message_id:str- ID of the referenced message.issuer:str- Entity ID of the issuer.
Returns:
str - Base64-encoded artifact.
artifact = idp.build_artifact(
source_message_id=response_id,
issuer="https://idp.example.com",
)
print(artifact)
>>> AQAAEAAA...
Build ArtifactResolve¶
Method: sp.build_artifact_resolve
Args:
artifact:str- The artifact to resolve.issuer:str- SP entity ID.destination:str- IdP artifact resolution service URL.**kwargs-binding("post","redirect", or"soap").
Returns:
str - POST: Base64. Redirect: signed URL. SOAP: raw XML.
resolve_xml = sp.build_artifact_resolve(
artifact=artifact,
issuer="https://sp.example.com",
destination="https://idp.example.com/artifact",
binding="soap",
)
Parse ArtifactResolve¶
Method: idp.parse_artifact_resolve
Args:
saml_request:str- Raw ArtifactResolve data.binding:str = "post"-"post","redirect", or"soap".**kwargs-issuer(expected issuer).
Returns:
SAMLArtifactResolve - Parsed resolve request.
resolve = idp.parse_artifact_resolve(soap_envelope, binding="soap")
print(resolve.artifact)
>>> AQAAEAAA...
Build ArtifactResponse¶
Method: idp.build_artifact_response
Args:
in_response_to:str- ArtifactResolve ID.original_message_xml:str- The original SAML message XML to embed.issuer:str- IdP entity ID.destination:str- SP endpoint URL.**kwargs-binding,status_code.
Returns:
str - POST: Base64. Redirect: signed URL. SOAP: raw XML.
response_xml = idp.build_artifact_response(
in_response_to=resolve.id,
original_message_xml=original_response_xml,
issuer="https://idp.example.com",
destination="https://sp.example.com/acs",
binding="soap",
)
Parse ArtifactResponse¶
Method: sp.parse_artifact_response
Args:
saml_response:str- Raw ArtifactResponse data.binding:str = "post"-"post","redirect", or"soap".**kwargs-issuer(expected issuer).
Returns:
SAMLArtifactResponse - Parsed response with the embedded original_message.
artifact_response = sp.parse_artifact_response(
soap_response,
binding="soap",
)
print(artifact_response.original_message)
>>> <samlp:Response ...>
Resolve artifact over HTTP¶
Method: sp.resolve_artifact
Sends the ArtifactResolve as a SOAP request to the IdP's artifact resolution service and returns the original message.
Args:
artifact:str- The artifact to resolve.issuer:str- SP entity ID.resolve_url:str- IdP artifact resolution service URL.**kwargs-timeout(int, default 10),expected_issuer(IdP entity ID for validation).
Returns:
str - The original SAML message XML from the ArtifactResponse.
original_message = sp.resolve_artifact(
artifact=artifact,
issuer="https://sp.example.com",
resolve_url="https://idp.example.com/artifact",
timeout=15,
)
NameID Management¶
Change or terminate a NameID with the other party.
Build ManageNameIDRequest¶
Method: saml.build_manage_name_id_request
Args:
name_id:str- Current NameID.issuer:str- Entity ID of the requester.destination:str- Recipient endpoint URL.new_id:str | None- New identifier (None= terminate).binding:str = "post"-"post"or"redirect".
Returns:
str - POST: Base64-encoded signed XML. Redirect: signed redirect URL.
Change the identifier:
encoded = sp.build_manage_name_id_request(
name_id="user@example.com",
new_id="newuser@example.com",
issuer="https://sp.example.com",
destination="https://idp.example.com/nameid",
binding="post",
)
Terminate the identifier (no new_id):
encoded = sp.build_manage_name_id_request(
name_id="user@example.com",
issuer="https://sp.example.com",
destination="https://idp.example.com/nameid",
binding="post",
)
Parse ManageNameIDRequest¶
Method: idp.parse_manage_name_id_request
Args:
saml_request:str- Raw ManageNameIDRequest data.binding:str = "redirect"-"redirect"or"post".**kwargs-issuer(expected issuer).
Returns:
SAMLManageNameIDRequest - Parsed request.
request = idp.parse_manage_name_id_request(encoded, binding="post")
print(request.name_id)
>>> user@example.com
print(request.new_id)
>>> newuser@example.com
Build ManageNameIDResponse¶
Method: saml.build_manage_name_id_response
Args:
in_response_to:str- ManageNameIDRequest ID.issuer:str- Entity ID of the responder.destination:str- Endpoint URL of the requester.status_code:str- SAML status code (default Success).**kwargs-binding,relay_state.
Returns:
str - POST: Base64-encoded signed XML. Redirect: signed redirect URL.
encoded = idp.build_manage_name_id_response(
in_response_to=request.id,
issuer="https://idp.example.com",
destination="https://sp.example.com/nameid",
binding="post",
)
Parse ManageNameIDResponse¶
Method: sp.parse_manage_name_id_response
Args:
saml_response:str- Raw ManageNameIDResponse data.binding:str = "redirect"-"redirect"or"post".**kwargs-issuer(expected issuer).
Returns:
SAMLManageNameIDResponse - Parsed response.
response = sp.parse_manage_name_id_response(encoded, binding="post")
print(response.status_code)
>>> urn:oasis:names:tc:SAML:2.0:status:Success
Metadata¶
Generate metadata¶
Method: saml.generate_metadata
Args:
entity_id:str | None- Entity ID (defaults to the instanceentity_id).sso_url:str | None- SSO URL (IdP metadata).acs_url:str | None- ACS URL (SP metadata).role:str | None-"idp"or"sp"(defaults to the instance role).
Returns:
str - SAML metadata XML.
idp_metadata = idp.generate_metadata(
entity_id="https://idp.example.com",
sso_url="https://idp.example.com/sso",
)
sp_metadata = sp.generate_metadata(
entity_id="https://sp.example.com",
acs_url="https://sp.example.com/acs",
)
Parse metadata¶
Method: saml.parse_metadata
Args:
metadata_xml:str- Raw metadata XML string.
Returns:
SAMLMetadata - Parsed metadata.
metadata = sp.parse_metadata(idp_metadata)
print(metadata.entity_id)
>>> https://idp.example.com
print(metadata.sso_url)
>>> https://idp.example.com/sso
Security¶
Signature verification¶
Assertions and protocol messages are signed with RSA-SHA256 and an embedded
certificate. The SP verifies assertions using the idp_public_key; the IdP
verifies requests using sp_public_key. When no key is configured, the
certificate embedded in the signature KeyInfo is used.
Set want_assertions_signed=False on the SP to skip assertion signature
verification:
sp = SAML(
role="sp",
want_assertions_signed=False,
)
Clock skew¶
Expired or not-yet-valid assertions are rejected. The allowed_clock_skew
(default 120 seconds) tolerates small clock differences between the parties.
Replay protection¶
Every incoming message ID is checked against the id_store before being
consumed, which raises JamSAMLReplayDetected on duplicate IDs. Pass your own
dict to share state between instances or processes:
sp = SAML(
role="sp",
id_store=shared_store,
)
XXE protection¶
Incoming XML is parsed with safe_fromstring, which rejects DTD/entity
declarations, preventing XXE and entity-expansion attacks.
Exceptions¶
| Exception | Description |
|---|---|
JamSAMLError |
Base SAML error. |
JamSAMLExpired |
The assertion has expired. |
JamSAMLNotYetValid |
The assertion is not yet valid. |
JamSAMLInvalidAudience |
Audience validation failed. |
JamSAMLInvalidIssuer |
Issuer validation failed. |
JamSAMLInvalidRecipient |
SubjectConfirmationData Recipient does not match the expected ACS URL. |
JamSAMLReplayDetected |
The message ID has already been consumed (replay attack). |
JamSAMLSOAPError |
SOAP / artifact resolution failed. |
JamSAMLValidationError |
Generic signature or XML validation failure. |
JamSAMLEmptyPrivateKey |
Signing requires a private_key. |
JamSAMLEmptyPublicKey |
Verification requires a public_key. |
JamSAMLUnsupportedAlgorithm |
Unsupported SAML algorithm. |