1
0
mirror of https://github.com/bitwarden/server synced 2026-02-10 05:30:02 +00:00

SSO cookie vendor

This commit is contained in:
Derek Nance
2026-01-26 17:40:28 -06:00
parent d0398e4f59
commit 0c7fbf8804
2 changed files with 479 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
using Bit.Core.Settings;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Controllers;
/// <summary>
/// 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.
/// </summary>
[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;
/// <summary>
/// Reads SSO cookie (shards supported) and redirects to the bitwarden://
/// URI with cookie value(s).
/// </summary>
/// <returns>
/// 302 redirect on success, 404 if no cookies found, 400 if URI too long,
/// 500 if misconfigured
/// </returns>
[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<string, string> 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<string, string> cookies)
{
var shardedCookies = new Dictionary<string, string>();
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<string, string> cookies)
{
var queryParams = new List<string>();
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)}";
}
}

View File

@@ -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<string, string> cookies)
{
var httpContext = new DefaultHttpContext();
var cookieCollection = Substitute.For<IRequestCookieCollection>();
// Mock the TryGetValue method
cookieCollection.TryGetValue(Arg.Any<string>(), out Arg.Any<string?>())
.Returns(callInfo =>
{
var key = callInfo.ArgAt<string>(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<string>()].Returns(callInfo =>
{
var key = callInfo.ArgAt<string>(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<NotFoundResult>(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<ObjectResult>(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<ObjectResult>(result);
Assert.Equal(500, statusCodeResult.StatusCode);
}
[Fact]
public void Get_WhenSingleCookieExists_ReturnsRedirectWithCorrectUri()
{
// Arrange
var cookies = new Dictionary<string, string>
{
{ "test-cookie", "my-token-value-123" }
};
MockHttpContextWithCookies(cookies);
// Act
var result = _sut.Get();
// Assert
var redirectResult = Assert.IsType<RedirectResult>(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<string, string>
{
{ "test-cookie", "value with spaces & special=chars!" }
};
MockHttpContextWithCookies(cookies);
// Act
var result = _sut.Get();
// Assert
var redirectResult = Assert.IsType<RedirectResult>(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<string, string>
{
{ "test-cookie-0", "part1" },
{ "test-cookie-1", "part2" },
{ "test-cookie-2", "part3" }
};
MockHttpContextWithCookies(cookies);
// Act
var result = _sut.Get();
// Assert
var redirectResult = Assert.IsType<RedirectResult>(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<string, string>
{
{ "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<RedirectResult>(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<string, string>
{
{ "test-cookie-2", "part2" },
{ "test-cookie-3", "part3" }
};
MockHttpContextWithCookies(cookies);
// Act
var result = _sut.Get();
// Assert
Assert.IsType<NotFoundObjectResult>(result);
}
[Fact]
public void Get_WhenNoCookiesFound_Returns404()
{
// Arrange
MockHttpContextWithCookies([]);
// Act
var result = _sut.Get();
// Assert
Assert.IsType<NotFoundObjectResult>(result);
}
[Fact]
public void Get_WhenUnrelatedCookiesExist_Returns404()
{
// Arrange
var cookies = new Dictionary<string, string>
{
{ "other-cookie", "value" },
{ "another-cookie", "value2" }
};
MockHttpContextWithCookies(cookies);
// Act
var result = _sut.Get();
// Assert
Assert.IsType<NotFoundObjectResult>(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<string, string>
{
{ "test-cookie", longValue }
};
MockHttpContextWithCookies(cookies);
// Act
var result = _sut.Get();
// Assert
Assert.IsType<BadRequestResult>(result);
}
[Fact]
public void Get_WhenSingleCookiePreferredOverSharded_ReturnsSingleCookie()
{
// Arrange - both single and sharded cookies exist
var cookies = new Dictionary<string, string>
{
{ "test-cookie", "single-value" },
{ "test-cookie-0", "shard0" },
{ "test-cookie-1", "shard1" }
};
MockHttpContextWithCookies(cookies);
// Act
var result = _sut.Get();
// Assert
var redirectResult = Assert.IsType<RedirectResult>(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<string, string>
{
{ "test-cookie", "" }
};
MockHttpContextWithCookies(cookies);
// Act
var result = _sut.Get();
// Assert
Assert.IsType<NotFoundObjectResult>(result);
}
[Fact]
public void Get_WhenShardedCookiesHaveMaxCount_ProcessesAllShards()
{
// Arrange - create 20 sharded cookies (MaxShardCount)
var cookies = new Dictionary<string, string>();
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<RedirectResult>(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<string, string>();
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<RedirectResult>(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);
}
}
}