API reference — Protocols

Nuvora.Nexus.Sentinel.Saml

SAML 2.0 for Sentinel, both sides: SP-initiated inbound SSO against external IdPs with hardened XML-DSig validation, and Sentinel-as-IdP issuing signed assertions to a registered SP registry with attribute mapping, metadata and redirect/POST bindings.

dotnet add package Nuvora.Nexus.Sentinel.Saml

Nuvora.Nexus.Sentinel.Saml

SamlAuthnRequest

public sealed record SamlAuthnRequest : IEquatable<SamlAuthnRequest>

A parsed (and shape-validated) samlp:AuthnRequest — only the fields the IdP flow consumes.

Constructors

SamlAuthnRequest(string Id, string Issuer, string? AssertionConsumerServiceUrl, string? Destination)

A parsed (and shape-validated) samlp:AuthnRequest — only the fields the IdP flow consumes.

Properties

string Id { get; init; }
string Issuer { get; init; }
string? AssertionConsumerServiceUrl { get; init; }
string? Destination { get; init; }

SamlConstants

public static class SamlConstants

The SAML 2.0 namespace URIs.

Fields

const string AssertionNamespace = "urn:oasis:names:tc:SAML:2.0:assertion"
const string BearerMethod = "urn:oasis:names:tc:SAML:2.0:cm:bearer"
const string MetadataNamespace = "urn:oasis:names:tc:SAML:2.0:metadata"
const string NameIdEmailFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
const string NameIdUnspecifiedFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
const string PostBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
const string ProtocolNamespace = "urn:oasis:names:tc:SAML:2.0:protocol"
const string RedirectBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
const string StatusSuccess = "urn:oasis:names:tc:SAML:2.0:status:Success"

SamlIdpService

public sealed class SamlIdpService

Sentinel as SAML IDENTITY PROVIDER: mints signed assertions for SPs in the realm’s registry. Assertions are signed with SigningKeyRing.Primary — the same RSA key that signs the realm’s JWTs, so key rotation covers both token formats at once. Only already-authenticated Sentinel principals reach SamlIdpService.IssueAsync: the SSO endpoint authenticates (or bounces to login) BEFORE calling here, and this class re-checks the user is active rather than trusting the caller.

Constructors

SamlIdpService(SigningKeyRing ring, SamlOptions options, ISentinelClock clock, ISentinelEventSink events)

Sentinel as SAML IDENTITY PROVIDER: mints signed assertions for SPs in the realm’s registry. Assertions are signed with SigningKeyRing.Primary — the same RSA key that signs the realm’s JWTs, so key rotation covers both token formats at once. Only already-authenticated Sentinel principals reach SamlIdpService.IssueAsync: the SSO endpoint authenticates (or bounces to login) BEFORE calling here, and this class re-checks the user is active rather than trusting the caller.

Properties

X509Certificate2 SigningCertificate { get; }

The certificate SPs pin: the realm’s primary signing key, wrapped. Served in IdP metadata.

Methods

ValueTask<SamlIssueResult?> IssueAsync(SamlSpConnection connection, User user, Guid? sessionId, string idpEntityId, string? inResponseTo, CancellationToken ct = default(CancellationToken))

Issues a signed Response for connection asserting user. inResponseTo is the AuthnRequest id for SP-initiated flows (null for IdP-initiated); sessionId becomes the SessionIndex. Returns null when the user cannot be asserted (inactive) — the endpoint collapses that into a generic denial.

Fields

const string AssertionIssuedEvent = "saml.assertion_issued"

Event kind emitted for every issued assertion (stable event identifier).

SamlIssueResult

public sealed record SamlIssueResult : IEquatable<SamlIssueResult>

An issued response: POST SamlIssueResult.SamlResponseBase64 to SamlIssueResult.AcsUrl via the auto-submit form.

Constructors

SamlIssueResult(string AcsUrl, string SamlResponseBase64)

An issued response: POST SamlIssueResult.SamlResponseBase64 to SamlIssueResult.AcsUrl via the auto-submit form.

Properties

string AcsUrl { get; init; }
string SamlResponseBase64 { get; init; }

SamlLoginBegin

public sealed record SamlLoginBegin : IEquatable<SamlLoginBegin>

A started SAML login: send the browser to SamlLoginBegin.RedirectUrl; SamlLoginBegin.RelayState comes back on the ACS POST.

