diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index b01b629e4f..7d970aef8b 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -2,6 +2,7 @@ using Bit.Api.Billing.Models.Requests.Payment; using Bit.Api.Billing.Models.Requests.Premium; using Bit.Core; +using Bit.Core.Billing.Licenses.Queries; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Premium.Commands; @@ -21,6 +22,7 @@ public class AccountBillingVNextController( ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand, IGetCreditQuery getCreditQuery, IGetPaymentMethodQuery getPaymentMethodQuery, + IGetUserLicenseQuery getUserLicenseQuery, IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController { [HttpGet("credit")] @@ -77,4 +79,13 @@ public class AccountBillingVNextController( user, paymentMethod, billingAddress, additionalStorageGb); return Handle(result); } + + [HttpGet("license")] + [InjectUser] + public async Task GetLicenseAsync( + [BindNever] User user) + { + var response = await getUserLicenseQuery.Run(user); + return TypedResults.Ok(response); + } } diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 5ceefed603..905f797bb4 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches.Implementations; +using Bit.Core.Billing.Licenses; using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Queries; @@ -28,6 +29,7 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient(); services.AddLicenseServices(); + services.AddLicenseOperations(); services.AddPricingClient(); services.AddPaymentOperations(); services.AddOrganizationLicenseCommandsQueries(); diff --git a/src/Core/Billing/Licenses/Models/Api/Response/LicenseResponseModel.cs b/src/Core/Billing/Licenses/Models/Api/Response/LicenseResponseModel.cs new file mode 100644 index 0000000000..60f8f0e81a --- /dev/null +++ b/src/Core/Billing/Licenses/Models/Api/Response/LicenseResponseModel.cs @@ -0,0 +1,44 @@ +using System.Security.Claims; +using Bit.Core.Billing.Licenses.Extensions; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Models.Api; + +namespace Bit.Core.Billing.Licenses.Models.Api.Response; + +/// +/// Response model containing user license information. +/// Separated from subscription data to maintain separation of concerns. +/// +public class LicenseResponseModel : ResponseModel +{ + public LicenseResponseModel(UserLicense license, ClaimsPrincipal? claimsPrincipal) + : base("license") + { + 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; + } + } + + /// + /// The user's license containing feature entitlements and metadata. + /// + public UserLicense License { get; set; } + + /// + /// The license expiration date. + /// Extracted from the cryptographically secured JWT token when available, + /// otherwise falls back to the license file's expiration date. + /// + public DateTime? Expiration { get; set; } +} diff --git a/src/Core/Billing/Licenses/Queries/GetUserLicenseQuery.cs b/src/Core/Billing/Licenses/Queries/GetUserLicenseQuery.cs new file mode 100644 index 0000000000..16344116cb --- /dev/null +++ b/src/Core/Billing/Licenses/Queries/GetUserLicenseQuery.cs @@ -0,0 +1,23 @@ +using Bit.Core.Billing.Licenses.Models.Api.Response; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Services; + +namespace Bit.Core.Billing.Licenses.Queries; + +public interface IGetUserLicenseQuery +{ + Task Run(User user); +} + +public class GetUserLicenseQuery( + IUserService userService, + ILicensingService licensingService) : IGetUserLicenseQuery +{ + public async Task Run(User user) + { + var license = await userService.GenerateLicenseAsync(user); + var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license); + return new LicenseResponseModel(license, claimsPrincipal); + } +} diff --git a/src/Core/Billing/Licenses/Registrations.cs b/src/Core/Billing/Licenses/Registrations.cs new file mode 100644 index 0000000000..74c449a355 --- /dev/null +++ b/src/Core/Billing/Licenses/Registrations.cs @@ -0,0 +1,13 @@ +using Bit.Core.Billing.Licenses.Queries; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Billing.Licenses; + +public static class Registrations +{ + public static void AddLicenseOperations(this IServiceCollection services) + { + // Queries + services.AddTransient(); + } +} diff --git a/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs index 16b9b26436..a2aff1b108 100644 --- a/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs @@ -85,6 +85,7 @@ public class AccountsControllerTests : IDisposable _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); user.Gateway = GatewayType.Stripe; // User has payment gateway @@ -124,6 +125,7 @@ public class AccountsControllerTests : IDisposable _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false); _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); user.Gateway = GatewayType.Stripe; // User has payment gateway @@ -161,6 +163,7 @@ public class AccountsControllerTests : IDisposable _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); user.Gateway = GatewayType.Stripe; // User has payment gateway @@ -207,6 +210,7 @@ public class AccountsControllerTests : IDisposable }; _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); _userService.GenerateLicenseAsync(user).Returns(license); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); // Act var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); @@ -243,6 +247,7 @@ public class AccountsControllerTests : IDisposable _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); user.Gateway = GatewayType.Stripe; // User has payment gateway @@ -293,6 +298,7 @@ public class AccountsControllerTests : IDisposable _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); user.Gateway = GatewayType.Stripe; @@ -349,6 +355,7 @@ public class AccountsControllerTests : IDisposable _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); user.Gateway = GatewayType.Stripe; // Act & Assert - Feature flag ENABLED @@ -413,6 +420,7 @@ public class AccountsControllerTests : IDisposable _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); user.Gateway = GatewayType.Stripe; // Act - Step 4: Call AccountsController.GetSubscriptionAsync @@ -507,6 +515,7 @@ public class AccountsControllerTests : IDisposable _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); user.Gateway = GatewayType.Stripe; // Act @@ -558,6 +567,7 @@ public class AccountsControllerTests : IDisposable _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); user.Gateway = GatewayType.Stripe; // Act @@ -611,6 +621,7 @@ public class AccountsControllerTests : IDisposable _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); user.Gateway = GatewayType.Stripe; // Act @@ -658,6 +669,7 @@ public class AccountsControllerTests : IDisposable _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); user.Gateway = GatewayType.Stripe; // Act @@ -726,6 +738,7 @@ public class AccountsControllerTests : IDisposable _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); user.Gateway = GatewayType.Stripe; // Act - Full pipeline: Stripe → SubscriptionInfo → SubscriptionResponseModel → API response @@ -791,6 +804,7 @@ public class AccountsControllerTests : IDisposable }; _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); _userService.GenerateLicenseAsync(user).Returns(license); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal()); _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled // Act diff --git a/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs new file mode 100644 index 0000000000..b087a0fd6d --- /dev/null +++ b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs @@ -0,0 +1,57 @@ +using Bit.Api.Billing.Controllers.VNext; +using Bit.Core.Billing.Licenses.Queries; +using Bit.Core.Billing.Payment.Commands; +using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Entities; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Billing.Controllers.VNext; + +public class AccountBillingVNextControllerTests +{ + private readonly ICreateBitPayInvoiceForCreditCommand _createBitPayInvoiceForCreditCommand; + private readonly ICreatePremiumCloudHostedSubscriptionCommand _createPremiumCloudHostedSubscriptionCommand; + private readonly IGetCreditQuery _getCreditQuery; + private readonly IGetPaymentMethodQuery _getPaymentMethodQuery; + private readonly IGetUserLicenseQuery _getUserLicenseQuery; + private readonly IUpdatePaymentMethodCommand _updatePaymentMethodCommand; + private readonly AccountBillingVNextController _sut; + + public AccountBillingVNextControllerTests() + { + _createBitPayInvoiceForCreditCommand = Substitute.For(); + _createPremiumCloudHostedSubscriptionCommand = Substitute.For(); + _getCreditQuery = Substitute.For(); + _getPaymentMethodQuery = Substitute.For(); + _getUserLicenseQuery = Substitute.For(); + _updatePaymentMethodCommand = Substitute.For(); + + _sut = new AccountBillingVNextController( + _createBitPayInvoiceForCreditCommand, + _createPremiumCloudHostedSubscriptionCommand, + _getCreditQuery, + _getPaymentMethodQuery, + _getUserLicenseQuery, + _updatePaymentMethodCommand); + } + + [Theory, BitAutoData] + public async Task GetLicenseAsync_ValidUser_ReturnsLicenseResponse(User user, + Core.Billing.Licenses.Models.Api.Response.LicenseResponseModel licenseResponse) + { + // Arrange + _getUserLicenseQuery.Run(user).Returns(licenseResponse); + + // Act + var result = await _sut.GetLicenseAsync(user); + + // Assert + var okResult = Assert.IsAssignableFrom(result); + await _getUserLicenseQuery.Received(1).Run(user); + } + +}