SAML 2.0 SP

Authagonal includes a homebrew SAML 2.0 Service Provider implementation. No third-party SAML library: built on System.Security.Cryptography.Xml.SignedXml (part of .NET).

Scope

Not Supported

IdP-initiated SSO is supported. The ACS endpoint handles responses without InResponseTo (the request-ID check is skipped for unsolicited responses, but assertion-ID single-use is still enforced, see Security).

Azure AD Setup

1. Create a SAML Provider

Option A: Configuration (recommended for static setups)

Add to appsettings.json:

{
  "SamlProviders": [
    {
      "ConnectionId": "acme-azure",
      "ConnectionName": "Acme Corp Azure AD",
      "EntityId": "https://auth.example.com/saml/acme-azure",
      "MetadataLocation": "https://login.microsoftonline.com/{tenant-id}/federationmetadata/2007-06/federationmetadata.xml?appid={app-id}",
      "AllowedDomains": ["acme.com"]
    }
  ]
}

Providers are seeded on startup. SSO domain mappings are registered automatically from AllowedDomains. Config-seeded providers require a MetadataLocation URL and do not get an SP keypair (so no signed AuthnRequests, encrypted assertions, or signed logout messages); use the Admin API for those features.

EntityId is your SP entity ID (the identifier you register at the IdP), not the IdP’s entity ID.

Option B: Admin API (for runtime management)

curl -X POST https://auth.example.com/api/v1/saml/connections \
  -H "Authorization: Bearer {admin-token}" \
  -H "Content-Type: application/json" \
  -d '{
    "connectionName": "Acme Corp Azure AD",
    "entityId": "https://auth.example.com/saml/acme-azure",
    "metadataLocation": "https://login.microsoftonline.com/{tenant-id}/federationmetadata/2007-06/federationmetadata.xml?appid={app-id}",
    "allowedDomains": ["acme.com"]
  }'

The API generates the connectionId (a GUID) and returns it in the Location header and response body. Additional optional fields: metadataXml (pasted metadata, see below), nameIdFormat (see below), signAuthnRequests (force signed AuthnRequests), iconUrl (login-button icon), disableJitProvisioning (reject unknown users instead of auto-creating them). API-created connections also get an auto-generated SP keypair (see SP Keypair below).

Connections are managed via POST / GET / PUT / DELETE on /api/v1/saml/connections[/{connectionId}]. PUT is a partial update: only fields supplied on the wire are modified.

2. Configure Azure AD

  1. In Azure AD → Enterprise Applications → New Application → Create your own
  2. Set up Single Sign-On → SAML
  3. Identifier (Entity ID): https://auth.example.com/saml/acme-azure
  4. Reply URL (ACS): https://auth.example.com/saml/acme-azure/acs
  5. Sign on URL: https://auth.example.com/saml/acme-azure/login

3. SSO Domain Routing

When AllowedDomains is specified (in config or via the create API), SSO domain mappings are registered automatically. When a user enters user@acme.com on the login page, the SPA detects SSO is required and shows “Continue with SSO”. A domain can only be mapped to one connection; the API rejects a domain already claimed by a different connection.

You can also manage domains at runtime via the Admin API; see Admin API.

Pasted Metadata XML

Some IdPs publish no metadata URL (Google Workspace), or their metadata endpoint is unreachable from the SP (private-network ADFS). For those, paste the metadata document instead: supply metadataXml on create/update. Exactly one of metadataLocation or metadataXml must be provided; supplying one on update clears the other.

Pasted metadata is validated at save time and condensed (SamlMetadataParser.Condense) to a canonical minimal EntityDescriptor holding exactly what the SP consumes: entityID, signing certificates, the SSO endpoint, the SLO endpoint if present, and the WantAuthnRequestsSigned flag. Vendor documents can exceed 100KB (ADFS FederationMetadata.xml), past the 64KB Azure Table property cap, while the parts the SP uses are a few KB. Unparseable pastes are rejected with a 400; the document must contain an IDPSSODescriptor with a signing certificate and a SingleSignOnService.

NameID Format

The nameIdFormat field controls the NameIDPolicy Format requested in the AuthnRequest:

Value Behavior
omitted / null urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress (the historic default)
"none" Omit the NameIDPolicy element entirely. The ADFS-safe setting: ADFS fails the whole login (MSIS7070) when its claim rules don’t emit the requested format.
any other value Sent verbatim as the Format URN (must start with urn:)

On update, "" resets to the emailAddress default. The SP metadata advertises the connection’s requested format (and omits NameIDFormat when set to "none").