Constructors

SamlLoginBegin(string RedirectUrl, string RelayState)

A started SAML login: send the browser to SamlLoginBegin.RedirectUrl; SamlLoginBegin.RelayState comes back on the ACS POST.

Properties

string RedirectUrl { get; init; }
string RelayState { get; init; }

SamlOptions

public sealed class SamlOptions

Knobs for both SAML surfaces. One small class, same posture as the other option types.

Properties

TimeSpan AssertionLifetime { get; set; }

Validity window of assertions Sentinel ISSUES: Conditions.NotOnOrAfter and the bearer Recipient window. Short — an assertion is a login redirect in flight.

TimeSpan ClockSkew { get; set; }

Tolerance applied to NotBefore/NotOnOrAfter when VALIDATING inbound assertions. Small on purpose: skew only exists to absorb clock drift between two servers, and every second of it extends an assertion’s usable life.

TimeSpan StateLifetime { get; set; }

How long a begin→ACS round-trip may take (the RelayState/pending-request lifetime). A login redirect, not a standing credential.

int MaxMessageBytes { get; set; }

Upper bound for any decoded SAML message (bytes). Caps the deflate bomb an attacker can mail through the redirect binding and bounds XML parsing work before any signature check.

string IdpInitiatedReturnUri { get; set; }

Where the USER lands after an IdP-initiated (unsolicited) login completes on the SP side — no begin call supplied a return URL, so this default applies.

string LoginPath { get; set; }

Where an unauthenticated browser hitting the IdP SSO endpoint is sent — the HOST app’s login page, same interaction contract as the OIDC authorize endpoint: the page finishes by sending the browser back to returnUrl (the SSO URL, always local).

string? IdpEntityId { get; set; }

OUR entity id as an IDENTITY PROVIDER: assertion Issuer and IdP metadata entityID. Null derives {scheme}://{host}{pathBase}{idpPrefix}/metadata from the live request.

string? SpEntityId { get; set; }

OUR entity id as a SERVICE PROVIDER, served in SP metadata. Null derives {scheme}://{host}{pathBase}{spPrefix}/metadata from the live request.

SamlProtocol

public static class SamlProtocol

The SAML 2.0 wire protocol, in-house (own-the-stack: no third-party protocol library): message construction and shape-validated parsing for AuthnRequest and Response, plus both transport bindings (HTTP-Redirect: DEFLATE+base64+urlencode; HTTP-POST: base64 form field) in both directions. Pure functions over SamlXml documents — no I/O, no crypto (signatures live in SamlSignatures).

Methods

static (XmlDocument Document, string ResponseId, string AssertionId) BuildResponse(SamlResponseContent content)

Builds a success Response with one bearer assertion, UNSIGNED — the caller signs the assertion via SamlSignatures.SignEnveloped before encoding, so signing stays concentrated in the one hardened file. Returns the document plus the ids needed for the signature reference.

static DateTimeOffset? TryParseInstant(string? value)

Parses a SAML instant; null (never a throw) on garbage — expiry math must fail closed, not 500.

static SamlAuthnRequest? TryParseAuthnRequest(XmlDocument document)

Parses an AuthnRequest, enforcing shape by exact local name + namespace at every step (the xsi:type / element-confusion guard: a look-alike element in another namespace never satisfies a lookup). Null on any violation — one generic denial for all malformed input.

static bool Matches(XmlElement element, string localName, string namespaceUri)

Exact local-name + namespace element check — the only element identity test used anywhere in the package.

static string BuildAuthnRequest(string id, string spEntityId, string destination, string acsUrl, DateTimeOffset issueInstant)

Builds the SP-side AuthnRequest: who we are (Issuer), where the answer goes (ACS), which endpoint this was minted for (Destination).

static string BuildPostBindingHtml(string actionUrl, string samlResponseBase64, string? relayState)

The POST-binding auto-submit page (SAML bindings §3.5.4): a self-submitting form carrying SAMLResponse (+ RelayState) to the ACS. All interpolated values are HTML-encoded; the ACS URL additionally rides through an attribute encoder so a config value can never break out of the action attribute.

static string EncodePostBinding(string xml)

POST-binding encode: plain base64 of the XML, carried in a form field.

static string EncodeRedirectBinding(string xml)

Redirect-binding encode: raw DEFLATE, then base64. The caller percent-encodes the result into the query string.

