多B2C租户共享单一WebApp与API部署技术问询
Great question—this is a common scenario when building multi-tenant SaaS solutions with Azure AD B2C. Let's tackle each part separately since Web Apps and APIs handle dynamic tenant configurations differently.
Problem 1: Web App - Redirect to the Correct Tenant for Authentication
Instead of hardcoding tenant settings at startup, you can dynamically adjust the OpenIdConnectAuthenticationOptions per request using OpenID Connect events. The key is to hook into the OnRedirectToIdentityProvider event to fetch tenant-specific config (from your database/in-memory store) based on the incoming request URL, then update the authentication options on the fly.
Step-by-Step Implementation:
Define a Tenant Configuration Provider
Create a service to fetch tenant settings (tenant ID, client ID, sign-in policy, etc.) using the request's host/domain. This keeps your tenant logic decoupled from the auth pipeline.public interface ITenantConfigProvider { Task<TenantConfig> GetConfigByRequestUrl(HttpRequest request); Task<TenantConfig> GetConfigByClientId(string clientId); // For API later } public class TenantConfig { public string TenantId { get; set; } public string TenantName { get; set; } // e.g., "contoso" for contoso.onmicrosoft.com public string ClientId { get; set; } public string ClientSecret { get; set; } public string SignInPolicy { get; set; } public bool IsActive { get; set; } } // Example implementation using a database public class DatabaseTenantConfigProvider : ITenantConfigProvider { private readonly IDbConnection _dbConn; public DatabaseTenantConfigProvider(IDbConnection dbConn) => _dbConn = dbConn; public async Task<TenantConfig> GetConfigByRequestUrl(HttpRequest request) { var domain = request.Host.Host; return await _dbConn.QueryFirstOrDefaultAsync<TenantConfig>( "SELECT TenantId, TenantName, ClientId, ClientSecret, SignInPolicy, IsActive FROM Tenants WHERE Domain = @Domain", new { Domain = domain }); } public async Task<TenantConfig> GetConfigByClientId(string clientId) { return await _dbConn.QueryFirstOrDefaultAsync<TenantConfig>( "SELECT TenantId, TenantName, ClientId, ClientSecret, SignInPolicy, IsActive FROM Tenants WHERE ClientId = @ClientId", new { ClientId = clientId }); } }Configure OpenID Connect with Dynamic Settings
In yourStartup.cs, set up the auth pipeline with base settings, then use theOnRedirectToIdentityProviderevent to inject tenant-specific config. You'll also need to handle the authorization code callback to ensure you use the correct tenant settings to exchange the code for tokens.services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) .AddOpenIdConnect(options => { // Base static config (no tenant-specific values) options.ResponseType = OpenIdConnectResponseType.CodeIdToken; options.CallbackPath = "/signin-oidc"; options.SaveTokens = true; options.Events = new OpenIdConnectEvents { OnRedirectToIdentityProvider = async context => { // Fetch tenant config based on the incoming request's domain var tenantProvider = context.HttpContext.RequestServices.GetRequiredService<ITenantConfigProvider>(); var tenantConfig = await tenantProvider.GetConfigByRequestUrl(context.HttpContext.Request); if (tenantConfig == null) { context.Response.StatusCode = StatusCodes.Status400BadRequest; context.HandleResponse(); return; } // Update auth options with tenant-specific values options.Authority = $"https://{tenantConfig.TenantName}.b2clogin.com/tfp/{tenantConfig.TenantId}.onmicrosoft.com/{tenantConfig.SignInPolicy}/v2.0/"; options.ClientId = tenantConfig.ClientId; options.ClientSecret = tenantConfig.ClientSecret; // Ensure the redirect URI matches the current request's scheme/domain context.ProtocolMessage.RedirectUri = $"{context.HttpContext.Request.Scheme}://{context.HttpContext.Request.Host}{options.CallbackPath}"; }, OnAuthorizationCodeReceived = async context => { // Re-fetch tenant config to use the correct token endpoint var tenantProvider = context.HttpContext.RequestServices.GetRequiredService<ITenantConfigProvider>(); var tenantConfig = await tenantProvider.GetConfigByRequestUrl(context.HttpContext.Request); context.TokenEndpointRequest.Address = $"https://{tenantConfig.TenantName}.b2clogin.com/{tenantConfig.TenantId}.onmicrosoft.com/{tenantConfig.SignInPolicy}/oauth2/v2.0/token"; } }; }); // Register your tenant provider with DI services.AddScoped<ITenantConfigProvider, DatabaseTenantConfigProvider>();Cache Tenant Config
Add caching (e.g.,IDistributedCache) to yourITenantConfigProviderto avoid repeated database calls—this will significantly improve performance for high-traffic apps.
Problem 2: API - Validate Tokens from the Correct Tenant
For APIs, you need to dynamically validate tokens against the tenant that issued them. The core challenge is verifying the token's signature and issuer without hardcoding tenant-specific authorities. We'll use a custom IssuerValidator and fetch the tenant's public keys (JWKS) on-demand.
Step-by-Step Implementation:
Configure JWT Bearer Authentication with Dynamic Validation
In your API'sStartup.cs, set up JWT bearer auth and override theIssuerValidatorto dynamically fetch tenant config based on the token's audience (client ID). You'll also fetch the tenant's OpenID config to get valid signing keys for token verification.services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.SaveToken = true; options.TokenValidationParameters = new TokenValidationParameters { ValidateAudience = true, ValidateIssuer = true, ValidateIssuerSigningKey = true, // Disable static issuer/key validation—we'll handle it dynamically ValidIssuers = null, IssuerSigningKeys = null, IssuerValidator = async (issuerToken, securityToken, validationParameters) => { var jwtToken = securityToken as JwtSecurityToken; if (jwtToken == null) throw new SecurityTokenInvalidIssuerException("Invalid token format"); // Get client ID from the token's audience claim var clientId = jwtToken.Claims.FirstOrDefault(c => c.Type == "aud")?.Value; if (string.IsNullOrEmpty(clientId)) throw new SecurityTokenInvalidIssuerException("Missing client ID in token"); // Fetch tenant config using the client ID var tenantProvider = validationParameters.RequestServices.GetRequiredService<ITenantConfigProvider>(); var tenantConfig = await tenantProvider.GetConfigByClientId(clientId); if (tenantConfig == null || !tenantConfig.IsActive) throw new SecurityTokenInvalidIssuerException($"Invalid or inactive tenant for client ID {clientId}"); // Verify the issuer matches the expected format for the tenant var expectedIssuer = $"https://{tenantConfig.TenantName}.b2clogin.com/{tenantConfig.TenantId}/v2.0/"; if (!string.Equals(issuerToken, expectedIssuer, StringComparison.OrdinalIgnoreCase)) throw new SecurityTokenInvalidIssuerException($"Invalid issuer: {issuerToken}. Expected: {expectedIssuer}"); // Fetch the tenant's OpenID config to get valid signing keys var configManager = new ConfigurationManager<OpenIdConnectConfiguration>( $"https://{tenantConfig.TenantName}.b2clogin.com/{tenantConfig.TenantId}.onmicrosoft.com/{tenantConfig.SignInPolicy}/v2.0/.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever()); var openIdConfig = await configManager.GetConfigurationAsync(CancellationToken.None); // Update validation parameters with the tenant's signing keys validationParameters.IssuerSigningKeys = openIdConfig.SigningKeys; return issuerToken; } }; // Optional: Add post-validation checks (e.g., tenant status) options.Events = new JwtBearerEvents { OnTokenValidated = async context => { var jwtToken = context.SecurityToken as JwtSecurityToken; var clientId = jwtToken.Claims.FirstOrDefault(c => c.Type == "aud")?.Value; var tenantProvider = context.HttpContext.RequestServices.GetRequiredService<ITenantConfigProvider>(); var tenantConfig = await tenantProvider.GetConfigByClientId(clientId); if (!tenantConfig.IsActive) { context.Fail("Tenant is inactive"); } } }; }); // Register the same tenant config provider as the web app services.AddScoped<ITenantConfigProvider, DatabaseTenantConfigProvider>();Cache OpenID Configs
Cache theOpenIdConnectConfigurationfor each tenant to avoid repeated calls to Azure AD B2C's well-known endpoint. UseIDistributedCachewith a TTL (e.g., 24 hours) since signing keys don't change frequently.
Key Notes for Both Scenarios:
- Security: Encrypt sensitive tenant data (like
ClientSecret) in your database—never store plaintext secrets. - Callback URIs: Ensure each tenant's AAD B2C application registration includes the correct redirect URIs for your web app's domain(s).
- Error Handling: Return clear, consistent error responses (e.g., 400 for invalid tenants, 401 for invalid tokens) to help clients debug issues.
内容的提问来源于stack exchange,提问作者Indi