Endpoints

Endpoint Description
GET /saml/{connectionId}/login?returnUrl=...&loginHint=... Initiates SP-initiated SSO. Builds an AuthnRequest (signed when applicable) and redirects to the IdP. loginHint is passed as login_hint for IdPs that honor it (Entra, Google).
POST /saml/{connectionId}/acs Assertion Consumer Service. Receives the SAML Response, validates it, creates/signs in the user.
GET /saml/{connectionId}/metadata SP metadata XML for configuring the IdP.
GET /saml/{connectionId}/logout?returnUrl=... SP-initiated Single Logout. Ends the local session, then sends a LogoutRequest to the IdP when it supports SLO.
GET/POST /saml/{connectionId}/slo Single Logout endpoint. Receives IdP-initiated LogoutRequests (Redirect or POST binding) and the LogoutResponse leg of SP-initiated SLO.

The post-login return URL is carried server-side on the stored AuthnRequest (keyed by request ID), not in RelayState: the SAML spec caps RelayState at 80 bytes and some IdPs truncate it. RelayState is only consulted for IdP-initiated flows.

SP Keypair & Encrypted Assertions

Every API-created connection gets an auto-generated SP keypair: a self-signed 2048-bit RSA certificate (10-year validity), stored as PKCS#12 and protected at rest by the host’s secret provider. It is server-only and never returned by the API. The keypair enables:

The SP metadata publishes the certificate as both a signing and an encryption KeyDescriptor, and sets AuthnRequestsSigned="true" when the connection forces signing.

Single Logout

The ACS records the SAML session on the auth cookie (saml_connection, saml_name_id, saml_name_id_format, saml_session_index claims) so logout can be tied back to the IdP session.

Metadata Caching & Cert Rollover

Azure AD Compatibility

Azure AD Behavior Handling
Signs assertion only (default) Validates signature on Assertion element
Signs response only Validates signature on Response element
Signs both Validates both signatures
SHA-256 (default) Supports SHA-256 and SHA-1
NameID: emailAddress Direct email extraction
NameID: persistent (opaque) Falls back to email claim from attributes
NameID: unspecified Falls back to email claim from attributes
NameID: transient Rotates every login, so it is never used as the federated key. The IdP’s stable object-id attribute is used instead; if none is asserted, the login is rejected with an actionable error (configure a persistent or emailAddress NameID, or assert an object-id attribute).

Attribute Mapping

Attributes are indexed case-insensitively under both their Name and FriendlyName (Okta and Shibboleth emit OID Names with human FriendlyNames; matching either is what makes vendor mapping work). Each field tries an alias list in order; the first alias is the Microsoft claim URI, so Entra/ADFS behavior is unchanged, and the rest cover the friendly and OID names Okta, OneLogin, Ping, Google and Shibboleth emit by default:

Field Accepted attribute names
email .../claims/emailaddress, email, mail, emailaddress, urn:oid:0.9.2342.19200300.100.1.3
firstName .../claims/givenname, givenName, given_name, firstName, first_name, urn:oid:2.5.4.42
lastName .../claims/surname, sn, surname, lastName, last_name, familyName, family_name, urn:oid:2.5.4.4
displayName http://schemas.microsoft.com/identity/claims/displayname, displayName, urn:oid:2.16.840.1.113730.3.1.241, cn, urn:oid:2.5.4.3
objectId http://schemas.microsoft.com/identity/claims/objectidentifier, objectGUID, user.objectid
groups .../claims/groups, groups, memberOf, .../claims/role, urn:oid:1.3.6.1.4.1.5923.1.5.1.1

(.../claims/... abbreviates the full http://schemas.xmlsoap.org/ws/2005/05/identity/claims/... or http://schemas.microsoft.com/ws/2008/06/identity/claims/... URI.)

Email resolution priority: explicit email attribute (any alias) → NameID when its format is emailAddress → the name claim if it contains @ → reject (an email is required).

Groups are multi-valued: every AttributeValue element is captured (one per group membership), not just the first.

JIT Provisioning

Unknown users are auto-created on first login (email, first/last name from the assertion, email marked confirmed) and linked to the connection by their stable federated identity (saml:{connectionId} + NameID, or the object-id for transient NameIDs). Set disableJitProvisioning: true to reject unknown users instead. Returning users are matched by the federated link first, never by email alone; an existing local account is attached by email only when the connection’s AllowedDomains covers that email’s domain (the admin’s explicit statement that this IdP owns the domain), preventing account takeover via a rogue IdP.

Security