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..69ffed0a5d65 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs @@ -1,4 +1,4 @@ -using System.Security.Claims; +using System.Globalization; using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; @@ -18,12 +18,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 +300,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); @@ -333,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,9 +340,9 @@ 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" } + { "redirect_uri", SsoAuthorizationCodeTestHelpers.RedirectUri } })); // Assert @@ -393,7 +392,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); @@ -402,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,9 +409,9 @@ 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" } + { "redirect_uri", SsoAuthorizationCodeTestHelpers.RedirectUri } })); // Assert @@ -436,7 +435,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); @@ -475,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,9 +482,9 @@ 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" } + { "redirect_uri", SsoAuthorizationCodeTestHelpers.RedirectUri } })); Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); @@ -590,6 +589,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,33 +656,39 @@ 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" }, - { "client_id", "web" }, + { "client_id", SsoAuthorizationCodeTestHelpers.ClientId }, { "twoFactorToken", "TEST" }, { "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" } + { "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"; } 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 +713,7 @@ private static async Task RunSuccessTestAsync(Func RunSuccessTestAsync(Func { { "scope", "api offline_access" }, - { "client_id", "web" }, + { "client_id", SsoAuthorizationCodeTestHelpers.ClientId }, { "deviceType", "10" }, { "deviceIdentifier", "test_id" }, { "deviceName", "firefox" }, @@ -672,9 +732,9 @@ private static async Task 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 +810,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 +856,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..fdc2d5edeff2 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; @@ -370,7 +367,7 @@ public async Task TokenEndpoint_GrantTypeClientCredential_IndvTwoFactorRequired_ { "client_secret", user.ApiKey }, { "scope", "api" }, { "DeviceIdentifier", deviceId }, - { "DeviceType", ((int)DeviceType.FirefoxBrowser).ToString() }, + { "DeviceType", ((int)DeviceType.FirefoxBrowser).ToString(CultureInfo.InvariantCulture) }, { "DeviceName", "firefox" }, })); @@ -393,21 +390,21 @@ public async Task TokenEndpoint_GrantTypeAuthCode_OrgTwoFactorRequired_IndvTwoFa { MemberDecryptionType = MemberDecryptionType.MasterPassword, }; - await CreateSsoOrganizationAndUserAsync( + var code = await CreateSsoOrganizationAndUserAsync( localFactory, ssoConfigData, challenge, _testEmail, userTwoFactor: _userEmailTwoFactor); // Act 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", "test_code" }, + { "code", code }, { "code_verifier", challenge }, - { "redirect_uri", "https://localhost:8080/sso-connector.html" } + { "redirect_uri", SsoAuthorizationCodeTestHelpers.RedirectUri } })); // Assert @@ -429,21 +426,21 @@ public async Task TokenEndpoint_GrantTypeAuthCode_OrgTwoFactorRequired_IndvTwoFa { MemberDecryptionType = MemberDecryptionType.MasterPassword, }; - await CreateSsoOrganizationAndUserAsync( + var code = await CreateSsoOrganizationAndUserAsync( localFactory, ssoConfigData, challenge, _testEmail, userTwoFactor: _userEmailTwoFactor); // Act 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", "test_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); @@ -452,7 +449,7 @@ await CreateSsoOrganizationAndUserAsync( 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" }, @@ -460,9 +457,9 @@ 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" } + { "redirect_uri", SsoAuthorizationCodeTestHelpers.RedirectUri } })); @@ -485,21 +482,21 @@ public async Task TokenEndpoint_GrantTypeAuthCode_OrgTwoFactorRequired_OrgDuoTwo MemberDecryptionType = MemberDecryptionType.MasterPassword, }; - await CreateSsoOrganizationAndUserAsync( + var code = await CreateSsoOrganizationAndUserAsync( localFactory, ssoConfigData, challenge, _testEmail, orgTwoFactor: _organizationTwoFactor); // Act 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", "test_code" }, + { "code", code }, { "code_verifier", challenge }, - { "redirect_uri", "https://localhost:8080/sso-connector.html" } + { "redirect_uri", SsoAuthorizationCodeTestHelpers.RedirectUri } })); // Assert @@ -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..16bcef1f699e --- /dev/null +++ b/test/Identity.Test/IdentityServer/AuthorizationCodeStoreTests.cs @@ -0,0 +1,187 @@ +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()); + persistedGrantStore.GetAsync(storedGrant.Key).Returns(storedGrant); + Assert.NotNull(await sut.GetAuthorizationCodeAsync(handle)); // Lookup reaches the grant. + + storedGrant.Type = IdentityServerConstants.PersistedGrantTypes.RefreshToken; + + // 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)); + } +}