Skip to main content

SAML2 authentication

This page covers the SAML2 configuration of a Lagoon application and the associated ADFS settings. Configuration happens server-side, in Main.cs, during service registration.

Information to provide​

ItemDescriptionExample
Display name (second parameter of AddSaml)explicit displays a button labelled with the scheme name on the login page. Any other value is a regular expression compared against the entered identifier: an identifier that matches is routed to this scheme.explicit
NameIdPolicyType of information used to identify the user. By default, the email address is used.NameIdFormat.EmailAddress
IdpMetadataLocationLocation of the identity provider's XML metadata file.https://login.microsoftonline.com/12aaf12a-12c0-1d12-1234-12345cdcf12f/federationmetadata/2007-06/federationmetadata.xml
EntityIdApplication identifier known to the identity provider.api://bf123456-f123-123c-b203-9d9a123f1234
RequireHttpsMetadataRequire the identity provider's metadata to be downloaded over HTTPS (true by default). A local file is always accepted.true
IdpMetadataFallbackDurationDuration during which the last downloaded metadata remains in use when a new download fails (1 day by default).TimeSpan.FromDays(1)
CallbackPathPath, relative to the application's public URL, on which the identity provider posts its responses. A GET on this path returns the application's metadata. /saml by default; each SAML2 scheme must have its own./saml
AllowedNameIdDomainsDomains of the users that the scheme's identity provider may authenticate. Empty list by default: all domains are accepted.{ "partner.example" }
Warning

If the server does not have internet access, use a local file for IdpMetadataLocation rather than a remote URL.

Register the SAML2 provider​

Server\Main.cs
protected override void OnConfigureServices(IServiceCollection services)
{
Services.Registry.RegisterServices(services);

services.AddAuthentication().AddSaml("Enterprise identity", "explicit", options =>
{
// Identifies the user with their email address.
options.NameIdPolicy = NameIdFormat.EmailAddress;
options.EntityId = Configuration["Saml:SpEntityId"];
options.IdpMetadataLocation = Configuration["Saml:IdpMetadataUrl"];
});
}
appsettings.json
{
"Saml": {
"SpEntityId": "api://bf123456-f123-123c-b203-9d9a123f1234",
"IdpMetadataUrl": "https://login.microsoftonline.com/12aaf12a-12c0-1d12-1234-12345cdcf12f/federationmetadata/2007-06/federationmetadata.xml"
}
}
Notes
  • If EntityId is not set, the application's root URL is used.
  • IdpMetadataLocation can reference a local path.
Server\Main.cs
options.IdpMetadataLocation = @"C:\...\IdpMetadata.xml";

Downloaded metadata is retained for 6 hours per location. If a new download fails, the previous version remains in use and the download is retried every 5 minutes, as long as it is less than IdpMetadataFallbackDuration old (1 day by default). Beyond that, authentication fails until the next successful download: an attacker who causes the download to fail cannot keep a certificate revoked by the identity provider in service. A download is abandoned after 30 seconds.

Warning

The metadata contains the certificates that sign the responses: downloaded over HTTP, they can be replaced by an attacker on the network. An http:// URL is therefore rejected. Only set RequireHttpsMetadata to false for a development identity provider.

Multiple identity providers​

An application can register several SAML2 schemes, each with its own identity provider, for example the company directory and that of a partner:

