1
0
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:
Patrick Pimentel
2026-01-23 17:57:39 -05:00
parent dd6c49e214
commit 094754b58f
3 changed files with 254 additions and 15 deletions

View 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,
}

View File

@@ -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"],

View File

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