static string FormatInstant(DateTimeOffset instant)

SAML instants are xs:dateTime in UTC; round-trip “O” keeps them lexically stable for signatures.

static string NewMessageId()

Mints a schema-valid message id: NCName-safe (leading underscore — xs:ID must not start with a digit) over 128 random bits.

static string? TryDecodePostBinding(string value, int maxBytes)

POST-binding decode with the same size cap and null-on-failure posture as the redirect decode.

static string? TryDecodeRedirectBinding(string value, int maxBytes)

Redirect-binding decode with a hard output cap: DEFLATE amplifies ~1000:1, so a small query parameter could otherwise inflate into memory exhaustion before any signature or schema check runs. Null on any failure — one bucket for malformed base64, bad DEFLATE data and over-limit payloads.

SamlResponseContent

public sealed record SamlResponseContent : IEquatable<SamlResponseContent>

Everything Sentinel-as-IdP needs to mint one Response. Attribute values are pre-resolved by the caller.

Constructors

SamlResponseContent(string IdpEntityId, string AcsUrl, string? InResponseTo, string Audience, string NameId, string NameIdFormat, DateTimeOffset IssueInstant, DateTimeOffset NotBefore, DateTimeOffset NotOnOrAfter, string? SessionIndex, IReadOnlyList<KeyValuePair<string, string>> Attributes)

Everything Sentinel-as-IdP needs to mint one Response. Attribute values are pre-resolved by the caller.

Properties

DateTimeOffset IssueInstant { get; init; }
DateTimeOffset NotBefore { get; init; }
DateTimeOffset NotOnOrAfter { get; init; }
IReadOnlyList<KeyValuePair<string, string>> Attributes { get; init; }
string AcsUrl { get; init; }
string Audience { get; init; }
string IdpEntityId { get; init; }
string NameId { get; init; }
string NameIdFormat { get; init; }
string? InResponseTo { get; init; }
string? SessionIndex { get; init; }

SamlSignatureResult

public enum SamlSignatureResult

Outcome of verifying one enveloped signature. Only SamlSignatureResult.Valid means the element may be trusted.

Values

  • NotSigned — The element carries no ds:Signature child. Never sufficient on its own — the caller decides whether a covering signature exists elsewhere.
  • Invalid — Anything wrong at all: bad signature value, wrong key, disallowed algorithm/transform/reference shape, or a signature that verifies but covers a DIFFERENT element. One bucket on purpose.
  • Valid — The signature verifies against the pinned certificate AND provably covers exactly the element passed in.

SamlSignatures

public static class SamlSignatures

ALL SignedXml usage in Sentinel lives here (XML-DSig is a recognized CVE-prone surface — one file to review, fuzz and pentest). Verification hardening, in order:

  1. Keys come ONLY from the pinned per-connection certificate. The document’s KeyInfo is never consulted: KeyInfo is attacker-controlled, and trusting it is the classic certificate -substitution break (sign the forged assertion with your own key, embed your own cert).
  2. Algorithm allowlists: RSA-SHA256/384/512 signatures, SHA-256/384/512 digests. Rejects HMAC methods outright (an HMAC “signature” keyed with public certificate bytes is another classic bypass) and legacy SHA-1.
  3. Exactly ONE Reference, and its URI must be a same-document #id pointer. Empty URIs, external URIs and multi-reference signatures are rejected — nothing is ever fetched, and there is never ambiguity about what was signed.
  4. Transform allowlist: enveloped-signature + (exclusive) canonicalization only. XPath and XSLT transforms are rejected — they can silently EXCLUDE the very content being tampered with (and XSLT is code execution in some stacks).
  5. The signature-wrapping guard: after CheckSignature succeeds, the Reference URI is resolved to its element and that element must be REFERENCE-EQUAL to the element the caller is about to consume. A valid signature over assertion A never launders an injected assertion B, no matter where A was moved in the tree or how ids were duplicated.

Methods

static SamlSignatureResult VerifyEnveloped(XmlDocument document, XmlElement element, X509Certificate2 pinnedCertificate)

Verifies the enveloped signature that is a DIRECT child of element, against pinnedCertificate and nothing else. See the class remarks for the full hardening list; SamlSignatureResult.Valid additionally guarantees the signature covers exactly element.

static X509Certificate2 CreateSigningCertificate(RSA key, string subjectName, DateTimeOffset now)