Server\Main.cs
services.AddAuthentication()
.AddSaml("Corporate", "explicit", options =>
{
options.IdpMetadataLocation = "https://adfs.corp.example/FederationMetadata/2007-06/FederationMetadata.xml";
})
.AddSaml("Partner", @"@partner\.example$", options =>
{
options.CallbackPath = "/saml-partner";
options.EntityId = "https://app.example/partner";
options.IdpMetadataLocation = "https://idp.partner.example/metadata";
// Le fournisseur d'identité du partenaire ne connecte que ses propres utilisateurs.
options.AllowedNameIdDomains.Add("partner.example");
});
  • One path per scheme: each scheme receives the responses posted to its CallbackPath, compared to the request case-sensitively, like the correlation cookie path. The application does not start if two schemes share a path, even with different casing; the error message suggests /saml-<scheme>. An empty path or / is rejected at startup. The path is processed before routing: do not give it the path of a page or that of another external provider (/signin-oidc).
  • One EntityId per scheme for the same identity provider: ADFS and Entra ID require a unique identifier for each relying party. Two schemes registering the same EntityId against the same metadata produce a warning in the logs at startup: this configuration only works with an identity provider that sends each response back to the return URL indicated by the request.
  • Application metadata per scheme: they are served via GET on the application's public URL followed by the scheme's CallbackPath (https://app.example/saml-partner), in a file named SpMetadata-<scheme>.xml. SAML routes are not available offline: in a browser where the application is installed (service worker), opening this URL displays the application page. Open it in a private window, or let the identity provider download it.
  • Options changed at runtime: options are validated at startup. If they are bound to a reloaded configuration and become invalid, only the affected scheme stops working (one error in the logs); the rest of the application continues to respond. If the change gives the same path to two schemes, this can no longer be rejected: an error names both schemes in the logs, and the first registered one receives the responses for both until the configuration is corrected.
  • Login page: an explicit scheme displays a button labelled with the scheme name. A regular expression routes to the scheme the identifiers that match it; it is case-sensitive, and the first registered matching scheme wins. AddSaml(scheme, options), without a display name, uses the expression SAML: two schemes registered this way are ambiguous. With multiple SSOs, the automatic redirect to the sole configured SSO disappears.

Trust granted to each identity provider​

Important

An SSO user is looked up by their login identifier (the NameID, or the claim returned by GetNameIdentifierClaimType for the scheme), regardless of the scheme that authenticated them. Without restriction, a partner's identity provider that issues the identifier pdg@corp.example logs the user in to the company executive's account.

Set AllowedNameIdDomains on each scheme whose identity provider does not manage all users of the application. The check applies to the login identifier used to find the account: the NameID, or the claim returned by GetNameIdentifierClaimType if the application overrides it for that scheme. The domain is the part of that identifier following its last @, compared case-insensitively:

  • x@partner.example is accepted for partner.example, x@PARTNER.example too;
  • a subdomain (x@evil.partner.example), another domain (x@partner.example.evil), and an identifier without @ are rejected;
  • a domain written with its @ (@partner.example), empty, surrounded by spaces, or ending with a period is rejected at startup.

A rejected response logs nobody in, and the error names the scheme and the rejected domain.

For a finer rule, LgAuthApplication methods receive the scheme name in context.IdentitySource.SourceName: GetIdentityUserAsync (account lookup), OnUnknownUserAsync (unknown account creation), and OnSignInAsync.

Warning

AllowedNameIdDomains controls the login identifier (context.IdentitySource.UserLogin). An override of GetIdentityUserAsync that looks up the account by another value, for example an email claim, is not protected: apply the same rule to that value with IsAllowedLogin from the scheme options, or designate that claim with GetNameIdentifierClaimType so that the check applies to it.

Server\Main.cs
protected override Task<ApplicationUser> GetIdentityUserAsync(AuthenticationContext context)
{
// Seul le schéma "Partner" retrouve les comptes par le claim e-mail : les autres connexions (formulaire,
// autres SSO) gardent la recherche par défaut
if (context.IdentitySource.SourceName != "Partner")
{
return base.GetIdentityUserAsync(context);
}
string email = context.IdentitySource.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value;
SamlOptions options = context.ServiceProvider.GetRequiredService<IOptionsMonitor<SamlOptions>>().Get("Partner");
// La valeur réellement utilisée pour retrouver le compte doit exister et appartenir aux domaines du schéma.
// Renvoyer null ne suffit pas : OnUnknownUserAsync pourrait alors créer le compte.
if (string.IsNullOrEmpty(email) || !options.IsAllowedLogin(email))
{
throw new AuthenticationException("Accès refusé.");
}
return context.UserManager.FindByEmailAsync(email);
}

For example, to allow only the company directory to create accounts:

Server\Main.cs
protected override Task<ApplicationUser> OnUnknownUserAsync(AuthenticationContext context)
{
if (context.IdentitySource.SourceName != "Corporate")
{
// Un utilisateur inconnu d'un autre fournisseur d'identité n'est pas créé
return Task.FromResult<ApplicationUser>(null);
}
// ...
}

Security controls​

Controls applied to each response​

These controls are always applied and cannot be disabled:

  • the response status is a success; an access denial from the identity provider raises an UnauthorizedException;
  • the response contains a single assertion, in clear text (encrypted assertions are not supported);
  • the signature is on the node containing it, with a unique identifier in the document (protection against "signature wrapping");
  • the signature uses RSA-SHA256 or stronger, and only the transforms provided for by SAML;
  • the assertion carries a bearer confirmation whose delivery deadline (NotOnOrAfter) has not passed;
  • the assertion is intended for the application (Audience) and has not expired;
  • the document contains no DTD; its size (3 MB) and depth are bounded.

Only signed values are authoritative: when only the assertion is signed, the destination (Destination) and the request (InResponseTo) of the response envelope are checked if present, but are not sufficient. It is the Recipient and InResponseTo of the bearer confirmation that are required.

Optional controls​

An assertion already used is rejected as long as it has not expired (EnableReplayCache, enabled by default). It is identified by its issuer and identifier, and stored in the application's in-memory cache: on multiple instances, the protection is therefore partial.

The Validation property of the options controls the other checks, all active by default:

PropertyDescriptionDefault
RequireDestinationThe bearer confirmation must designate the application's URL (Recipient).true
RequireInResponseToThe response must respond to the authentication request sent by the application.true
RequireIssuerMatchThe issuer of the assertion must be the expected identity provider.true
RequireNotBeforeAn assertion whose validity has not started is rejected.true
ClockSkewTolerance for clock skew with the identity provider.2 minutes
AllowSha1Accept a SHA-1 signature.false

RequireDestination can reveal a return URL declared in the metadata that differs from the application's actual URL, typically behind a reverse proxy: correct the application's public URL rather than disabling the check.

The identity provider-initiated flow (unsolicited response) is not supported, regardless of the value of RequireInResponseTo: a response must respond to a request sent by the application.

To read a response yourself with SamlResponse, provide the expected values with ForRequest(identifiantDeLaRequête, urlDeRetour). An active check whose expected value is unknown causes the read to fail, rather than accepting the response without checking it. Explicitly disable the check (RequireDestination or RequireInResponseTo to false) if it does not apply.

SamlResponseValidation validation = options.Validation.ForRequest(requestId, "https://mon-application/saml");
SamlResponse response = SamlResponse.FromHttpRequest(idpMetadata, options.EntityId, Request, logger, validation);

Identity provider signing in SHA-1​

SHA-1 signatures are rejected by default. For an old identity provider that cannot sign in SHA-256, allow them explicitly:

Server\Main.cs
services.AddAuthentication().AddSaml("Enterprise identity", "explicit", options =>
{
// ...
// Old identity provider: reserved for the time of its migration to SHA-256.
options.Validation.AllowSha1 = true;
});

On AD FS, prefer to change the Relying Party Trust signing algorithm to SHA-256 (Advanced tab).

The authentication request sets a correlation cookie, valid for 15 minutes, on the path of the scheme's return URL (the CallbackPath, /saml by default, appended to the application's public URL). The response is only accepted by the browser carrying this cookie: an attacker cannot make the victim log in under their own account ("login CSRF").

  • In HTTPS, the cookie __Secure-Lagoon.Saml.Correlation.<identifier> is SameSite=None; Secure: it directly accompanies the identity provider's POST. The __Secure- prefix prevents an attacker on the network from setting it via an HTTP response.
  • In HTTP, a browser refuses a SameSite=None cookie without Secure. The cookie .Lagoon.Saml.Correlation.<identifier> is therefore SameSite=Lax, and it is not sent with the identity provider's POST, which comes from another site. The application then responds with a page that posts the response to itself again, automatically: this second POST carries the cookie. The connection works, with one extra round-trip.
