SAML Federation Local Test Mode
Context
Prism supports SAML 2.0 as a federation profile for enterprise identity providers (RFC-064). During local development and testing, developers need to validate the complete SAML assertion handling pipeline without requiring an actual SAML IdP (Okta, Azure AD, OneLogin).
Current Problems:
- Production SAML IdPs require network access, entity ID registration, and metadata exchange
- SAML assertion validation involves XML signature verification, audience restriction, replay detection, and attribute mapping
- Testing SAML-to-Prism subject resolution requires realistic assertion data
- Edge cases (expired assertions, wrong audience, replay attacks) are difficult to reproduce against real IdPs
- Developers without enterprise IdP access cannot test SAML federation flows
Requirements:
- Local SAML assertion generation without a running IdP
- Full assertion validation pipeline (audience, expiry, replay, recipient)
- Canonical subject resolution matching production behavior
- Configurable attributes, groups, and NameID formats
- Zero external dependencies (no XML Signature, no metadata endpoint)
Decision
We implement a programmatic SAML assertion model for local testing, where test code constructs SamlAssertion structs directly and validates them through the same SamlValidator used in production.
Why not a real SAML IdP?
- SAML IdPs are heavy (Keycloak requires Java, Shibboleth is complex to configure)
- SAML metadata exchange and XML Signature add configuration burden for local dev
- The Prism proxy does not parse raw SAML XML itself -- it receives pre-processed assertions from the federation layer
- Testing validation logic does not require real XML or signatures; it requires realistic assertion data structures
Why not mock the validator?
- Mocking bypasses the actual validation logic (audience checks, expiry, replay detection)
- Production bugs in validation would not be caught by mocked tests
- The
SamlValidatoris pure logic with no external I/O -- it is fast and deterministic
Architecture
Production Flow:
External IdP → SAML Response (XML) → Proxy Federation Layer → SamlAssertion → SamlValidator → CanonicalSubject
Local Test Flow:
Test Code → SamlAssertion (struct) → SamlValidator → CanonicalSubject
Test Code → SamlResponse (JSON) → parse_from_json() → SamlAssertion → SamlValidator → CanonicalSubject
The local test mode omits only the XML parsing and signature verification layers. All other validation (audience, expiry, replay, recipient, NameID) runs exactly as in production.
Assertion Model
The SamlAssertion struct captures all fields relevant to Prism authorization:
| Field | Purpose |
|---|---|
id | Unique assertion ID (replay detection) |
issuer | IdP entity ID |
subject_name_id | Subject identifier |
subject_format | NameID format (email, persistent, etc.) |
audience | Audience restriction values |
recipient | ACS URL recipient |
not_before | Validity window start |
not_on_or_after | Validity window end |
attributes | SAML attributes (email, groups, displayName) |
authn_context | Authentication context class |
session_index | IdP session reference |
Validator Configuration
SamlConfig {
idp_slug: "corp-okta", // Slug for subject prefix
metadata_url: "https://corp.okta.com/metadata", // Unused locally
audience: "prism-data-layer", // Must match assertion
recipient: Some("https://prism.example.com/acs"), // Must match assertion
attribute_mapping: {"email": "email", "groups": "groups"},
max_clock_skew_secs: 300, // 5-minute tolerance
max_assertion_age_secs: 3600, // 1-hour replay window
}
Canonical Subject Resolution
SAML assertions resolve to issuer-scoped subjects per RFC-064:
saml:<idp-slug>|<name-id>
Examples:
saml:corp-okta|alice@example.com
saml:vendor-idp|cn=bob,ou=engineering,dc=corp
saml:azure-ad|a94d4c2e-8f3b-4d12-a7e6-1b5c3d8f9a02
Different IdPs with the same NameID produce different Prism subjects. This prevents accidental identity merging across providers.
Test Patterns
Pattern 1: Direct Assertion Construction
let assertion = SamlAssertion {
id: format!("assertion-{}", Uuid::new_v4()),
issuer: "https://corp.okta.com".to_string(),
subject_name_id: "alice@example.com".to_string(),
audience: vec!["prism-data-layer".to_string()],
not_on_or_after: now_secs() + 300,
..Default::default()
};
let subject = validator.validate(&assertion)?;
Pattern 2: JSON Response Parsing
let json = r#"{"assertion": {"id": "...", ...}}"#;
let response = SamlResponse::parse_from_json(json)?;
let assertion = response.to_assertion();
let subject = validator.validate(&assertion)?;
Pattern 3: Session Bridging
let subject = validator.validate(&assertion)?;
let mut auth_ctx = AuthContext::unauthenticated(namespace);
auth_ctx.subject = subject.to_subject_string();
auth_ctx.subject_type = "user".to_string();
auth_ctx.permission = determine_permission(&path);
auth_ctx.issuer = Some(assertion.issuer.clone());
let headers = auth_ctx.to_headers();
Implementation
Module Location
prism-proxy/src/federation/
| File | Responsibility |
|---|---|
mod.rs | Module exports |
provider.rs | FederationProvider model, provider config, namespace policy |
subject.rs | CanonicalSubject resolution (OIDC, SAML, service) |
saml.rs | SamlAssertion model, SamlValidator, SamlConfig, SamlResponse |
Test Location
prism-proxy/tests/saml_federation_test.rs
15 integration tests covering:
- Full SAML federation flow (assertion validation to canonical subject)
- Session bridging (assertion to AuthContext to proxy headers)
- Expiry rejection
- Audience mismatch rejection
- Empty NameID rejection
- Replay detection
- Multiple valid assertions in sequence
- Recipient mismatch rejection
- Clock skew tolerance
- Cross-provider identity isolation
- JSON assertion parsing
- Attribute extraction
- Federation provider model (OIDC vs SAML)
- Namespace federation policy
- Canonical subject issuer scoping
Environment Variables
# SAML federation is configured per-provider in federation config, not via env vars.
# For local testing, test code constructs SamlConfig directly.
# No DEX_ISSUER equivalent needed for SAML local testing.
Future: Real SAML IdP for Integration Testing
When end-to-end SAML testing is needed (testing XML parsing, metadata exchange, SP-initiated flow), the recommended approach is:
- Dex with SAML connector: Dex supports SAML connectors and is already in the local-dev stack
- Testcontainers Keycloak: For more complete SAML testing, use Keycloak in a testcontainers-managed container
This is deferred to RFC-064 Phase 3 production hardening.
Consequences
Positive
- Fast test execution: no network, no XML parsing, no IdP startup
- Full validation coverage: audience, expiry, replay, recipient, NameID checks all execute
- Deterministic: no flaky IdP interactions
- Zero configuration: test code constructs assertions inline
- Production parity: same
SamlValidatorcode path as production - Cross-provider isolation testing: multiple IdP slugs tested simultaneously
Negative
- XML parsing and signature verification are not tested locally
- SAML metadata exchange is not tested
- SP-initiated and IdP-initiated SSO flows are not testable in this mode
Neutral
- This mode validates Prism's SAML assertion handling, not the SAML protocol itself
- Production SAML deployments require XML parsing, metadata, and signature verification in the federation layer before assertions reach the validator
References
- RFC-064: Federation Profile for Namespace-Aware Identity
- RFC-063: Normative Proxy Auth Contract and Namespace Security Model
- ADR-046: Dex IDP for Local Identity Testing
- ADR-007: Authentication and Authorization
- SAML 2.0 Core Specification
Revision History
- 2026-04-15: Initial ADR for SAML local test mode