Articles · Enterprise SSO & Federation
SAML in both directions
Try out the running example
SAML has two chairs at the table: the service provider that consumes assertions (“let our employees SSO into your product”) and the identity provider that issues them (“let this legacy vendor app SSO against us”). Sentinel sits in both, from one package and one registration call — and the cleanest way to see the whole protocol is to put both chairs in one process and let it authenticate against itself. That loopback is not a party trick; it’s the technique Sentinel’s own SAML acceptance tests use, and it’s what the companion sample runs.
One call, both roles
// file: SamlLoopComposition.cs
services.AddSentinelSaml(o =>
{
o.SpEntityId = SpEntityId;
o.IdpEntityId = IdpEntityId;
o.LoginPath = "/login"; // where the IdP SSO endpoint bounces an anonymous browser
});
AddSentinelSaml registers SamlSpService and SamlIdpService side by side. What it
deliberately does not default is IFederatedIdentityStore — links and JIT-created
users are identity data, and Sentinel never invents an identity store behind your back —
so the host registers it (and ISamlStore) before the call, same TryAdd choreography
as every other Sentinel port.
MapSentinelSaml() then mounts five endpoints:
| Endpoint | Role | Purpose |
|---|---|---|
GET /auth/saml/{key}/start |
SP | begin SP-initiated login → 302 to the IdP |
POST /auth/saml/acs |
SP | consume the assertion, mint the session |
GET /auth/saml/metadata |
SP | SPSSODescriptor, WantAssertionsSigned="true" |
GET/POST /saml/idp/sso |
IdP | consume the AuthnRequest, issue the assertion |
GET /saml/idp/metadata |
IdP | IDPSSODescriptor + the signing certificate |
Configuration is per-connection, and the cert is the point
Host-level SamlOptions carry protocol plumbing (state lifetime, clock skew, message
size cap). Trust lives on two record types in the store:
SamlIdpConnection(SP side — “authenticate against THIS IdP”): entity id, SSO URL, andIdpCertificatePem— the pin. Assertion signatures verify against this certificate and nothing else. Never against certificates embedded in the document.SamlSpConnection(IdP side — “issue assertions to THIS SP”): entity id (requests are routed by AuthnRequest issuer),AcsUrl(assertions go here and only here), optionalSpCertificatePem+RequireSignedRequests, andAttributeMappings.
In the loop, both records live in one realm and point at each other on the same origin. The pin comes from the IdP’s live signing key — Sentinel wraps the realm’s JWT signing key in a self-signed certificate on demand — so no external PKI is involved:
// file: SamlLoopComposition.cs — after the host is built
using (var scope = services.CreateScope())
{
certificatePem = scope.ServiceProvider.GetRequiredService<SamlIdpService>()
.SigningCertificate.ExportCertificatePem();
}
One operational sharp edge: endpoint URLs (AcsUrl, IdpSsoUrl, Destination) are
compared ordinally against the live request URL. A trailing slash or a different port
breaks the loop with acs_mismatch — which is the correct paranoia, but means the seeded
URLs must match the launch origin exactly (the sample pins http://localhost:5010).
The round trip
browser ── GET /auth/saml/self/start?redirect_uri=/welcome
◀─ 302 to /saml/idp/sso?SAMLRequest=… (redirect binding, deflated)
── GET /saml/idp/sso (unauthenticated? 302 /login?returnUrl=… — host-owned page)
◀─ 200 auto-submit form: action=/auth/saml/acs, SAMLResponse + RelayState
── POST /auth/saml/acs
◀─ 302 /welcome + sentinel_at / sentinel_rt / sentinel_csrf cookies
Sentinel ships no hosted pages — the /login page is the host’s, exactly like the OIDC
interaction contract in article 006. The sample’s
tests drive this headlessly with a cookie jar and form scraping;
Sp_initiated_round_trip_signs_the_browser_in asserts the redirect, the cookies, the
saml.assertion_issued and saml.login_success events, and the identity link that
appears.
Verification: the pin decides, in order
The ACS runs a fixed gauntlet before any user resolution: XXE-hardened parsing (DOCTYPE is an instant rejection), response shape (exactly one assertion), status, issuer, destination, then the signature — where the discipline concentrates:
- Signature algorithms allowlisted (RSA-SHA256/384/512 — no SHA-1, no HMAC).
- Exactly one same-document reference; external and empty URIs rejected.
- Transforms allowlisted (enveloped + c14n — no XPath, no XSLT).
CheckSignature(pinnedCertificate, verifySignatureOnly: true)— the document’s KeyInfo is never consulted, and no chain is built, because pinning is the trust decision.- The signed element must be reference-equal to the assertion being consumed — the signature-wrapping guard (the wrapping recipe dissects the attack).
Then conditions (NotBefore/NotOnOrAfter with bounded skew), audience restriction,
bearer confirmation, recipient, InResponseTo, and single-use replay state.
On the wire, every one of those failures is the same 401 saml_failed — a hostile
relying party learns nothing about which gate refused. The distinction goes to the
audit stream as saml.login_denied with a reason. The sample pins three of them:
| Test | Reason recorded |
|---|---|
A_tampered_assertion_fails_the_pinned_signature_check |
signature_invalid |
An_authentic_assertion_fails_when_the_pin_no_longer_matches |
signature_invalid |
A_replayed_response_is_rejected_the_second_time |
state_invalid |
The second one is the pin’s whole argument in one test: the assertion is genuinely signed by the real IdP key, the correct certificate is right there in the document’s KeyInfo — and it still fails, because the stored pin says otherwise.
From assertion to session
Account resolution is a three-step ladder, in order: an existing link for this
(connection, NameID) pair; a verified-email match (attribute email, then mail,
then an @-shaped NameID — normalized, and trusted because the pinned IdP asserted it);
then JIT creation, but only when the connection opted into
JitMode = JitProvisioningMode.CreateUsers, with mapping rules for orgs, roles and
attributes. No match and no JIT → no_linked_identity, and nobody gets in on the
strength of an unlinkable assertion.
The session minted at the end is an ordinary Sentinel session — same cookies, same
refresh family from article 003 — recorded at
SessionMfaLevel.None, deliberately: whatever factors the external IdP enforced are
invisible from here, and claiming MFA you didn’t witness would poison every downstream
policy decision.
Metadata closes the loop operationally: the SP document advertises the ACS, the IdP
document publishes the signing certificate — trust exchanged out of band, never taken
from a message. Both_metadata_documents_are_served_and_the_idp_publishes_the_pinned_cert
asserts the published certificate is byte-for-byte the pinned one. The
enterprise capabilities page covers the surface in reference form;
SCIM is the other half of the enterprise-directory story.