From 094754b58f2e560c5a77eb2d6d8c0b05686b5b41 Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Fri, 23 Jan 2026 17:57:39 -0500 Subject: [PATCH] fix(redirect): [PM-30810] Https Redirection for Cloud Users - Clarified messaging around how specific clients will build the response as well as added tests. --- src/Core/Auth/Enums/DeeplinkScheme.cs | 17 ++ .../DuoUniversalTokenService.cs | 50 +++-- .../Services/DuoUniversalTokenServiceTests.cs | 202 ++++++++++++++++++ 3 files changed, 254 insertions(+), 15 deletions(-) create mode 100644 src/Core/Auth/Enums/DeeplinkScheme.cs 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); + } }