Skip to content

Repository files navigation

Stateless SAML 2.0 IdP Prototype

A minimal, educational SAML 2.0 Identity Provider built with Python/Flask. Authenticates users from a CSV file, maintains sessions via signed JWT cookies (fully stateless), and produces signed SAML assertions.

⚠️ Educational prototype — not for production.

This code is written to be readable, not bulletproof. It runs on plain HTTP, has no rate limiting on login, does not validate AssertionConsumerServiceURL against a registered whitelist, ships SCIM without auth by default, and uses a CSV-backed user store with no concurrency control. Use it to learn SAML, then throw it away. See the Security limitations section before doing anything non-trivial with it.

Licensed under the MIT License. See LICENSE.

Requirements

  • Python 3.11+
  • uvcurl -LsSf https://astral.sh/uv/install.sh | sh

Quick Start

# One-shot setup (installs deps into .venv via uv, generates certs, adds sample users)
make setup

# Configure environment
cp .env.example .env
# Edit .env and set JWT_SECRET to a random value

# Run the IdP
make run

The IdP starts on http://localhost:5001.

Available Make targets

make help         # Show all targets
make install      # Install dependencies (uv sync)
make certs        # Generate self-signed certs
make certs-force  # Regenerate certs
make users        # Add sample users (alice, bob)
make run          # Run the IdP server
make test         # Run the pytest test suite
make clean        # Remove certs and users.csv
make clean-all    # Also remove .venv

Endpoints

Endpoint Method Description
/metadata GET SAML metadata XML
/sso GET/POST SSO endpoint (HTTP-Redirect & HTTP-POST bindings)
/sso/login POST Login form handler
/slo GET/POST Single Logout
/logout GET Local logout (clears session cookie)
/scim/v2/Users/<email> DELETE SCIM user deletion (bearer auth)
/scim/v2/Users/<email> PATCH SCIM user deactivation (bearer auth)

SCIM endpoints require Authorization: Bearer <SCIM_BEARER_TOKEN> when that env var is set. If unset, SCIM is unauthenticated and the server prints a warning at startup.

Running the tests

make test

The tests/ directory is written as executable documentation. Test names describe SAML properties and attack scenarios, test bodies read like spec vignettes. Notable cases:

  • test_signed_assertion_verifies_with_matching_cert — happy path.
  • test_tampered_attribute_fails_verification — detect MITM edits.
  • test_wrong_cert_fails_verification — reject rogue IdP.
  • test_sp_side_check_would_reject_expired_timestamp — validity window.
  • test_sp_side_audience_check — reject misrouted assertions.
  • test_deactivated_user_cannot_login — SCIM deprovisioning teeth.
  • test_password_is_stored_as_bcrypt_hash — no plaintext on disk.

Read them top-to-bottom for a guided tour of SAML security properties.

Minimal SP verifier

examples/sp_verifier.py is a small, annotated script that performs the seven checks a real Service Provider must do before honoring a SAML Response. Point it at a captured Response to see each step printed:

python examples/sp_verifier.py "$SAML_RESPONSE_B64" \
    --cert certs/idp_cert.pem \
    --expected-audience http://sp.example.com \
    --expected-acs https://sp.example.com/acs \
    --expected-request-id _my_request_id

