diff --git a/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs b/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs
index fcf386d7ee..349bdebb88 100644
--- a/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs
+++ b/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs
@@ -121,7 +121,7 @@ public class SsoConfigurationDataRequest : IValidatableObject
new[] { nameof(IdpEntityId) });
}
- if (!Uri.IsWellFormedUriString(IdpEntityId, UriKind.Absolute) && string.IsNullOrWhiteSpace(IdpSingleSignOnServiceUrl))
+ if (string.IsNullOrWhiteSpace(IdpSingleSignOnServiceUrl))
{
yield return new ValidationResult(i18nService.GetLocalizedHtmlString("IdpSingleSignOnServiceUrlValidationError"),
new[] { nameof(IdpSingleSignOnServiceUrl) });
@@ -139,6 +139,7 @@ public class SsoConfigurationDataRequest : IValidatableObject
new[] { nameof(IdpSingleLogoutServiceUrl) });
}
+ // TODO: On server, make public certificate required for SAML2 SSO: https://bitwarden.atlassian.net/browse/PM-26028
if (!string.IsNullOrWhiteSpace(IdpX509PublicCert))
{
// Validate the certificate is in a valid format
diff --git a/src/Core/Resources/SharedResources.en.resx b/src/Core/Resources/SharedResources.en.resx
index 17b4489454..28ae70ca96 100644
--- a/src/Core/Resources/SharedResources.en.resx
+++ b/src/Core/Resources/SharedResources.en.resx
@@ -389,7 +389,7 @@
If SAML Binding Type is set to artifact, identity provider resolution service URL is required.
- If Identity Provider Entity ID is not a URL, single sign on service URL is required.
+ Single sign on service URL is required.
The configured authentication scheme is not valid: "{0}"
diff --git a/test/Api.Test/Auth/Models/Request/OrganizationSsoRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/OrganizationSsoRequestModelTests.cs
new file mode 100644
index 0000000000..8348ba885d
--- /dev/null
+++ b/test/Api.Test/Auth/Models/Request/OrganizationSsoRequestModelTests.cs
@@ -0,0 +1,313 @@
+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();
+ 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