From 0c7fbf8804d8321e156babff044112860504704a Mon Sep 17 00:00:00 2001 From: Derek Nance Date: Mon, 26 Jan 2026 17:40:28 -0600 Subject: [PATCH] SSO cookie vendor --- .../Controllers/SsoCookieVendorController.cs | 117 ++++++ .../SsoCookieVendorControllerTests.cs | 362 ++++++++++++++++++ 2 files changed, 479 insertions(+) create mode 100644 src/Api/Controllers/SsoCookieVendorController.cs create mode 100644 test/Api.Test/Controllers/SsoCookieVendorControllerTests.cs diff --git a/src/Api/Controllers/SsoCookieVendorController.cs b/src/Api/Controllers/SsoCookieVendorController.cs new file mode 100644 index 0000000000..f160b14d73 --- /dev/null +++ b/src/Api/Controllers/SsoCookieVendorController.cs @@ -0,0 +1,117 @@ +using Bit.Core.Settings; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Controllers; + +/// +/// Provides an endpoint to read an SSO cookie and redirect to a custom URI +/// scheme. The load balancer must be configured such that requests to this +/// endpoint do not have the auth cookie stripped. +/// +[Route("sso-cookie-vendor")] +public class SsoCookieVendorController(IGlobalSettings globalSettings) : Controller +{ + private readonly IGlobalSettings _globalSettings = globalSettings; + private const int _maxShardCount = 20; + private const int _maxUriLength = 8192; + + /// + /// Reads SSO cookie (shards supported) and redirects to the bitwarden:// + /// URI with cookie value(s). + /// + /// + /// 302 redirect on success, 404 if no cookies found, 400 if URI too long, + /// 500 if misconfigured + /// + [HttpGet] + [AllowAnonymous] + public IActionResult Get() + { + var bootstrap = _globalSettings.Communication?.Bootstrap; + if (string.IsNullOrEmpty(bootstrap) || bootstrap != "ssoCookieVendor") + { + return NotFound(); + } + + var cookieName = _globalSettings.Communication?.SsoCookieVendor?.CookieName; + if (string.IsNullOrWhiteSpace(cookieName)) + { + return StatusCode(500, "SSO cookie vendor is not properly configured"); + } + + var uri = string.Empty; + if (TryGetCookie(cookieName, out var cookie)) + { + uri = BuildRedirectUri(cookie); + } + else if (TryGetShardedCookie(cookieName, out var shardedCookie)) + { + uri = BuildRedirectUri(shardedCookie); + } + + if (uri == string.Empty) + { + return NotFound("No SSO cookies found"); + } + + if (uri.Length > _maxUriLength) + { + return BadRequest(); + } + + return Redirect(uri); + } + + private bool TryGetCookie(string cookieName, out Dictionary cookie) + { + cookie = []; + + if (Request.Cookies.TryGetValue(cookieName, out var value) && !string.IsNullOrEmpty(value)) + { + cookie[cookieName] = value; + return true; + } + + return false; + } + + private bool TryGetShardedCookie(string cookieName, out Dictionary cookies) + { + var shardedCookies = new Dictionary(); + + for (var i = 0; i < _maxShardCount; i++) + { + var shardName = $"{cookieName}-{i}"; + if (Request.Cookies.TryGetValue(shardName, out var value) && !string.IsNullOrEmpty(value)) + { + shardedCookies[shardName] = value; + } + else + { + // Stop at first missing shard to maintain order integrity + break; + } + } + + cookies = shardedCookies; + return shardedCookies.Count > 0; + } + + private static string BuildRedirectUri(Dictionary cookies) + { + var queryParams = new List(); + + foreach (var kvp in cookies) + { + var encodedValue = Uri.EscapeDataString(kvp.Value); + queryParams.Add($"{kvp.Key}={encodedValue}"); + } + + // Add a sentinel value so clients can detect a truncated URI, in the + // event a user agent decides the URI is too long. + queryParams.Add("d=1"); + + return $"bitwarden://sso_cookie_vendor?{string.Join("&", queryParams)}"; + } +} diff --git a/test/Api.Test/Controllers/SsoCookieVendorControllerTests.cs b/test/Api.Test/Controllers/SsoCookieVendorControllerTests.cs new file mode 100644 index 0000000000..1e954e68ff --- /dev/null +++ b/test/Api.Test/Controllers/SsoCookieVendorControllerTests.cs @@ -0,0 +1,362 @@ +#nullable enable + +using Bit.Api.Controllers; +using Bit.Core.Settings; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Controllers; + +public class SsoCookieVendorControllerTests : IDisposable +{ + private readonly SsoCookieVendorController _sut; + private readonly GlobalSettings _globalSettings; + + public SsoCookieVendorControllerTests() + { + _globalSettings = new GlobalSettings + { + Communication = new GlobalSettings.CommunicationSettings + { + Bootstrap = "ssoCookieVendor", + SsoCookieVendor = new GlobalSettings.SsoCookieVendorSettings + { + CookieName = "test-cookie" + } + } + }; + _sut = new SsoCookieVendorController(_globalSettings); + } + + public void Dispose() + { + _sut?.Dispose(); + } + + private void MockHttpContextWithCookies(Dictionary cookies) + { + var httpContext = new DefaultHttpContext(); + var cookieCollection = Substitute.For(); + + // Mock the TryGetValue method + cookieCollection.TryGetValue(Arg.Any(), out Arg.Any()) + .Returns(callInfo => + { + var key = callInfo.ArgAt(0); + if (cookies.TryGetValue(key, out var value)) + { + callInfo[1] = value; + return true; + } + callInfo[1] = null; + return false; + }); + + // Mock the indexer if needed + cookieCollection[Arg.Any()].Returns(callInfo => + { + var key = callInfo.ArgAt(0); + return cookies.TryGetValue(key, out var value) ? value : null; + }); + + httpContext.Request.Cookies = cookieCollection; + _sut.ControllerContext = new ControllerContext { HttpContext = httpContext }; + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("none")] + public void Get_WhenBootstrapNotConfigured_Returns404(string? bootstrap) + { + // Arrange +#nullable disable + _globalSettings.Communication.Bootstrap = bootstrap; +#nullable restore + MockHttpContextWithCookies([]); + + // Act + var result = _sut.Get(); + + // Assert + Assert.IsType(result); + } + + [Fact] + public void Get_WhenCookieNameNotConfigured_Returns500() + { + // Arrange + _globalSettings.Communication.SsoCookieVendor.CookieName = string.Empty; + MockHttpContextWithCookies([]); + + // Act + var result = _sut.Get(); + + // Assert + var statusCodeResult = Assert.IsType(result); + Assert.Equal(500, statusCodeResult.StatusCode); + } + + [Fact] + public void Get_WhenCookieNameIsEmpty_Returns500() + { + // Arrange + _globalSettings.Communication.SsoCookieVendor.CookieName = ""; + MockHttpContextWithCookies([]); + + // Act + var result = _sut.Get(); + + // Assert + var statusCodeResult = Assert.IsType(result); + Assert.Equal(500, statusCodeResult.StatusCode); + } + + [Fact] + public void Get_WhenSingleCookieExists_ReturnsRedirectWithCorrectUri() + { + // Arrange + var cookies = new Dictionary + { + { "test-cookie", "my-token-value-123" } + }; + MockHttpContextWithCookies(cookies); + + // Act + var result = _sut.Get(); + + // Assert + var redirectResult = Assert.IsType(result); + Assert.Equal("bitwarden://sso_cookie_vendor?test-cookie=my-token-value-123&d=1", redirectResult.Url); + } + + [Fact] + public void Get_WhenSingleCookieHasSpecialCharacters_EncodesCorrectly() + { + // Arrange + var cookies = new Dictionary + { + { "test-cookie", "value with spaces & special=chars!" } + }; + MockHttpContextWithCookies(cookies); + + // Act + var result = _sut.Get(); + + // Assert + var redirectResult = Assert.IsType(result); + Assert.Contains("value%20with%20spaces", redirectResult.Url); + Assert.Contains("%26", redirectResult.Url); // & encoded + Assert.Contains("%3D", redirectResult.Url); // = encoded + Assert.Contains("%21", redirectResult.Url); // ! encoded + } + + [Fact] + public void Get_WhenShardedCookiesExist_ReturnsRedirectWithShardedUri() + { + // Arrange + var cookies = new Dictionary + { + { "test-cookie-0", "part1" }, + { "test-cookie-1", "part2" }, + { "test-cookie-2", "part3" } + }; + MockHttpContextWithCookies(cookies); + + // Act + var result = _sut.Get(); + + // Assert + var redirectResult = Assert.IsType(result); + Assert.StartsWith("bitwarden://sso_cookie_vendor?", redirectResult.Url); + Assert.Contains("test-cookie-0=part1", redirectResult.Url); + Assert.Contains("test-cookie-1=part2", redirectResult.Url); + Assert.Contains("test-cookie-2=part3", redirectResult.Url); + Assert.EndsWith("d=1", redirectResult.Url); + } + + [Fact] + public void Get_WhenShardedCookiesWithGap_StopsAtFirstGap() + { + // Arrange + var cookies = new Dictionary + { + { "test-cookie-0", "part0" }, + { "test-cookie-1", "part1" }, + // Missing test-cookie-2 + { "test-cookie-3", "part3" }, + { "test-cookie-4", "part4" } + }; + MockHttpContextWithCookies(cookies); + + // Act + var result = _sut.Get(); + + // Assert + var redirectResult = Assert.IsType(result); + Assert.Contains("test-cookie-0=part0", redirectResult.Url); + Assert.Contains("test-cookie-1=part1", redirectResult.Url); + Assert.DoesNotContain("test-cookie-3", redirectResult.Url); + Assert.DoesNotContain("test-cookie-4", redirectResult.Url); + Assert.EndsWith("d=1", redirectResult.Url); + } + + [Fact] + public void Get_WhenOnlyGappedShardsExist_Returns404() + { + // Arrange - only test-cookie-2 exists, not test-cookie-0 or test-cookie-1 + var cookies = new Dictionary + { + { "test-cookie-2", "part2" }, + { "test-cookie-3", "part3" } + }; + MockHttpContextWithCookies(cookies); + + // Act + var result = _sut.Get(); + + // Assert + Assert.IsType(result); + } + + [Fact] + public void Get_WhenNoCookiesFound_Returns404() + { + // Arrange + MockHttpContextWithCookies([]); + + // Act + var result = _sut.Get(); + + // Assert + Assert.IsType(result); + } + + [Fact] + public void Get_WhenUnrelatedCookiesExist_Returns404() + { + // Arrange + var cookies = new Dictionary + { + { "other-cookie", "value" }, + { "another-cookie", "value2" } + }; + MockHttpContextWithCookies(cookies); + + // Act + var result = _sut.Get(); + + // Assert + Assert.IsType(result); + } + + [Fact] + public void Get_WhenUriExceedsMaxLength_Returns400() + { + // Arrange - create a very long cookie value that will exceed 8192 characters + // URI format: "bitwarden://sso_cookie_vendor?test-cookie={value}" + // Base URI length is about 43 characters, so we need value > 8149 + var longValue = new string('a', 8200); + var cookies = new Dictionary + { + { "test-cookie", longValue } + }; + MockHttpContextWithCookies(cookies); + + // Act + var result = _sut.Get(); + + // Assert + Assert.IsType(result); + } + + [Fact] + public void Get_WhenSingleCookiePreferredOverSharded_ReturnsSingleCookie() + { + // Arrange - both single and sharded cookies exist + var cookies = new Dictionary + { + { "test-cookie", "single-value" }, + { "test-cookie-0", "shard0" }, + { "test-cookie-1", "shard1" } + }; + MockHttpContextWithCookies(cookies); + + // Act + var result = _sut.Get(); + + // Assert + var redirectResult = Assert.IsType(result); + Assert.Equal("bitwarden://sso_cookie_vendor?test-cookie=single-value&d=1", redirectResult.Url); + } + + [Fact] + public void Get_WhenEmptyCookieValue_TreatsAsNotFound() + { + // Arrange + var cookies = new Dictionary + { + { "test-cookie", "" } + }; + MockHttpContextWithCookies(cookies); + + // Act + var result = _sut.Get(); + + // Assert + Assert.IsType(result); + } + + [Fact] + public void Get_WhenShardedCookiesHaveMaxCount_ProcessesAllShards() + { + // Arrange - create 20 sharded cookies (MaxShardCount) + var cookies = new Dictionary(); + for (var i = 0; i < 20; i++) + { + cookies[$"test-cookie-{i}"] = $"part{i}"; + } + MockHttpContextWithCookies(cookies); + + // Act + var result = _sut.Get(); + + // Assert + var redirectResult = Assert.IsType(result); + for (var i = 0; i < 20; i++) + { + Assert.Contains($"test-cookie-{i}=part{i}", redirectResult.Url); + } + Assert.EndsWith("d=1", redirectResult.Url); + } + + [Fact] + public void Get_WhenShardedCookiesExceedMaxCount_OnlyProcessesFirst20() + { + // Arrange - create 25 sharded cookies (more than MaxShardCount of 20) + var cookies = new Dictionary(); + for (var i = 0; i < 25; i++) + { + cookies[$"test-cookie-{i}"] = $"part{i}"; + } + MockHttpContextWithCookies(cookies); + + // Act + var result = _sut.Get(); + + // Assert + var redirectResult = Assert.IsType(result); + // Should contain first 20 + for (var i = 0; i < 20; i++) + { + Assert.Contains($"test-cookie-{i}=part{i}", redirectResult.Url); + } + // Should NOT contain 21-25 + for (var i = 20; i < 25; i++) + { + Assert.DoesNotContain($"test-cookie-{i}=part{i}", redirectResult.Url); + } + } +}