Table of Contents

Who signs in where

External providers registers the identity providers a user can sign in through. This page is what the server does with them: which subject it keeps for the provider, how a client sends a user straight to one, and how a client restricts who may sign in to it and how recently. A federation gateway uses all of it, with home-realm discovery by email domain and accounts keyed by provider and subject. The code is included from samples/Server.Clients.

The provider's subject

Users that sign in through an external provider are auto-provisioned with a local subject id and the provider's claims stored on the TestUser (or your own IProfileService).

The subject at the provider is carried onto the local principal as ClaimTypes.NameIdentifier, alongside the local sub and the idp claim naming the scheme. It is a different fact from the local subject — the local one is this server's name for the user, and only the provider's can be sent back to the provider, which is what a SAML LogoutRequest needs. It reaches no issued token: IClaimsService copies an allow-list of subject claim types into a token and this is not one of them.

ClaimTypes.NameIdentifier is the contract, not a default. A SAML service-provider registration may set NameIdClaimType to have the NameID carried under a claim type of its own as well — for a deployment whose own external sign-in handling reads something else — but the canonical type is always present alongside it, because that is what the shipped external sign-in page reads to find the provider subject and what the SAML service-provider handler reads off the local principal to name the user back to the identity provider on the way out.

Before 0.52.0 the option replaced the canonical type, which made every value but its default a registration that broke sign-in outright — the external identity carried a subject nothing in this tree could find — and, had sign-in somehow succeeded, sent a LogoutRequest naming nobody. A host that had set it needs no change: the claim it asked for is still there.

Which provider a sign-in belongs to

The provider half of the pair — whose subject this is — is the scheme the sign-in was recorded against, and never something the external identity says about itself. The shipped external sign-in page records it as the scheme item of the challenge's properties (Duende's template reads only that item), and the SAML service-provider handler records its own scheme for an assertion nobody asked for. The page's callback refuses an external identity with no such item: a UserLoginFailureEvent, the external cookie dropped, and the browser sent home.

Security (0.178.0). Until 0.178.0 a missing scheme item fell back to the external identity's own idp claim, and then to external. An unsolicited SAML assertion records none, and an identity provider can name its attributes idp and sub, so a partner trusted only for its own users could sign in as a user linked to any other provider — past a client's IdentityProviderRestrictions, which compare the same value. Both halves are closed: the item is required, and the SAML handler records its scheme and drops attributes named for protocol claims (what an assertion has to be).

A host whose own code challenges an external provider and returns to this page — rather than going through /account/externallogin?scheme=… — must put the scheme in AuthenticationProperties.Items["scheme"], as the page's own challenge does; without it the sign-in is now refused. A host with its own callback should take the provider from the same item and nothing else.

Home-realm discovery

A client that already knows where its user signs in says so with an idp: entry in acr_values, and the sign-in page acts on it rather than offering the chooser:

GET /connect/authorize?…&acr_values=idp:google

The user is redirected straight to google and never sees the provider list.

Hint What the sign-in page does
idp:<scheme> Redirects to that authentication scheme
idp:local Shows the local form with no federated buttons
idp:<unknown> Logs it and shows the full chooser — a stale hint does not strand the user

The hint is matched against a whole acr_values entry, so idport:x is not an idp: hint. Several entries can be sent together (acr_values=tenant:northwind idp:google); the first idp: entry wins.

tenant: is parsed the same way and reaches AuthorizationRequest.Tenant for a host to read. This library does nothing with it by default, and the difference from idp: is the point: a home realm names an authentication scheme this server has registered, so it can be resolved and acted on here. A tenant means whatever the application says it means, and a library that invented a behaviour for it would be guessing at your domain.

Both decide whether a session is reused, since 0.95.0 — Duende's rule, measured from its authorize interaction. A user who already has a session from another provider than the idp: hint names is sent to sign in again (login_required under prompt=none); a local session counts as idp:local, and a hint the client's IdentityProviderRestrictions drops asks for nothing. With ValidateTenantOnAuthorization on (off by default, as in Duende), the same happens when the session's tenant claim differs from the tenant: hint — a session with no tenant claim included, so a host turning it on should have its sign-in put the claim there:

// A session whose tenant claim differs from the request's tenant: hint is not reused.
options.ValidateTenantOnAuthorization = true;

Fixed in 0.32.0. The hint had been parsed since 0.12.0 and read by nothing: a client asking for a specific provider got the full list anyway. No test caught it because the fixture registered a single external provider — with one, "went where the hint said" and "went to the only one there is" are the same outcome.

Who may sign in to a client, and how recently

new Client
{
    ClientId = "partner-portal",
    ClientSecrets = { new Secret("partner-portal-secret".Sha256()) },
    AllowedGrantTypes = { OidcConstants.GrantTypes.AuthorizationCode },
    RedirectUris = { "https://partners.example.com/signin-oidc" },
    AllowedScopes = { "openid", "profile", "partner.api" },

    EnableLocalLogin = false,                           // no username and password
    IdentityProviderRestrictions = { "partner-oidc" },  // this provider and no other
    UserSsoLifetime = 3600,                             // reuse no session older than an hour
},

New in 0.81.0. Three properties on Client, with Duende's names, defaults and rules — the rules measured from its authorize interaction:

Property Sign-in page A session the user already has
EnableLocalLogin = false no local form; a local post is refused a local one is not reused
IdentityProviderRestrictions (empty allows any) only the named providers one from another provider is not reused
UserSsoLifetime (seconds; null, no limit) — one older than this is not reused

A session that is not reused sends the user to the sign-in page, or answers login_required under prompt=none. A local session — no idp claim, or local — is not held to IdentityProviderRestrictions: that list restricts providers, and EnableLocalLogin is what restricts local accounts. That is Duende's branch, decoded from its assembly. The SSO lifetime is strict, as max_age is, and a session with no auth_time counts as too old.

An idp: hint naming a provider outside the restrictions is dropped — removed from AuthorizationRequest.AcrValues and IdP alike — so the page never acts on it; a CIBA request's is dropped with a warning. An idp:local hint is not obeyed for a client that disallows local sign-in, because obeying it would leave the page offering nothing.

For native SAML 2.0, see this server as a SAML service provider and as a SAML identity provider.