Table of Contents

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 AuthenticationMiddleware takes IAuthenticationSchemeProvider in 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 ConfigurationManager that caches the discovery document — so it would re-fetch upstream metadata on every request. Only Type and the stored Properties are 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.