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);
+ }
+ }
+}