Dynamic identity providers
The external providers registered with AddExternalOpenIdConnect and its siblings (see
External providers) are fixed when the host starts, which is right for a deployment whose
partners are known when it is built and wrong for one where a tenant brings its own. A dynamic provider is a row
instead. The code on this page is included from
samples/Protocols.DynamicProviders,
which a test boots: its three rows are buttons on the sign-in page and authentication schemes, the SAML row publishes its
metadata, and the host's own kind challenges with the row's settings. In a federation gateway
the rows are the providers business customers bring when they sign up.
A provider as a row
var northwind = new OidcProvider
{
Scheme = "northwind",
DisplayName = "Northwind",
Authority = "https://login.northwind.example.com",
ClientId = "fdyay",
ClientSecret = "northwind-secret",
};
// The mechanism, the kinds of row it serves, and the rows. Each row becomes an authentication scheme.
identityServer
.AddDynamicProviders()
.AddDynamicOidcProviders()
.AddSamlDynamicProvider()
.AddInMemoryIdentityProviders([northwind, contoso, fabrikam]);
AddDynamicProviders adds the mechanism with no kinds; AddDynamicOidcProviders and AddSamlDynamicProvider are the two kinds this server ships, and AddInMemoryIdentityProviders the rows — every kind's, in one list.
A stored provider is an authentication scheme everywhere one is expected, and that is the whole of the feature.
DynamicAuthenticationSchemeProvider decorates the framework's, so the three places that already ask what schemes exist
find dynamic ones without knowing they are stored:
| Reader | What it gets |
|---|---|
| The sign-in page's chooser | A button, if the row has a DisplayName and is Enabled |
The idp: home-realm hint |
A stored scheme is matched and redirected to like any other |
ISignOutService |
A session that began at a stored provider is signed out of there too |
That last one is why this arrived after 0.42.0 rather than before it: a dynamic scheme absent from GetSchemeAsync
would be offerable, signed in through, and then silently not signed out of — the defect 0.42.0 fixed, reintroduced
by the feature that followed it.
Since 0.105.0 the scheme handed out for a stored provider is a DynamicAuthenticationScheme, as in Duende, carrying the row it was made from — so code that looks a scheme up can tell a stored provider from a registered one, and read it:
// Code that looks a scheme up can tell a stored provider from a registered one, and read the row it came from.
app.MapGet("/providers/{name}", async (string name, IAuthenticationSchemeProvider schemes) =>
await schemes.GetSchemeAsync(name) is DynamicAuthenticationScheme { IdentityProvider: OidcProvider oidc }
? Results.Json(new { stored = true, authority = oidc.Authority })
: Results.Json(new { stored = false }));
Each row is validated each time it is read (new in 0.102.0), by all three store registrations, as Duende validates
it. A row naming a kind this host has not registered, with no scheme, or — for OpenID Connect — with no authority, client
id, response type or scope is answered as no such provider: it is not offered on the sign-in page and a challenge to
it is refused, where an OpenID Connect row with no authority used to be offered and fail with a 500 when picked. Each
refusal is logged with its reason and raises InvalidIdentityProviderConfigurationEvent, an error event, so it reaches
your sink when error events are on. Add rules by registering your own
IIdentityProviderConfigurationValidator, or by deriving from
DefaultIdentityProviderConfigurationValidator and overriding
ValidateOidcProviderAsync. A store registered as IIdentityProviderStore by hand, rather than through these methods,
is not validated.
Rows from a store of your own
A store of the host's own answers IIdentityProviderStore. One that keeps every
provider in one table reads its rows as the base IdentityProvider and hands each to
IIdentityProviderFactory — registered by AddDynamicProviders — which makes it the
type its kind registered: an OidcProvider for an oidc row, to which the validator's
OpenID Connect checks then apply, and null for a row of a kind nothing serves.
/// <summary>
/// Every tenant's provider in one table, of whatever kind: rows are held as the base <see cref="IdentityProvider"/>
/// and the factory makes each the type its kind registered — an <see cref="OidcProvider"/> for an <c>oidc</c> row,
/// null for a kind nothing serves.
/// </summary>
public sealed class TenantProviderStore(TenantDirectory tenants, IIdentityProviderFactory factory) : IIdentityProviderStore
{
/// <inheritdoc />
public Task<IEnumerable<IdentityProviderName>> GetAllSchemeNamesAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(tenants.Providers.Select(row => new IdentityProviderName(row.Scheme, row.DisplayName, row.Enabled)));
/// <inheritdoc />
public Task<IdentityProvider?> GetBySchemeAsync(string scheme, CancellationToken cancellationToken = default)
{
var row = tenants.Providers.FirstOrDefault(row => row.Scheme == scheme);
return Task.FromResult(row is null ? null : factory.Create(row));
}
}
Register it with AddIdentityProviderStore, or with AddIdentityProviderStoreCache to hold what it says for a while:
builder
.AddDynamicOidcProviders()
.AddIdentityProviderStoreCache<TenantProviderStore>(TimeSpan.FromMinutes(10)); // Duende's 60 without an argument
The cache is a HybridCache under the key
ConfigurationStoreCacheKey. Out of the box it is
in memory, private to each process, and needs no package: each instance of a multi-instance server caches separately and
asks its store again when its entry expires. Register your own HybridCache under that key — before or after the call —
to replace it; Microsoft's (Microsoft.Extensions.Caching.Hybrid,
services.AddKeyedHybridCache(DynamicProviderExtensions.ConfigurationStoreCacheKey)) gives it a distributed second level
shared by every instance. An edited row reaches the server within the duration rather than at once.
Where a provider calls back
Each provider's callback is served under PathPrefix
(/federation by default) at /federation/{scheme}/signin, so two providers cannot claim the same path. Static
registrations set their own and are untouched.
Since 0.104.0 a callback is routed to its own provider and no other, as Duende routes it: the scheme is the path
segment after PathPrefix, and only that provider's handler is asked. Until then every stored provider was asked about
every request, so each request the server answered — a discovery document, a token request — read the store's list and
every row in it. A host whose providers call back elsewhere names the scheme itself:
builder.AddDynamicProviders(options =>
{
// Asked instead of PathPrefix; null means "not a dynamic provider's callback".
options.PathMatchingCallback = context => Task.FromResult(TenantSchemeFrom(context.Request.Path));
// Duende's defaults: the external cookie to sign in to, this server's session to sign out of.
options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
options.SignOutScheme = IdentityServerConstants.DefaultCookieAuthenticationScheme;
});
// The callback only names the handler a request is for. The path the handler answers is its own option.
builder.Services.AddTransient<IConfigureDynamicProvider<OpenIdConnectOptions>, TenantCallbackPath>();
The callback only says which handler a request is for; the paths a handler answers are its own options, set through an IConfigureDynamicProvider<TOptions>, which runs after the mechanism has set the sign-in scheme and the callback path. The other settings are DynamicProviderOptions.
Stored SAML providers
New in 0.110.0, as Duende 8.0.1 has it: a SAML 2.0 identity provider can be a row too, served by this server's own
service-provider handler — the one AddExternalSaml2 registers statically.
var contoso = new SamlProvider
{
Scheme = "contoso",
DisplayName = "Contoso",
IdpEntityId = "https://sts.contoso.example.com",
SingleSignOnServiceUrl = "https://sts.contoso.example.com/sso",
SingleLogoutServiceUrl = "https://sts.contoso.example.com/slo", // optional
// The identity provider's certificate (DER, base64). Every response is verified against it, and a row
// without one is refused. SpSigningCertificateBase64 and its password would add a key of yours.
SigningCertificateBase64 = Convert.ToBase64String(File.ReadAllBytes(contosoCertificate)),
BindingType = SamlProvider.PostBinding, // "redirect" by default
};
SamlProvider keeps Duende's property names, defaults and flag spelling, so a row
written for one reads the same in the other. With no SpEntityId this server's issuer is its entity id;
OutboundSigningAlgorithm (RSA-SHA256 unless set) signs when the row carries a key of yours
(SpSigningCertificateBase64, PKCS#12, with SpSigningCertificatePassword); AllowUnsolicitedAuthnResponse accepts an
IdP-initiated sign-on. A host whose rows are all SAML can register them with
AddInMemorySamlProviders. Each provider
answers at its own addresses under PathPrefix:
| Address | What |
|---|---|
/federation/{scheme}/signin |
The assertion consumer service |
/federation/{scheme}/signout-callback |
Where the provider returns its LogoutResponse |
/federation/{scheme}/metadata |
This service provider's metadata, for the partner to configure itself from |
A host overrides the row per scheme through SamlAuthenticationOptions, which is filled from the row first:
// A stored SAML provider's options are filled from its row first; a host overrides them per scheme.
builder.Services.Configure<SamlAuthenticationOptions>("contoso", options =>
options.SpEntityId = "https://fdyay.example.com/saml");
The validator makes Duende's SAML checks — an IdP entity id, an absolute single sign-on URL, an absolute logout URL if
there is one, certificates that load, a binding of redirect or post — and one Duende does not: a row with no
SigningCertificateBase64 is refused, because this handler verifies every response against that key and has nowhere
else to get it. WantAssertionsSigned is carried but cannot be turned off: this handler always requires a signed
assertion and its metadata says so, and a row set to false is logged as a warning.
Adding a kind of provider
OpenID Connect and SAML are the kinds this server ships. A host adds its own through the same public extension point,
which is the test of whether the mechanism is one. Here, a plain OAuth 2.0 provider of the sort a social sign-in is,
served by ASP.NET Core's own OAuth handler. The row type keeps its settings in Properties:
/// <summary>
/// A kind of stored provider this server does not ship: a plain OAuth 2.0 provider, of the sort a social sign-in
/// is. Its settings live in <see cref="IdentityProvider.Properties"/>, so a store that keeps every provider in one
/// table holds it like any other row; these members are the reading of them.
/// </summary>
public sealed class OAuth2Provider : IdentityProvider
{
/// <summary>The <see cref="IdentityProvider.Type"/> of this kind's rows.</summary>
public const string ProviderType = "oauth2";
/// <summary>A new row of this kind.</summary>
public OAuth2Provider() => Type = ProviderType;
/// <summary>
/// A row of this kind read from one held as the base type. Required: it is what the provider factory calls.
/// </summary>
[SetsRequiredMembers]
public OAuth2Provider(IdentityProvider other)
: base(ProviderType, other)
{
}
/// <summary>The client id this server is registered under at the provider.</summary>
public string? ClientId { get => GetProperty(nameof(ClientId)); set => SetProperty(nameof(ClientId), value); }
/// <summary>The client secret this server is registered with at the provider.</summary>
public string? ClientSecret { get => GetProperty(nameof(ClientSecret)); set => SetProperty(nameof(ClientSecret), value); }
/// <summary>The provider's authorization endpoint.</summary>
public string? AuthorizationEndpoint
{
get => GetProperty(nameof(AuthorizationEndpoint));
set => SetProperty(nameof(AuthorizationEndpoint), value);
}
/// <summary>The provider's token endpoint.</summary>
public string? TokenEndpoint { get => GetProperty(nameof(TokenEndpoint)); set => SetProperty(nameof(TokenEndpoint), value); }
/// <summary>Where the signed-in user's profile is read, with its <c>id</c> and <c>name</c>.</summary>
public string? UserInformationEndpoint
{
get => GetProperty(nameof(UserInformationEndpoint));
set => SetProperty(nameof(UserInformationEndpoint), value);
}
}
An IConfigureDynamicProvider<TOptions> reads the row into whatever else the handler needs; the mechanism has already set the sign-in scheme and the callback path:
/// <summary>
/// Reads an <see cref="OAuth2Provider"/> row into the options of ASP.NET Core's OAuth handler. The mechanism has
/// already set the sign-in scheme and the callback path; everything else the handler needs is the row's.
/// </summary>
public sealed class ConfigureOAuth2Provider : IConfigureDynamicProvider<OAuthOptions>
{
/// <inheritdoc />
public void Configure(IdentityProvider provider, OAuthOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var row = provider as OAuth2Provider ?? new OAuth2Provider(provider);
options.ClientId = row.ClientId!;
options.ClientSecret = row.ClientSecret!;
options.AuthorizationEndpoint = row.AuthorizationEndpoint!;
options.TokenEndpoint = row.TokenEndpoint!;
options.UserInformationEndpoint = row.UserInformationEndpoint!;
options.UsePkce = true;
options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "name");
options.Events.OnCreatingTicket = async context =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken);
using var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
response.EnsureSuccessStatusCode();
using var user = JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
context.RunClaimActions(user.RootElement);
};
}
}
Then the kind is registered, under the name its rows' Type carries:
// A kind of this host's own: what reads its rows into the handler's options, and the kind, under the name its
// rows' Type carries.
builder.Services.AddSingleton<IConfigureDynamicProvider<OAuthOptions>, ConfigureOAuth2Provider>();
identityServer.AddDynamicProviderType<OAuthHandler<OAuthOptions>, OAuthOptions, OAuth2Provider>(OAuth2Provider.ProviderType);
// The handler's own post-configuration — its state protection and back channel — which AddOAuth registers for a
// static scheme and nothing registers for a dynamic one. Without it the first challenge fails.
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<
IPostConfigureOptions<OAuthOptions>, OAuthPostConfigureOptions<OAuthOptions, OAuthHandler<OAuthOptions>>>());
var fabrikam = new OAuth2Provider
{
Scheme = "fabrikam-social",
DisplayName = "Fabrikam Social",
ClientId = "fdyay",
ClientSecret = "fabrikam-secret",
AuthorizationEndpoint = "https://social.fabrikam.example.com/oauth/authorize",
TokenEndpoint = "https://social.fabrikam.example.com/oauth/token",
UserInformationEndpoint = "https://social.fabrikam.example.com/api/me",
};
The handler's post-configuration is the host's to register. A static AddOAuth (or AddOpenIdConnect, or
AddExternalSaml2) registers the handler's own IPostConfigureOptions — for the OAuth handler, the state protection and
the back channel — and nothing registers it for a kind that only exists as rows; without it the first challenge fails
with a NullReferenceException inside the handler. The two shipped kinds register theirs.
Since 0.103.0 a kind's provider type needs a copy constructor, as in Duende, and a kind is registered once — both
are refused when the options are built, in Duende's words. The copy constructor is what IIdentityProviderFactory calls
to make the registered type from a row held as the base IdentityProvider. Calling AddDynamicOidcProviders() twice
fails at startup with Type 'oidc' already configured.
A registered kind is read back with
FindProviderType — Find until 0.97.0 — as
a DynamicProviderType carrying Duende 8.0.1's HandlerType, OptionsType and
IdentityProviderType. Its name is the key it was registered under, so it no longer repeats it as a Type member, which
Duende's does not have. Its fourth, CopyConstructor, is internal there and here, and is what the factory calls.
Edited in place
As of 0.68.0 a provider can be added, removed and changed while the server runs. The scheme lookup already read the store on every call; what it now also does is notice that the row has changed and drop the options the framework had built for that scheme, so the next handler constructed for it is configured from the new row.
The limitation this replaces was recorded against the wrong mechanism, which is worth keeping rather than quietly deleting. It read: the scheme provider must be a singleton, because
AuthenticationMiddlewaretakesIAuthenticationSchemeProviderin its constructor and is built from the root provider, so a per-request chain that would give fresh reads fails scope validation at startup. All true, and about scoping rather than about staleness — the resolved row was never stale. The framework's named-options cache was, and being a singleton made it reachable from the singleton that resolves schemes rather than out of reach. The README had in fact named the remedy — "an invalidation hook rather than a different lifetime" — while the code's own remarks two files away described a wall. Nobody reconciled the two for twenty-one releases.
Two things about it are deliberate, and both are the difference between working and appearing to:
- The drop is driven by a change, not by a lookup. Rebuilding options on every lookup would also make edits
visible, and for OpenID Connect the options object owns the
ConfigurationManagerthat caches the discovery document — so it would re-fetch upstream metadata on every request. OnlyTypeand the storedPropertiesare compared, because they are the whole of what the options binding reads; renaming a provider's display name does not cost a metadata fetch. - The comparison is against a snapshot, not against the object the store returned last time. A store may hand back the same mutable instance twice — an in-memory one does — and comparing that object with itself reports no change however much it was edited.