Testing SSO and SAML Flows: SP-Initiated, IdP-Initiated, and Assertion Validation

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):

  1. User visits your app (SP — Service Provider)
  2. SP generates an AuthnRequest, redirects user to IdP
  3. User authenticates at IdP
  4. IdP sends SAML Response (with assertions) to SP's ACS URL
  5. SP validates assertion, creates session

IdP-Initiated:

  1. User logs in at IdP dashboard, clicks your app
  2. IdP sends unsolicited SAML Response to SP's ACS URL
  3. 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/metadata

This 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.url

Testing 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.

Read more