The script exits non-zero on any failed check and prints exactly which one. Try tampering with the base64 body to see the signature check catch it (see ## Attack examples below).

Attack examples

The canonical way to internalize a security protocol is to break it deliberately. Each attack below assumes a captured valid SAMLResponse (base64) — see docs/transcript.md for how to produce one with curl.

Assume in your shell:

SAML="<paste captured base64 SAMLResponse>"
EXPECTED_REQ=_sp_verifier_demo   # ID from the AuthnRequest that produced it

1. Replay to a different SP (wrong audience)

An assertion issued for sp.example.com is intercepted and presented to evil-sp.example.com. The signature is still valid (bytes are unchanged), so the attacker hopes the target SP will accept it.

The AudienceRestriction check stops this:

python examples/sp_verifier.py "$SAML" \
    --cert certs/idp_cert.pem \
    --expected-audience http://evil-sp.example.com \
    --expected-acs https://sp.example.com/acs \
    --expected-request-id $EXPECTED_REQ
# ✗ VERIFICATION FAILED: our audience 'http://evil-sp.example.com'
#   not in ['http://sp.example.com']

Why it works in defense: the IdP binds the assertion to a specific SP via <Audience>. The SP's job is to require its own entityID to be listed — skipping this check is a well-known class of CVE.

2. Replay onto a different AuthnRequest (wrong InResponseTo)

An assertion is intercepted and replayed into an SSO flow the victim didn't initiate. Both the <Response> and the <SubjectConfirmationData> carry InResponseTo. The SP holds a pending-request cache and rejects if the values don't match a live entry.

python examples/sp_verifier.py "$SAML" \
    --cert certs/idp_cert.pem \
    --expected-audience http://sp.example.com \
    --expected-acs https://sp.example.com/acs \
    --expected-request-id _some_other_request
# ✗ VERIFICATION FAILED: Response InResponseTo '_sp_verifier_demo'
#   != expected '_some_other_request'

Why it matters: without this, a captured assertion could be replayed indefinitely. Pairing InResponseTo with short NotOnOrAfter windows and a one-shot pending-request cache closes the replay window.

3. Tampered assertion (privilege escalation attempt)

An attacker edits the plaintext Assertion to elevate privileges — changing Alice Smith to Admin Attacker, or adding a roles=superuser entry. Any byte change invalidates the XML signature because the <ds:DigestValue> no longer matches.

# Replace 'Alice Smith' with 'Evil Attack1' (same length) in raw XML
python -c "
import base64, sys
b = base64.b64decode(sys.argv[1])
b2 = b.replace(b'Alice Smith', b'Evil Attack1')
print(base64.b64encode(b2).decode())
" "$SAML" > /tmp/tampered.txt
TAMPERED=$(cat /tmp/tampered.txt)

python examples/sp_verifier.py "$TAMPERED" \
    --cert certs/idp_cert.pem \
    --expected-audience http://sp.example.com \
    --expected-acs https://sp.example.com/acs \
    --expected-request-id $EXPECTED_REQ
# ✗ VERIFICATION FAILED: signature verification failed:
#   Digest mismatch for reference 0 (#_xxx)

Why it works in defense: XMLDSig signs a canonicalized digest of the Assertion subtree. Flipping any byte (including attribute names, whitespace inside elements, or namespace declarations) changes the digest; the signature math fails.

A more sophisticated class of attacks, XML Signature Wrapping (XSW), tries to smuggle an attacker-controlled <Assertion> past the signature check by placing it in a location the parser picks up but the verifier doesn't. Defending against XSW requires strict "verify and extract the SAME element" discipline — see saml/assertion.py for notes. Our sp_verifier.py uses signxml's signed_xml return value, which is the only trusted bytes, as defense against XSW.

4. Replay after expiry (stale assertion)

Even without edits, an assertion captured hours ago should not be accepted. The Conditions.NotOnOrAfter window (default 5 minutes here) bounds the attacker's window. Replay after expiry:

# Wait 6+ minutes with a captured response, then re-run sp_verifier.py.
# ✗ VERIFICATION FAILED: assertion expired: now=..., NotOnOrAfter=...

Short windows are the simplest replay defense. Combined with the pending-request cache from attack #2, a single assertion is strictly one-shot and short-lived.

What these don't cover

Real-world SAML has a few more edges worth noting:

  • AuthnRequest spoofing: by default, anyone can send an AuthnRequest claiming to be any SP. See the next section for how to turn on signed AuthnRequest validation in this prototype.
  • Metadata poisoning: if you fetch IdP metadata over plain HTTP, an attacker can swap the signing cert. Production federations serve metadata signed by the federation operator and verified out-of-band.
  • Encrypted assertions: for confidentiality from an intermediary, the IdP can encrypt the Assertion to the SP's public key. Not done here (most modern deployments rely on TLS end-to-end instead).

Signed AuthnRequest validation (opt-in)

By default this IdP trusts any incoming AuthnRequest — good for experimentation, terrible for production. To flip the trust model so only pre-registered SPs can initiate SSO, and each must sign its request:

  1. Register the SP in sp_registry.json:

    {
      "service_providers": [
        {
          "entity_id": "http://sp.example.com",
          "cert_path": "certs/sp_cert.pem"
        }
      ]
    }
  2. Enable the flag:

    REQUIRE_SIGNED_AUTHN_REQUESTS=true python app.py
  3. Metadata now advertises WantAuthnRequestsSigned="true", informing SPs they must sign.

Two very different signature mechanisms apply depending on the binding the SP uses (see saml/authn_request_verify.py for detailed spec-anchored comments):

  • HTTP-Redirect: signature is over the raw URL query string octets, appended as &Signature=<base64>. See SAML Bindings §3.4.4.1.
  • HTTP-POST: signature is an enveloped <ds:Signature> inside the AuthnRequest XML (same mechanism as our Response signature).

Trust boundary: unregistered issuers are rejected outright — the IdP has no way to know whose cert to trust otherwise. This is the SP's analog of the SP-side cert pinning discussed in the attack examples.

Testing with samltest.id

  1. Start the IdP: python app.py
  2. Make metadata accessible (use ngrok or similar for public URL)
  3. Upload metadata at https://samltest.id/upload.php
  4. Start IdP test at https://samltest.id/start-idp-test/
  5. Login with sample credentials
  6. Verify assertion attributes are displayed

SAML Flow

SP-initiated Web SSO with HTTP-Redirect binding (request) and HTTP-POST binding (response). This is the SAML Web Browser SSO Profile (SAML Profiles §4.1).

sequenceDiagram
    autonumber
    actor U as User (browser)
    participant SP as Service Provider
    participant I as IdP (this app)

    U->>SP: 1. GET /protected
    SP-->>U: 2. 302 Redirect to IdP<br/>?SAMLRequest=base64(deflate(AuthnRequest))<br/>&RelayState=/protected

    U->>I: 3. GET /sso?SAMLRequest=...&RelayState=...
    Note over I: parse_authn_request()<br/>base64 decode + zlib raw-deflate<br/>→ {id, acs_url, issuer}

    alt No JWT cookie
        I-->>U: 4. 200 login.html
        U->>I: 5. POST /sso/login (email, password, SAMLRequest, RelayState)
        Note over I: validate_credentials() — bcrypt.checkpw
    else Has valid JWT cookie
        Note over I: get_current_user() — decode JWT
    end

    Note over I: build_saml_assertion()<br/>Issuer → Signature(placeholder) → Subject<br/>→ Conditions → AuthnStatement → AttributeStatement
    Note over I: sign_assertion()<br/>XMLSigner(enveloped, RSA-SHA256, Exclusive C14N)<br/>replaces placeholder with real ds:Signature
    Note over I: build_saml_response()<br/>samlp:Response wraps signed Assertion,<br/>adds Status(Success), base64-encode

    I-->>U: 6. 200 auto_post.html (Set-Cookie: idp_session=JWT)<br/>form action=ACS, hidden SAMLResponse + RelayState
    Note over U: onload="document.forms[0].submit()"

    U->>SP: 7. POST ACS (SAMLResponse, RelayState)
    Note over SP: base64 decode → verify ds:Signature<br/>check Audience, NotBefore/NotOnOrAfter,<br/>InResponseTo, Recipient, Destination
    SP-->>U: 8. 302 Redirect to RelayState (/protected)
    U->>SP: 9. GET /protected (authenticated)
    SP-->>U: 10. 200 protected content
Loading

Key observations:

  • Steps 1-2: SP bounces the user to us. The SAMLRequest is deflate-compressed to fit in URL length limits.
  • Step 3: We parse the AuthnRequest to learn the SP's identity (Issuer), where to send the response (AssertionConsumerServiceURL), and the request ID to echo back (InResponseTo).
  • Steps 4-5: Login only happens if the user has no valid IdP session cookie. After login, subsequent SSO flows to other SPs are transparent.
  • Step 6: The IdP never talks to the SP directly. All SAML traffic is carried by the user's browser — "via front channel". This is what lets SAML work across firewalls.
  • Step 7: The auto-POST form uses <body onload="document.forms[0].submit()"> so the user experiences a single redirect. With JavaScript disabled, they see a "Continue" button (noscript fallback).

SCIM API Examples

SCIM endpoints require Authorization: Bearer $SCIM_BEARER_TOKEN when the env var is set. If unset, the header is not checked (dev only — the server prints a warning at boot).

export TOKEN=$SCIM_BEARER_TOKEN  # whatever you set in .env

Delete a user

curl -X DELETE http://localhost:5001/scim/v2/Users/bob@example.com \
  -H "Authorization: Bearer $TOKEN"
# Returns: 204 No Content

Deactivate a user

curl -X PATCH http://localhost:5001/scim/v2/Users/bob@example.com \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
    "Operations": [{"op": "replace", "path": "active", "value": false}]
  }'

