Ends in:
3 DAYS
5 HRS
2 MIN
36 SEC
Ends in:
3 D
5 H
2 M
36 S
Open Closed

Issue with OpenIddict Redirect URIs in Multitenant Environment #7372


User avatar
0
anurag.tyagi created
  • ABP Framework version: v8.0.2
  • UI Type: Angular
  • Database System: EF Core (MySQL)
  • Tiered (for MVC) or Auth Server Separated (for Angular): no
  • Exception message and full stack trace: Redirect URI not matched to the allowed list
  • Steps to reproduce the issue:Enable multitenancy in abp using official documentation (https://docs.abp.io/en/abp/latest/Multi-Tenancy#domain-subdomain-tenant-resolver, https://github.com/abpframework/abp-samples/tree/master/DomainTenantResolver), create new tenant, try to login to the tenant using tenant URL.

I need assistance with an issue we're encountering with our ABP Commercial application configured for multitenancy (https://docs.abp.io/en/abp/1.0/Multi-Tenancy#domain-tenant-resolver). We have enabled multitenancy and are using OpenIddict for authentication. The configuration details for OpenIddict are stored in a database table named OpenIddictApplications, including the RedirectUris and PostLogoutRedirectUris columns.

Our application uses subdomain-based URLs for each tenant (e.g., tenant.mydomain.com). When creating a new tenant, the new tenant URL must be added to the list of allowed URIs in the RedirectUris and PostLogoutRedirectUris columns. However, this is not happening automatically, and we are facing a significant issue.

Redirect URI Mismatch Error: If the new tenant URL is not added to these lists, we receive an error indicating a mismatch of redirect URIs, preventing login and logout operations. No Support for Wildcards: The redirection endpoint URI must be an absolute URI as defined by [RFC3986] Section 4.3 (https://www.rfc-editor.org/rfc/rfc3986#section-4.3), and therefore we cannot use wildcard values like https://*.mydomain.com or https://{0}.mydomain.com. Given these constraints, we cannot dynamically handle new tenant URLs without manual updates to the allowed URIs lists.

Could you please guide how to resolve this issue? Specifically, we are looking for a solution that allows us to manage tenant-specific redirect URIs more dynamically without requiring manual updates to the database for each new tenant.

Any suggestions or best practices to handle this situation effectively would be greatly appreciated.


10 Answer(s)
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    hi

    Can you try that?

    PreConfigure<AbpOpenIddictWildcardDomainOptions>(options =>
    {
        options.EnableWildcardDomainSupport = true;
        options.WildcardDomainsFormat.Add("https://{0}.web.getabp.net:44303/signin-oidc");
        options.WildcardDomainsFormat.Add("https://{0}.web.getabp.net:44303/signout-callback-oidc");
    });
    
    Configure<OpenIddictServerOptions>(options =>
    {
        options.TokenValidationParameters.IssuerValidator = TokenWildcardIssuerValidator.IssuerValidator;
        options.TokenValidationParameters.ValidIssuers = new[]
        {
            "https://ids.getabp.net:44301/",
            "https://{0}.ids.getabp.net:44301/"
        };
    });
    

    https://github.com/abpframework/abp-samples/blob/master/DomainTenantResolver/OpenIddict/MVC-TIERED/src/Acme.BookStore.AuthServer/BookStoreIdentityServerModule.cs#L65-L70 https://github.com/abpframework/abp-samples/blob/master/DomainTenantResolver/OpenIddict/MVC-TIERED/src/Acme.BookStore.AuthServer/BookStoreIdentityServerModule.cs#L83-L91

  • User Avatar
    0
    anurag.tyagi created

    Hi,

    Thanks for your reply.

    I have already implemented the provided configuration in our project at the time of creating this ticket. However, this solution does not resolve the issue.

    Could you please provide further assistance or an alternative approach to dynamically handle tenant-specific redirect URIs?

    Thanks in advance

  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    hi

    Redirect URI Mismatch Error: If the new tenant URL is not added to these lists, we receive an error indicating a mismatch of redirect URIs, preventing login and logout operations.

    Please share the logs of this case, Thanks.

  • User Avatar
    0
    anurag.tyagi created

    Hi, please see the logs below:

    20:03:07 [18:03:07 INF] Request finished HTTP/1.1 GET https://mybackend.com/api/saas/tenants/3a114414-b352-6751-1023-c40755a4a1bb/image - 500 null application/json; charset=utf-8 194.2802ms 20/06/2024
    20:03:07 [18:03:07 INF] Request starting HTTP/1.1 GET http://mybackend.com/connect/authorize?response_type=code&client_id=Myapp_App&state=MFFzdn5-NENLRkFKTlZOLlZuRDhNVFdiUC4zVmRlMC1UX3RxV00zWkVvSk05%3B%252Ffree-inquiry&redirect_uri=https%3A%2F%2Fmyfrontend.com&scope=openid%20offline_access%20Myapp&code_challenge=0bxO4MWionxUKMExy-9tJkcC-sN8WJhDjQts2c6LHbo&code_challenge_method=S256&nonce=MFFzdn5-NENLRkFKTlZOLlZuRDhNVFdiUC4zVmRlMC1UX3RxV00zWkVvSk05&culture=en&ui-culture=en&returnUrl=%2Ffree-inquiry - null null 20/06/2024
    20:03:07 [18:03:07 INF] The request URI matched a server endpoint: Authorization. 20/06/2024
    20:03:07 [18:03:07 INF] The authorization request was successfully extracted: { 20/06/2024
    20:03:07   "response_type": "code", 20/06/2024
    20:03:07   "client_id": "Myapp_App", 20/06/2024
    20:03:07   "state": "MFFzdn5-NENLRkFKTlZOLlZuRDhNVFdiUC4zVmRlMC1UX3RxV00zWkVvSk05;%2Ffree-inquiry", 20/06/2024
    20:03:07   "redirect_uri": "https://myfrontend.com", 20/06/2024
    20:03:07   "scope": "openid offline_access Myapp", 20/06/2024
    20:03:07   "code_challenge": "0bxO4MWionxUKMExy-9tJkcC-sN8WJhDjQts2c6LHbo", 20/06/2024
    20:03:07   "code_challenge_method": "S256", 20/06/2024
    20:03:07   "nonce": "MFFzdn5-NENLRkFKTlZOLlZuRDhNVFdiUC4zVmRlMC1UX3RxV00zWkVvSk05", 20/06/2024
    20:03:07   "culture": "en", 20/06/2024
    20:03:07   "ui-culture": "en", 20/06/2024
    20:03:07   "returnUrl": "/free-inquiry" 20/06/2024
    20:03:07 }. 20/06/2024
    20:03:08 [18:03:08 INF] Client validation failed because 'https://myfrontend.com' was not a valid redirect_uri for Myapp_App. 20/06/2024
    20:03:08 [18:03:08 INF] The authorization request was rejected because the redirect_uri was invalid: 'https://myfrontend.com'. 20/06/2024
    20:03:08 [18:03:08 INF] Request finished HTTP/1.1 GET https://mybackend.com/connect/authorize?response_type=code&client_id=Myapp_App&state=MFFzdn5-NENLRkFKTlZOLlZuRDhNVFdiUC4zVmRlMC1UX3RxV00zWkVvSk05%3B%252Ffree-inquiry&redirect_uri=https%3A%2F%2Fmyfrontend.com&scope=openid%20offline_access%20Myapp&code_challenge=0bxO4MWionxUKMExy-9tJkcC-sN8WJhDjQts2c6LHbo&code_challenge_method=S256&nonce=MFFzdn5-NENLRkFKTlZOLlZuRDhNVFdiUC4zVmRlMC1UX3RxV00zWkVvSk05&culture=en&ui-culture=en&returnUrl=%2Ffree-inquiry - 302 0 null 269.0968ms 20/06/2024
    
    

    FYI, I have substituted the real domains with:

    mybackend.com representing our backend domain myfrontend.com in place of the actual frontend domain

  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    because 'https://myfrontend.com' was not a valid redirect_uri for Myapp_App.

    Please share your authserver(openiddict) module code.

    Does the https://myfrontend.com in your Myapp_App's redirect_uri?

  • User Avatar
    0
    anurag.tyagi created

    Hi,

    Can you please tell me which file specifically you need?

  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    hi

    The module that has the PreConfigure<AbpOpenIddictWildcardDomainOptions>(options => code.

  • User Avatar
    0
    anurag.tyagi created
    public class MyappHttpApiHostModule : AbpModule
    {
        public override void PreConfigureServices(ServiceConfigurationContext context)
        {
            var hostingEnvironment = context.Services.GetHostingEnvironment();
            var configuration = context.Services.GetConfiguration();
    
            PreConfigure<OpenIddictBuilder>(builder =>
            {
                builder.AddValidation(options =>
                {
                    options.AddAudiences("Myapp");
                    options.UseLocalServer();
                    options.UseAspNetCore();
                });
            });
            
            PreConfigure<AbpOpenIddictWildcardDomainOptions>(options =>
            {
                options.EnableWildcardDomainSupport = true;
                options.WildcardDomainsFormat.Add("https://{0}.mybackend.com/");
                options.WildcardDomainsFormat.Add("https://{0}.mybackend.com/signin-oidc");
                options.WildcardDomainsFormat.Add("https://{0}.mybackend.com/signout-callback-oidc");
                options.WildcardDomainsFormat.Add("https://{0}.mybackend.com/authentication/login-callback");
                options.WildcardDomainsFormat.Add("https://{0}.myfrontend.com/");
            });
    
    #if DEBUG
            PreConfigure<OpenIddictServerBuilder>(options => { options.UseAspNetCore().DisableTransportSecurityRequirement(); });
    #endif
            
            if (!hostingEnvironment.IsDevelopment())
            {
                PreConfigure<AbpOpenIddictAspNetCoreOptions>(options =>
                {
                    options.AddDevelopmentEncryptionAndSigningCertificate = false;
                });
    
                PreConfigure<OpenIddictServerBuilder>(builder =>
                {
                    builder.AddSigningCertificate(GetSigningCertificate(hostingEnvironment, configuration));
                    builder.AddEncryptionCertificate(GetSigningCertificate(hostingEnvironment, configuration));
                });
            }
            
            PreConfigure<IdentityBuilder>(identityBuilder=>{identityBuilder.AddSignInManager<MyAbpSignInManager>();});
        }
    
        public override void ConfigureServices(ServiceConfigurationContext context)
        {
            var configuration = context.Services.GetConfiguration();
            var hostingEnvironment = context.Services.GetHostingEnvironment();
    
            if (!Convert.ToBoolean(configuration["App:DisablePII"]))
            {
                Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = true;
            }
    
            Configure<AbpTenantResolveOptions>(options =>
            {
                options.AddDomainTenantResolver(configuration["App:Domain"]!);
            });
            
            if (!Convert.ToBoolean(configuration["AuthServer:RequireHttpsMetadata"]))
            {
                Configure<OpenIddictServerAspNetCoreOptions>(options =>
                {
                    options.DisableTransportSecurityRequirement = true;
                });
            }
            
            Configure<AbpTenantResolveOptions>(options =>
            {
                options.AddDomainTenantResolver(configuration["App:TenantResolverDomain"]!);
            });
            
            
            context.Services.AddHangfire(config =>
            {
                config.UseStorage(new MySqlStorage(configuration.GetConnectionString("Default"), new MySqlStorageOptions
                {
                }));
            });
    
            ConfigureAuthentication(context);
            ConfigureUrls(configuration);
            ConfigureBundles();
            ConfigureConventionalControllers();
            ConfigureImpersonation(context, configuration);
            ConfigureSwagger(context, configuration);
            ConfigureVirtualFileSystem(context);
            ConfigureCors(context, configuration);
            ConfigureExternalProviders(context);
            ConfigureHealthChecks(context);
            ConfigureTheme();
            
            context.Services.AddSignalR(hubOptions =>
            {
                hubOptions.MaximumParallelInvocationsPerClient = 5;
            });
        }
    
        private void ConfigureTheme()
        {
            Configure<LeptonXThemeOptions>(options =>
            {
                options.DefaultStyle = LeptonXStyleNames.System;
            });
        }
    
        private void ConfigureAuthentication(ServiceConfigurationContext context)
        {
            context.Services.ForwardIdentityAuthenticationForBearer(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
        }
    
        private void ConfigureHealthChecks(ServiceConfigurationContext context)
        {
            context.Services.AddMyappHealthChecks();
        }
    
        private void ConfigureUrls(IConfiguration configuration)
        {
            Configure<AppUrlOptions>(options =>
            {
                options.Applications["MVC"].RootUrl = configuration["App:SelfUrl"];
                options.Applications["Angular"].RootUrl = configuration["App:AngularUrl"];
                options.Applications["Angular"].Urls[AccountUrlNames.PasswordReset] = "account/reset-password";
                options.Applications["Angular"].Urls[AccountUrlNames.EmailConfirmation] = "account/email-confirmation";
                options.RedirectAllowedUrls.AddRange(configuration["App:RedirectAllowedUrls"]?.Split(',') ?? Array.Empty<string>());
            });
        }
    
        private void ConfigureBundles()
        {
            Configure<AbpBundlingOptions>(options =>
            {
                options.StyleBundles.Configure(
                    LeptonXThemeBundles.Styles.Global,
                    bundle =>
                    {
                        bundle.AddFiles("/global-styles.css");
                    }
                );
            });
        }
    
    
        private void ConfigureVirtualFileSystem(ServiceConfigurationContext context)
        {
            var hostingEnvironment = context.Services.GetHostingEnvironment();
    
            if (hostingEnvironment.IsDevelopment())
            {
                Configure<AbpVirtualFileSystemOptions>(options =>
                {
                    options.FileSets.ReplaceEmbeddedByPhysical<MyappDomainSharedModule>(Path.Combine(hostingEnvironment.ContentRootPath, $"..{Path.DirectorySeparatorChar}Pm.Myapp.Domain.Shared"));
                    options.FileSets.ReplaceEmbeddedByPhysical<MyappDomainModule>(Path.Combine(hostingEnvironment.ContentRootPath, $"..{Path.DirectorySeparatorChar}Pm.Myapp.Domain"));
                    options.FileSets.ReplaceEmbeddedByPhysical<MyappApplicationContractsModule>(Path.Combine(hostingEnvironment.ContentRootPath, $"..{Path.DirectorySeparatorChar}Pm.Myapp.Application.Contracts"));
                    options.FileSets.ReplaceEmbeddedByPhysical<MyappApplicationModule>(Path.Combine(hostingEnvironment.ContentRootPath, $"..{Path.DirectorySeparatorChar}Pm.Myapp.Application"));
                });
            }
        }
    
        private void ConfigureConventionalControllers()
        {
            Configure<AbpAspNetCoreMvcOptions>(options =>
            {
                options.ConventionalControllers.Create(typeof(MyappApplicationModule).Assembly);
            });
        }
    
        private static void ConfigureSwagger(ServiceConfigurationContext context, IConfiguration configuration)
        {
            context.Services.AddAbpSwaggerGenWithOAuth(
                configuration["AuthServer:Authority"]!,
                new Dictionary<string, string>
                {
                        {"Myapp", "Myapp API"}
                },
                options =>
                {
                    options.SwaggerDoc("v1", new OpenApiInfo { Title = "Myapp API", Version = "v1" });
                    options.DocInclusionPredicate((docName, description) => true);
                    options.CustomSchemaIds(type => type.FullName);
                });
        }
    
        private void ConfigureCors(ServiceConfigurationContext context, IConfiguration configuration)
        {
            context.Services.AddCors(options =>
            {
                options.AddDefaultPolicy(builder =>
                {
                    builder
                        .WithOrigins(
                            configuration["App:CorsOrigins"]?
                                .Split(",", StringSplitOptions.RemoveEmptyEntries)
                                .Select(o => o.Trim().RemovePostFix("/"))
                                .ToArray() ?? Array.Empty<string>()
                        )
                        .WithAbpExposedHeaders()
                        .SetIsOriginAllowedToAllowWildcardSubdomains()
                        .AllowAnyHeader()
                        .AllowAnyMethod()
                        .AllowCredentials();
                });
            });
        }
    
        private void ConfigureExternalProviders(ServiceConfigurationContext context)
        {
            var configuration = context.Services.GetConfiguration();
            context.Services.AddAuthentication()
                .AddJwtBearer(options =>
                {
                    options.Authority = configuration["AuthServer:Authority"];
                    options.RequireHttpsMetadata = Convert.ToBoolean(configuration["AuthServer:RequireHttpsMetadata"]);
                    options.Audience = "Myapp";
            
                    options.TokenValidationParameters.IssuerValidator = TokenWildcardIssuerValidator.IssuerValidator;
                    options.TokenValidationParameters.ValidIssuers = new[]
                    {
                        configuration["App:CommonUrl"]
                    };
                })
                .AddGoogle(GoogleDefaults.AuthenticationScheme, _ => { })
                .WithDynamicOptions<GoogleOptions, GoogleHandler>(
                    GoogleDefaults.AuthenticationScheme,
                    options =>
                    {
                        options.WithProperty(x => x.ClientId);
                        options.WithProperty(x => x.ClientSecret, isSecret: true);
                    }
                )
                .AddMicrosoftAccount(MicrosoftAccountDefaults.AuthenticationScheme, options =>
                {
                        //Personal Microsoft accounts as an example.
                        options.AuthorizationEndpoint = "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize";
                    options.TokenEndpoint = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
                })
                .WithDynamicOptions<MicrosoftAccountOptions, MicrosoftAccountHandler>(
                    MicrosoftAccountDefaults.AuthenticationScheme,
                    options =>
                    {
                        options.WithProperty(x => x.ClientId);
                        options.WithProperty(x => x.ClientSecret, isSecret: true);
                    }
                )
                .AddTwitter(TwitterDefaults.AuthenticationScheme, options => options.RetrieveUserDetails = true)
                .WithDynamicOptions<TwitterOptions, TwitterHandler>(
                    TwitterDefaults.AuthenticationScheme,
                    options =>
                    {
                        options.WithProperty(x => x.ConsumerKey);
                        options.WithProperty(x => x.ConsumerSecret, isSecret: true);
                    }
                );
        }
    
        private void ConfigureImpersonation(ServiceConfigurationContext context, IConfiguration configuration)
        {
            context.Services.Configure<AbpAccountOptions>(options =>
            {
                options.TenantAdminUserName = "admin";
                options.ImpersonationTenantPermission = SaasHostPermissions.Tenants.Impersonation;
                options.ImpersonationUserPermission = IdentityPermissions.Users.Impersonation;
            });
        }
    
        public override void OnApplicationInitialization(ApplicationInitializationContext context)
        {
            var app = context.GetApplicationBuilder();
            var env = context.GetEnvironment();
    
            var forwardOptions = new ForwardedHeadersOptions {
                ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost, RequireHeaderSymmetry = false
            };
    
            forwardOptions.KnownNetworks.Clear();
            forwardOptions.KnownProxies.Clear();
    
        // ref: https://github.com/aspnet/Docs/issues/2384
            app.UseForwardedHeaders(forwardOptions);
    
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
    
            app.UseAbpRequestLocalization();
    
            if (!env.IsDevelopment())
            {
                app.UseErrorPage();
            }
    
            app.UseAbpSecurityHeaders();
            app.UseStaticFiles();
            app.UseRouting();
            app.UseCors();
            app.UseAuthentication();
            app.UseAbpOpenIddictValidation();
    
            if (MultiTenancyConsts.IsEnabled)
            {
                app.UseMultiTenancy();
            }
    
            app.UseUnitOfWork();
    
            app.UseAuthorization();
    
    
    
            if (!env.IsDevelopment())
            {
                app.Use(async (httpContext, next) =>
                {
                    if (httpContext.Request.Path.StartsWithSegments("/swagger") && !httpContext.User.IsInRole("admin"))
                    {
                        httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
                        return;
                    }
    
                    await next.Invoke();
                });
            }
    
    
            app.UseSwagger();
            app.UseAbpSwaggerUI(options =>
            {
                options.SwaggerEndpoint("/swagger/v1/swagger.json", "Myapp API");
    
                var configuration = context.ServiceProvider.GetRequiredService<IConfiguration>();
                options.OAuthClientId(configuration["AuthServer:SwaggerClientId"]);
            });
            
            RecurringJob.AddOrUpdate<ResetMonthlyQuestionsCountJob>("reset-monthly-questions",
                job => job.ExecuteAsync(new ResetMonthlyQuestionsCountArgs()), Cron.Monthly);
            
            
            app.UseAuditing();
            app.UseAbpSerilogEnrichers();
            app.UseConfiguredEndpoints();
        }
    
        private X509Certificate2 GetSigningCertificate(IWebHostEnvironment hostingEnv, IConfiguration configuration)
        {
            var fileName = "authserver.pfx";
            var passPhrase = "f123a4f5-678a-4b4d-a2c4-3f62b6033d4d";
            var file = Path.Combine(hostingEnv.ContentRootPath, fileName);
    
            if (!File.Exists(file))
            {
                throw new FileNotFoundException($"Signing Certificate couldn't found: {file}");
            }
    
            return new X509Certificate2(file, passPhrase);
        }
    }
    
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    hi

    Your module code is no problem.

    The WildcardDomainsFormat is enough.

    PreConfigure<AbpOpenIddictWildcardDomainOptions>(options =>
    {
        options.EnableWildcardDomainSupport = true;
        options.WildcardDomainsFormat.Add("https://{0}.mybackend.com/");
        options.WildcardDomainsFormat.Add("https://{0}.mybackend.com/signin-oidc");
        options.WildcardDomainsFormat.Add("https://{0}.mybackend.com/signout-callback-oidc");
        options.WildcardDomainsFormat.Add("https://{0}.mybackend.com/authentication/login-callback");
        options.WildcardDomainsFormat.Add("https://{0}.myfrontend.com/");
    });
    

    Can you set the log level to Debug(MinimumLevel.Debug()) and share the Logs.txt

    
    public class Program
    {
        public async static Task<int> Main(string[] args)
        {
            Log.Logger = new LoggerConfiguration()
                .MinimumLevel.Debug()
                .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
                .Enrich.FromLogContext()
                .WriteTo.Async(c => c.File("Logs/logs.txt"))
                .WriteTo.Async(c => c.Console())
                .CreateLogger();
    

    liming.ma@volosoft.com

    Thanks

  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    hi

    Can you remove the / from the end of the URL?

    Change options.WildcardDomainsFormat.Add("http://{0}.localhost:4200/"); to options.WildcardDomainsFormat.Add("http://{0}.localhost:4200");

    [10:55:12 DBG] Checking wildcard domain for url: http://test.localhost:4200
    [10:55:12 DBG] Checking wildcard domain format: http://{0}.localhost:44367/
    [10:55:12 DBG] Checking wildcard domain format: http://{0}.localhost:44367/signin-oidc
    [10:55:12 DBG] Checking wildcard domain format: http://{0}.localhost:44367/signout-callback-oidc
    [10:55:12 DBG] Checking wildcard domain format: http://{0}.localhost:44367/authentication/login-callback
    [10:55:12 DBG] Checking wildcard domain format: http://{0}.localhost:4200/
    [10:55:12 DBG] Checking wildcard domain format: http://{0}.localhost:44367/
    [10:55:12 DBG] Checking wildcard domain format: http://{0}.localhost:44367/signin-oidc
    [10:55:12 DBG] Checking wildcard domain format: http://{0}.localhost:44367/signout-callback-oidc
    [10:55:12 DBG] Checking wildcard domain format: http://{0}.localhost:44367/authentication/login-callback
    [10:55:12 DBG] Checking wildcard domain format: http://{0}.localhost:4200/
    [10:55:12 DBG] Wildcard domain not found for url: http://test.localhost:4200
    [10:55:13 INF] Client validation failed because 'http://test.localhost:4200' was not a valid redirect_uri for GovernmentGpt_App.
    
Made with ❤️ on ABP v9.1.0-preview. Updated on December 02, 2024, 12:35