mirror of
https://github.com/bitwarden/server
synced 2026-02-07 20:23:49 +00:00
fix(redirect): [PM-30810] Https Redirection for Cloud Users - Clarified messaging around how specific clients will build the response as well as added tests.
This commit is contained in:
17
src/Core/Auth/Enums/DeeplinkScheme.cs
Normal file
17
src/Core/Auth/Enums/DeeplinkScheme.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Bit.Core.Auth.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Deeplink scheme values used for mobile client redirects after Duo authentication.
|
||||
/// </summary>
|
||||
public enum DeeplinkScheme : byte
|
||||
{
|
||||
/// <summary>
|
||||
/// HTTPS scheme used for Bitwarden cloud-hosted environments.
|
||||
/// </summary>
|
||||
Https = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Custom bitwarden:// scheme used for self-hosted environments.
|
||||
/// </summary>
|
||||
Bitwarden = 1,
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
#nullable disable
|
||||
|
||||
using System.Globalization;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Context;
|
||||
@@ -76,6 +77,15 @@ public interface IDuoUniversalTokenService
|
||||
/// <param name="provider">TwoFactorProvider Duo or OrganizationDuo</param>
|
||||
/// <returns>Duo.Client object or null</returns>
|
||||
Task<Duo.Client> BuildDuoTwoFactorClientAsync(TwoFactorProvider provider);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the redirect URI for Duo authentication based on the client type and request context.
|
||||
/// Mobile clients include a deeplinkScheme parameter (https for cloud, bitwarden for self-hosted).
|
||||
/// Desktop clients always use the bitwarden scheme.
|
||||
/// Other clients (web, browser, cli) do not include the deeplinkScheme parameter.
|
||||
/// </summary>
|
||||
/// <returns>The redirect URI to be used for Duo authentication</returns>
|
||||
string BuildDuoTwoFactorRedirectUri();
|
||||
}
|
||||
|
||||
public class DuoUniversalTokenService(
|
||||
@@ -187,7 +197,7 @@ public class DuoUniversalTokenService(
|
||||
normalizedHost.EndsWith(".localhost");
|
||||
}
|
||||
|
||||
private static string GetDeeplinkSchemeOverride(HttpContext httpContext)
|
||||
private static DeeplinkScheme? GetDeeplinkSchemeOverride(HttpContext httpContext)
|
||||
{
|
||||
if (httpContext == null)
|
||||
{
|
||||
@@ -204,13 +214,13 @@ public class DuoUniversalTokenService(
|
||||
// Querystring has precedence over header for manual local testing
|
||||
var overrideFromQuery = httpContext.Request?.Query["deeplinkScheme"].FirstOrDefault();
|
||||
var overrideFromHeader = httpContext.Request?.Headers["Bitwarden-Deeplink-Scheme"].FirstOrDefault();
|
||||
var candidate = (overrideFromQuery ?? overrideFromHeader)?.Trim().ToLowerInvariant();
|
||||
var candidate = (overrideFromQuery ?? overrideFromHeader)?.Trim();
|
||||
|
||||
// Allow only the two supported values
|
||||
return candidate is "https" or "bitwarden" ? candidate : null;
|
||||
return Enum.TryParse<DeeplinkScheme>(candidate, ignoreCase: true, out var scheme) ? scheme : null;
|
||||
}
|
||||
|
||||
public async Task<Duo.Client> BuildDuoTwoFactorClientAsync(TwoFactorProvider provider)
|
||||
public string BuildDuoTwoFactorRedirectUri()
|
||||
{
|
||||
// Fetch Client name from header value since duo auth can be initiated from multiple clients and we want
|
||||
// to redirect back to the initiating client
|
||||
@@ -221,28 +231,38 @@ public class DuoUniversalTokenService(
|
||||
: ClientType.Web;
|
||||
var clientName = clientType.ToString().ToLowerInvariant();
|
||||
|
||||
string redirectUri;
|
||||
|
||||
// Handle mobile case separately because mobile needs to define the scheme ahead of time
|
||||
// for security reasons.
|
||||
if (clientType == ClientType.Mobile)
|
||||
{
|
||||
var requestHost = _currentContext.HttpContext.Request.Host.Host;
|
||||
var deeplinkScheme = GetDeeplinkSchemeOverride(_currentContext.HttpContext) ??
|
||||
(IsBitwardenCloudHost(requestHost) ? "https" : "bitwarden");
|
||||
redirectUri = string.Format(CultureInfo.InvariantCulture,
|
||||
(IsBitwardenCloudHost(requestHost) ? DeeplinkScheme.Https : DeeplinkScheme.Bitwarden);
|
||||
return string.Format(CultureInfo.InvariantCulture,
|
||||
"{0}/duo-redirect-connector.html?client={1}&deeplinkScheme={2}",
|
||||
_globalSettings.BaseServiceUri.Vault, clientName, deeplinkScheme);
|
||||
_globalSettings.BaseServiceUri.Vault, clientName, deeplinkScheme.ToString().ToLowerInvariant());
|
||||
}
|
||||
// All other clients will not use the deep link scheme property built into the duo token provided
|
||||
// back from their api.
|
||||
else
|
||||
|
||||
// Explicitly have the desktop client use the bitwarden scheme. See the complimentary
|
||||
// duo web connector in the client project.
|
||||
if (clientType == ClientType.Desktop)
|
||||
{
|
||||
redirectUri = string.Format(CultureInfo.InvariantCulture,
|
||||
"{0}/duo-redirect-connector.html?client={1}",
|
||||
_globalSettings.BaseServiceUri.Vault, clientName);
|
||||
return string.Format(CultureInfo.InvariantCulture,
|
||||
"{0}/duo-redirect-connector.html?client={1}&deeplinkScheme={2}",
|
||||
_globalSettings.BaseServiceUri.Vault, clientName, DeeplinkScheme.Bitwarden.ToString().ToLowerInvariant());
|
||||
}
|
||||
|
||||
// All other clients will not provide an explicit handling. See the complimentary
|
||||
// duo web connector in the client project to understand how defaulting is handled.
|
||||
return string.Format(CultureInfo.InvariantCulture,
|
||||
"{0}/duo-redirect-connector.html?client={1}",
|
||||
_globalSettings.BaseServiceUri.Vault, clientName);
|
||||
}
|
||||
|
||||
public async Task<Duo.Client> BuildDuoTwoFactorClientAsync(TwoFactorProvider provider)
|
||||
{
|
||||
var redirectUri = BuildDuoTwoFactorRedirectUri();
|
||||
|
||||
var client = new Duo.ClientBuilder(
|
||||
(string)provider.MetaData["ClientId"],
|
||||
(string)provider.MetaData["ClientSecret"],
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
using Bit.Core.Auth.Identity.TokenProviders;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using CoreSettings = Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.Test.Auth.Services;
|
||||
|
||||
@@ -87,5 +91,203 @@ public class DuoUniversalTokenServiceTests
|
||||
Assert.Equal(result, expectedResponse);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("vault.bitwarden.com")] // Cloud US
|
||||
[BitAutoData("vault.bitwarden.eu")] // Cloud EU
|
||||
public void BuildDuoTwoFactorRedirectUri_MobileClient_CloudHost_ReturnsHttpsScheme(
|
||||
string requestHost)
|
||||
{
|
||||
// Arrange
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Headers["Bitwarden-Client-Name"] = "mobile";
|
||||
httpContext.Request.Host = new HostString(requestHost);
|
||||
|
||||
var currentContext = Substitute.For<ICurrentContext>();
|
||||
currentContext.HttpContext.Returns(httpContext);
|
||||
|
||||
var globalSettings = new CoreSettings.GlobalSettings
|
||||
{
|
||||
BaseServiceUri = new CoreSettings.GlobalSettings.BaseServiceUriSettings(new CoreSettings.GlobalSettings()) { Vault = "https://vault.bitwarden.com" }
|
||||
};
|
||||
|
||||
var sut = new DuoUniversalTokenService(currentContext, globalSettings);
|
||||
|
||||
// Act
|
||||
var result = sut.BuildDuoTwoFactorRedirectUri();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("client=mobile", result);
|
||||
Assert.Contains("deeplinkScheme=https", result);
|
||||
Assert.StartsWith("https://vault.bitwarden.com/duo-redirect-connector.html", result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("selfhosted.example.com")]
|
||||
[BitAutoData("192.168.1.100")]
|
||||
public void BuildDuoTwoFactorRedirectUri_MobileClient_SelfHosted_ReturnsBitwardenScheme(
|
||||
string requestHost)
|
||||
{
|
||||
// Arrange
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Headers["Bitwarden-Client-Name"] = "mobile";
|
||||
httpContext.Request.Host = new HostString(requestHost);
|
||||
|
||||
var currentContext = Substitute.For<ICurrentContext>();
|
||||
currentContext.HttpContext.Returns(httpContext);
|
||||
|
||||
var globalSettings = new CoreSettings.GlobalSettings
|
||||
{
|
||||
BaseServiceUri = new CoreSettings.GlobalSettings.BaseServiceUriSettings(new CoreSettings.GlobalSettings()) { Vault = "https://vault.example.com" }
|
||||
};
|
||||
|
||||
var sut = new DuoUniversalTokenService(currentContext, globalSettings);
|
||||
|
||||
// Act
|
||||
var result = sut.BuildDuoTwoFactorRedirectUri();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("client=mobile", result);
|
||||
Assert.Contains("deeplinkScheme=bitwarden", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDuoTwoFactorRedirectUri_DesktopClient_ReturnsBitwardenScheme()
|
||||
{
|
||||
// Arrange
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Headers["Bitwarden-Client-Name"] = "desktop";
|
||||
httpContext.Request.Host = new HostString("vault.bitwarden.com");
|
||||
|
||||
var currentContext = Substitute.For<ICurrentContext>();
|
||||
currentContext.HttpContext.Returns(httpContext);
|
||||
|
||||
var globalSettings = new CoreSettings.GlobalSettings
|
||||
{
|
||||
BaseServiceUri = new CoreSettings.GlobalSettings.BaseServiceUriSettings(new CoreSettings.GlobalSettings()) { Vault = "https://vault.bitwarden.com" }
|
||||
};
|
||||
|
||||
var sut = new DuoUniversalTokenService(currentContext, globalSettings);
|
||||
|
||||
// Act
|
||||
var result = sut.BuildDuoTwoFactorRedirectUri();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("client=desktop", result);
|
||||
Assert.Contains("deeplinkScheme=bitwarden", result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("web")]
|
||||
[BitAutoData("browser")]
|
||||
[BitAutoData("cli")]
|
||||
public void BuildDuoTwoFactorRedirectUri_NonMobileNonDesktopClient_NoDeeplinkScheme(
|
||||
string clientName)
|
||||
{
|
||||
// Arrange
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Headers["Bitwarden-Client-Name"] = clientName;
|
||||
httpContext.Request.Host = new HostString("vault.bitwarden.com");
|
||||
|
||||
var currentContext = Substitute.For<ICurrentContext>();
|
||||
currentContext.HttpContext.Returns(httpContext);
|
||||
|
||||
var globalSettings = new CoreSettings.GlobalSettings
|
||||
{
|
||||
BaseServiceUri = new CoreSettings.GlobalSettings.BaseServiceUriSettings(new CoreSettings.GlobalSettings()) { Vault = "https://vault.bitwarden.com" }
|
||||
};
|
||||
|
||||
var sut = new DuoUniversalTokenService(currentContext, globalSettings);
|
||||
|
||||
// Act
|
||||
var result = sut.BuildDuoTwoFactorRedirectUri();
|
||||
|
||||
// Assert
|
||||
Assert.Contains($"client={clientName}", result);
|
||||
Assert.DoesNotContain("deeplinkScheme", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDuoTwoFactorRedirectUri_NoClientHeader_DefaultsToWeb()
|
||||
{
|
||||
// Arrange
|
||||
var httpContext = new DefaultHttpContext();
|
||||
// No Bitwarden-Client-Name header set
|
||||
httpContext.Request.Host = new HostString("vault.bitwarden.com");
|
||||
|
||||
var currentContext = Substitute.For<ICurrentContext>();
|
||||
currentContext.HttpContext.Returns(httpContext);
|
||||
|
||||
var globalSettings = new CoreSettings.GlobalSettings
|
||||
{
|
||||
BaseServiceUri = new CoreSettings.GlobalSettings.BaseServiceUriSettings(new CoreSettings.GlobalSettings()) { Vault = "https://vault.bitwarden.com" }
|
||||
};
|
||||
|
||||
var sut = new DuoUniversalTokenService(currentContext, globalSettings);
|
||||
|
||||
// Act
|
||||
var result = sut.BuildDuoTwoFactorRedirectUri();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("client=web", result);
|
||||
Assert.DoesNotContain("deeplinkScheme", result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("invalid-client")]
|
||||
[BitAutoData("unknown")]
|
||||
public void BuildDuoTwoFactorRedirectUri_InvalidClientHeader_DefaultsToWeb(
|
||||
string invalidClientName)
|
||||
{
|
||||
// Arrange
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Headers["Bitwarden-Client-Name"] = invalidClientName;
|
||||
httpContext.Request.Host = new HostString("vault.bitwarden.com");
|
||||
|
||||
var currentContext = Substitute.For<ICurrentContext>();
|
||||
currentContext.HttpContext.Returns(httpContext);
|
||||
|
||||
var globalSettings = new CoreSettings.GlobalSettings
|
||||
{
|
||||
BaseServiceUri = new CoreSettings.GlobalSettings.BaseServiceUriSettings(new CoreSettings.GlobalSettings()) { Vault = "https://vault.bitwarden.com" }
|
||||
};
|
||||
|
||||
var sut = new DuoUniversalTokenService(currentContext, globalSettings);
|
||||
|
||||
// Act
|
||||
var result = sut.BuildDuoTwoFactorRedirectUri();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("client=web", result);
|
||||
Assert.DoesNotContain("deeplinkScheme", result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("MOBILE")]
|
||||
[BitAutoData("Mobile")]
|
||||
[BitAutoData("MoBiLe")]
|
||||
public void BuildDuoTwoFactorRedirectUri_ClientHeaderCaseInsensitive(
|
||||
string clientName)
|
||||
{
|
||||
// Arrange
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Headers["Bitwarden-Client-Name"] = clientName;
|
||||
httpContext.Request.Host = new HostString("vault.bitwarden.com");
|
||||
|
||||
var currentContext = Substitute.For<ICurrentContext>();
|
||||
currentContext.HttpContext.Returns(httpContext);
|
||||
|
||||
var globalSettings = new CoreSettings.GlobalSettings
|
||||
{
|
||||
BaseServiceUri = new CoreSettings.GlobalSettings.BaseServiceUriSettings(new CoreSettings.GlobalSettings()) { Vault = "https://vault.bitwarden.com" }
|
||||
};
|
||||
|
||||
var sut = new DuoUniversalTokenService(currentContext, globalSettings);
|
||||
|
||||
// Act
|
||||
var result = sut.BuildDuoTwoFactorRedirectUri();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("client=mobile", result);
|
||||
Assert.Contains("deeplinkScheme=https", result);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user