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
| Item | Description | Example |
|---|---|---|
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 |
NameIdPolicy | Type of information used to identify the user. By default, the email address is used. | NameIdFormat.EmailAddress |
IdpMetadataLocation | Location of the identity provider's XML metadata file. | https://login.microsoftonline.com/12aaf12a-12c0-1d12-1234-12345cdcf12f/federationmetadata/2007-06/federationmetadata.xml |
EntityId | Application identifier known to the identity provider. | api://bf123456-f123-123c-b203-9d9a123f1234 |
RequireHttpsMetadata | Require the identity provider's metadata to be downloaded over HTTPS (true by default). A local file is always accepted. | true |
IdpMetadataFallbackDuration | Duration during which the last downloaded metadata remains in use when a new download fails (1 day by default). | TimeSpan.FromDays(1) |
CallbackPath | Path, 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 |
AllowedNameIdDomains | Domains of the users that the scheme's identity provider may authenticate. Empty list by default: all domains are accepted. | { "partner.example" } |
If the server does not have internet access, use a local file for IdpMetadataLocation rather than a remote URL.
Register the SAML2 provider
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"];
});
}
{
"Saml": {
"SpEntityId": "api://bf123456-f123-123c-b203-9d9a123f1234",
"IdpMetadataUrl": "https://login.microsoftonline.com/12aaf12a-12c0-1d12-1234-12345cdcf12f/federationmetadata/2007-06/federationmetadata.xml"
}
}
- If
EntityIdis not set, the application's root URL is used. IdpMetadataLocationcan reference a local path.
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.
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:
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
EntityIdper scheme for the same identity provider: ADFS and Entra ID require a unique identifier for each relying party. Two schemes registering the sameEntityIdagainst 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 namedSpMetadata-<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
explicitscheme 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 expressionSAML: 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
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.exampleis accepted forpartner.example,x@PARTNER.exampletoo;- 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.
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.
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:
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
bearerconfirmation 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:
| Property | Description | Default |
|---|---|---|
RequireDestination | The bearer confirmation must designate the application's URL (Recipient). | true |
RequireInResponseTo | The response must respond to the authentication request sent by the application. | true |
RequireIssuerMatch | The issuer of the assertion must be the expected identity provider. | true |
RequireNotBefore | An assertion whose validity has not started is rejected. | true |
ClockSkew | Tolerance for clock skew with the identity provider. | 2 minutes |
AllowSha1 | Accept 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:
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).
Correlation cookie
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>isSameSite=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=Nonecookie withoutSecure. The cookie.Lagoon.Saml.Correlation.<identifier>is thereforeSameSite=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.
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.
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.
- Click
Add rule. - Select
Send LDAP Attributes as Claims. - For
Claim rule name, enterSend attributes. - For
Attribute store, selectActive Directory. - Define the following mapping table, then validate.
| LDAP Attribute Outgoing | Claim type |
|---|---|
E-Mail-Addresses | E-Mail Address |
Given-Name | Given Name |
Surname | Surname |
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.
There will be as many claims of type http://schemas.xmlsoap.org/claims/Group as the user has groups.