Webhooks

Configure webhook URLs in webhooks.json:

[
  {
    "url": "http://localhost:9000/webhook",
    "events": ["user.deleted", "user.deactivated"]
  }
]

Events fire on SCIM operations with a JSON payload:

{
  "event": "user.deleted",
  "timestamp": "2025-01-15T10:30:00+00:00",
  "user": { "email": "bob@example.com", "display_name": "Bob Jones", "roles": ["user"], "active": true }
}

Architecture

  • Stateless sessions: JWT cookies — no server-side session store
  • User store: CSV file with bcrypt-hashed passwords
  • SAML signing: RSA-SHA256 enveloped signatures via signxml
  • Certificates: Auto-generated self-signed RSA 2048 (10-year validity)

Security limitations

Read these before exposing this service to anything but localhost.

Transport & session

  • No HTTPS out of the box. Credentials and assertions travel in plaintext. Terminate TLS in front of this service (nginx, Caddy, cloud LB) and set IDP_SSO_URL=https://… so COOKIE_SECURE auto-enables.
  • JWT_SECRET auto-generated if env var unset. Every restart invalidates all live sessions. Set a persistent value in .env.

Trust boundaries

  • AssertionConsumerServiceURL is trusted blindly. A malicious SP could direct the auto-POST form to any URL. A real IdP validates this against a pre-registered list from SP metadata.
  • WantAuthnRequestsSigned=false by default. Anyone can initiate SSO on behalf of any SP. Flip REQUIRE_SIGNED_AUTHN_REQUESTS=true and register SPs in sp_registry.json to close this.
  • Metadata served over HTTP. An attacker could swap the cert. Real federations serve signed metadata over HTTPS and pin it out-of-band.
  • Single signing cert for the whole IdP. No key rotation support.

Login surface

  • No rate limiting / lockout on /sso/login. Brute-forcing is possible.
  • No CSRF token on the login form. Lower-impact than on an authenticated app, but still worth adding.
  • Usernames are enumeration-safe (login returns the same error for unknown user and wrong password) — that part is fine.

SCIM & user store

  • SCIM unauthenticated by default. Set SCIM_BEARER_TOKEN for any network-reachable deployment.
  • CSV-backed store with no concurrency control. Two simultaneous writes can lose data. Use a real database.
  • No audit log of logins, deactivations, or webhook dispatches.

Protocol edge cases we skip

  • No XML Schema validation on inbound AuthnRequests.
  • No XML Signature Wrapping (XSW) defense beyond "trust only the element signxml returns as signed_xml".
  • No encrypted assertions (relies on transport TLS for confidentiality).
  • No replay cache — InResponseTo uniqueness and NotOnOrAfter windows are your only defenses.
  • Single Logout is a minimal cookie-clear, not true federated SLO.

See the Attack examples section for hands-on demos of what the SP-side checks catch and what they don't.

About

Educational SAML 2.0 Identity Provider prototype (Python/Flask)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages