You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
313 lines
10 KiB
313 lines
10 KiB
using System.ComponentModel.DataAnnotations; |
|
using Bit.Api.Auth.Models.Request.Organizations; |
|
using Bit.Core.Auth.Entities; |
|
using Bit.Core.Auth.Enums; |
|
using Bit.Core.Services; |
|
using Bit.Core.Sso; |
|
using Microsoft.Extensions.Localization; |
|
using NSubstitute; |
|
using Xunit; |
|
|
|
namespace Bit.Api.Test.Auth.Models.Request; |
|
|
|
public class OrganizationSsoRequestModelTests |
|
{ |
|
[Fact] |
|
public void ToSsoConfig_WithOrganizationId_CreatesNewSsoConfig() |
|
{ |
|
// Arrange |
|
var organizationId = Guid.NewGuid(); |
|
var model = new OrganizationSsoRequestModel |
|
{ |
|
Enabled = true, |
|
Identifier = "test-identifier", |
|
Data = new SsoConfigurationDataRequest |
|
{ |
|
ConfigType = SsoType.OpenIdConnect, |
|
Authority = "https://example.com", |
|
ClientId = "test-client", |
|
ClientSecret = "test-secret" |
|
} |
|
}; |
|
|
|
// Act |
|
var result = model.ToSsoConfig(organizationId); |
|
|
|
// Assert |
|
Assert.NotNull(result); |
|
Assert.Equal(organizationId, result.OrganizationId); |
|
Assert.True(result.Enabled); |
|
} |
|
|
|
[Fact] |
|
public void ToSsoConfig_WithExistingConfig_UpdatesExistingConfig() |
|
{ |
|
// Arrange |
|
var organizationId = Guid.NewGuid(); |
|
var existingConfig = new SsoConfig |
|
{ |
|
Id = 1, |
|
OrganizationId = organizationId, |
|
Enabled = false |
|
}; |
|
|
|
var model = new OrganizationSsoRequestModel |
|
{ |
|
Enabled = true, |
|
Identifier = "updated-identifier", |
|
Data = new SsoConfigurationDataRequest |
|
{ |
|
ConfigType = SsoType.Saml2, |
|
IdpEntityId = "test-entity", |
|
IdpSingleSignOnServiceUrl = "https://sso.example.com" |
|
} |
|
}; |
|
|
|
// Act |
|
var result = model.ToSsoConfig(existingConfig); |
|
|
|
// Assert |
|
Assert.Same(existingConfig, result); |
|
Assert.Equal(organizationId, result.OrganizationId); |
|
Assert.True(result.Enabled); |
|
} |
|
} |
|
|
|
public class SsoConfigurationDataRequestTests |
|
{ |
|
private readonly TestI18nService _i18nService; |
|
private readonly ValidationContext _validationContext; |
|
|
|
public SsoConfigurationDataRequestTests() |
|
{ |
|
_i18nService = new TestI18nService(); |
|
var serviceProvider = Substitute.For<IServiceProvider>(); |
|
serviceProvider.GetService(typeof(II18nService)).Returns(_i18nService); |
|
_validationContext = new ValidationContext(new object(), serviceProvider, null); |
|
} |
|
|
|
[Fact] |
|
public void ToConfigurationData_MapsProperties() |
|
{ |
|
// Arrange |
|
var model = new SsoConfigurationDataRequest |
|
{ |
|
ConfigType = SsoType.OpenIdConnect, |
|
MemberDecryptionType = MemberDecryptionType.KeyConnector, |
|
Authority = "https://authority.example.com", |
|
ClientId = "test-client-id", |
|
ClientSecret = "test-client-secret", |
|
IdpX509PublicCert = "-----BEGIN CERTIFICATE-----\nMIIC...test\n-----END CERTIFICATE-----", |
|
SpOutboundSigningAlgorithm = null // Test default |
|
}; |
|
|
|
// Act |
|
var result = model.ToConfigurationData(); |
|
|
|
// Assert |
|
Assert.Equal(SsoType.OpenIdConnect, result.ConfigType); |
|
Assert.Equal(MemberDecryptionType.KeyConnector, result.MemberDecryptionType); |
|
Assert.Equal("https://authority.example.com", result.Authority); |
|
Assert.Equal("test-client-id", result.ClientId); |
|
Assert.Equal("test-client-secret", result.ClientSecret); |
|
Assert.Equal("MIIC...test", result.IdpX509PublicCert); // PEM headers stripped |
|
Assert.Equal(SamlSigningAlgorithms.Sha256, result.SpOutboundSigningAlgorithm); // Default applied |
|
Assert.Null(result.IdpArtifactResolutionServiceUrl); // Always null |
|
} |
|
|
|
[Fact] |
|
public void KeyConnectorEnabled_Setter_UpdatesMemberDecryptionType() |
|
{ |
|
// Arrange |
|
var model = new SsoConfigurationDataRequest(); |
|
|
|
// Act & Assert |
|
#pragma warning disable CS0618 // Type or member is obsolete |
|
model.KeyConnectorEnabled = true; |
|
Assert.Equal(MemberDecryptionType.KeyConnector, model.MemberDecryptionType); |
|
|
|
model.KeyConnectorEnabled = false; |
|
Assert.Equal(MemberDecryptionType.MasterPassword, model.MemberDecryptionType); |
|
#pragma warning restore CS0618 // Type or member is obsolete |
|
} |
|
|
|
// Validation Tests |
|
[Fact] |
|
public void Validate_OpenIdConnect_ValidData_NoErrors() |
|
{ |
|
// Arrange |
|
var model = new SsoConfigurationDataRequest |
|
{ |
|
ConfigType = SsoType.OpenIdConnect, |
|
Authority = "https://example.com", |
|
ClientId = "test-client", |
|
ClientSecret = "test-secret" |
|
}; |
|
|
|
// Act |
|
var results = model.Validate(_validationContext).ToList(); |
|
|
|
// Assert |
|
Assert.Empty(results); |
|
} |
|
|
|
[Theory] |
|
[InlineData("", "test-client", "test-secret", "AuthorityValidationError")] |
|
[InlineData("https://example.com", "", "test-secret", "ClientIdValidationError")] |
|
[InlineData("https://example.com", "test-client", "", "ClientSecretValidationError")] |
|
public void Validate_OpenIdConnect_MissingRequiredFields_ReturnsErrors(string authority, string clientId, string clientSecret, string expectedError) |
|
{ |
|
// Arrange |
|
var model = new SsoConfigurationDataRequest |
|
{ |
|
ConfigType = SsoType.OpenIdConnect, |
|
Authority = authority, |
|
ClientId = clientId, |
|
ClientSecret = clientSecret |
|
}; |
|
|
|
// Act |
|
var results = model.Validate(_validationContext).ToList(); |
|
|
|
// Assert |
|
Assert.Single(results); |
|
Assert.Equal(expectedError, results[0].ErrorMessage); |
|
} |
|
|
|
[Fact] |
|
public void Validate_Saml2_ValidData_NoErrors() |
|
{ |
|
// Arrange |
|
var model = new SsoConfigurationDataRequest |
|
{ |
|
ConfigType = SsoType.Saml2, |
|
IdpEntityId = "https://idp.example.com", |
|
IdpSingleSignOnServiceUrl = "https://sso.example.com", |
|
IdpSingleLogoutServiceUrl = "https://logout.example.com" |
|
}; |
|
|
|
// Act |
|
var results = model.Validate(_validationContext).ToList(); |
|
|
|
// Assert |
|
Assert.Empty(results); |
|
} |
|
|
|
[Theory] |
|
[InlineData("", "https://sso.example.com", "IdpEntityIdValidationError")] |
|
[InlineData("not-a-valid-uri", "", "IdpSingleSignOnServiceUrlValidationError")] |
|
public void Validate_Saml2_MissingRequiredFields_ReturnsErrors(string entityId, string signOnUrl, string expectedError) |
|
{ |
|
// Arrange |
|
var model = new SsoConfigurationDataRequest |
|
{ |
|
ConfigType = SsoType.Saml2, |
|
IdpEntityId = entityId, |
|
IdpSingleSignOnServiceUrl = signOnUrl |
|
}; |
|
|
|
// Act |
|
var results = model.Validate(_validationContext).ToList(); |
|
|
|
// Assert |
|
Assert.Contains(results, r => r.ErrorMessage == expectedError); |
|
} |
|
|
|
[Theory] |
|
[InlineData("not-a-url")] |
|
[InlineData("ftp://example.com")] |
|
[InlineData("https://example.com<script>")] |
|
[InlineData("https://example.com\"onclick")] |
|
public void Validate_Saml2_InvalidUrls_ReturnsErrors(string invalidUrl) |
|
{ |
|
// Arrange |
|
var model = new SsoConfigurationDataRequest |
|
{ |
|
ConfigType = SsoType.Saml2, |
|
IdpEntityId = "https://idp.example.com", |
|
IdpSingleSignOnServiceUrl = invalidUrl, |
|
IdpSingleLogoutServiceUrl = invalidUrl |
|
}; |
|
|
|
// Act |
|
var results = model.Validate(_validationContext).ToList(); |
|
|
|
// Assert |
|
Assert.Contains(results, r => r.ErrorMessage == "IdpSingleSignOnServiceUrlInvalid"); |
|
Assert.Contains(results, r => r.ErrorMessage == "IdpSingleLogoutServiceUrlInvalid"); |
|
} |
|
|
|
[Fact] |
|
public void Validate_Saml2_MissingSignOnUrl_AlwaysReturnsError() |
|
{ |
|
// Arrange - SignOnUrl is always required for SAML2, regardless of EntityId format |
|
var model = new SsoConfigurationDataRequest |
|
{ |
|
ConfigType = SsoType.Saml2, |
|
IdpEntityId = "https://idp.example.com", // Valid URI |
|
IdpSingleSignOnServiceUrl = "" // Missing - always causes error |
|
}; |
|
|
|
// Act |
|
var results = model.Validate(_validationContext).ToList(); |
|
|
|
// Assert - Should always fail validation when SignOnUrl is missing |
|
Assert.Contains(results, r => r.ErrorMessage == "IdpSingleSignOnServiceUrlValidationError"); |
|
} |
|
|
|
[Fact] |
|
public void Validate_Saml2_InvalidCertificate_ReturnsError() |
|
{ |
|
// Arrange |
|
var model = new SsoConfigurationDataRequest |
|
{ |
|
ConfigType = SsoType.Saml2, |
|
IdpEntityId = "https://idp.example.com", |
|
IdpSingleSignOnServiceUrl = "https://sso.example.com", |
|
IdpX509PublicCert = "invalid-certificate-data" |
|
}; |
|
|
|
// Act |
|
var results = model.Validate(_validationContext).ToList(); |
|
|
|
// Assert |
|
Assert.Contains(results, r => r.ErrorMessage.Contains("IdpX509PublicCert") && r.ErrorMessage.Contains("ValidationError")); |
|
} |
|
|
|
// TODO: On server, make public certificate required for SAML2 SSO: https://bitwarden.atlassian.net/browse/PM-26028 |
|
[Fact] |
|
public void Validate_Saml2_EmptyCertificate_PassesValidation() |
|
{ |
|
// Arrange |
|
var model = new SsoConfigurationDataRequest |
|
{ |
|
ConfigType = SsoType.Saml2, |
|
IdpEntityId = "https://idp.example.com", |
|
IdpSingleSignOnServiceUrl = "https://sso.example.com", |
|
IdpX509PublicCert = "" |
|
}; |
|
|
|
// Act |
|
var results = model.Validate(_validationContext).ToList(); |
|
|
|
// Assert |
|
Assert.DoesNotContain(results, r => r.MemberNames.Contains("IdpX509PublicCert")); |
|
} |
|
|
|
private class TestI18nService : I18nService |
|
{ |
|
public TestI18nService() : base(CreateMockLocalizerFactory()) { } |
|
|
|
private static IStringLocalizerFactory CreateMockLocalizerFactory() |
|
{ |
|
var factory = Substitute.For<IStringLocalizerFactory>(); |
|
var localizer = Substitute.For<IStringLocalizer>(); |
|
|
|
localizer[Arg.Any<string>()].Returns(callInfo => new LocalizedString(callInfo.Arg<string>(), callInfo.Arg<string>())); |
|
localizer[Arg.Any<string>(), Arg.Any<object[]>()].Returns(callInfo => new LocalizedString(callInfo.Arg<string>(), callInfo.Arg<string>())); |
|
|
|
factory.Create(Arg.Any<string>(), Arg.Any<string>()).Returns(localizer); |
|
return factory; |
|
} |
|
} |
|
}
|
|
|