From 37994045b66994bd6beefd6b67e09cda2166eb08 Mon Sep 17 00:00:00 2001 From: Ike Kottlowski Date: Mon, 2 Nov 2026 17:00:04 -0500 Subject: [PATCH 1/2] [PM-40601] adding tests as a baseline for method signature changes happening in version 8 major update --- .../TwoFactorControllerEmailTests.cs | 45 +---- .../Endpoints/IdentityServerSsoTests.cs | 135 ++++++++----- .../Endpoints/IdentityServerTwoFactorTests.cs | 57 ++---- .../AuthorizationCodeStoreTests.cs | 186 ++++++++++++++++++ .../PersistedGrantStoreTests.cs | 144 ++++++++++++++ .../SsoAuthorizationCodeTestHelpers.cs | 63 ++++++ 6 files changed, 497 insertions(+), 133 deletions(-) create mode 100644 test/Identity.Test/IdentityServer/AuthorizationCodeStoreTests.cs create mode 100644 test/Identity.Test/IdentityServer/PersistedGrantStoreTests.cs create mode 100644 test/IntegrationTestCommon/SsoAuthorizationCodeTestHelpers.cs diff --git a/test/Api.IntegrationTest/Auth/Controllers/TwoFactor/TwoFactorControllerEmailTests.cs b/test/Api.IntegrationTest/Auth/Controllers/TwoFactor/TwoFactorControllerEmailTests.cs index ef1cd59dc541..ccaf63927370 100644 --- a/test/Api.IntegrationTest/Auth/Controllers/TwoFactor/TwoFactorControllerEmailTests.cs +++ b/test/Api.IntegrationTest/Auth/Controllers/TwoFactor/TwoFactorControllerEmailTests.cs @@ -1,8 +1,6 @@ -using System.Collections.Concurrent; -using System.Globalization; +using System.Globalization; using System.Net; using System.Net.Http.Headers; -using System.Security.Claims; using System.Text.Json; using Bit.Api.Auth.Models.Request; using Bit.Api.IntegrationTest.Factories; @@ -22,12 +20,10 @@ using Bit.Core.Services; using Bit.Core.Tokens; using Bit.Core.Utilities; +using Bit.IntegrationTestCommon; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.Helpers; using Core.Auth.Enums; -using Duende.IdentityModel; -using Duende.IdentityServer.Models; -using Duende.IdentityServer.Stores; using Microsoft.Extensions.Caching.Distributed; using NSubstitute; using Xunit; @@ -50,19 +46,13 @@ public class TwoFactorControllerEmailTests : IClassFixture - /// Authorization codes the substituted Identity code store hands out, keyed by code. The store is created - /// once per class fixture, so each SSO test registers its own uniquely named code here. - /// - private static readonly ConcurrentDictionary _ssoAuthorizationCodes = new(); - private readonly HttpClient _client; private readonly ApiApplicationFactory _factory; private readonly LoginHelper _loginHelper; @@ -82,9 +72,6 @@ public TwoFactorControllerEmailTests(ApiApplicationFactory factory) svc.CompleteTwoFactorWebAuthnRegistrationAsync(default!, default, default!, default!).ReturnsForAnyArgs(true)); _factory.SubstituteService(_ => { }); _factory.ShareIdentityPersistentCache(); - _factory.Identity.SubstituteService(store => - store.GetAuthorizationCodeAsync(Arg.Any()) - .Returns(call => Task.FromResult(_ssoAuthorizationCodes.GetValueOrDefault(call.Arg())))); _client = factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); _mailService = _factory.GetService(); @@ -1091,30 +1078,10 @@ await _factory.GetService().CreateAsync(new SsoConfig }); var codeVerifier = new string('c', 50); - var authorizationCodeKey = $"sso-code-{Guid.NewGuid()}"; - _ssoAuthorizationCodes[authorizationCodeKey] = new AuthorizationCode - { - ClientId = "web", - CreationTime = DateTime.UtcNow, - Lifetime = (int)TimeSpan.FromMinutes(5).TotalSeconds, - RedirectUri = SsoRedirectUri, - RequestedScopes = ["api", "offline_access"], - CodeChallenge = codeVerifier.Sha256(), - CodeChallengeMethod = "plain", - Subject = new ClaimsPrincipal(new ClaimsIdentity([ - new Claim(JwtClaimTypes.Subject, user.Id.ToString()), - new Claim(JwtClaimTypes.Name, _userEmail), - new Claim(JwtClaimTypes.IdentityProvider, "sso"), - new Claim("organizationId", organization.Id.ToString()), - new Claim(JwtClaimTypes.SessionId, "SOMETHING"), - new Claim(JwtClaimTypes.AuthenticationMethod, "external"), - new Claim(JwtClaimTypes.AuthenticationTime, - new DateTimeOffset(DateTime.UtcNow.AddMinutes(-1)).ToUnixTimeSeconds() - .ToString(CultureInfo.InvariantCulture)) - ], "Duende.IdentityServer", JwtClaimTypes.Name, JwtClaimTypes.Role)), - }; + var authorizationCode = await _factory.Identity.StoreSsoAuthorizationCodeAsync( + user.Id, _userEmail, organization.Id, codeVerifier); - return new SsoLogin(authorizationCodeKey, codeVerifier); + return new SsoLogin(authorizationCode, codeVerifier); } private async Task PostSsoTokenAsync(SsoLogin sso, string deviceIdentifier, string? twoFactorToken) diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs index c28975800ed5..c1ccc1bef4f7 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs @@ -1,5 +1,4 @@ -using System.Security.Claims; -using System.Text.Json; +using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -18,12 +17,11 @@ using Bit.Identity.IdentityServer; using Bit.Identity.IdentityServer.RequestValidators; using Bit.Identity.IntegrationTest.Grants; +using Bit.IntegrationTestCommon; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.Constants; using Bit.Test.Common.Helpers; -using Duende.IdentityModel; -using Duende.IdentityServer.Models; -using Duende.IdentityServer.Stores; +using Duende.IdentityServer; using Microsoft.EntityFrameworkCore; using NSubstitute; using Xunit; @@ -301,7 +299,7 @@ public async Task SsoLogin_TrustedDeviceEncryptionAndNoMasterPassword_DeviceAlre { // Arrange var challenge = new string('c', 50); - var factory = await CreateFactoryAsync(new SsoConfigurationData + var (factory, code) = await CreateFactoryAsync(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption, }, challenge); @@ -341,7 +339,7 @@ public async Task SsoLogin_TrustedDeviceEncryptionAndNoMasterPassword_DeviceAlre { "twoFactorProvider", "5" }, // RememberMe Provider { "twoFactorRemember", "0" }, { "grant_type", "authorization_code" }, - { "code", "test_code" }, + { "code", code }, { "code_verifier", challenge }, { "redirect_uri", "https://localhost:8080/sso-connector.html" } })); @@ -393,7 +391,7 @@ public async Task SsoLogin_TrustedDeviceEncryption_UserHasManageResetPasswordPer // create user permissions with the ManageResetPassword permission var permissionsWithManageResetPassword = new Permissions() { ManageResetPassword = true }; - var factory = await CreateFactoryAsync(new SsoConfigurationData + var (factory, code) = await CreateFactoryAsync(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption, }, challenge, permissions: permissionsWithManageResetPassword); @@ -410,7 +408,7 @@ public async Task SsoLogin_TrustedDeviceEncryption_UserHasManageResetPasswordPer { "twoFactorProvider", "5" }, // RememberMe Provider { "twoFactorRemember", "0" }, { "grant_type", "authorization_code" }, - { "code", "test_code" }, + { "code", code }, { "code_verifier", challenge }, { "redirect_uri", "https://localhost:8080/sso-connector.html" } })); @@ -436,7 +434,7 @@ public async Task SsoLogin_TrustedDeviceEncryption_ProviderUserHasManageResetPas { var challenge = new string('c', 50); - var factory = await CreateFactoryAsync(new SsoConfigurationData + var (factory, code) = await CreateFactoryAsync(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption, }, challenge); @@ -483,7 +481,7 @@ await providerOrganizationRepository.CreateAsync(new ProviderOrganization { "twoFactorProvider", "5" }, // RememberMe Provider { "twoFactorRemember", "0" }, { "grant_type", "authorization_code" }, - { "code", "test_code" }, + { "code", code }, { "code_verifier", challenge }, { "redirect_uri", "https://localhost:8080/sso-connector.html" } })); @@ -590,6 +588,61 @@ public async Task SsoLogin_RefreshToken_AfterUserStampChange_IsRejected() Assert.Equal("invalid_grant", error); } + /// + /// The authorization code is persisted as a grant and read back by the token endpoint, as it is after a real + /// SSO login, and the exchange issues both an access token and a refresh token. + /// + [Fact] + public async Task SsoLogin_AuthorizationCodeFromGrantStore_ReturnsTokens() + { + // Arrange + var challenge = new string('c', 50); + var (factory, code) = await CreateFactoryAsync(new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.MasterPassword, + }, challenge); + + var database = factory.GetDatabaseContext(); + Assert.Single(await database.Grants + .Where(g => g.Type == IdentityServerConstants.PersistedGrantTypes.AuthorizationCode) + .ToListAsync()); + + // Act + var context = await PostAuthorizationCodeAsync(factory, code, challenge, includeDevice: true); + + // Assert + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + using var body = await AssertHelper.AssertResponseTypeIs(context); + AssertHelper.AssertJsonProperty(body.RootElement, "access_token", JsonValueKind.String); + AssertHelper.AssertJsonProperty(body.RootElement, "refresh_token", JsonValueKind.String); + } + + /// + /// intentionally keeps a redeemed code until + /// it expires, so a client can redeem the same code again within its lifetime, for example after a two-factor + /// challenge. + /// + [Fact] + public async Task SsoLogin_SameAuthorizationCodeRedeemedTwice_BothSucceed() + { + // Arrange + var challenge = new string('c', 50); + var (factory, code) = await CreateFactoryAsync(new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.MasterPassword, + }, challenge); + + // Act + var firstContext = await PostAuthorizationCodeAsync(factory, code, challenge, includeDevice: true); + var secondContext = await PostAuthorizationCodeAsync(factory, code, challenge, includeDevice: true); + + // Assert + Assert.Equal(StatusCodes.Status200OK, firstContext.Response.StatusCode); + Assert.Equal(StatusCodes.Status200OK, secondContext.Response.StatusCode); + using var secondBody = await AssertHelper.AssertResponseTypeIs(secondContext); + AssertHelper.AssertJsonProperty(secondBody.RootElement, "access_token", JsonValueKind.String); + } + private static async Task<(IdentityApplicationFactory Factory, JsonElement LoginRoot)> SsoLoginAsync() { var (factory, loginContext) = await PostSsoLoginAsync(includeDevice: true); @@ -602,11 +655,18 @@ public async Task SsoLogin_RefreshToken_AfterUserStampChange_IsRejected() bool includeDevice) { var challenge = new string('c', 50); - var factory = await CreateFactoryAsync(new SsoConfigurationData + var (factory, code) = await CreateFactoryAsync(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.MasterPassword, }, challenge); + var loginContext = await PostAuthorizationCodeAsync(factory, code, challenge, includeDevice); + return (factory, loginContext); + } + + private static async Task PostAuthorizationCodeAsync(IdentityApplicationFactory factory, + string code, string challenge, bool includeDevice) + { var form = new Dictionary { { "scope", "api offline_access" }, @@ -615,7 +675,7 @@ public async Task SsoLogin_RefreshToken_AfterUserStampChange_IsRejected() { "twoFactorProvider", "5" }, // RememberMe Provider { "twoFactorRemember", "0" }, { "grant_type", "authorization_code" }, - { "code", "test_code" }, + { "code", code }, { "code_verifier", challenge }, { "redirect_uri", "https://localhost:8080/sso-connector.html" } }; @@ -627,8 +687,7 @@ public async Task SsoLogin_RefreshToken_AfterUserStampChange_IsRejected() } using var content = new FormUrlEncodedContent(form); - var loginContext = await factory.Server.PostAsync("/connect/token", content); - return (factory, loginContext); + return await factory.Server.PostAsync("/connect/token", content); } private static async Task PostRefreshTokenAsync(IdentityApplicationFactory factory, string refreshToken) @@ -653,7 +712,7 @@ private static async Task RunSuccessTestAsync(Func RunSuccessTestAsync(Func RunSuccessTestAsync(Func(context); } - private static async Task CreateFactoryAsync( + /// + /// Seeds an SSO user and organization, and stores an authorization code for them through the real code store. + /// + /// The factory, and the authorization code to redeem with . + private static async Task<(IdentityApplicationFactory Factory, string Code)> CreateFactoryAsync( SsoConfigurationData ssoConfigurationData, string challenge, bool trustedDeviceEnabled = true, @@ -746,25 +809,6 @@ private static async Task CreateFactoryAsync( .Returns(true); }); - var authorizationCode = new AuthorizationCode - { - ClientId = "web", - CreationTime = DateTime.UtcNow, - Lifetime = (int)TimeSpan.FromMinutes(5).TotalSeconds, - RedirectUri = "https://localhost:8080/sso-connector.html", - RequestedScopes = ["api", "offline_access"], - CodeChallenge = challenge.Sha256(), - CodeChallengeMethod = "plain", - Subject = null!, // Temporarily set it to null - }; - - factory.SubstituteService(service => - { - // Return our pre-built authorization code regardless of handle representation - service.GetAuthorizationCodeAsync("test_code") - .Returns(authorizationCode); - }); - var user = await factory.RegisterNewIdentityFactoryUserAsync( new RegisterFinishRequestModel { @@ -811,20 +855,9 @@ await ssoConfigRepository.CreateAsync(new SsoConfig Data = JsonSerializer.Serialize(ssoConfigurationData, JsonHelpers.CamelCase), }); - var subject = new ClaimsPrincipal(new ClaimsIdentity(new[] - { - new Claim(JwtClaimTypes.Subject, user.Id.ToString()), // Get real user id - new Claim(JwtClaimTypes.Name, TestEmail), - new Claim(JwtClaimTypes.IdentityProvider, "sso"), - new Claim("organizationId", organization.Id.ToString()), - new Claim(JwtClaimTypes.SessionId, "SOMETHING"), - new Claim(JwtClaimTypes.AuthenticationMethod, "external"), - new Claim(JwtClaimTypes.AuthenticationTime, new DateTimeOffset(DateTime.UtcNow.AddMinutes(-1)).ToUnixTimeSeconds().ToString()) - }, "Duende.IdentityServer", JwtClaimTypes.Name, JwtClaimTypes.Role)); - - authorizationCode.Subject = subject; - - return factory; + var code = await factory.StoreSsoAuthorizationCodeAsync(user.Id, TestEmail, organization.Id, challenge); + + return (factory, code); } private static async Task UpdateUserAsync(IdentityApplicationFactory factory, Action changeUser) diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs index 17c3d4524220..dc90e42a8ef2 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Security.Claims; using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; @@ -16,13 +15,11 @@ using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; +using Bit.IntegrationTestCommon; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Fakes; using Bit.Test.Common.Helpers; -using Duende.IdentityModel; -using Duende.IdentityServer.Models; -using Duende.IdentityServer.Stores; using Fido2NetLib; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Distributed; @@ -393,7 +390,7 @@ public async Task TokenEndpoint_GrantTypeAuthCode_OrgTwoFactorRequired_IndvTwoFa { MemberDecryptionType = MemberDecryptionType.MasterPassword, }; - await CreateSsoOrganizationAndUserAsync( + var code = await CreateSsoOrganizationAndUserAsync( localFactory, ssoConfigData, challenge, _testEmail, userTwoFactor: _userEmailTwoFactor); // Act @@ -405,7 +402,7 @@ await CreateSsoOrganizationAndUserAsync( { "deviceIdentifier", deviceId }, { "deviceName", "edge" }, { "grant_type", "authorization_code" }, - { "code", "test_code" }, + { "code", code }, { "code_verifier", challenge }, { "redirect_uri", "https://localhost:8080/sso-connector.html" } })); @@ -429,7 +426,7 @@ public async Task TokenEndpoint_GrantTypeAuthCode_OrgTwoFactorRequired_IndvTwoFa { MemberDecryptionType = MemberDecryptionType.MasterPassword, }; - await CreateSsoOrganizationAndUserAsync( + var code = await CreateSsoOrganizationAndUserAsync( localFactory, ssoConfigData, challenge, _testEmail, userTwoFactor: _userEmailTwoFactor); // Act @@ -441,7 +438,7 @@ await CreateSsoOrganizationAndUserAsync( { "deviceIdentifier", deviceId }, { "deviceName", "edge" }, { "grant_type", "authorization_code" }, - { "code", "test_code" }, + { "code", code }, { "code_verifier", challenge }, { "redirect_uri", "https://localhost:8080/sso-connector.html" } })); @@ -460,7 +457,7 @@ await CreateSsoOrganizationAndUserAsync( { "twoFactorProvider", "1" }, { "twoFactorRemember", "0" }, { "grant_type", "authorization_code" }, - { "code", "test_code" }, + { "code", code }, { "code_verifier", challenge }, { "redirect_uri", "https://localhost:8080/sso-connector.html" } })); @@ -485,7 +482,7 @@ public async Task TokenEndpoint_GrantTypeAuthCode_OrgTwoFactorRequired_OrgDuoTwo MemberDecryptionType = MemberDecryptionType.MasterPassword, }; - await CreateSsoOrganizationAndUserAsync( + var code = await CreateSsoOrganizationAndUserAsync( localFactory, ssoConfigData, challenge, _testEmail, orgTwoFactor: _organizationTwoFactor); // Act @@ -497,7 +494,7 @@ await CreateSsoOrganizationAndUserAsync( { "deviceIdentifier", deviceId }, { "deviceName", "edge" }, { "grant_type", "authorization_code" }, - { "code", "test_code" }, + { "code", code }, { "code_verifier", challenge }, { "redirect_uri", "https://localhost:8080/sso-connector.html" } })); @@ -743,7 +740,11 @@ private async Task CreateUserAsync( } } - private async Task CreateSsoOrganizationAndUserAsync( + /// + /// Seeds an SSO user and organization, and stores an authorization code for them through the real code store. + /// + /// The authorization code to redeem with . + private async Task CreateSsoOrganizationAndUserAsync( IdentityApplicationFactory factory, SsoConfigurationData ssoConfigurationData, string challenge, @@ -753,24 +754,6 @@ private async Task CreateSsoOrganizationAndUserAsync string userTwoFactor = null, Permissions permissions = null) { - var authorizationCode = new AuthorizationCode - { - ClientId = "web", - CreationTime = DateTime.UtcNow, - Lifetime = (int)TimeSpan.FromMinutes(5).TotalSeconds, - RedirectUri = "https://localhost:8080/sso-connector.html", - RequestedScopes = ["api", "offline_access"], - CodeChallenge = challenge.Sha256(), - CodeChallengeMethod = "plain", - Subject = null!, // Temporarily set it to null - }; - - factory.SubstituteService(service => - { - service.GetAuthorizationCodeAsync("test_code") - .Returns(authorizationCode); - }); - var user = await factory.RegisterNewIdentityFactoryUserAsync( new RegisterFinishRequestModel { @@ -837,18 +820,6 @@ await ssoConfigRepository.CreateAsync(new SsoConfig Data = JsonSerializer.Serialize(ssoConfigurationData, JsonHelpers.CamelCase), }); - var subject = new ClaimsPrincipal(new ClaimsIdentity([ - new Claim(JwtClaimTypes.Subject, user.Id.ToString()), // Get real user id - new Claim(JwtClaimTypes.Name, testEmail), - new Claim(JwtClaimTypes.IdentityProvider, "sso"), - new Claim("organizationId", organization.Id.ToString()), - new Claim(JwtClaimTypes.SessionId, "SOMETHING"), - new Claim(JwtClaimTypes.AuthenticationMethod, "external"), - new Claim(JwtClaimTypes.AuthenticationTime, new DateTimeOffset(DateTime.UtcNow.AddMinutes(-1)).ToUnixTimeSeconds().ToString()) - ], "Duende.IdentityServer", JwtClaimTypes.Name, JwtClaimTypes.Role)); - - authorizationCode.Subject = subject; - - return factory; + return await factory.StoreSsoAuthorizationCodeAsync(user.Id, testEmail, organization.Id, challenge); } } diff --git a/test/Identity.Test/IdentityServer/AuthorizationCodeStoreTests.cs b/test/Identity.Test/IdentityServer/AuthorizationCodeStoreTests.cs new file mode 100644 index 000000000000..8cd5f76a932a --- /dev/null +++ b/test/Identity.Test/IdentityServer/AuthorizationCodeStoreTests.cs @@ -0,0 +1,186 @@ +using System.Security.Claims; +using Bit.Identity.IdentityServer; +using Duende.IdentityModel; +using Duende.IdentityServer; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Stores; +using Duende.IdentityServer.Stores.Serialization; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Bit.Identity.Test.IdentityServer; + +public class AuthorizationCodeStoreTests +{ + private const string Handle = "test-handle"; + private const string SubjectId = "test-subject-id"; + + private readonly IHandleGenerationService _handleGenerationService; + private readonly IPersistentGrantSerializer _serializer; + + public AuthorizationCodeStoreTests() + { + _handleGenerationService = Substitute.For(); + _handleGenerationService.GenerateAsync(Arg.Any()).Returns(Handle); + + _serializer = new PersistentGrantSerializer(new PersistentGrantOptions(), + new EphemeralDataProtectionProvider()); + } + + [Fact] + public async Task StoreAuthorizationCodeAsync_PersistsGrantWithCodeMetadata() + { + // Arrange + var persistedGrantStore = Substitute.For(); + PersistedGrant storedGrant = null; + await persistedGrantStore.StoreAsync(Arg.Do(g => storedGrant = g)); + var sut = CreateSut(persistedGrantStore); + var code = CreateAuthorizationCode(); + + // Act + var result = await sut.StoreAuthorizationCodeAsync(code); + + // Assert + Assert.StartsWith(Handle, result); + Assert.NotNull(storedGrant); + Assert.Equal(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode, storedGrant.Type); + Assert.Equal(code.ClientId, storedGrant.ClientId); + Assert.Equal(SubjectId, storedGrant.SubjectId); + Assert.Equal(code.SessionId, storedGrant.SessionId); + Assert.Equal(code.Description, storedGrant.Description); + Assert.Equal(code.CreationTime, storedGrant.CreationTime); + Assert.Equal(code.CreationTime.AddSeconds(code.Lifetime), storedGrant.Expiration); + Assert.False(string.IsNullOrEmpty(storedGrant.Data)); + } + + [Fact] + public async Task StoreAuthorizationCodeAsync_DoesNotPersistRawHandleAsKey() + { + // Arrange + var persistedGrantStore = Substitute.For(); + PersistedGrant storedGrant = null; + await persistedGrantStore.StoreAsync(Arg.Do(g => storedGrant = g)); + var sut = CreateSut(persistedGrantStore); + + // Act + await sut.StoreAuthorizationCodeAsync(CreateAuthorizationCode()); + + // Assert + Assert.NotNull(storedGrant); + Assert.False(string.IsNullOrEmpty(storedGrant.Key)); + Assert.NotEqual(Handle, storedGrant.Key); + Assert.DoesNotContain(Handle, storedGrant.Key); + } + + [Fact] + public async Task GetAuthorizationCodeAsync_AfterStore_ReturnsStoredCode() + { + // Arrange + var sut = CreateSut(new InMemoryPersistedGrantStore()); + var code = CreateAuthorizationCode(); + var handle = await sut.StoreAuthorizationCodeAsync(code); + + // Act + var result = await sut.GetAuthorizationCodeAsync(handle); + + // Assert + Assert.NotNull(result); + Assert.Equal(code.ClientId, result.ClientId); + Assert.Equal(code.SessionId, result.SessionId); + Assert.Equal(code.RedirectUri, result.RedirectUri); + Assert.Equal(code.RequestedScopes, result.RequestedScopes); + Assert.Equal(code.CodeChallenge, result.CodeChallenge); + Assert.Equal(code.CodeChallengeMethod, result.CodeChallengeMethod); + Assert.Equal(code.Lifetime, result.Lifetime); + Assert.Equal(SubjectId, result.Subject.FindFirst(JwtClaimTypes.Subject)?.Value); + } + + [Fact] + public async Task GetAuthorizationCodeAsync_UnknownCode_ReturnsNull() + { + // Arrange + var sut = CreateSut(new InMemoryPersistedGrantStore()); + + // Act + var result = await sut.GetAuthorizationCodeAsync("unknown-code"); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetAuthorizationCodeAsync_GrantOfDifferentType_ReturnsNull() + { + // Arrange + var persistedGrantStore = Substitute.For(); + PersistedGrant storedGrant = null; + await persistedGrantStore.StoreAsync(Arg.Do(g => storedGrant = g)); + var sut = CreateSut(persistedGrantStore); + var handle = await sut.StoreAuthorizationCodeAsync(CreateAuthorizationCode()); + + storedGrant.Type = IdentityServerConstants.PersistedGrantTypes.RefreshToken; + persistedGrantStore.GetAsync(storedGrant.Key).Returns(storedGrant); + + // Act + var result = await sut.GetAuthorizationCodeAsync(handle); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task RemoveAuthorizationCodeAsync_DoesNotRemoveGrant() + { + // Arrange + var sut = CreateSut(new InMemoryPersistedGrantStore()); + var handle = await sut.StoreAuthorizationCodeAsync(CreateAuthorizationCode()); + + // Act + await sut.RemoveAuthorizationCodeAsync(handle); + + // Assert + Assert.NotNull(await sut.GetAuthorizationCodeAsync(handle)); + } + + [Fact] + public async Task RemoveAuthorizationCodeAsync_DoesNotCallPersistedGrantStore() + { + // Arrange + var persistedGrantStore = Substitute.For(); + var sut = CreateSut(persistedGrantStore); + + // Act + await sut.RemoveAuthorizationCodeAsync(Handle); + + // Assert + await persistedGrantStore.DidNotReceiveWithAnyArgs().RemoveAsync(default); + await persistedGrantStore.DidNotReceiveWithAnyArgs().RemoveAllAsync(default); + } + + private AuthorizationCodeStore CreateSut(IPersistedGrantStore persistedGrantStore) + { + return new AuthorizationCodeStore(persistedGrantStore, _serializer, _handleGenerationService, + NullLogger.Instance); + } + + private static AuthorizationCode CreateAuthorizationCode() + { + return new AuthorizationCode + { + ClientId = "web", + Subject = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(JwtClaimTypes.Subject, SubjectId)], "test")), + SessionId = "test-session-id", + Description = "test-description", + CreationTime = DateTime.UtcNow, + Lifetime = (int)TimeSpan.FromMinutes(5).TotalSeconds, + RedirectUri = "https://localhost/sso-connector.html", + RequestedScopes = ["api", "offline_access"], + CodeChallenge = "test-code-challenge", + CodeChallengeMethod = OidcConstants.CodeChallengeMethods.Sha256, + }; + } +} diff --git a/test/Identity.Test/IdentityServer/PersistedGrantStoreTests.cs b/test/Identity.Test/IdentityServer/PersistedGrantStoreTests.cs new file mode 100644 index 000000000000..d90531f72509 --- /dev/null +++ b/test/Identity.Test/IdentityServer/PersistedGrantStoreTests.cs @@ -0,0 +1,144 @@ +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Identity.IdentityServer; +using Bit.Test.Common.AutoFixture.Attributes; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Stores; +using NSubstitute; +using Xunit; +using Grant = Bit.Core.Auth.Entities.Grant; + +namespace Bit.Identity.Test.IdentityServer; + +public class PersistedGrantStoreTests +{ + private readonly IGrantRepository _grantRepository; + private readonly PersistedGrantStore _sut; + + public PersistedGrantStoreTests() + { + _grantRepository = Substitute.For(); + _sut = new PersistedGrantStore(_grantRepository, g => new Grant(g)); + } + + [Theory, BitAutoData] + public async Task GetAsync_GrantExists_ReturnsMappedPersistedGrant(Grant grant) + { + // Arrange + _grantRepository.GetByKeyAsync(grant.Key).Returns(grant); + + // Act + var result = await _sut.GetAsync(grant.Key); + + // Assert + Assert.NotNull(result); + AssertMapped(grant, result); + } + + [Theory, BitAutoData] + public async Task GetAsync_GrantMissing_ReturnsNull(string key) + { + // Arrange + _grantRepository.GetByKeyAsync(key).Returns((IGrant)null); + + // Act + var result = await _sut.GetAsync(key); + + // Assert + Assert.Null(result); + } + + [Theory, BitAutoData] + public async Task GetAllAsync_PassesFilterAndReturnsMappedGrants( + PersistedGrantFilter filter, Grant firstGrant, Grant secondGrant) + { + // Arrange + _grantRepository.GetManyAsync(filter.SubjectId, filter.SessionId, filter.ClientId, filter.Type) + .Returns(new List { firstGrant, secondGrant }); + + // Act + var result = (await _sut.GetAllAsync(filter)).ToList(); + + // Assert + Assert.Equal(2, result.Count); + AssertMapped(firstGrant, result[0]); + AssertMapped(secondGrant, result[1]); + } + + [Theory, BitAutoData] + public async Task GetAllAsync_NoGrants_ReturnsEmpty(PersistedGrantFilter filter) + { + // Arrange + _grantRepository.GetManyAsync(filter.SubjectId, filter.SessionId, filter.ClientId, filter.Type) + .Returns(new List()); + + // Act + var result = await _sut.GetAllAsync(filter); + + // Assert + Assert.Empty(result); + } + + [Theory, BitAutoData] + public async Task RemoveAllAsync_DeletesGrantsMatchingFilter(PersistedGrantFilter filter) + { + // Act + await _sut.RemoveAllAsync(filter); + + // Assert + await _grantRepository.Received(1) + .DeleteManyAsync(filter.SubjectId, filter.SessionId, filter.ClientId, filter.Type); + } + + [Theory, BitAutoData] + public async Task RemoveAsync_DeletesGrantByKey(string key) + { + // Act + await _sut.RemoveAsync(key); + + // Assert + await _grantRepository.Received(1).DeleteByKeyAsync(key); + } + + [Theory, BitAutoData] + public async Task StoreAsync_SavesConvertedGrant(PersistedGrant persistedGrant) + { + // Arrange + IGrant savedGrant = null; + await _grantRepository.SaveAsync(Arg.Do(g => savedGrant = g)); + + // Act + await _sut.StoreAsync(persistedGrant); + + // Assert + Assert.NotNull(savedGrant); + AssertMapped(savedGrant, persistedGrant); + } + + [Theory, BitAutoData] + public async Task StoreAsync_UsesProvidedGrantConverter(PersistedGrant persistedGrant, Grant convertedGrant) + { + // Arrange + var sut = new PersistedGrantStore(_grantRepository, _ => convertedGrant); + + // Act + await sut.StoreAsync(persistedGrant); + + // Assert + await _grantRepository.Received(1).SaveAsync(convertedGrant); + } + + private static void AssertMapped(IGrant grant, PersistedGrant persistedGrant) + { + Assert.Equal(grant.Key, persistedGrant.Key); + Assert.Equal(grant.Type, persistedGrant.Type); + Assert.Equal(grant.SubjectId, persistedGrant.SubjectId); + Assert.Equal(grant.SessionId, persistedGrant.SessionId); + Assert.Equal(grant.ClientId, persistedGrant.ClientId); + Assert.Equal(grant.Description, persistedGrant.Description); + Assert.Equal(grant.CreationDate, persistedGrant.CreationTime); + Assert.Equal(grant.ExpirationDate, persistedGrant.Expiration); + Assert.Equal(grant.ConsumedDate, persistedGrant.ConsumedTime); + Assert.Equal(grant.Data, persistedGrant.Data); + } +} diff --git a/test/IntegrationTestCommon/SsoAuthorizationCodeTestHelpers.cs b/test/IntegrationTestCommon/SsoAuthorizationCodeTestHelpers.cs new file mode 100644 index 000000000000..d452628a83e9 --- /dev/null +++ b/test/IntegrationTestCommon/SsoAuthorizationCodeTestHelpers.cs @@ -0,0 +1,63 @@ +using System.Globalization; +using System.Security.Claims; +using Bit.IntegrationTestCommon.Factories; +using Duende.IdentityModel; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Stores; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.IntegrationTestCommon; + +/// +/// Issues SSO authorization codes through the Identity host's real , so a +/// token request redeems them through the same store, serializer, and grant repository as a real SSO login. +/// +public static class SsoAuthorizationCodeTestHelpers +{ + public const string ClientId = "web"; + public const string RedirectUri = "https://localhost:8080/sso-connector.html"; + + /// + /// Stores an authorization code for an SSO login by through + /// , and returns the code that a client sends to the token endpoint. + /// + /// The PKCE verifier that the token request must send as code_verifier. + public static async Task StoreSsoAuthorizationCodeAsync( + this IdentityApplicationFactory factory, + Guid userId, + string email, + Guid organizationId, + string codeVerifier) + { + var authorizationCode = new AuthorizationCode + { + ClientId = ClientId, + CreationTime = DateTime.UtcNow, + Lifetime = (int)TimeSpan.FromMinutes(5).TotalSeconds, + RedirectUri = RedirectUri, + RequestedScopes = ["api", "offline_access"], + CodeChallenge = codeVerifier.Sha256(), + CodeChallengeMethod = OidcConstants.CodeChallengeMethods.Plain, + Subject = BuildSsoSubject(userId, email, organizationId), + }; + + using var scope = factory.Services.CreateScope(); + var authorizationCodeStore = scope.ServiceProvider.GetRequiredService(); + return await authorizationCodeStore.StoreAuthorizationCodeAsync(authorizationCode); + } + + private static ClaimsPrincipal BuildSsoSubject(Guid userId, string email, Guid organizationId) + { + return new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, userId.ToString()), + new Claim(JwtClaimTypes.Name, email), + new Claim(JwtClaimTypes.IdentityProvider, "sso"), + new Claim("organizationId", organizationId.ToString()), + new Claim(JwtClaimTypes.SessionId, "SOMETHING"), + new Claim(JwtClaimTypes.AuthenticationMethod, "external"), + new Claim(JwtClaimTypes.AuthenticationTime, + new DateTimeOffset(DateTime.UtcNow.AddMinutes(-1)).ToUnixTimeSeconds() + .ToString(CultureInfo.InvariantCulture)) + ], "Duende.IdentityServer", JwtClaimTypes.Name, JwtClaimTypes.Role)); + } +} From 01fca8e6f2314a8ef805a137f1cc98c23c7596e4 Mon Sep 17 00:00:00 2001 From: Ike Kottlowski Date: Tue, 3 Nov 2026 13:03:46 -0500 Subject: [PATCH 2/2] applying feedback for more flexible test suite --- .../Endpoints/IdentityServerSsoTests.cs | 25 ++++++++++--------- .../Endpoints/IdentityServerTwoFactorTests.cs | 18 ++++++------- .../AuthorizationCodeStoreTests.cs | 3 ++- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs index c1ccc1bef4f7..69ffed0a5d65 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Globalization; +using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -331,7 +332,7 @@ public async Task SsoLogin_TrustedDeviceEncryptionAndNoMasterPassword_DeviceAlre var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { { "scope", "api offline_access" }, - { "client_id", "web" }, + { "client_id", SsoAuthorizationCodeTestHelpers.ClientId }, { "deviceType", "10" }, { "deviceIdentifier", deviceIdentifier }, { "deviceName", "firefox" }, @@ -341,7 +342,7 @@ public async Task SsoLogin_TrustedDeviceEncryptionAndNoMasterPassword_DeviceAlre { "grant_type", "authorization_code" }, { "code", code }, { "code_verifier", challenge }, - { "redirect_uri", "https://localhost:8080/sso-connector.html" } + { "redirect_uri", SsoAuthorizationCodeTestHelpers.RedirectUri } })); // Assert @@ -400,7 +401,7 @@ public async Task SsoLogin_TrustedDeviceEncryption_UserHasManageResetPasswordPer var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { { "scope", "api offline_access" }, - { "client_id", "web" }, + { "client_id", SsoAuthorizationCodeTestHelpers.ClientId }, { "deviceType", "10" }, { "deviceIdentifier", "test_id" }, { "deviceName", "firefox" }, @@ -410,7 +411,7 @@ public async Task SsoLogin_TrustedDeviceEncryption_UserHasManageResetPasswordPer { "grant_type", "authorization_code" }, { "code", code }, { "code_verifier", challenge }, - { "redirect_uri", "https://localhost:8080/sso-connector.html" } + { "redirect_uri", SsoAuthorizationCodeTestHelpers.RedirectUri } })); // Assert @@ -473,7 +474,7 @@ await providerOrganizationRepository.CreateAsync(new ProviderOrganization var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { { "scope", "api offline_access" }, - { "client_id", "web" }, + { "client_id", SsoAuthorizationCodeTestHelpers.ClientId }, { "deviceType", "10" }, { "deviceIdentifier", "test_id" }, { "deviceName", "firefox" }, @@ -483,7 +484,7 @@ await providerOrganizationRepository.CreateAsync(new ProviderOrganization { "grant_type", "authorization_code" }, { "code", code }, { "code_verifier", challenge }, - { "redirect_uri", "https://localhost:8080/sso-connector.html" } + { "redirect_uri", SsoAuthorizationCodeTestHelpers.RedirectUri } })); Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); @@ -670,18 +671,18 @@ private static async Task PostAuthorizationCodeAsync(IdentityApplic var form = new Dictionary { { "scope", "api offline_access" }, - { "client_id", "web" }, + { "client_id", SsoAuthorizationCodeTestHelpers.ClientId }, { "twoFactorToken", "TEST" }, { "twoFactorProvider", "5" }, // RememberMe Provider { "twoFactorRemember", "0" }, { "grant_type", "authorization_code" }, { "code", code }, { "code_verifier", challenge }, - { "redirect_uri", "https://localhost:8080/sso-connector.html" } + { "redirect_uri", SsoAuthorizationCodeTestHelpers.RedirectUri } }; if (includeDevice) { - form["deviceType"] = ((int)DeviceType.FirefoxBrowser).ToString(); + form["deviceType"] = ((int)DeviceType.FirefoxBrowser).ToString(CultureInfo.InvariantCulture); form["deviceIdentifier"] = SsoDeviceIdentifier; form["deviceName"] = "firefox"; } @@ -723,7 +724,7 @@ private static async Task RunSuccessTestAsync(Func { { "scope", "api offline_access" }, - { "client_id", "web" }, + { "client_id", SsoAuthorizationCodeTestHelpers.ClientId }, { "deviceType", "10" }, { "deviceIdentifier", "test_id" }, { "deviceName", "firefox" }, @@ -733,7 +734,7 @@ private static async Task RunSuccessTestAsync(Func { { "scope", "api offline_access" }, - { "client_id", "web" }, + { "client_id", SsoAuthorizationCodeTestHelpers.ClientId }, { "deviceType", "12" }, { "deviceIdentifier", deviceId }, { "deviceName", "edge" }, { "grant_type", "authorization_code" }, { "code", code }, { "code_verifier", challenge }, - { "redirect_uri", "https://localhost:8080/sso-connector.html" } + { "redirect_uri", SsoAuthorizationCodeTestHelpers.RedirectUri } })); // Assert @@ -433,14 +433,14 @@ public async Task TokenEndpoint_GrantTypeAuthCode_OrgTwoFactorRequired_IndvTwoFa var failedTokenContext = await localFactory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { { "scope", "api offline_access" }, - { "client_id", "web" }, + { "client_id", SsoAuthorizationCodeTestHelpers.ClientId }, { "deviceType", "12" }, { "deviceIdentifier", deviceId }, { "deviceName", "edge" }, { "grant_type", "authorization_code" }, { "code", code }, { "code_verifier", challenge }, - { "redirect_uri", "https://localhost:8080/sso-connector.html" } + { "redirect_uri", SsoAuthorizationCodeTestHelpers.RedirectUri } })); Assert.Equal(StatusCodes.Status400BadRequest, failedTokenContext.Response.StatusCode); @@ -449,7 +449,7 @@ public async Task TokenEndpoint_GrantTypeAuthCode_OrgTwoFactorRequired_IndvTwoFa var twoFactorProvidedContext = await localFactory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { { "scope", "api offline_access" }, - { "client_id", "web" }, + { "client_id", SsoAuthorizationCodeTestHelpers.ClientId }, { "deviceType", "12" }, { "deviceIdentifier", deviceId }, { "deviceName", "edge" }, @@ -459,7 +459,7 @@ public async Task TokenEndpoint_GrantTypeAuthCode_OrgTwoFactorRequired_IndvTwoFa { "grant_type", "authorization_code" }, { "code", code }, { "code_verifier", challenge }, - { "redirect_uri", "https://localhost:8080/sso-connector.html" } + { "redirect_uri", SsoAuthorizationCodeTestHelpers.RedirectUri } })); @@ -489,14 +489,14 @@ public async Task TokenEndpoint_GrantTypeAuthCode_OrgTwoFactorRequired_OrgDuoTwo var context = await localFactory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { { "scope", "api offline_access" }, - { "client_id", "web" }, + { "client_id", SsoAuthorizationCodeTestHelpers.ClientId }, { "deviceType", "12" }, { "deviceIdentifier", deviceId }, { "deviceName", "edge" }, { "grant_type", "authorization_code" }, { "code", code }, { "code_verifier", challenge }, - { "redirect_uri", "https://localhost:8080/sso-connector.html" } + { "redirect_uri", SsoAuthorizationCodeTestHelpers.RedirectUri } })); // Assert diff --git a/test/Identity.Test/IdentityServer/AuthorizationCodeStoreTests.cs b/test/Identity.Test/IdentityServer/AuthorizationCodeStoreTests.cs index 8cd5f76a932a..16bcef1f699e 100644 --- a/test/Identity.Test/IdentityServer/AuthorizationCodeStoreTests.cs +++ b/test/Identity.Test/IdentityServer/AuthorizationCodeStoreTests.cs @@ -120,9 +120,10 @@ public async Task GetAuthorizationCodeAsync_GrantOfDifferentType_ReturnsNull() await persistedGrantStore.StoreAsync(Arg.Do(g => storedGrant = g)); var sut = CreateSut(persistedGrantStore); var handle = await sut.StoreAuthorizationCodeAsync(CreateAuthorizationCode()); + persistedGrantStore.GetAsync(storedGrant.Key).Returns(storedGrant); + Assert.NotNull(await sut.GetAuthorizationCodeAsync(handle)); // Lookup reaches the grant. storedGrant.Type = IdentityServerConstants.PersistedGrantTypes.RefreshToken; - persistedGrantStore.GetAsync(storedGrant.Key).Returns(storedGrant); // Act var result = await sut.GetAuthorizationCodeAsync(handle);