Wraps an RSA key in a self-signed certificate, for metadata KeyDescriptors and KeyInfo. The certificate is pure key packaging (SAML metadata has no key format that is not a certificate) — trust still comes from pinning, so subject and lifetime carry no meaning.

static X509Certificate2? TryLoadCertificate(string pem)

Loads the pinned certificate from its PEM config value; null when unparseable (which the caller turns into a denial — a connection with a broken pin must fail closed).

static bool VerifyRedirectBinding(string rawQuery, X509Certificate2 pinnedCertificate)

Verifies the DETACHED signature of a redirect-binding message (SAML bindings §3.4.4.1): RSA-SHA256/384/512 over the RAW percent-encoded SAMLRequest=…[&RelayState=…]&SigAlg=… byte string, against the pinned SP certificate. The raw query is used verbatim — re-encoding would change the signed bytes.

static string SignRedirectBinding(string samlRequestEncoded, string? relayStateEncoded, RSA key)

Signs a redirect-binding query (SAMLRequest[&RelayState]) with RSA-SHA256, returning the full signed query string. Counterpart of SamlSignatures.VerifyRedirectBinding.

static void SignEnveloped(XmlDocument document, XmlElement element, string id, RSA key, X509Certificate2? certificate)

Signs element (which must carry ID="") with an enveloped RSA-SHA256 signature over exclusive-c14n, inserting ds:Signature directly after the Issuer child per the SAML schema. The certificate rides in KeyInfo for interop convenience only — conforming verifiers (ours included) pin the key out of band.

SamlSpService

public sealed class SamlSpService

Sentinel as SAML SERVICE PROVIDER: the SAML mirror of FederatedLoginService, reusing its seams end to end — the IChallengeStore/IFederationStateStore pair for the single-use RelayState round-trip, IFederatedIdentityStore for links/JIT writes, and PasskeyLoginCompleter to turn the externally-verified identity into a session + token pair. SessionMfaLevel.None is minted deliberately, same rationale as the OIDC federation path: whatever factors the external IdP enforced are invisible here.

Response validation order (each step a hard gate, every failure one generic denial): XXE-safe parse → shape (exact names/namespaces) → single-assertion rule → signature against the PINNED certificate with the wrapping guard (SamlSignatures) → issuer → destination → Conditions (NotBefore/NotOnOrAfter with small skew, AudienceRestriction must contain OUR SP entity id) → bearer SubjectConfirmation (Recipient/NotOnOrAfter/InResponseTo) → InResponseTo against the pending request id (unsolicited rejected unless the connection opts in) → assertion-id replay cache. Only then does account resolution run.

Constructors

SamlSpService(ISamlStore saml, IFederatedIdentityStore federated, IUserStore users, IChallengeStore challenges, IFederationStateStore states, PasskeyLoginCompleter completer, SamlOptions options, ISentinelClock clock, ISentinelEventSink events)

Sentinel as SAML SERVICE PROVIDER: the SAML mirror of FederatedLoginService, reusing its seams end to end — the IChallengeStore/IFederationStateStore pair for the single-use RelayState round-trip, IFederatedIdentityStore for links/JIT writes, and PasskeyLoginCompleter to turn the externally-verified identity into a session + token pair. SessionMfaLevel.None is minted deliberately, same rationale as the OIDC federation path: whatever factors the external IdP enforced are invisible here. Response validation order (each step a hard gate, every failure one generic denial): XXE-safe parse → shape (exact names/namespaces) → single-assertion rule → signature against the PINNED certificate with the wrapping guard (SamlSignatures) → issuer → destination → Conditions (NotBefore/NotOnOrAfter with small skew, AudienceRestriction must contain OUR SP entity id) → bearer SubjectConfirmation (Recipient/NotOnOrAfter/InResponseTo) → InResponseTo against the pending request id (unsolicited rejected unless the connection opts in) → assertion-id replay cache. Only then does account resolution run.

Methods

ValueTask<FederatedLoginResult> CompleteAsync(Guid realmId, string samlResponseBase64, string? relayState, string actualAcsUrl, string audience = "sentinel", string? ip = null, string? deviceDescription = null, CancellationToken ct = default(CancellationToken))

Completes a SAML login from the ACS post. actualAcsUrl is the URL this post was actually received on — it must equal the ACS the flow began with, and it is what Destination/Recipient are checked against.

