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
LogoutRequestnaming 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
schemeitem fell back to the external identity's ownidpclaim, and then toexternal. An unsolicited SAML assertion records none, and an identity provider can name its attributesidpandsub, so a partner trusted only for its own users could sign in as a user linked to any other provider — past a client'sIdentityProviderRestrictions, 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 inAuthenticationProperties.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.