diff --git a/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs b/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs index 9b80239..2361ee4 100644 --- a/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs +++ b/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs @@ -60,8 +60,9 @@ internal static class NetworkConfigurationExtensions options.OwnerActivationUrlTemplate.Replace("{host}", "tenant.example.com", StringComparison.Ordinal), UriKind.Absolute, out var activationOrigin) && - (activationOrigin.Scheme == Uri.UriSchemeHttp || activationOrigin.Scheme == Uri.UriSchemeHttps), - "Tenant provisioning requires a default offering code, valid trial/activation durations, and an absolute HTTP(S) owner activation URL template containing {host}.") + activationOrigin.Scheme == Uri.UriSchemeHttps && + ValidateDevelopmentActivationTemplate(options, environment.IsDevelopment()), + "Tenant provisioning requires a default offering code, valid trial/activation durations, an HTTPS owner activation URL template, and permits an HTTP template only for Development .localhost sites.") .ValidateOnStart(); if (environment.IsProduction()) { @@ -107,4 +108,24 @@ internal static class NetworkConfigurationExtensions return services; } + + private static bool ValidateDevelopmentActivationTemplate( + TenantProvisioningOptions options, + bool isDevelopment) + { + if (string.IsNullOrWhiteSpace(options.DevelopmentLocalhostOwnerActivationUrlTemplate)) + { + return true; + } + + return isDevelopment && + options.DevelopmentLocalhostOwnerActivationUrlTemplate.Contains("{host}", StringComparison.Ordinal) && + Uri.TryCreate( + options.DevelopmentLocalhostOwnerActivationUrlTemplate.Replace( + "{host}", "tenant.localhost", StringComparison.Ordinal), + UriKind.Absolute, + out var developmentOrigin) && + (developmentOrigin.Scheme == Uri.UriSchemeHttp || + developmentOrigin.Scheme == Uri.UriSchemeHttps); + } } diff --git a/Tiku.Api/appsettings.Development.json b/Tiku.Api/appsettings.Development.json index 4f65b31..e680379 100644 --- a/Tiku.Api/appsettings.Development.json +++ b/Tiku.Api/appsettings.Development.json @@ -29,7 +29,7 @@ "EnableDevelopmentLocalhostBypass": true }, "TenantProvisioning": { - "OwnerActivationUrlTemplate": "http://{host}:5180" + "DevelopmentLocalhostOwnerActivationUrlTemplate": "http://{host}:5180" }, "Authentication": { "Sms": { diff --git a/Tiku.Application/PlatformAdmin/PlatformAdminModels.cs b/Tiku.Application/PlatformAdmin/PlatformAdminModels.cs index e238b31..e4e91f1 100644 --- a/Tiku.Application/PlatformAdmin/PlatformAdminModels.cs +++ b/Tiku.Application/PlatformAdmin/PlatformAdminModels.cs @@ -139,6 +139,40 @@ public sealed class TenantProvisioningOptions public int DefaultTrialDays { get; set; } = 14; public int OwnerActivationMinutes { get; set; } = 30; public string OwnerActivationUrlTemplate { get; set; } = "https://{host}"; + public string? DevelopmentLocalhostOwnerActivationUrlTemplate { get; set; } +} + +public static class TenantOwnerActivationUrlPolicy +{ + public static string Build( + TenantProvisioningOptions options, + string host, + Guid activationId, + string token) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(host); + ArgumentException.ThrowIfNullOrWhiteSpace(token); + + var normalizedHost = host.Trim().TrimEnd('.').ToLowerInvariant(); + var useDevelopmentLocalhost = normalizedHost.EndsWith(".localhost", StringComparison.Ordinal) && + !string.IsNullOrWhiteSpace(options.DevelopmentLocalhostOwnerActivationUrlTemplate); + var template = useDevelopmentLocalhost + ? options.DevelopmentLocalhostOwnerActivationUrlTemplate! + : options.OwnerActivationUrlTemplate; + var origin = template.Replace("{host}", normalizedHost, StringComparison.Ordinal).TrimEnd('/'); + if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri) || + (!useDevelopmentLocalhost && uri.Scheme != Uri.UriSchemeHttps) || + (useDevelopmentLocalhost && + uri.Scheme != Uri.UriSchemeHttp && + uri.Scheme != Uri.UriSchemeHttps)) + { + throw new InvalidOperationException( + "Owner activation URLs must use HTTPS except for an explicitly configured Development .localhost origin."); + } + + return $"{origin}/activate/{activationId}#token={token}"; + } } public sealed record TenantBillingPolicyItem( diff --git a/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs b/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs index 191487c..3b5fd15 100644 --- a/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs +++ b/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs @@ -1436,12 +1436,7 @@ internal sealed class PlatformAdminService( Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); private string BuildOwnerActivationUrl(string host, Guid activationId, string token) - { - var siteOrigin = provisioning.OwnerActivationUrlTemplate - .Replace("{host}", host, StringComparison.Ordinal) - .TrimEnd('/'); - return $"{siteOrigin}/activate/{activationId}#token={token}"; - } + => TenantOwnerActivationUrlPolicy.Build(provisioning, host, activationId, token); private static JsonElement JsonObjectOrDefault(JsonElement value) => value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone(); diff --git a/Tiku.UnitTests/Tenancy/TenantOwnerActivationUrlPolicyTests.cs b/Tiku.UnitTests/Tenancy/TenantOwnerActivationUrlPolicyTests.cs new file mode 100644 index 0000000..9011a30 --- /dev/null +++ b/Tiku.UnitTests/Tenancy/TenantOwnerActivationUrlPolicyTests.cs @@ -0,0 +1,48 @@ +using Tiku.Application.PlatformAdmin; + +namespace Tiku.UnitTests.Tenancy; + +public sealed class TenantOwnerActivationUrlPolicyTests +{ + [Fact] + public void Custom_domain_always_uses_secure_activation_origin() + { + var id = Guid.NewGuid(); + var options = new TenantProvisioningOptions + { + OwnerActivationUrlTemplate = "https://{host}", + DevelopmentLocalhostOwnerActivationUrlTemplate = "http://{host}:5180" + }; + + var url = TenantOwnerActivationUrlPolicy.Build(options, "School.Example.Test", id, "secret"); + + Assert.Equal($"https://school.example.test/activate/{id}#token=secret", url); + } + + [Fact] + public void Development_localhost_can_use_explicit_http_origin() + { + var id = Guid.NewGuid(); + var options = new TenantProvisioningOptions + { + OwnerActivationUrlTemplate = "https://{host}", + DevelopmentLocalhostOwnerActivationUrlTemplate = "http://{host}:5180" + }; + + var url = TenantOwnerActivationUrlPolicy.Build(options, "school.localhost", id, "secret"); + + Assert.Equal($"http://school.localhost:5180/activate/{id}#token=secret", url); + } + + [Fact] + public void Insecure_custom_domain_template_is_rejected() + { + var options = new TenantProvisioningOptions + { + OwnerActivationUrlTemplate = "http://{host}" + }; + + Assert.Throws(() => + TenantOwnerActivationUrlPolicy.Build(options, "school.example.test", Guid.NewGuid(), "secret")); + } +}