Testing SSO and SAML Flows: SP-Initiated, IdP-Initiated, and Assertion Validation
SAML SSO testing requires testing both SP-initiated and IdP-initiated flows, SAML assertion validation (signature, conditions, audience), and error handling for invalid assertions. Most teams only test the happy path through the browser UI. This guide covers automated testing of SAML assertion handling, IdP simulation for integration tests, and the security checks your SP must perform on every assertion.
Key Takeaways
SAML assertion validation is security-critical — test every check. Your SP must verify: assertion signature, issuer matches configured IdP, audience restriction matches your entity ID, NotBefore and NotOnOrAfter time conditions, and that the SubjectConfirmation bears your AuthnRequest ID.
Use a mock IdP for integration tests. Tools like SimpleSAMLphp, saml-idp (Node.js), or python3-saml's test utilities let you generate valid SAML assertions without a real IdP, giving you reproducible, isolated integration tests.
Test IdP-initiated login separately. IdP-initiated login skips the SP's AuthnRequest, so some checks (InResponseTo, RelayState) don't apply. This is a separate code path that needs its own tests.
Test assertion replay prevention. A valid SAML assertion reused after it was already consumed should be rejected. Your SP should track used assertion IDs.
Test XML signature wrapping attacks. A attacker can wrap an invalid signature around a valid one. Test that your SP verifies the signature on the specific assertion element, not just that a signature exists in the document.
SAML Flow Overview
SAML 2.0 has two main flows:
SP-Initiated (most common):
- User visits your app (SP — Service Provider)
- SP generates an AuthnRequest, redirects user to IdP
- User authenticates at IdP
- IdP sends SAML Response (with assertions) to SP's ACS URL
- SP validates assertion, creates session
IdP-Initiated:
- User logs in at IdP dashboard, clicks your app
- IdP sends unsolicited SAML Response to SP's ACS URL
- SP validates assertion, creates session (no InResponseTo to verify)
Your test strategy covers both flows and all validation steps.
Mock IdP Setup
Option 1: python3-saml test utilities (Python)
pip install python3-saml lxml# mock_idp.py
from onelogin.saml2.utils import OneLogin_Saml2_Utils
from onelogin.saml2.response import OneLogin_Saml2_Response
import base64
import gzip
from lxml import etree
from datetime import datetime, timedelta
import uuid
MOCK_IDP_CERT = """-----BEGIN CERTIFICATE-----
MIIBkTCB+wIJAJVbGRHPrT+fMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMTBnRl
c3RJZDAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMBExDzANBgNVBAMT
BnRlc3RJZDA...
-----END CERTIFICATE-----"""
MOCK_IDP_PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY-----
... your test private key ...
-----END RSA PRIVATE KEY-----"""
def build_saml_response(
sp_entity_id: str,
acs_url: str,
name_id: str,
attributes: dict = None,
authn_request_id: str = None,
valid: bool = True,
) -> str:
"""Build a SAML response for testing."""
now = datetime.utcnow()
not_on_or_after = (now + timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ")
not_before = (now - timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ")
assertion_id = f"_{uuid.uuid4().hex}"
response_id = f"_{uuid.uuid4().hex}"
attributes_xml = ""
if attributes:
attr_statements = "".join([
f"""<saml:Attribute Name="{key}">
<saml:AttributeValue>{value}</saml:AttributeValue>
</saml:Attribute>"""
for key, value in attributes.items()
])
attributes_xml = f"<saml:AttributeStatement>{attr_statements}</saml:AttributeStatement>"
in_response_to = f'InResponseTo="{authn_request_id}"' if authn_request_id else ""
saml_response = f"""<?xml version="1.0" encoding="UTF-8"?>
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="{response_id}"
Version="2.0"
IssueInstant="{now.strftime('%Y-%m-%dT%H:%M:%SZ')}"
Destination="{acs_url}"
{in_response_to}>
<saml:Issuer>https://mock-idp.example.com</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</samlp:Status>
<saml:Assertion ID="{assertion_id}" Version="2.0"
IssueInstant="{now.strftime('%Y-%m-%dT%H:%M:%SZ')}">
<saml:Issuer>https://mock-idp.example.com</saml:Issuer>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
{name_id}
</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData NotOnOrAfter="{not_on_or_after}"
Recipient="{acs_url}"
{in_response_to}/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="{not_before}" NotOnOrAfter="{not_on_or_after}">
<saml:AudienceRestriction>
<saml:Audience>{sp_entity_id}</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
{attributes_xml}
</saml:Assertion>
</samlp:Response>"""
# Sign the assertion
signed = OneLogin_Saml2_Utils.add_sign(
saml_response,
MOCK_IDP_PRIVATE_KEY,
MOCK_IDP_CERT,
)
return base64.b64encode(signed).decode()Option 2: saml-idp (Node.js mock IdP)
npm install -g saml-idp
saml-idp --port 7000 --acsUrl http://localhost:3000/auth/saml/callback \
--audience https://your-app.com/metadataThis runs a full mock IdP at http://localhost:7000 that you can redirect to in tests.
Testing the ACS (Assertion Consumer Service) Endpoint
# test_saml_acs.py
import pytest
import base64
SP_ENTITY_ID = "https://your-app.com"
ACS_URL = "http://localhost:5000/auth/saml/callback"
def test_valid_saml_response_creates_session(client):
"""Valid SAML response should create authenticated session."""
saml_response_b64 = build_saml_response(
sp_entity_id=SP_ENTITY_ID,
acs_url=ACS_URL,
name_id="user@example.com",
attributes={"email": "user@example.com", "displayName": "Test User"},
)
response = client.post(
"/auth/saml/callback",
data={"SAMLResponse": saml_response_b64, "RelayState": "/dashboard"},
)
assert response.status_code == 302
assert response.headers["Location"] == "/dashboard"
# Verify session was created
with client.session_transaction() as sess:
assert "user_id" in sess or "email" in sess
def test_expired_assertion_rejected(client):
"""Assertions with NotOnOrAfter in the past must be rejected."""
from datetime import datetime, timedelta
# Build response with expired time conditions
saml_response_b64 = build_saml_response_with_custom_times(
not_on_or_after=(datetime.utcnow() - timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ"),
not_before=(datetime.utcnow() - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ"),
)
response = client.post(
"/auth/saml/callback",
data={"SAMLResponse": saml_response_b64},
)
assert response.status_code in (400, 403)
def test_wrong_audience_rejected(client):
"""Assertions for a different SP entity ID must be rejected."""
saml_response_b64 = build_saml_response(
sp_entity_id="https://different-sp.com", # Wrong SP
acs_url=ACS_URL,
name_id="user@example.com",
)
response = client.post(
"/auth/saml/callback",
data={"SAMLResponse": saml_response_b64},
)
assert response.status_code in (400, 403)
def test_wrong_destination_rejected(client):
"""Assertions with wrong Destination ACS URL must be rejected."""
saml_response_b64 = build_saml_response(
sp_entity_id=SP_ENTITY_ID,
acs_url="https://attacker.com/callback", # Wrong destination
name_id="user@example.com",
)
response = client.post(
"/auth/saml/callback",
data={"SAMLResponse": saml_response_b64},
)
assert response.status_code in (400, 403)
def test_unsigned_assertion_rejected(client):
"""Unsigned SAML assertions must be rejected."""
# Build an unsigned response
saml_response_b64 = build_unsigned_saml_response(
sp_entity_id=SP_ENTITY_ID,
acs_url=ACS_URL,
name_id="attacker@evil.com",
)
response = client.post(
"/auth/saml/callback",
data={"SAMLResponse": saml_response_b64},
)
assert response.status_code in (400, 403), "Unsigned assertion was accepted!"Testing Assertion Replay Prevention
def test_assertion_replay_prevented(client):
"""Same assertion must not be accepted twice."""
saml_response_b64 = build_saml_response(
sp_entity_id=SP_ENTITY_ID,
acs_url=ACS_URL,
name_id="user@example.com",
)
# First use — should succeed
response1 = client.post(
"/auth/saml/callback",
data={"SAMLResponse": saml_response_b64},
)
assert response1.status_code == 302
# Second use — should fail (assertion replay)
response2 = client.post(
"/auth/saml/callback",
data={"SAMLResponse": saml_response_b64},
)
assert response2.status_code in (400, 403), \
"Assertion replay allowed — security vulnerability!"E2E Testing with Playwright
For full SP-initiated flow testing with a live mock IdP:
def test_sp_initiated_sso_flow(page: Page):
# Navigate to app — should redirect to IdP for unauthenticated users
page.goto("https://your-app.com/dashboard")
# Should redirect to IdP login
page.wait_for_url("**/auth/sso**") # Or your IdP URL
# Complete login at mock IdP
# (Specific selectors depend on your mock IdP UI)
page.fill("#username", "testuser@example.com")
page.fill("#password", "testpassword")
page.click("#login-submit")
# Should return to app dashboard
page.wait_for_url("**/dashboard**")
assert page.locator("[data-testid='user-greeting']").is_visible()
def test_idp_initiated_sso(page: Page):
# Navigate directly to IdP with app reference
page.goto(
f"http://mock-idp.local/sso?entityId={SP_ENTITY_ID}"
f"&returnTo=https://your-app.com/auth/saml/callback"
)
page.fill("#username", "testuser@example.com")
page.fill("#password", "testpassword")
page.click("#submit")
# IdP posts assertion to ACS URL
page.wait_for_url("https://your-app.com/**")
assert "/dashboard" in page.url or "/home" in page.urlTesting SAML Metadata
Your SP publishes a metadata XML that the IdP uses to configure the connection. Test that it's valid:
def test_sp_metadata_is_valid(client):
response = client.get("/auth/saml/metadata")
assert response.status_code == 200
assert response.content_type == "application/xml" or "text/xml" in response.content_type
# Parse and verify required metadata elements
from lxml import etree
root = etree.fromstring(response.data)
ns = {"md": "urn:oasis:names:tc:SAML:2.0:metadata"}
# Entity ID must be present
assert root.get("entityID") == SP_ENTITY_ID
# ACS URL must be in metadata
acs_elements = root.findall(".//md:AssertionConsumerService", ns)
assert len(acs_elements) > 0
acs_url = acs_elements[0].get("Location")
assert acs_url == ACS_URL
# SP certificate should be present for signed AuthnRequests
cert_elements = root.findall(".//md:KeyDescriptor[@use='signing']", ns)
# (Optional based on your configuration)Testing Single Logout (SLO)
def test_slo_request_invalidates_session(client):
"""SAML Single Logout must invalidate the SP session."""
# Login first
login(client, "user@example.com")
# Verify session works
assert client.get("/api/profile").status_code == 200
# Simulate IdP sending SLO request
slo_request_b64 = build_logout_request(
name_id="user@example.com",
session_index="session123",
)
response = client.get(
f"/auth/saml/slo?SAMLRequest={slo_request_b64}",
)
# Session should be invalidated
assert client.get("/api/profile").status_code in (401, 403)SAML Security Checklist
| Test | Validates |
|---|---|
| Valid assertion → session created | Happy path |
| Expired NotOnOrAfter | Time condition enforcement |
| Future NotBefore | Time condition enforcement |
| Wrong audience | SP entity ID validation |
| Wrong destination | ACS URL validation |
| Unsigned assertion | Signature requirement |
| Wrong IdP (issuer) | Issuer validation |
| Assertion replay | Replay prevention |
| InResponseTo mismatch | Binding to AuthnRequest |
| XML signature wrapping | Correct signature scope |
| SLO invalidates session | Logout federation |
Summary
SAML testing is mostly about what you reject, not what you accept. The security properties are in the assertion validation: time conditions, audience restriction, destination, signature, issuer, and replay prevention. Use a mock IdP to generate test assertions with specific properties, and test each validation check in isolation. The replay prevention test is the one most commonly missing — add it explicitly, as it guards against a real attack vector.