diff --git a/src/Core/Auth/Enums/DeeplinkScheme.cs b/src/Core/Auth/Enums/DeeplinkScheme.cs
new file mode 100644
index 0000000000..3249c160d6
--- /dev/null
+++ b/src/Core/Auth/Enums/DeeplinkScheme.cs
@@ -0,0 +1,17 @@
+namespace Bit.Core.Auth.Enums;
+
+///
+/// Deeplink scheme values used for mobile client redirects after Duo authentication.
+///
+public enum DeeplinkScheme : byte
+{
+ ///
+ /// HTTPS scheme used for Bitwarden cloud-hosted environments.
+ ///
+ Https = 0,
+
+ ///
+ /// Custom bitwarden:// scheme used for self-hosted environments.
+ ///
+ Bitwarden = 1,
+}
diff --git a/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenService.cs b/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenService.cs
index 3b23e7ce0f..778d124510 100644
--- a/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenService.cs
+++ b/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenService.cs
@@ -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
/// TwoFactorProvider Duo or OrganizationDuo
/// Duo.Client object or null
Task BuildDuoTwoFactorClientAsync(TwoFactorProvider provider);
+
+ ///
+ /// 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.
+ ///
+ /// The redirect URI to be used for Duo authentication
+ 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(candidate, ignoreCase: true, out var scheme) ? scheme : null;
}
- public async Task 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 BuildDuoTwoFactorClientAsync(TwoFactorProvider provider)
+ {
+ var redirectUri = BuildDuoTwoFactorRedirectUri();
+
var client = new Duo.ClientBuilder(
(string)provider.MetaData["ClientId"],
(string)provider.MetaData["ClientSecret"],
diff --git a/test/Core.Test/Auth/Services/DuoUniversalTokenServiceTests.cs b/test/Core.Test/Auth/Services/DuoUniversalTokenServiceTests.cs
index 4d205dc44b..2ad06fdd76 100644
--- a/test/Core.Test/Auth/Services/DuoUniversalTokenServiceTests.cs
+++ b/test/Core.Test/Auth/Services/DuoUniversalTokenServiceTests.cs
@@ -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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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);
+ }
}