diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs index 89a2d4b51f..9a3543f4bb 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -1,10 +1,13 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using System.Security.Claims; using System.Text.Json.Serialization; using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Models.Api; using Bit.Core.Models.Business; @@ -177,6 +180,30 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel } } + public OrganizationSubscriptionResponseModel(Organization organization, OrganizationLicense license, ClaimsPrincipal claimsPrincipal) : + this(organization, (Plan)null) + { + if (license != null) + { + // CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim + // The token's expiration is cryptographically secured and cannot be tampered with + // The file's Expires property can be manually edited and should NOT be trusted for display + if (claimsPrincipal != null) + { + Expiration = claimsPrincipal.GetValue(OrganizationLicenseConstants.Expires); + ExpirationWithoutGracePeriod = claimsPrincipal.GetValue(OrganizationLicenseConstants.ExpirationWithoutGracePeriod); + } + else + { + // No token - use the license file expiration (for older licenses without tokens) + Expiration = license.Expires; + ExpirationWithoutGracePeriod = license.ExpirationWithoutGracePeriod ?? (license.Trial + ? license.Expires + : license.Expires?.AddDays(-Constants.OrganizationSelfHostSubscriptionGracePeriodDays)); + } + } + } + public string StorageName { get; set; } public double? StorageGb { get; set; } public BillingCustomerDiscount CustomerDiscount { get; set; } diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index 075218dd74..506ce13e4e 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -26,7 +26,8 @@ public class AccountsController( IUserService userService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IUserAccountKeysQuery userAccountKeysQuery, - IFeatureService featureService) : Controller + IFeatureService featureService, + ILicensingService licensingService) : Controller { [HttpPost("premium")] public async Task PostPremiumAsync( @@ -97,12 +98,14 @@ public class AccountsController( var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); var subscriptionInfo = await paymentService.GetSubscriptionAsync(user); var license = await userService.GenerateLicenseAsync(user, subscriptionInfo); - return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount); + var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license); + return new SubscriptionResponseModel(user, subscriptionInfo, license, claimsPrincipal, includeMilestone2Discount); } else { var license = await userService.GenerateLicenseAsync(user); - return new SubscriptionResponseModel(user, license); + var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license); + return new SubscriptionResponseModel(user, null, license, claimsPrincipal); } } else diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index 5494c5a90e..6b8061c03c 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -67,7 +67,8 @@ public class OrganizationsController( if (globalSettings.SelfHosted) { var orgLicense = await licensingService.ReadOrganizationLicenseAsync(organization); - return new OrganizationSubscriptionResponseModel(organization, orgLicense); + var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(orgLicense); + return new OrganizationSubscriptionResponseModel(organization, orgLicense, claimsPrincipal); } var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index 29a47e160c..32d12aa416 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Constants; +using System.Security.Claims; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Models.Business; using Bit.Core.Entities; using Bit.Core.Models.Api; @@ -37,6 +40,46 @@ public class SubscriptionResponseModel : ResponseModel : null; } + /// The user entity containing storage and premium subscription information + /// Subscription information retrieved from the payment provider (Stripe/Braintree) + /// The user's license containing expiration and feature entitlements + /// The claims principal containing cryptographically secure token claims + /// + /// Whether to include discount information in the response. + /// Set to true when the PM23341_Milestone_2 feature flag is enabled AND + /// you want to expose Milestone 2 discount information to the client. + /// The discount will only be included if it matches the specific Milestone 2 coupon ID. + /// + public SubscriptionResponseModel(User user, SubscriptionInfo? subscription, UserLicense license, ClaimsPrincipal? claimsPrincipal, bool includeMilestone2Discount = false) + : base("subscription") + { + Subscription = subscription?.Subscription != null ? new BillingSubscription(subscription.Subscription) : null; + UpcomingInvoice = subscription?.UpcomingInvoice != null ? + new BillingSubscriptionUpcomingInvoice(subscription.UpcomingInvoice) : null; + StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null; + StorageGb = user.Storage.HasValue ? Math.Round(user.Storage.Value / 1073741824D, 2) : 0; // 1 GB + MaxStorageGb = user.MaxStorageGb; + License = license; + + // CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim + // The token's expiration is cryptographically secured and cannot be tampered with + // The file's Expires property can be manually edited and should NOT be trusted for display + if (claimsPrincipal != null) + { + Expiration = claimsPrincipal.GetValue(UserLicenseConstants.Expires); + } + else + { + // No token - use the license file expiration (for older licenses without tokens) + Expiration = License.Expires; + } + + // Only display the Milestone 2 subscription discount on the subscription page. + CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription?.CustomerDiscount) + ? new BillingCustomerDiscount(subscription!.CustomerDiscount!) + : null; + } + public SubscriptionResponseModel(User user, UserLicense? license = null) : base("subscription") { diff --git a/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs index d84fddd282..0309264096 100644 --- a/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs @@ -4,6 +4,7 @@ using Bit.Core; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.KeyManagement.Queries.Interfaces; @@ -30,6 +31,7 @@ public class AccountsControllerTests : IDisposable private readonly IPaymentService _paymentService; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IUserAccountKeysQuery _userAccountKeysQuery; + private readonly ILicensingService _licensingService; private readonly GlobalSettings _globalSettings; private readonly AccountsController _sut; @@ -40,13 +42,15 @@ public class AccountsControllerTests : IDisposable _paymentService = Substitute.For(); _twoFactorIsEnabledQuery = Substitute.For(); _userAccountKeysQuery = Substitute.For(); + _licensingService = Substitute.For(); _globalSettings = new GlobalSettings { SelfHosted = false }; _sut = new AccountsController( _userService, _twoFactorIsEnabledQuery, _userAccountKeysQuery, - _featureService + _featureService, + _licensingService ); }