A federation gateway
Your applications trust one server. That server trusts many identity providers — a cloud directory, a social sign-in, a customer's own AD FS, and the providers your business customers bring when they sign up — and brokers every sign-in to the right one. Each application gets the same tokens, from the same issuer, signed with the same keys, naming each user by one stable subject and the same claims, whichever provider the user came through; a SAML-only application gets the same users as SAML assertions. Adding a provider, or a customer with a provider of their own, changes no application.
flowchart LR
subgraph apps["Applications: trust one server"]
portal["Customer portal<br/>OpenID Connect, code + PKCE"]
api["Orders API<br/>validates access tokens"]
expenses["Expenses<br/>SAML 2.0 service provider"]
end
gateway(["Fdyay.IdentityServer<br/>the gateway"])
subgraph idps["Identity providers: trusted by the gateway"]
entra["Microsoft Entra ID<br/>multi-tenant, OpenID Connect"]
google["Google<br/>OpenID Connect"]
adfs["Contoso AD FS<br/>SAML 2.0"]
rows["Customers' own providers<br/>stored rows, added at runtime"]
end
portal -- "OpenID Connect" --> gateway
api -. "JWKS, issuer" .-> gateway
expenses -- "SAML 2.0" --> gateway
gateway -- "OpenID Connect" --> entra
gateway -- "OpenID Connect" --> google
gateway -- "SAML 2.0" --> adfs
gateway -- "either" --> rows
This page builds that gateway. The code is included from
samples/FederationGateway,
which the build compiles and a test drives through both hops of a sign-in: it answers as Contoso's AD FS and as the
Expenses application with keys of its own, and a server built from this library stands in for Google. Two recorded
flows show the hops on the wire: federated sign-in through an OpenID Connect provider,
and a SAML identity provider to an OpenID Connect client. Each section links to the page
that covers its subject in full.
dotnet add package Fdyay.IdentityServer.AspNetCore
dotnet add package Fdyay.IdentityServer.UI
dotnet add package Fdyay.IdentityServer.Federation
dotnet add package Fdyay.IdentityServer.Saml
The whole host
// Shared by every instance; before AddSaml, whose stores are TryAdd.
builder.Services.AddGatewaySharedState(builder.Configuration, builder.Environment);
// The gateway's own accounts, and how a provider's user becomes one.
builder.Services.AddSingleton<TenantDirectory>();
builder.Services.AddSingleton<GatewayUsers>();
builder.Services.AddSingleton<ClaimsNormaliser>();
builder.Services.AddTransient<IUserSignInService, GatewayUserSignIn>();
builder.Services.AddIdentityServer()
// Downstream: the applications that trust the gateway, over OpenID Connect…
.AddInMemoryIdentityResources(Downstream.IdentityResources)
.AddInMemoryApiScopes(Downstream.ApiScopes)
.AddInMemoryApiResources(Downstream.ApiResources)
.AddInMemoryClients(Downstream.Clients)
// …and over SAML 2.0: the gateway is their identity provider, at /saml/sso, /saml/slo and /saml/metadata.
.AddSaml(
options => options.EntityId = builder.Configuration["Gateway:PublicOrigin"] + "/saml",
Downstream.Expenses(builder.Configuration, builder.Environment))
// One identity whichever provider the user came through, and the provider chosen for them where it can be.
.AddProfileService<GatewayProfileService>()
.AddCustomAuthorizeRequestValidator<HomeRealmDiscovery>()
// Upstream, at runtime: customers' own identity providers, stored as rows.
.AddTenantProviders();
// Upstream, at deployment: the providers every customer can use.
builder.Services.AddAuthentication().AddUpstreamProviders(builder.Configuration, builder.Environment);
Every piece is an ordinary registration; what makes it a gateway is which pieces. The sign-in page
(AddIdentityServerUi) offers each registered provider and signs its user in through the
IUserSignInService the host registers; the gateway keeps no passwords, so its own refuses one. The rest of this page
takes the registrations in turn.
Upstream: the identity providers
Microsoft Entra ID, for any organisation
// Microsoft Entra ID, multi-tenant: one registration that any organisation's work account can reach.
authentication.AddExternalOpenIdConnect("entra", "Microsoft Entra ID", options =>
{
options.Authority = "https://login.microsoftonline.com/organizations/v2.0";
options.ClientId = configuration["Upstream:Entra:ClientId"];
options.ClientSecret = configuration["Upstream:Entra:ClientSecret"]; // user secrets or the environment
options.ResponseType = "code";
options.Scope.Add("email");
options.MapInboundClaims = false; // keep oid, tid, email as the provider named them
// Three addresses per provider, none shared with another provider's.
options.CallbackPath = "/signin-entra";
options.SignedOutCallbackPath = "/signout-callback-entra";
options.RemoteSignOutPath = "/signout-entra";
});
// "organizations" publishes an issuer template, https://login.microsoftonline.com/{tenantid}/v2.0, which every
// Entra tenant in the world matches. Admit only the directories of onboarded customers.
authentication.Services.AddOptions<OpenIdConnectOptions>("entra").Configure<TenantDirectory>((options, tenants) =>
options.TokenValidationParameters.IssuerValidator = (issuer, token, _) =>
token is JsonWebToken jwt
&& jwt.TryGetPayloadValue<string>("tid", out var tid)
&& tenants.ByEntraTenantId(tid) is not null
&& issuer == $"https://login.microsoftonline.com/{tid}/v2.0"
? issuer
: throw new SecurityTokenInvalidIssuerException("The token is not from an onboarded Entra tenant."));
AddExternalOpenIdConnect is ASP.NET Core's OpenID Connect handler, whose user lands in the external cookie the external
sign-in page reads, and whose remote sign-out ends the gateway's session
(external providers).
The organizations authority accepts a work account from any Entra directory, and its discovery document says so: its
issuer is the template https://login.microsoftonline.com/{tenantid}/v2.0.
Security. A multi-tenant registration with no issuer check of your own admits every Entra tenant in the world — anyone can create one. The validator above admits a token only when its
tidis an onboarded customer's directory and its issuer is that directory's. The sample's boot test refuses a stranger'stid, and an onboardedtidunder another directory's issuer.
MapInboundClaims = false keeps Entra's oid, tid and email under their own names for the
normalisation below.
// Google, for people with no organisation behind them. OpenID Connect rather than the plain OAuth handler, so
// the gateway gets a signed id_token and email_verified.
authentication.AddExternalOpenIdConnect("google", "Google", options =>
{
options.Authority = "https://accounts.google.com";
options.ClientId = configuration["Upstream:Google:ClientId"];
options.ClientSecret = configuration["Upstream:Google:ClientSecret"];
options.ResponseType = "code";
options.Scope.Add("email");
options.MapInboundClaims = false;
options.CallbackPath = "/signin-google";
options.SignedOutCallbackPath = "/signout-callback-google";
options.RemoteSignOutPath = "/signout-google";
});
The same handler, for people with no organisation behind them. Google publishes no end_session_endpoint, and since
0.179.0 signing out of a session that began there ends it at the gateway and carries on
(signing out); before 0.179.0 it answered 500 and left the user signed
in.
A customer's SAML 2.0 identity provider
// A customer's own SAML 2.0 identity provider (AD FS here; Okta, Ping and Shibboleth are configured alike). The
// gateway is its service provider, and publishes the metadata the customer imports at MetadataPath.
authentication.AddExternalSaml2("contoso", "Contoso (AD FS)", options =>
{
options.SpEntityId = configuration["Gateway:PublicOrigin"] + "/saml/contoso";
options.IdpEntityId = "http://adfs.contoso.com/adfs/services/trust";
options.IdpSingleSignOnUrl = "https://adfs.contoso.com/adfs/ls/";
options.IdpSingleLogoutUrl = "https://adfs.contoso.com/adfs/ls/";
// From the customer's federation metadata: the key its assertions are verified with.
options.IdpSigningCertificate = X509CertificateLoader.LoadCertificateFromFile(
Path.Combine(environment.ContentRootPath, configuration["Upstream:Contoso:SigningCertificate"]!));
options.CallbackPath = "/saml/contoso/acs";
options.SignedOutCallbackPath = "/saml/contoso/slo";
options.MetadataPath = "/saml/contoso/metadata";
});
// Every SAML provider's RelayState within SAML's 80 bytes (SharedState.cs). After the handler's own defaults.
authentication.Services.AddSingleton<IPostConfigureOptions<SamlSpOptions>, ShortRelayState>();
AddExternalSaml2 makes the gateway a SAML service provider to the customer's identity provider — native SAML 2.0, with
no third-party library. The assertion is verified against IdpSigningCertificate and held to the Web SSO profile's
rules — audience, recipient, InResponseTo, times, accepted once, from the browser that asked
(what an assertion has to be). The customer configures
their side from the metadata the gateway publishes at MetadataPath — the boot test reads
https://id.example.com/saml/contoso and its assertion consumer service from it.
Known issue (0.179.0). The service provider sends its data-protected state as
RelayState— about 750 bytes in the recorded flow — and SAML Bindings §3.4.3 allows 80. An identity provider that enforces the limit, as this server's own does and as Duende's does, refuses the request (relay_state_too_long). Until the handler keeps its state elsewhere, a host keeps it server-side throughSamlSpOptions.StateDataFormat, as the gateway does:
/// <summary>
/// Keeps each SAML service provider's sign-in state in the shared cache, and sends the identity provider a 22-character
/// key as <c>RelayState</c> instead of the state itself. SAML Bindings §3.4.3 caps <c>RelayState</c> at 80 bytes, the
/// handler's protected state is longer, and an identity provider that enforces the cap refuses the request.
/// </summary>
public sealed class ShortRelayState(IDistributedCache cache) : IPostConfigureOptions<SamlSpOptions>
{
/// <inheritdoc />
public void PostConfigure(string? name, SamlSpOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.StateDataFormat = new CachedState(options.StateDataFormat, cache);
}
private sealed class CachedState(ISecureDataFormat<AuthenticationProperties> inner, IDistributedCache cache)
: ISecureDataFormat<AuthenticationProperties>
{
public string Protect(AuthenticationProperties data) => Protect(data, purpose: null);
public string Protect(AuthenticationProperties data, string? purpose)
{
var key = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(16));
cache.SetString("saml-relay-state:" + key, inner.Protect(data, purpose), new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15),
});
return key;
}
public AuthenticationProperties? Unprotect(string? protectedText) => Unprotect(protectedText, purpose: null);
public AuthenticationProperties? Unprotect(string? protectedText, string? purpose)
=> protectedText is not null && cache.GetString("saml-relay-state:" + protectedText) is { } state
? inner.Unprotect(state, purpose)
: null;
}
}
The state then lives in the shared cache, so every instance must share it; the boot test holds the RelayState to 80
bytes.
Customers onboarded at runtime
A business customer that signs up with its own identity provider is a row, not a deployment:
/// <summary>
/// The customers' own identity providers, read from the gateway's tenant directory. Every row is an authentication scheme:
/// a button on the sign-in page, a target for home-realm discovery, a provider signed out of. A customer onboarded while
/// the gateway runs is a new row, not a deployment.
/// </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)
{
// Made the type its kind registered (an OidcProvider for an "oidc" row) and validated on the way out.
var row = tenants.Providers.FirstOrDefault(row => row.Scheme == scheme);
return Task.FromResult(row is null ? null : factory.Create(row));
}
}
// The kinds of row the gateway serves, and where the rows come from. Each calls back under its own path,
// /federation/{scheme}/signin, so a new customer needs no new route.
identityServer
.AddDynamicProviders()
.AddDynamicOidcProviders()
.AddSamlDynamicProvider()
.AddIdentityProviderStoreCache<TenantProviderStore>(TimeSpan.FromMinutes(5));
Each row is an authentication scheme everywhere one is expected: a button on the sign-in page, a target of the idp:
hint, a provider signed out of. Rows are validated as they are read — a row with no authority, say, is no provider at
all — and cached for the duration given; each calls back under /federation/{scheme}/signin, so a new customer needs no
new route. A row can be an OpenID Connect or a SAML 2.0 provider, or a kind of your own, and can be edited in place
(dynamic identity providers).
Downstream: the applications
/// <summary>What a token can say about the user: the standard scopes and the user's organisation.</summary>
public static IdentityResource[] IdentityResources =>
[
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
new IdentityResources.Email(),
new IdentityResource("tenant", "Your organisation", [ClaimsNormaliser.Tenant]),
];
/// <summary>The API's scope.</summary>
public static ApiScope[] ApiScopes => [new ApiScope("orders", "Orders API")];
/// <summary>The API, which receives the user's organisation in its access tokens.</summary>
public static ApiResource[] ApiResources =>
[
new ApiResource("https://orders.example.com", "Orders API")
{
Scopes = { "orders" },
UserClaims = { ClaimsNormaliser.Tenant },
},
];
/// <summary>The OpenID Connect applications.</summary>
public static Client[] Clients =>
[
// The customer portal: any provider the gateway offers. Authorization code with PKCE, and told at its back
// channel when the user's session ends.
new Client
{
ClientId = "portal",
ClientName = "Customer portal",
ClientSecrets = { new Secret("portal-secret".Sha256()) },
AllowedGrantTypes = { OidcConstants.GrantTypes.AuthorizationCode },
RequirePkce = true,
RedirectUris = { "https://portal.example.com/signin-oidc" },
PostLogoutRedirectUris = { "https://portal.example.com/signout-callback-oidc" },
BackChannelLogoutUri = "https://portal.example.com/bff/backchannel",
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "email", "tenant", "orders", "offline_access" },
// The gateway keeps no passwords, so its sign-in page shows the providers only.
EnableLocalLogin = false,
},
// Contoso's intranet: Contoso's employees, from Contoso's own identity provider, and nobody else.
new Client
{
ClientId = "contoso-intranet",
ClientName = "Contoso intranet",
ClientSecrets = { new Secret("contoso-intranet-secret".Sha256()) },
AllowedGrantTypes = { OidcConstants.GrantTypes.AuthorizationCode },
RequirePkce = true,
RedirectUris = { "https://intranet.contoso.com/signin-oidc" },
AllowedScopes = { "openid", "profile", "email", "tenant" },
EnableLocalLogin = false,
IdentityProviderRestrictions = { "contoso" },
},
];
The applications are ordinary clients (clients and resources). Nothing in the portal names an
identity provider; Contoso's intranet names the one it accepts (below). The portal signs users in with the authorization code flow and PKCE. The Orders API is an audience: the
access tokens the gateway issues for its scope name it in aud, and it validates them against the gateway's keys as any
API does (APIs). An application that speaks only SAML 2.0 is registered as a service provider of the gateway's SAML identity provider:
/// <summary>
/// An application that speaks SAML 2.0 only. Users from every provider sign in to it — an Entra or Google user gets
/// an assertion, as a Contoso user does — with the same claims an OpenID Connect application gets, as attributes.
/// </summary>
public static SamlServiceProvider Expenses(IConfiguration configuration, IHostEnvironment environment)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(environment);
return new SamlServiceProvider
{
EntityId = "https://expenses.example.com/saml",
AssertionConsumerServiceUrl = "https://expenses.example.com/saml/acs",
SingleLogoutServiceUrl = "https://expenses.example.com/saml/slo",
// Asked of the profile service, as an OpenID Connect client's scopes are.
AllowedScopes = { "openid", "profile", "email", "tenant" },
// The application's key, which verifies its signed AuthnRequests (required by default).
RequestSigningCertificate = X509CertificateLoader.LoadCertificateFromFile(
Path.Combine(environment.ContentRootPath, configuration["Downstream:Expenses:SigningCertificate"]!)),
};
}
That is protocol bridging, both ways. A user from Google — an OpenID Connect provider — signs in to Expenses and the
application receives a signed assertion, its attributes the gateway's claims under the names SAML applications expect
(…/claims/name, …/claims/emailaddress; what an assertion says about the user);
the boot test validates one against the gateway's metadata. A user from Contoso's AD FS — a SAML identity provider —
signs in to the portal and the portal receives OpenID Connect tokens, as recorded in
SAML identity provider to OpenID Connect client.
Choosing the provider
With nothing to go on, the sign-in page offers every provider the client accepts — for the portal, Microsoft Entra ID,
Google, Contoso (AD FS) and Adatum (Okta), and no password form, because the portal has EnableLocalLogin = false. Three
things narrow it, and the library acts on the first two itself:
idp:inacr_values. A client that knows where its user signs in names the scheme, and the page sends the browser straight there (home-realm discovery). The recorded flow starts this way.IdentityProviderRestrictions. Contoso's intranet accepts Contoso's provider and no other: its sign-in page offers only that one, anidp:hint naming another is dropped, and a session that began at another provider is not reused for it (who may sign in to a client).- The user's organisation, from
acr_values=tenant:contosoor alogin_hintat one of its email domains. The library parsestenant:and carrieslogin_hintto the pages but decides nothing from either, because what a tenant or a domain means is the deployment's. The host turns them into anidp:hint with an authorize-request validator, which runs wherever an authorize request is validated — including when the sign-in page reads it (your own checks on requests):
/// <summary>
/// Home-realm discovery: an authorize request that names its user's organisation — <c>acr_values=tenant:contoso</c>, or a
/// <c>login_hint</c> at one of the organisation's email domains — is given the <c>idp:</c> hint for that organisation's
/// identity provider, and the sign-in page sends the user straight there.
/// </summary>
public sealed class HomeRealmDiscovery(TenantDirectory tenants) : ICustomAuthorizeRequestValidator
{
/// <inheritdoc />
public Task ValidateAsync(CustomAuthorizeRequestValidationContext context, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(context);
var request = context.Result.ValidatedRequest!;
// A client that already chose a provider has said where its user signs in.
if (request.GetIdP() is not null)
{
return Task.CompletedTask;
}
var tenant = request.GetTenant() is { } name
? tenants.ByName(name)
: request.LoginHint?.Split('@') is [_, var domain] ? tenants.ByEmailDomain(domain) : null;
// Only to a provider the client accepts: a client held to other providers keeps its own sign-in page.
var restrictions = request.Client.IdentityProviderRestrictions;
if (tenant is not null && (restrictions.Count == 0 || restrictions.Contains(tenant.Scheme)))
{
request.AddAcrValue(IdentityServerConstants.KnownAcrValues.HomeRealm + tenant.Scheme);
}
return Task.CompletedTask;
}
}
The boot test sends login_hint=alice@contoso.com straight to Contoso's AD FS and tenant:adatum straight to Adatum's
stored provider, and shows the intranet's page unmoved by a hint for a provider it does not accept.
Security. Which provider may vouch for which users is the gateway's most important decision. A provider is trusted for the users it signs in, and nothing stops it signing in anybody it likes; hold each client to the providers its users actually come from with
IdentityProviderRestrictions, and admit only the tenants you onboarded from a multi-tenant provider.
One identity, whichever provider
The shipped external sign-in page receives the user from the provider and calls the host's
IUserSignInService with three things: the provider, the user's subject
there, and every claim the provider sent. The gateway's finds or provisions its own account and signs it in:
/// <summary>
/// What the shipped external sign-in page calls once a provider has vouched for a user: finds or provisions the
/// gateway's account for that provider's user and signs it in. The gateway keeps no passwords.
/// </summary>
public sealed class GatewayUserSignIn(GatewayUsers users, ClaimsNormaliser normaliser) : IUserSignInService
{
/// <inheritdoc />
public Task<UserSignInResult> SignInWithPasswordAsync(
HttpContext context, string username, string password, CancellationToken cancellationToken = default)
=> Task.FromResult(UserSignInResult.Failure("The gateway has no local accounts."));
/// <inheritdoc />
public async Task<UserSignInResult> SignInExternalAsync(
HttpContext context, ExternalUserSignIn external, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(external);
// The account is the pair: the scheme the sign-in was recorded against (never a claim the provider sent about
// itself) and the provider's subject. Two providers' "alice" are two accounts, and so are two users with one email.
var user = users.FindOrProvision(external.Provider, external.ProviderSubjectId);
user.Claims = normaliser.Normalise(external.Provider, external.Claims);
// An OpenID Connect provider's id_token, kept with the session (not in it) for signing out there.
var session = new AuthenticationProperties();
if (external.Claims.FirstOrDefault(claim => claim.Type == Upstream.IdTokenClaim) is { } idToken)
{
session.StoreTokens([new AuthenticationToken { Name = "id_token", Value = idToken.Value }]);
}
var name = user.Claims.FirstOrDefault(claim => claim.Type == JwtClaimTypes.Name)?.Value;
await context.SignInAsync(new IdentityServerUser(user.SubjectId)
{
DisplayName = name,
// The session's idp: what IdentityProviderRestrictions compare, and where single sign-out goes.
IdentityProvider = external.Provider,
Tenant = user.Claims.FirstOrDefault(claim => claim.Type == ClaimsNormaliser.Tenant)?.Value,
// The provider's own name for the user, which a SAML LogoutRequest to it has to carry. It reaches no token.
AdditionalClaims = { new Claim(ClaimTypes.NameIdentifier, external.ProviderSubjectId) },
}, session);
return UserSignInResult.Success(user.SubjectId, user.SubjectId, name);
}
}
The provider is the scheme the sign-in was recorded against — never a claim the provider sent about itself; since
0.178.0 the page refuses an external sign-in that recorded none
(which provider a sign-in belongs to). The provider's subject
is its sub (OpenID Connect) or the assertion's NameID (SAML). So the account key is the pair, and Contoso's alice and
Google's alice are two accounts. The provider's own name for the user stays with the gateway's session, as
ClaimTypes.NameIdentifier, for the SAML LogoutRequest that has to name it; it reaches no token
(the provider's subject).
Security: do not link accounts by email. It is tempting to give a new external user the existing account with the same email address. Doing so lets any provider that can assert an address — and a multi-tenant directory lets each tenant's administrator set any address they like — sign in as whoever owns it elsewhere. The sample links by provider and subject only: in the boot test Contoso's AD FS asserts the address a Google user owns, and gets an account of its own, with the address unverified. Linking two providers' accounts to one person is a deliberate act the user proves, not a lookup.
Each provider names the same facts differently — email, …/claims/emailaddress; an Entra tid; Google's
email_verified — so the gateway puts them into one vocabulary as the user signs in:
/// <summary>
/// One vocabulary for every provider: <c>name</c>, <c>email</c>, <c>email_verified</c> and <c>tenant</c>, whichever
/// identity provider the user came through and whatever it called them.
/// </summary>
public sealed class ClaimsNormaliser(TenantDirectory tenants)
{
/// <summary>The claim naming the user's organisation.</summary>
public const string Tenant = "tenant";
private const string SamlName = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name";
private const string SamlEmail = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress";
/// <summary>The gateway's claims for a user, from what <paramref name="provider"/> said about them.</summary>
public IReadOnlyList<Claim> Normalise(string provider, IReadOnlyCollection<Claim> claims)
{
ArgumentNullException.ThrowIfNull(claims);
string? First(params string[] types) => types
.Select(type => claims.FirstOrDefault(claim => claim.Type == type)?.Value)
.FirstOrDefault(value => !string.IsNullOrEmpty(value));
// Entra is one scheme for many organisations: its tid says which. Every other provider belongs to one tenant, or
// to none (Google).
var tenant = provider == "entra"
? First("tid") is { } tid ? tenants.ByEntraTenantId(tid) : null
: tenants.ByScheme(provider);
var normalised = new List<Claim>();
if (First(JwtClaimTypes.Name, SamlName) is { } name)
{
normalised.Add(new Claim(JwtClaimTypes.Name, name));
}
if (First(JwtClaimTypes.Email, SamlEmail) is { } email)
{
// Verified only when the provider stands behind it: Google says so, and a tenant's own identity provider is
// trusted for the tenant's own domains and no others.
var domain = email[(email.LastIndexOf('@') + 1)..];
var verified = provider == "google"
? First(JwtClaimTypes.EmailVerified) == "true"
: tenant?.EmailDomains.Contains(domain, StringComparer.OrdinalIgnoreCase) == true;
normalised.Add(new Claim(JwtClaimTypes.Email, email));
normalised.Add(new Claim(JwtClaimTypes.EmailVerified, verified ? "true" : "false", ClaimValueTypes.Boolean));
}
if (tenant is not null)
{
normalised.Add(new Claim(Tenant, tenant.Name));
}
return normalised;
}
}
An address is marked verified only when the provider stands behind it: Google says so, and a customer's own provider is trusted for that customer's domains. Then a profile service issues the normalised claims — to tokens, and to SAML assertions, which ask it for the claims of a partner's allowed scopes:
/// <summary>
/// What goes into tokens, and into SAML assertions: the gateway account's normalised claims, as many as the request's
/// scopes (or a SAML partner's allowed scopes) ask for. The upstream token itself never reaches an application.
/// </summary>
public sealed class GatewayProfileService(GatewayUsers users) : IProfileService
{
/// <inheritdoc />
public Task GetProfileDataAsync(ProfileDataRequestContext context, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
if (users.FindBySubject(context.Subject.FindFirst(JwtClaimTypes.Subject)!.Value) is { } user)
{
context.AddRequestedClaims(user.Claims);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task IsActiveAsync(IsActiveContext context, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
context.IsActive = users.FindBySubject(context.Subject.FindFirst(JwtClaimTypes.Subject)!.Value) is not null;
return Task.CompletedTask;
}
}
What reaches an application, as the boot test reads it for a Contoso user signing in to the portal and the recorded flow shows in full:
| Token | Claims |
|---|---|
| Identity token | sub — the gateway's usr_…, never the provider's; idp — the provider's scheme; amr — external; auth_time, sid; and per scope name (profile), email and email_verified (email), tenant (tenant) |
| Access token for the Orders API | the same sub, idp and amr, client_id, scope, aud — https://orders.example.com — and tenant, because the API resource asks for it; no email, which it does not |
The provider's own tokens never reach an application. The federated sign-in flow shows
both sets on the wire: the provider's identity token naming alice, and the gateway's naming its own subject.
Signing out across the chain
Downstream, the applications are told. Signing out at the gateway — on its sign-out page, or by an application's
end-session request — ends the session and runs one sequence: a logout token to each client's back channel (the portal's
BackChannelLogoutUri; the boot test sees it posted), the front-channel logout URLs, and a LogoutRequest to each SAML
application the session reached (signing out).
Upstream, the provider is asked to end its session too, last, because the session began there: an OpenID Connect
provider at its end-session endpoint, a SAML identity provider with a LogoutRequest naming the user by its NameID — the
boot test reads CONTOSO\carol in the one sent to AD FS. A provider that cannot be asked (Google; a SAML provider with no
IdpSingleLogoutUrl) is skipped.
An OpenID Connect provider ends the session that the identity token it is shown (id_token_hint) names, and the shipped
external sign-in page does not keep that token — Duende's template does. Without it the provider has to ask the user, and may not send them back.
The gateway keeps it with its session (the session properties in the sign-in service above) and hands it over from
every OpenID Connect provider's handler:
// Every OpenID Connect provider, registered or stored.
authentication.Services.PostConfigureAll<OpenIdConnectOptions>(options =>
{
// Hand the provider's id_token to the sign-in service with the user's claims, so the session can keep it for
// the id_token_hint the provider's end-session endpoint expects.
var validated = options.Events.OnTokenValidated;
options.Events.OnTokenValidated = async context =>
{
await validated(context);
if (context.TokenEndpointResponse?.IdToken is { } idToken)
{
((ClaimsIdentity)context.Principal!.Identity!).AddClaim(new Claim(IdTokenClaim, idToken));
}
};
// And end this session from the provider's front channel only when the provider names it: the sid and iss of
// that id_token. With no sid on the session the handler checks nothing, and any page could sign users out.
var remoteSignOut = options.Events.OnRemoteSignOut;
options.Events.OnRemoteSignOut = async context =>
{
await remoteSignOut(context);
var session = await context.HttpContext.AuthenticateAsync(options.SignOutScheme);
var kept = session.Properties?.GetTokenValue("id_token") is { } token ? new JsonWebToken(token) : null;
if (kept is null
|| !kept.TryGetPayloadValue<string>("sid", out var sid)
|| context.ProtocolMessage?.Sid != sid
|| context.ProtocolMessage?.Iss != kept.Issuer)
{
context.HandleResponse();
}
};
});
The boot test signs a Google user out: the gateway sends the end-session request with the identity token Google issued, Google ends its session without asking, and the way back from Google lands on the gateway's signed-out page.
Inbound, the provider can end the gateway's session. An OpenID Connect provider that signs its user out loads the
gateway's front-channel URL, the handler's RemoteSignOutPath; a SAML identity provider sends a signed LogoutRequest
to SignedOutCallbackPath, checked for destination, time, replay and subject
(logout). Either way the session ends and the applications are told, as for any
sign-out. ASP.NET Core's handler compares the request's sid and iss only with sid and iss claims on the
session's user, and the gateway's session has neither from the provider — so with nothing more any page could sign
gateway users out by framing that URL. The second half of the region above obeys a remote sign-out only when it names
the provider's session the gateway kept; the boot test shows a bare request ignored and Google's real one obeyed.
What production needs
One set of addresses per provider. Each OpenID Connect registration has three — CallbackPath,
SignedOutCallbackPath, RemoteSignOutPath — and each SAML registration three — CallbackPath,
SignedOutCallbackPath, MetadataPath. ASP.NET Core's defaults are the same for every registration, so two providers
left on them claim the same requests; set every one, and register each at its provider. Stored providers get their
own under /federation/{scheme}/.
Shared state. Behind a load balancer each browser hop can reach a different instance:
// One key ring: cookies, the state an upstream provider hands back, and the grants are protected with it, and
// one instance must read what another wrote. A directory every instance mounts here; Redis, Azure Blob Storage
// or a database through their Data Protection packages.
services.AddDataProtection()
.SetApplicationName("federation-gateway")
.PersistKeysToFileSystem(new DirectoryInfo(
Path.Combine(environment.ContentRootPath, configuration["SharedState:DataProtectionKeys"]!)));
// One cache: the replay cache that accepts a SAML assertion once, and the throttles, live here.
services.AddDistributedMemoryCache(); // a shared cache in production: Redis, SQL Server, …
// The state of a sign-in in flight at an OpenID Connect provider, kept in that cache rather than in the URL.
services.AddOidcStateDataFormatterCache();
// SAML sign-in and single logout, each hop of which may reach another instance.
services.AddSingleton<ISamlSigninStateStore, DistributedSamlSigninStateStore>();
services.AddSingleton<ISamlLogoutSessionStore, DistributedSamlLogoutSessionStore>();
The key ring protects the session and external cookies, the state each provider hands back and the stored grants (server state); the distributed cache holds the replay cache that accepts each SAML assertion once (what is kept between requests), the OpenID Connect sign-in state, and — in this sample — the SAML service providers' state. The SAML identity provider's own stores default to memory, so the gateway gives the sign-in state (an application's AuthnRequest, kept while its user is away at a provider) a shared one:
/// <summary>
/// A SAML application's validated AuthnRequest, kept while its user is away at an upstream provider, where every
/// instance can read it. The default store is in memory, for a single instance.
/// </summary>
public sealed class DistributedSamlSigninStateStore(IDistributedCache cache, TimeProvider time) : ISamlSigninStateStore
{
private const string Prefix = "saml-signin:";
/// <inheritdoc />
public Task StoreAsync(string key, SamlSignInState state, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(state);
return cache.SetAsync(
Prefix + key,
JsonSerializer.SerializeToUtf8Bytes(state),
new DistributedCacheEntryOptions { AbsoluteExpiration = state.ExpiresAt ?? state.Created + InMemorySamlSigninStateStore.Lifetime },
cancellationToken);
}
/// <inheritdoc />
public async Task<SamlSignInState?> TakeAsync(string key, CancellationToken cancellationToken = default)
{
// Read and removed, so a return URL left in history cannot be answered twice.
var state = await PeekAsync(key, cancellationToken);
await cache.RemoveAsync(Prefix + key, cancellationToken);
return state;
}
/// <inheritdoc />
public async Task<SamlSignInState?> PeekAsync(string key, CancellationToken cancellationToken = default)
{
var bytes = await cache.GetAsync(Prefix + key, cancellationToken);
var state = bytes is null ? null : JsonSerializer.Deserialize<SamlSignInState>(bytes);
return state?.ExpiresAt < time.GetUtcNow() ? null : state;
}
}
and the single-logout sequence the shared store the signing-out page
shows (the sample's DistributedSamlLogoutSessionStore).
The gateway's signing keys must be shared too (several instances on one key store;
the sample keeps them in ./keys, the default), and so must the clients, resources and grants once they live in a
database (persistence).
Certificates and metadata.
- To the applications, the gateway signs assertions with its own key material, so a key rotation reaches SAML as it
reaches tokens, and its metadata at
/saml/metadatapublishes every current key, withcacheDurationandvalidUntiltelling partners how often to read it again (what SAML signs with). An application rolling its own key over is registered with the new certificate beside the old in itsCertificates, and a request either verifies is served (registering a partner). - From the providers, a SAML registration trusts one
IdpSigningCertificate, and nothing reads a provider's metadata for it. When a customer rolls its key over, replace the certificate at the cut-over: a stored SAML row'sSigningCertificateBase64is read again when the row changes, without a restart (edited in place); a registered provider's needs a deployment. OpenID Connect providers publish their keys at their JWKS endpoint, and the handler follows their rotation itself. - To the providers, the gateway publishes each SAML registration's service-provider metadata at its
MetadataPath, and a stored row's at/federation/{scheme}/metadata.
Where each piece is covered
| Subject | Page |
|---|---|
| Registering providers | External providers |
| Who signs in where: subjects, home realm, restrictions | Who signs in where |
| Providers as rows | Dynamic identity providers |
| The gateway as a SAML service provider, and as an identity provider | SAML service provider, SAML identity provider |
| Signing out | Signing out |
| Claims in tokens | The profile service |
| Both hops on the wire | Federated sign-in, SAML identity provider to OpenID Connect client |