Open Closed

Blazor WebApp custom OpenIdConnect urls per tenant #10220


User avatar
0
ddelamare created

We have a situation where the authentication is not working properly for multiple tenants. We have several tenants hosted on a single instance and each tenant has their own unique domain. These domains are used to determine the tenant, tenant host, and database connections. We are not using the tenant cookie resolver. So here is ultimately the question, what is the recommended way to have each tenant's auth use their configured client id and tenant specific url for all transactions? The OAuth server is currently embedded in our API and all the domains point to it.


4 Answer(s)
  • User Avatar
    0
    AI-Bot created
    Support Team ABP Agent
    1. Solution
    • Goal: Resolve tenant by domain and use per-tenant OpenId Connect settings (Authority, ClientId, redirect/logout URIs) in a single instance with unique domains per tenant, Blazor Web App UI, OpenIddict embedded in HttpApi.Host.

    • Backend (HttpApi.Host) – enable domain-based tenant resolution and wildcard redirect/cors support:

    using Volo.Abp.MultiTenancy;
      using Volo.Abp.OpenIddict.WildcardDomains;
    
      public override void ConfigureServices(ServiceConfigurationContext context)
      {
          var configuration = context.Services.GetConfiguration();
    
          // Resolve tenant by domain/subdomain
          Configure<AbpTenantResolveOptions>(options =>
          {
              // Example: {0}.yourdomain.com -> tenant name from subdomain
              options.AddDomainTenantResolver(configuration["TenantDomain"]); // e.g. "https://{0}.api.yourdomain.com"
          });
    
          // OpenIddict wildcard domain support for redirect & post-logout redirect URIs
          PreConfigure<AbpOpenIddictWildcardDomainOptions>(options =>
          {
              options.EnableWildcardDomainSupport = true;
              // Add all app URL formats that will appear as redirect_uri/post_logout_redirect_uri
              // Examples:
              options.WildcardDomainsFormat.Add(configuration["WildCardDomains:Blazor"]);      // "https://{0}.yourdomain.com"
              options.WildcardDomainsFormat.Add(configuration["WildCardDomains:Api"]);         // "https://{0}.api.yourdomain.com"
          });
      }
    

    appsettings.json (examples):

    {
        "TenantDomain": "https://{0}.yourdomain.com", // or "https://{0}.api.yourdomain.com" depending on which host resolves tenants
        "WildCardDomains": {
          "Blazor": "https://{0}.yourdomain.com",
          "Api": "https://{0}.api.yourdomain.com"
        },
        "AuthServer": {
          "Authority": "https://{0}.api.yourdomain.com", // embedded OpenIddict issuer format
          "RequireHttpsMetadata": "true"
        }
      }
    
    • OpenIddict issuer and discovery handling (Blazor Server/Web/MVC if applicable): If you need to force login/logout to tenant-specific endpoints, adjust OIDC events to rewrite the IssuerAddress based on ICurrentTenant:
    context.Services.Configure<OpenIdConnectOptions>("oidc", options =>
      {
          var prevLogin = options.Events.OnRedirectToIdentityProvider;
          options.Events.OnRedirectToIdentityProvider = async ctx =>
          {
              var cfg = ctx.HttpContext.RequestServices.GetRequiredService<IConfiguration>();
              var currentTenant = ctx.HttpContext.RequestServices.GetRequiredService<ICurrentTenant>();
              var baseAuthority = cfg["AuthServer:Authority"]!.EnsureEndsWith('/') + "connect/authorize";
              ctx.ProtocolMessage.IssuerAddress = baseAuthority;
    
              var tenantDomainFormat = cfg["TenantDomain"];
              if (currentTenant.IsAvailable && !string.IsNullOrEmpty(tenantDomainFormat))
              {
                  ctx.ProtocolMessage.IssuerAddress = ctx.ProtocolMessage.IssuerAddress.Replace("{0}", currentTenant.Name);
              }
              else
              {
                  ctx.ProtocolMessage.IssuerAddress = ctx.ProtocolMessage.IssuerAddress.Replace("{0}.", string.Empty);
              }
    
              if (prevLogin != null) await prevLogin(ctx);
          };
    
          var prevLogout = options.Events.OnRedirectToIdentityProviderForSignOut;
          options.Events.OnRedirectToIdentityProviderForSignOut = async ctx =>
          {
              var cfg = ctx.HttpContext.RequestServices.GetRequiredService<IConfiguration>();
              var currentTenant = ctx.HttpContext.RequestServices.GetRequiredService<ICurrentTenant>();
              var baseLogout = cfg["AuthServer:Authority"]!.EnsureEndsWith('/') + "connect/logout";
              ctx.ProtocolMessage.IssuerAddress = baseLogout;
    
              var tenantDomainFormat = cfg["TenantDomain"];
              if (currentTenant.IsAvailable && !string.IsNullOrEmpty(tenantDomainFormat))
              {
                  ctx.ProtocolMessage.IssuerAddress = ctx.ProtocolMessage.IssuerAddress.Replace("{0}", currentTenant.Name);
              }
              else
              {
                  ctx.ProtocolMessage.IssuerAddress = ctx.ProtocolMessage.IssuerAddress.Replace("{0}.", string.Empty);
              }
    
              if (prevLogout != null) await prevLogout(ctx);
          };
      });
    

    Note: For Blazor WebAssembly, configure the client at startup (below).

    • Blazor Web App (WASM) – set Authority/BaseUrl dynamically per tenant from current domain: In the Blazor WASM project module (Program.cs or module class), compute URLs by replacing {0} with the tenant segment parsed from window.location.
    private static string[] Protocols = { "http://", "https://" };
    
      private static string GetTenantSegment(string baseUrl)
      {
          var host = baseUrl;
          foreach (var p in Protocols) if (host.StartsWith(p, StringComparison.OrdinalIgnoreCase)) host = host.Substring(p.Length);
          var parts = host.Split('.');
          // If pattern is tenant.domain.tld -> odd length implies first part is tenant
          return parts.Length % 2 == 1 ? parts[0] : null;
      }
    
      private static string ReplaceTenant(string patternUrl, string currentBase)
      {
          var tenant = GetTenantSegment(currentBase);
          return tenant != null ? patternUrl.Replace("{0}", tenant) : patternUrl.Replace("{0}.", string.Empty);
      }
    
      private static void ConfigureAuthentication(WebAssemblyHostBuilder builder)
      {
          builder.Services.AddOidcAuthentication(options =>
          {
              builder.Configuration.Bind("AuthServer", options.ProviderOptions);
              // Override Authority with tenant-specific domain
              var currentBase = builder.HostEnvironment.BaseAddress;
              var configuredAuthority = builder.Configuration["AuthServer:Authority"]; // e.g. "https://{0}.api.yourdomain.com"
              options.ProviderOptions.Authority = ReplaceTenant(configuredAuthority, currentBase);
          });
      }
    
      private static void ConfigureRemoteServices(WebAssemblyHostBuilder builder)
      {
          Configure<AbpRemoteServiceOptions>(opts =>
          {
              var baseConfigured = builder.Configuration["RemoteServices:Default:BaseUrl"]; // e.g. "https://{0}.api.yourdomain.com"
              opts.RemoteServices.Default = new RemoteServiceConfiguration(ReplaceTenant(baseConfigured, builder.HostEnvironment.BaseAddress));
          });
      }
    

    appsettings.json (Blazor WASM):

    {
        "App": { "SelfUrl": "https://{0}.yourdomain.com" },
        "AuthServer": {
          "Authority": "https://{0}.api.yourdomain.com",
          "ClientId": "YourClient_Wasm",
          "ResponseType": "code"
        },
        "RemoteServices": {
          "Default": { "BaseUrl": "https://{0}.api.yourdomain.com" }
        }
      }
    
    • OpenIddict client seeding – allow wildcard redirect/logout URIs: With AbpOpenIddictWildcardDomainOptions enabled on the AuthServer/Host, you don’t need to seed every tenant domain. Seed clients with RootUrl formats or just enable wildcard formats via options as shown above. This prevents “invalid redirect_uri” for per-tenant domains.

    • If you validate access tokens on separate hosts with wildcard issuer: When tokens are validated by an API that uses a format Authority (https://{0}.api.yourdomain.com), configure issuer validation to accept the wildcard issuer. If you’re not using the same web host for validation or need strict issuer validation with a single scheme, use Owl.TokenWildcardIssuerValidator:

    // using Owl.TokenWildcardIssuerValidator;
      context.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
          .AddJwtBearer(options =>
          {
              options.Authority = configuration["AuthServer:Authority"]; // "https://{0}.api.yourdomain.com"
              options.RequireHttpsMetadata = true;
              options.TokenValidationParameters.IssuerValidator = TokenWildcardIssuerValidator.IssuerValidator;
              options.TokenValidationParameters.ValidIssuers = new[]
              {
                  "https://{0}.api.yourdomain.com/"
              };
          });
    
    • Summary checklist:
      • Configure AbpTenantResolveOptions.AddDomainTenantResolver with your tenant domain format.
      • Enable AbpOpenIddictWildcardDomainOptions and add all wildcard formats used by clients (Blazor app, API, etc.).
      • In Blazor WASM, compute Authority/BaseUrl at runtime based on current host, replacing {0}.
      • If validating tokens with wildcard issuer, add wildcard issuer validator (when needed).
      • Do not use “*.domain.com” in redirect URIs; use ABP OpenIddict wildcard support as above.
    1. Sources
    • https://docs.abp.io/en/abp/latest/Multi-Tenancy#tenant-resolution
    • https://abp.io/docs/10.0//guides/ms-multi-tenant-domain-resolving
    • https://abp.io/docs/10.0//framework/architecture/multi-tenancy/

    This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

  • User Avatar
    0
    ddelamare created

    This does not work for us since each of the tenant URLs are entirely unique and do not share any base domain. Also, it does not set the per tenant client id as it should.

  • User Avatar
    0
    AI-Bot created
    Support Team ABP Agent
    1. Solution
    • Problem recap: Each tenant has a completely different domain (no common base), and each tenant must use its own OIDC ClientId and its own authority/redirect/logout URLs. You’re on ABP 10.0.0, Blazor Web App, Auth server embedded in your API. You also don’t use the tenant cookie resolver.

    • Key points supported by ABP:

      • Domain tenant resolution supports full-domain patterns (whole-domain mapping), not only subdomains. You can add multiple domain formats or implement a custom tenant resolver to map arbitrary domains to tenants.
      • Per-tenant OIDC clients are first-class in OpenIddict module: you can persist multiple applications (clients) and select them per-tenant.
      • For server-rendered Blazor (OpenIdConnect handler), you can override the IssuerAddress on redirects; for WASM, set ProviderOptions.Authority at runtime based on current host.
      • Wildcard domain helpers are for subdomain patterns; since your domains are all different and not sharing a placeholder, do not use wildcard-domain feature. Seed exact redirect/cors origins for each tenant.
    • Step-by-step

    A) Resolve tenant by arbitrary domain (no shared base):

    • Use DomainTenantResolver with multiple patterns when possible (hostnames that can be expressed as formats). For fully custom domains that don’t fit a single pattern, add a custom ITenantResolveContributor to map hostnames to tenants.
    using Volo.Abp.MultiTenancy;
    using Microsoft.AspNetCore.Http;
    
    public class CustomDomainTenantResolveContributor : TenantResolveContributorBase
    {
        public const string ContributorName = "CustomDomain";
        public override string Name => ContributorName;
    
        protected override Task<string?> GetTenantIdOrNameFromHttpContextOrNullAsync(
            ITenantResolveContext context, HttpContext httpContext)
        {
            var host = httpContext.Request.Host.Host.ToLowerInvariant();
    
            // Map arbitrary domains to tenant names
            // Example mappings; move to DB/config as needed
            return Task.FromResult(host switch
            {
                "contoso-portal.com" => "contoso",
                "fabrikam-portal.net" => "fabrikam",
                "tailspinsolutions.io" => "tailspin",
                _ => null
            });
        }
    }
    
    public class YourHttpApiHostModule : AbpModule
    {
        public override void ConfigureServices(ServiceConfigurationContext context)
        {
            Configure<AbpTenantResolveOptions>(opts =>
            {
                // Optional: keep any domain formats you can express
                // opts.AddDomainTenantResolver("{0}.example.com");
    
                // Add custom mapping contributor at the beginning
                opts.TenantResolvers.Insert(0, new CustomDomainTenantResolveContributor());
            });
        }
    }
    
    • Reference: ABP’s tenant resolvers and domain resolver (you can add your own contributor to handle full-domain mappings).

    B) Store and use per-tenant OIDC client settings (ClientId, redirect/logout URLs):

    • Seed one OpenIddict application (client) per tenant with its exact redirect and logout URIs and allowed CORS origins. Do not rely on wildcard domain feature here, since your tenants don’t share a domain pattern.
    • You can seed OpenIddict applications in a data seeder that runs per tenant:
    public class MyOpenIddictSeeder : ITransientDependency
    {
        private readonly ICurrentTenant _currentTenant;
        private readonly IOpenIddictApplicationManager _appManager;
    
        public MyOpenIddictSeeder(ICurrentTenant currentTenant, IOpenIddictApplicationManager appManager)
        {
            _currentTenant = currentTenant;
            _appManager = appManager;
        }
    
        public async Task SeedAsync()
        {
            using (_currentTenant.Change("contoso")) // repeat for each tenant, or loop from a store
            {
                var clientId = "contoso_blazor";
                var redirectUri = "https://contoso-portal.com/signin-oidc";
                var postLogoutRedirectUri = "https://contoso-portal.com/signout-callback-oidc";
                var corsOrigin = "https://contoso-portal.com";
    
                if (await _appManager.FindByClientIdAsync(clientId) == null)
                {
                    await _appManager.CreateAsync(new OpenIddictApplicationDescriptor
                    {
                        ClientId = clientId,
                        DisplayName = "Contoso Blazor",
                        Type = OpenIddictConstants.ClientTypes.Public,
                        ConsentType = OpenIddictConstants.ConsentTypes.Implicit,
                        RedirectUris = { new Uri(redirectUri) },
                        PostLogoutRedirectUris = { new Uri(postLogoutRedirectUri) },
                        Permissions =
                        {
                            OpenIddictConstants.Permissions.Endpoints.Authorization,
                            OpenIddictConstants.Permissions.Endpoints.Token,
                            OpenIddictConstants.Permissions.Endpoints.Logout,
                            OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
                            OpenIddictConstants.Permissions.ResponseTypes.Code,
                            OpenIddictConstants.Permissions.Scopes.Email,
                            OpenIddictConstants.Permissions.Scopes.Profile,
                            OpenIddictConstants.Permissions.Scopes.Roles
                        },
                        Requirements = { OpenIddictConstants.Requirements.Features.ProofKeyForCodeExchange }
                    });
    
                    // If you use ABP helper validators for client config, also register CORS origins appropriately.
                }
            }
    
            // Repeat for each tenant with its own exact domains/URIs.
        }
    }
    
    • Important: Each tenant gets its own ClientId (e.g., contoso_blazor, fabrikam_blazor), and each uses its own exact redirect/logout URIs. No wildcard.

    C) Make the Blazor Web App use the tenant’s client and authority dynamically

    • For Blazor Server/Web (OpenIdConnect handler), select authority and client based on the resolved tenant in redirect events, and select the ClientId per tenant. Store a mapping in a configuration or data store.
    using Microsoft.AspNetCore.Authentication.OpenIdConnect;
    using Volo.Abp.MultiTenancy;
    
    public class TenantOidcOptionsConfigurator : IConfigureNamedOptions<OpenIdConnectOptions>
    {
        private readonly IConfiguration _cfg;
        private readonly ICurrentTenant _currentTenant;
    
        public TenantOidcOptionsConfigurator(IConfiguration cfg, ICurrentTenant currentTenant)
        {
            _cfg = cfg;
            _currentTenant = currentTenant;
        }
    
        public void Configure(string name, OpenIdConnectOptions options)
        {
            if (name != "oidc") return;
    
            var map = _cfg.GetSection("TenantOidc").Get<Dictionary<string, TenantOidcConfig>>();
            // TenantOidc: { "contoso": { "Authority": "https://api.contoso.com", "ClientId": "contoso_blazor" }, ... }
    
            var tenantName = _currentTenant.Name;
            if (!tenantName.IsNullOrWhiteSpace() && map != null && map.TryGetValue(tenantName!, out var t))
            {
                options.Authority = t.Authority; // must match issuer
                options.ClientId = t.ClientId;
    
                var prevLogin = options.Events.OnRedirectToIdentityProvider;
                options.Events.OnRedirectToIdentityProvider = async ctx =>
                {
                    ctx.ProtocolMessage.IssuerAddress = options.Authority!.EnsureEndsWith('/') + "connect/authorize";
                    if (prevLogin != null) await prevLogin(ctx);
                };
    
                var prevLogout = options.Events.OnRedirectToIdentityProviderForSignOut;
                options.Events.OnRedirectToIdentityProviderForSignOut = async ctx =>
                {
                    ctx.ProtocolMessage.IssuerAddress = options.Authority!.EnsureEndsWith('/') + "connect/logout";
                    if (prevLogout != null) await prevLogout(ctx);
                };
            }
        }
    
        public void Configure(OpenIdConnectOptions options) => Configure(Options.DefaultName, options);
    }
    
    public class TenantOidcConfig
    {
        public string Authority { get; set; }
        public string ClientId { get; set; }
    }
    
    • Register:
    context.Services.AddSingleton<IConfigureOptions<OpenIdConnectOptions>, TenantOidcOptionsConfigurator>();
    
    • appsettings.json example:
    {
      "TenantOidc": {
        "contoso": {
          "Authority": "https://api.contoso.com",
          "ClientId": "contoso_blazor"
        },
        "fabrikam": {
          "Authority": "https://api.fabrikam.net",
          "ClientId": "fabrikam_blazor"
        }
      }
    }
    
    • For Blazor WASM: compute Authority and ClientId at runtime from the current host. Keep a dictionary in appsettings and select by the domain (or tenant name you resolved client-side).
    builder.Services.AddOidcAuthentication(options =>
    {
        var cfg = builder.Configuration.GetSection("TenantOidc").GetChildren()
            .ToDictionary(x => x.Key, x => new { Authority = x["Authority"], ClientId = x["ClientId"] });
    
        var host = new Uri(builder.HostEnvironment.BaseAddress).Host.ToLowerInvariant();
    
        // Map host -> tenant key, you can keep a map section HostToTenant in config
        var hostMap = builder.Configuration.GetSection("HostToTenant").Get<Dictionary<string,string>>();
        if (hostMap != null && hostMap.TryGetValue(host, out var tenantKey) && cfg.TryGetValue(tenantKey, out var t))
        {
            options.ProviderOptions.Authority = t.Authority;
            options.ProviderOptions.ClientId = t.ClientId;
        }
    
        options.ProviderOptions.ResponseType = "code";
    });
    
    • appsettings for WASM:
    {
      "TenantOidc": {
        "contoso": { "Authority": "https://api.contoso.com", "ClientId": "contoso_wasm" },
        "fabrikam": { "Authority": "https://api.fabrikam.net", "ClientId": "fabrikam_wasm" }
      },
      "HostToTenant": {
        "contoso-portal.com": "contoso",
        "fabrikam-portal.net": "fabrikam"
      }
    }
    

    D) Token validation in API when multiple issuers exist

    • Since each tenant can have a different Authority/Issuer, configure JWT bearer validation to accept multiple issuers or implement a custom IssuerValidator. If all tenants share the same embedded OpenIddict server with a single issuer, keep a single Authority. If issuers differ by tenant, set ValidIssuers accordingly or implement a custom validator.
    context.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.RequireHttpsMetadata = true;
    
        // If each tenant has a different issuer:
        options.TokenValidationParameters.ValidIssuers = new[]
        {
            "https://api.contoso.com/",
            "https://api.fabrikam.net/"
        };
    
        // Or implement a custom IssuerValidator to check against a store of tenant issuers.
    });
    

    E) Disable tenant switching UI on AuthServer

    • When tenant must be forced by domain, don’t expose tenant switching. With domain or custom resolver in place, the current tenant is already determined from the request host, and you can hide the switcher in the Account UI. ABP’s guidance: use domain-based resolver and do not rely on cookie/header switchers.

    F) Summary checklist for your scenario (unique domains)

    • Implement a CustomDomainTenantResolveContributor to map arbitrary domains to tenants.
    • Seed one OpenIddict application per tenant with exact redirect/logout URIs and CORS origins. Use per-tenant ClientId.
    • In Blazor Server: configure OpenIdConnectOptions per request using ICurrentTenant to choose Authority and ClientId before redirecting.
    • In Blazor WASM: select Authority and ClientId at startup based on current host.
    • Configure JWT validation to support multiple issuers if necessary.
    • Do not enable wildcard domain feature for OpenIddict in this case, since domains are unrelated.
    • Ensure each tenant’s domain is registered in AllowedCorsOrigins and RedirectUris exactly.
    1. Sources
    • Multi-tenancy tenant resolution (domain/route and custom contributors):
      • https://docs.abp.io/en/abp/latest/Multi-Tenancy#tenant-resolution
      • https://abp.io/docs/latest/framework/architecture/multi-tenancy
    • OpenIddict module and deployment in ABP:
      • https://abp.io/docs/10.0//modules/openiddict
      • https://abp.io/docs/10.0//solution-templates/layered-web-application/deployment/openiddict-deployment
    • Server-side apps intercepting OIDC redirect to enforce proper Authority (pattern to adapt for per-tenant):
      • https://abp.io/docs/10.0//guides/ms-multi-tenant-domain-resolving

    This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    hi

    You can make sure the tenant domain works first.

    See https://abp.io/docs/latest/framework/architecture/multi-tenancy#domainsubdomain-tenant-resolver

    Thanks

Boost Your Development
ABP Live Training
Packages
See Trainings
Mastering ABP Framework Book
The Official Guide
Mastering
ABP Framework
Learn More
Mastering ABP Framework Book
Made with ❤️ on ABP v10.1.0-preview. Updated on December 16, 2025, 09:09
1
ABP Assistant
🔐 You need to be logged in to use the chatbot. Please log in first.