ValueTask<SamlLoginBegin?> BeginAsync(Guid realmId, string idpKey, string acsUrl, string returnUri, CancellationToken ct = default(CancellationToken))

Starts a SAML login against the realm’s idpKey connection. acsUrl is OUR ACS endpoint (where the IdP posts the response); returnUri is where the USER lands afterwards. Returns null when the connection is unknown or suspended — one shape, so URL probing cannot map the registry.

Fields

const string IdentityLinkedEvent = "saml.identity_linked"

Event kind emitted when a verified match auto-links an existing user.

const string LoginDeniedEvent = "saml.login_denied"

Event kind emitted when an ACS post is denied (stable event identifier).

const string LoginSucceededEvent = "saml.login_success"

Event kind emitted on a successful SAML login (stable event identifier).

const string UserProvisionedEvent = "saml.user_provisioned"

Event kind emitted when JIT provisioning creates a user.

SamlXml

public static class SamlXml

XXE-safe XML plumbing (SAML’s CVE-prone surface): EVERY XmlDocument in the SAML packages is created or loaded through this class — one place to audit that DTD processing is disabled and no resolver exists.

Methods

static List<XmlElement> ChildElements(XmlElement parent, string localName, string namespaceUri)

Direct children only, matched on BOTH local name and namespace — never a name-only or descendant search, so an attacker cannot satisfy a lookup from a decoy branch of the tree (xsi:type / element-confusion guard).

static XmlDocument CreateDocument()

The one XmlDocument factory. XmlResolver = null so nothing can ever be fetched from the network or disk (XXE / SSRF via external entities or external DTDs), and PreserveWhitespace = true because XML-DSig digests are computed over the canonical bytes — letting the DOM “tidy” whitespace would break every signature round-trip.

static XmlDocument? TryLoadDocument(string xml, int maxBytes)

Parses untrusted XML, hard-failing on anything outside a plain element tree: DtdProcessing.Prohibit makes a DOCTYPE a parse error (XXE, billion-laughs entity expansion), XmlResolver = null forbids all external fetches even if a DTD slipped through, and MaxCharactersInDocument bounds parser work. Returns null on ANY failure — the caller treats every malformed document as one generic denial.

static XmlElement? ChildElement(XmlElement parent, string localName, string namespaceUri)

Single direct child by exact local name + namespace, or null (missing or ambiguous both yield null — fail closed).

static string? Attribute(XmlElement element, string name)

Attribute value, or null when absent/empty.

Nuvora.Nexus.Sentinel.Saml.DependencyInjection

SentinelSamlServiceCollectionExtensions

public static class SentinelSamlServiceCollectionExtensions

Registration for both SAML surfaces, following the Sentinel DI conventions (TryAdd, host wins).

Methods

static IServiceCollection AddSentinelSaml(this IServiceCollection services, Action<SamlOptions>? configure = null)

Registers SamlSpService, SamlIdpService and their collaborators: the in-memory challenge/state stores (hot state — shared with the OIDC federation surface when both are mounted), an in-memory ISamlStore default (acceptable: an empty registry denies every SAML flow — fail-closed), and the PasskeyLoginCompleter completion seam (same TryAdd as the other surfaces, so whichever lands first wins and all login flows share one completer). What is NOT defaulted: IFederatedIdentityStore — links and JIT users are identity data (silently running on an in-memory store would be a footgun): register a store adapter (AddSentinelEfFederationStores + AddSentinelEfSamlStore) or in-memory instances explicitly, BEFORE this call. Call alongside AddSentinel() + AddSentinelAuthentication(), then mount with MapSentinelSaml().

Nuvora.Nexus.Sentinel.Saml.Endpoints

SentinelSamlEndpoints

public static class SentinelSamlEndpoints

Both SAML endpoint groups, mountable and opt-in like the other groups. SP side: GET {sp}/{key}/start, POST {sp}/acs, GET {sp}/metadata. IdP side: GET|POST {idp}/sso, GET {idp}/metadata. Requires AddSentinelSaml() plus an IFederatedIdentityStore in DI.

Methods

static IEndpointRouteBuilder MapSentinelSaml(this IEndpointRouteBuilder endpoints, string spPrefix = "/auth/saml", string idpPrefix = "/saml/idp", string authPrefix = "/auth")