tip

The cookie's scheme, path, and secure flag follow the application's public URL, not the received request: they remain correct behind a reverse proxy that terminates TLS or removes a path prefix.

SAML2 configuration for ADFS​

Application configuration​

Configure the application to use the Transient format for NameIdPolicy: urn:oasis:names:tc:SAML:2.0:nameid-format:transient.

Warning

Some versions of ADFS seem to have trouble returning the email format for NameId. In that case, the email is returned in the transient format.

Create a Relying Party Trust in AD FS​

To register the Lagoon application in ADFS, retrieve the application's metadata file by appending the scheme's CallbackPath (saml by default) to the application's root URL.

If the ADFS server accesses the application server over HTTPS, add the Relying Party Trust from the URL. Otherwise, download the metadata file and import it into ADFS.

Edit the Claim Rules​

In the Edit Claim Issuance Policy window of the Relying Party Trust, create the rules that detail the attributes required by the application and provided by AD FS at each login.

Rule 1: send the claims​

This rule tells AD FS to send the three required attributes: email, first name, and last name.

  1. Click Add rule.
  2. Select Send LDAP Attributes as Claims.
  3. For Claim rule name, enter Send attributes.
  4. For Attribute store, select Active Directory.
  5. Define the following mapping table, then validate.
LDAP Attribute OutgoingClaim type
E-Mail-AddressesE-Mail Address
Given-NameGiven Name
SurnameSurname

Rule 2: pass the email address as NameId​

Pass the email address as a Transient Identifier so that it can be used by the Lagoon application.

User groups​

Add a rule to send the list of groups the user belongs to as claims.

tip

There will be as many claims of type http://schemas.xmlsoap.org/claims/Group as the user has groups.