mirror of
https://github.com/bitwarden/server
synced 2026-02-10 05:30:02 +00:00
SSO cookie vendor
This commit is contained in:
117
src/Api/Controllers/SsoCookieVendorController.cs
Normal file
117
src/Api/Controllers/SsoCookieVendorController.cs
Normal 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)}";
|
||||
}
|
||||
}
|
||||
362
test/Api.Test/Controllers/SsoCookieVendorControllerTests.cs
Normal file
362
test/Api.Test/Controllers/SsoCookieVendorControllerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user