1
0
mirror of https://github.com/bitwarden/server synced 2026-02-18 10:23:27 +00:00

Merge branch 'main' into auth/pm-30810/http-redirect-cloud

This commit is contained in:
Patrick-Pimentel-Bitwarden
2026-01-15 13:32:15 -05:00
committed by GitHub
214 changed files with 21758 additions and 2523 deletions

View File

@@ -71,10 +71,10 @@ dotnet_naming_symbols.any_async_methods.applicable_kinds = method
dotnet_naming_symbols.any_async_methods.applicable_accessibilities = *
dotnet_naming_symbols.any_async_methods.required_modifiers = async
dotnet_naming_style.end_in_async.required_prefix =
dotnet_naming_style.end_in_async.required_prefix =
dotnet_naming_style.end_in_async.required_suffix = Async
dotnet_naming_style.end_in_async.capitalization = pascal_case
dotnet_naming_style.end_in_async.word_separator =
dotnet_naming_style.end_in_async.word_separator =
# Obsolete warnings, this should be removed or changed to warning once we address some of the obsolete items.
dotnet_diagnostic.CS0618.severity = suggestion
@@ -85,6 +85,12 @@ dotnet_diagnostic.CS0612.severity = suggestion
# Remove unnecessary using directives https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005
dotnet_diagnostic.IDE0005.severity = warning
# Specify CultureInfo https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1304
dotnet_diagnostic.CA1304.severity = warning
# Specify IFormatProvider https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1305
dotnet_diagnostic.CA1305.severity = warning
# CSharp code style settings:
[*.cs]
# Prefer "var" everywhere

84
.vscode/launch.json vendored
View File

@@ -69,6 +69,28 @@
"preLaunchTask": "buildFullServer",
"stopAll": true
},
{
"name": "Full Server with Seeder API",
"configurations": [
"run-Admin",
"run-API",
"run-Events",
"run-EventsProcessor",
"run-Identity",
"run-Sso",
"run-Icons",
"run-Billing",
"run-Notifications",
"run-SeederAPI"
],
"presentation": {
"hidden": false,
"group": "AA_compounds",
"order": 6
},
"preLaunchTask": "buildFullServerWithSeederApi",
"stopAll": true
},
{
"name": "Self Host: Bit",
"configurations": [
@@ -204,6 +226,17 @@
},
"preLaunchTask": "buildSso",
},
{
"name": "Seeder API",
"configurations": [
"run-SeederAPI"
],
"presentation": {
"hidden": false,
"group": "cloud",
},
"preLaunchTask": "buildSeederAPI",
},
{
"name": "Admin Self Host",
"configurations": [
@@ -270,6 +303,17 @@
},
"preLaunchTask": "buildSso",
},
{
"name": "Seeder API Self Host",
"configurations": [
"run-SeederAPI-SelfHost"
],
"presentation": {
"hidden": false,
"group": "self-host",
},
"preLaunchTask": "buildSeederAPI",
}
],
"configurations": [
// Configurations represent run-only scenarios so that they can be used in multiple compounds
@@ -311,6 +355,25 @@
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-SeederAPI",
"presentation": {
"hidden": true,
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll",
"args": [],
"cwd": "${workspaceFolder}/util/SeederApi",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-Billing",
"presentation": {
@@ -488,6 +551,27 @@
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-SeederAPI-SelfHost",
"presentation": {
"hidden": true,
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll",
"args": [],
"cwd": "${workspaceFolder}/util/SeederApi",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://localhost:5048",
"developSelfHosted": "true",
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-Admin-SelfHost",
"presentation": {

69
.vscode/tasks.json vendored
View File

@@ -43,6 +43,21 @@
"label": "buildFullServer",
"hide": true,
"dependsOrder": "sequence",
"dependsOn": [
"buildAdmin",
"buildAPI",
"buildEventsProcessor",
"buildIdentity",
"buildSso",
"buildIcons",
"buildBilling",
"buildNotifications"
],
},
{
"label": "buildFullServerWithSeederApi",
"hide": true,
"dependsOrder": "sequence",
"dependsOn": [
"buildAdmin",
"buildAPI",
@@ -52,6 +67,7 @@
"buildIcons",
"buildBilling",
"buildNotifications",
"buildSeederAPI"
],
},
{
@@ -89,6 +105,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -102,6 +121,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -115,6 +137,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -128,6 +153,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -141,6 +169,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -154,6 +185,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -167,6 +201,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -180,6 +217,29 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "buildSeederAPI",
"hide": true,
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/util/SeederApi/SeederApi.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
@@ -197,6 +257,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
@@ -214,6 +277,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
@@ -224,6 +290,9 @@
"label": "test",
"type": "shell",
"command": "dotnet test",
"options": {
"cwd": "${workspaceFolder}"
},
"group": {
"kind": "test",
"isDefault": true

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2025.12.2</Version>
<Version>2026.1.0</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
@@ -13,21 +13,21 @@
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<MicrosoftNetTestSdkVersion>18.0.1</MicrosoftNetTestSdkVersion>
<XUnitVersion>2.6.6</XUnitVersion>
<XUnitRunnerVisualStudioVersion>2.5.6</XUnitRunnerVisualStudioVersion>
<CoverletCollectorVersion>6.0.0</CoverletCollectorVersion>
<NSubstituteVersion>5.1.0</NSubstituteVersion>
<AutoFixtureXUnit2Version>4.18.1</AutoFixtureXUnit2Version>
<AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion>
</PropertyGroup>
</Project>

View File

@@ -1,7 +1,7 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29102.190
# Visual Studio Version 17
VisualStudioVersion = 17.14.36705.20 d17.14
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src - AGPL", "src - AGPL", "{DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D}"
EndProject
@@ -11,19 +11,19 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{DD5BD056-4
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{458155D3-BCBC-481D-B37A-40D2ED10F0A4}"
ProjectSection(SolutionItems) = preProject
.dockerignore = .dockerignore
.editorconfig = .editorconfig
.gitignore = .gitignore
CONTRIBUTING.md = CONTRIBUTING.md
Directory.Build.props = Directory.Build.props
global.json = global.json
.gitignore = .gitignore
README.md = README.md
.editorconfig = .editorconfig
TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md
SECURITY.md = SECURITY.md
LICENSE_FAQ.md = LICENSE_FAQ.md
LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt
LICENSE_AGPL.txt = LICENSE_AGPL.txt
LICENSE.txt = LICENSE.txt
CONTRIBUTING.md = CONTRIBUTING.md
.dockerignore = .dockerignore
LICENSE_AGPL.txt = LICENSE_AGPL.txt
LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt
LICENSE_FAQ.md = LICENSE_FAQ.md
README.md = README.md
SECURITY.md = SECURITY.md
TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core", "src\Core\Core.csproj", "{3973D21B-A692-4B60-9B70-3631C057423A}"
@@ -134,10 +134,19 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi", "util\SeederApi\SeederApi.csproj", "{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi.IntegrationTest", "test\SeederApi.IntegrationTest\SeederApi.IntegrationTest.csproj", "{A2E067EF-609C-4D13-895A-E054C61D48BB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sso.IntegrationTest", "bitwarden_license\test\Sso.IntegrationTest\Sso.IntegrationTest.csproj", "{FFB09376-595B-6F93-36F0-70CAE90AFECB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server.IntegrationTest", "test\Server.IntegrationTest\Server.IntegrationTest.csproj", "{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -350,10 +359,26 @@ Global
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.Build.0 = Release|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.Build.0 = Release|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.Build.0 = Release|Any CPU
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.Build.0 = Release|Any CPU
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -410,7 +435,11 @@ Global
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{A2E067EF-609C-4D13-895A-E054C61D48BB} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
{FFB09376-595B-6F93-36F0-70CAE90AFECB} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@@ -44,6 +44,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -462,6 +462,7 @@ public class AccountController : Controller
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
var provider = result.Properties.Items["scheme"];
//Todo: Validate provider is a valid GUID with TryParse instead. When this is invalid it throws an exception
var orgId = new Guid(provider);
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId);
if (ssoConfig == null || !ssoConfig.Enabled)
@@ -615,7 +616,7 @@ public class AccountController : Controller
// Since we're in the auto-provisioning logic, this means that the user exists, but they have not
// authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them).
// We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed
// We've verified that the user is Accepted or Confirmed, so we can create an SsoUser link and proceed
// with authentication.
await CreateSsoUserRecordAsync(providerUserId, guaranteedExistingUser.Id, organization.Id, guaranteedOrgUser);

View File

@@ -0,0 +1,102 @@
using Bit.Sso.Utilities;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Stores;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Sso.IdentityServer;
/// <summary>
/// Distributed cache-backed persisted grant store for short-lived grants.
/// Uses IFusionCache (which wraps IDistributedCache) for horizontal scaling support,
/// and fall back to in-memory caching if Redis is not configured.
/// Designed for SSO authorization codes which are short-lived (5 minutes) and single-use.
/// </summary>
/// <remarks>
/// This is purposefully a different implementation from how Identity solves Persisted Grants.
/// Because even flavored grant store, e.g., AuthorizationCodeGrantStore, can add intermediary
/// logic to a grant's handling by type, the fact that they all wrap IdentityServer's IPersistedGrantStore
/// leans on IdentityServer's opinion that all grants, regardless of type, go to the same persistence
/// mechanism (cache, database).
/// <seealso href="https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/"/>
/// </remarks>
public class DistributedCachePersistedGrantStore : IPersistedGrantStore
{
private readonly IFusionCache _cache;
public DistributedCachePersistedGrantStore(
[FromKeyedServices(PersistedGrantsDistributedCacheConstants.CacheKey)] IFusionCache cache)
{
_cache = cache;
}
public async Task<PersistedGrant?> GetAsync(string key)
{
var result = await _cache.TryGetAsync<PersistedGrant>(key);
if (!result.HasValue)
{
return null;
}
var grant = result.Value;
// Check if grant has expired - remove expired grants from cache
if (grant.Expiration.HasValue && grant.Expiration.Value < DateTime.UtcNow)
{
await RemoveAsync(key);
return null;
}
return grant;
}
public Task<IEnumerable<PersistedGrant>> GetAllAsync(PersistedGrantFilter filter)
{
// Cache stores are key-value based and don't support querying by filter criteria.
// This method is typically used for cleanup operations on long-lived grants in databases.
// For SSO's short-lived authorization codes, we rely on TTL expiration instead.
return Task.FromResult(Enumerable.Empty<PersistedGrant>());
}
public Task RemoveAllAsync(PersistedGrantFilter filter)
{
// Revocation Strategy: SSO's logout flow (AccountController.LogoutAsync) only clears local
// authentication cookies and performs federated logout with external IdPs. It does not invoke
// Duende's EndSession or TokenRevocation endpoints. Authorization codes are single-use and expire
// within 5 minutes, making explicit revocation unnecessary for SSO's security model.
// https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/
// Cache stores are key-value based and don't support bulk deletion by filter.
// This method is typically used for cleanup operations on long-lived grants in databases.
// For SSO's short-lived authorization codes, we rely on TTL expiration instead.
return Task.FromResult(0);
}
public async Task RemoveAsync(string key)
{
await _cache.RemoveAsync(key);
}
public async Task StoreAsync(PersistedGrant grant)
{
// Calculate TTL based on grant expiration
var duration = grant.Expiration.HasValue
? grant.Expiration.Value - DateTime.UtcNow
: TimeSpan.FromMinutes(5); // Default to 5 minutes if no expiration set
// Ensure positive duration
if (duration <= TimeSpan.Zero)
{
return;
}
// Cache key "sso-grants:" is configured by service registration. Going through the consumed KeyedService will
// give us a consistent cache key prefix for these grants.
await _cache.SetAsync(
grant.Key,
grant,
new FusionCacheEntryOptions { Duration = duration });
}
}

View File

@@ -41,6 +41,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -0,0 +1,10 @@
namespace Bit.Sso.Utilities;
public static class PersistedGrantsDistributedCacheConstants
{
/// <summary>
/// The SSO Persisted Grant cache key. Identifies the keyed service consumed by the SSO Persisted Grant Store as
/// well as the cache key/namespace for grant storage.
/// </summary>
public const string CacheKey = "sso-grants";
}

View File

@@ -9,6 +9,7 @@ using Bit.Sso.IdentityServer;
using Bit.Sso.Models;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.ResponseHandling;
using Duende.IdentityServer.Stores;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Sustainsys.Saml2.AspNetCore2;
@@ -77,6 +78,17 @@ public static class ServiceCollectionExtensions
})
.AddIdentityServerCertificate(env, globalSettings);
// PM-23572
// Register named FusionCache for SSO authorization code grants.
// Provides separation of concerns and automatic Redis/in-memory negotiation
// .AddInMemoryCaching should still persist above; this handles configuration caching, etc.,
// and is separate from this keyed service, which only serves grant negotiation.
services.AddExtendedCache(PersistedGrantsDistributedCacheConstants.CacheKey, globalSettings);
// Store authorization codes in distributed cache for horizontal scaling
// Uses named FusionCache which gracefully degrades to in-memory when Redis isn't configured
services.AddSingleton<IPersistedGrantStore, DistributedCachePersistedGrantStore>();
return identityServerBuilder;
}
}

View File

@@ -17,9 +17,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.93.2",
"sass": "1.97.2",
"sass-loader": "16.0.5",
"webpack": "5.102.1",
"webpack": "5.104.1",
"webpack-cli": "5.1.4"
}
},
@@ -749,9 +749,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
"version": "2.9.13",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz",
"integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -792,9 +792,9 @@
}
},
"node_modules/browserslist": {
"version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
@@ -813,11 +813,11 @@
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
"electron-to-chromium": "^1.5.227",
"node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3"
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
@@ -834,9 +834,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"version": "1.0.30001763",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz",
"integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==",
"dev": true,
"funding": [
{
@@ -988,9 +988,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"dev": true,
"license": "ISC"
},
@@ -1022,9 +1022,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
@@ -1418,13 +1418,17 @@
}
},
"node_modules/loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.11.5"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/locate-path": {
@@ -1541,9 +1545,9 @@
"optional": true
},
"node_modules/node-releases": {
"version": "2.0.26",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true,
"license": "MIT"
},
@@ -1874,9 +1878,9 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -2109,9 +2113,9 @@
}
},
"node_modules/terser-webpack-plugin": {
"version": "5.3.14",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"version": "5.3.16",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2165,9 +2169,9 @@
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
@@ -2217,9 +2221,9 @@
}
},
"node_modules/webpack": {
"version": "5.102.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -2232,21 +2236,21 @@
"@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.15.0",
"acorn-import-phases": "^1.0.3",
"browserslist": "^4.26.3",
"browserslist": "^4.28.1",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.3",
"es-module-lexer": "^1.2.1",
"enhanced-resolve": "^5.17.4",
"es-module-lexer": "^2.0.0",
"eslint-scope": "5.1.1",
"events": "^3.2.0",
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.2.11",
"json-parse-even-better-errors": "^2.3.1",
"loader-runner": "^4.2.0",
"loader-runner": "^4.3.1",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^4.3.3",
"tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.11",
"terser-webpack-plugin": "^5.3.16",
"watchpack": "^2.4.4",
"webpack-sources": "^3.3.3"
},

View File

@@ -16,9 +16,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.93.2",
"sass": "1.97.2",
"sass-loader": "16.0.5",
"webpack": "5.102.1",
"webpack": "5.104.1",
"webpack-cli": "5.1.4"
}
}

View File

@@ -0,0 +1,257 @@
using Bit.Sso.IdentityServer;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Stores;
using NSubstitute;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.SSO.Test.IdentityServer;
public class DistributedCachePersistedGrantStoreTests
{
private readonly IFusionCache _cache;
private readonly DistributedCachePersistedGrantStore _sut;
public DistributedCachePersistedGrantStoreTests()
{
_cache = Substitute.For<IFusionCache>();
_sut = new DistributedCachePersistedGrantStore(_cache);
}
[Fact]
public async Task StoreAsync_StoresGrantWithCalculatedTTL()
{
// Arrange
var grant = CreateTestGrant("test-key", expiration: DateTime.UtcNow.AddMinutes(5));
// Act
await _sut.StoreAsync(grant);
// Assert
await _cache.Received(1).SetAsync(
"test-key",
grant,
Arg.Is<FusionCacheEntryOptions>(opts =>
opts.Duration >= TimeSpan.FromMinutes(4.9) &&
opts.Duration <= TimeSpan.FromMinutes(5)));
}
[Fact]
public async Task StoreAsync_WithNoExpiration_UsesDefaultFiveMinuteTTL()
{
// Arrange
var grant = CreateTestGrant("no-expiry-key", expiration: null);
// Act
await _sut.StoreAsync(grant);
// Assert
await _cache.Received(1).SetAsync(
"no-expiry-key",
grant,
Arg.Is<FusionCacheEntryOptions>(opts => opts.Duration == TimeSpan.FromMinutes(5)));
}
[Fact]
public async Task StoreAsync_WithAlreadyExpiredGrant_DoesNotStore()
{
// Arrange
var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1));
// Act
await _sut.StoreAsync(expiredGrant);
// Assert
await _cache.DidNotReceive().SetAsync(
Arg.Any<string>(),
Arg.Any<PersistedGrant>(),
Arg.Any<FusionCacheEntryOptions?>());
}
[Fact]
public async Task StoreAsync_EnablesDistributedCache()
{
// Arrange
var grant = CreateTestGrant("distributed-key", expiration: DateTime.UtcNow.AddMinutes(5));
// Act
await _sut.StoreAsync(grant);
// Assert
await _cache.Received(1).SetAsync(
"distributed-key",
grant,
Arg.Is<FusionCacheEntryOptions>(opts =>
opts.SkipDistributedCache == false &&
opts.SkipDistributedCacheReadWhenStale == false));
}
[Fact]
public async Task GetAsync_WithValidGrant_ReturnsGrant()
{
// Arrange
var grant = CreateTestGrant("valid-key", expiration: DateTime.UtcNow.AddMinutes(5));
_cache.TryGetAsync<PersistedGrant>("valid-key")
.Returns(MaybeValue<PersistedGrant>.FromValue(grant));
// Act
var result = await _sut.GetAsync("valid-key");
// Assert
Assert.NotNull(result);
Assert.Equal("valid-key", result.Key);
Assert.Equal("authorization_code", result.Type);
Assert.Equal("test-subject", result.SubjectId);
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
}
[Fact]
public async Task GetAsync_WithNonExistentKey_ReturnsNull()
{
// Arrange
_cache.TryGetAsync<PersistedGrant>("nonexistent-key")
.Returns(MaybeValue<PersistedGrant>.None);
// Act
var result = await _sut.GetAsync("nonexistent-key");
// Assert
Assert.Null(result);
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
}
[Fact]
public async Task GetAsync_WithExpiredGrant_RemovesAndReturnsNull()
{
// Arrange
var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1));
_cache.TryGetAsync<PersistedGrant>("expired-key")
.Returns(MaybeValue<PersistedGrant>.FromValue(expiredGrant));
// Act
var result = await _sut.GetAsync("expired-key");
// Assert
Assert.Null(result);
await _cache.Received(1).RemoveAsync("expired-key");
}
[Fact]
public async Task GetAsync_WithNoExpiration_ReturnsGrant()
{
// Arrange
var grant = CreateTestGrant("no-expiry-key", expiration: null);
_cache.TryGetAsync<PersistedGrant>("no-expiry-key")
.Returns(MaybeValue<PersistedGrant>.FromValue(grant));
// Act
var result = await _sut.GetAsync("no-expiry-key");
// Assert
Assert.NotNull(result);
Assert.Equal("no-expiry-key", result.Key);
Assert.Null(result.Expiration);
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
}
[Fact]
public async Task RemoveAsync_RemovesGrantFromCache()
{
// Act
await _sut.RemoveAsync("remove-key");
// Assert
await _cache.Received(1).RemoveAsync("remove-key");
}
[Fact]
public async Task GetAllAsync_ReturnsEmptyCollection()
{
// Arrange
var filter = new PersistedGrantFilter
{
SubjectId = "test-subject",
SessionId = "test-session",
ClientId = "test-client",
Type = "authorization_code"
};
// Act
var result = await _sut.GetAllAsync(filter);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public async Task RemoveAllAsync_CompletesWithoutError()
{
// Arrange
var filter = new PersistedGrantFilter
{
SubjectId = "test-subject",
ClientId = "test-client"
};
// Act & Assert - should not throw
await _sut.RemoveAllAsync(filter);
// Verify no cache operations were performed
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
}
[Fact]
public async Task StoreAsync_PreservesAllGrantProperties()
{
// Arrange
var grant = new PersistedGrant
{
Key = "full-grant-key",
Type = "authorization_code",
SubjectId = "user-123",
SessionId = "session-456",
ClientId = "client-789",
Description = "Test grant",
CreationTime = DateTime.UtcNow.AddMinutes(-1),
Expiration = DateTime.UtcNow.AddMinutes(5),
ConsumedTime = null,
Data = "{\"test\":\"data\"}"
};
PersistedGrant? capturedGrant = null;
await _cache.SetAsync(
Arg.Any<string>(),
Arg.Do<PersistedGrant>(g => capturedGrant = g),
Arg.Any<FusionCacheEntryOptions?>());
// Act
await _sut.StoreAsync(grant);
// Assert
Assert.NotNull(capturedGrant);
Assert.Equal(grant.Key, capturedGrant.Key);
Assert.Equal(grant.Type, capturedGrant.Type);
Assert.Equal(grant.SubjectId, capturedGrant.SubjectId);
Assert.Equal(grant.SessionId, capturedGrant.SessionId);
Assert.Equal(grant.ClientId, capturedGrant.ClientId);
Assert.Equal(grant.Description, capturedGrant.Description);
Assert.Equal(grant.CreationTime, capturedGrant.CreationTime);
Assert.Equal(grant.Expiration, capturedGrant.Expiration);
Assert.Equal(grant.ConsumedTime, capturedGrant.ConsumedTime);
Assert.Equal(grant.Data, capturedGrant.Data);
}
private static PersistedGrant CreateTestGrant(string key, DateTime? expiration)
{
return new PersistedGrant
{
Key = key,
Type = "authorization_code",
SubjectId = "test-subject",
ClientId = "test-client",
CreationTime = DateTime.UtcNow,
Expiration = expiration,
Data = "{\"test\":\"data\"}"
};
}
}

View File

@@ -1,35 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="AutoFixture.Xunit2" Version="$(AutoFixtureXUnit2Version)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Scim\Scim.csproj" />
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
<ProjectReference Include="..\..\..\test\IntegrationTestCommon\IntegrationTestCommon.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="Properties\launchSettings.json">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<IsPackable>false</IsPackable>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="AutoFixture.Xunit2" Version="$(AutoFixtureXUnit2Version)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Scim\Scim.csproj" />
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
<ProjectReference Include="..\..\..\test\IntegrationTestCommon\IntegrationTestCommon.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="Properties\launchSettings.json">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,952 @@
using System.Net;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Sso.IntegrationTest.Utilities;
using Bit.Test.Common.AutoFixture.Attributes;
using Bitwarden.License.Test.Sso.IntegrationTest.Utilities;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Testing;
using NSubstitute;
using Xunit;
using AuthenticationSchemes = Bit.Core.AuthenticationSchemes;
namespace Bit.Sso.IntegrationTest.Controllers;
public class AccountControllerTests(SsoApplicationFactory factory) : IClassFixture<SsoApplicationFactory>
{
private readonly SsoApplicationFactory _factory = factory;
/*
* Test to verify the /Account/ExternalCallback endpoint exists and is reachable.
*/
[Fact]
public async Task ExternalCallback_EndpointExists_ReturnsExpectedStatusCode()
{
// Arrange
var client = _factory.CreateClient();
// Act - Verify the endpoint is accessible (even if it fails due to missing auth)
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - The endpoint should exist and return 500 (not 404) due to missing authentication
Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode);
}
/*
* Test to verify calling /Account/ExternalCallback without an authentication cookie
* results in an error as expected.
*/
[Fact]
public async Task ExternalCallback_WithNoAuthenticationCookie_ReturnsError()
{
// Arrange
var client = _factory.CreateClient();
// Act - Call ExternalCallback without proper authentication setup
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because there's no external authentication cookie
Assert.False(response.IsSuccessStatusCode);
// The endpoint will throw an exception when authentication fails
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify behavior of /Account/ExternalCallback with PM24579 feature flag
*/
[Theory]
[BitAutoData(true)]
[BitAutoData(false)]
public async Task ExternalCallback_WithPM24579FeatureFlag_AndNoAuthCookie_ReturnsError
(
bool featureFlagEnabled
)
{
// Arrange
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(featureFlagEnabled);
services.AddSingleton(featureService);
});
}).CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert
Assert.False(response.IsSuccessStatusCode);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify behavior of /Account/ExternalCallback simulating failed authentication.
*/
[Fact]
public async Task ExternalCallback_WithMockedAuthenticationService_FailedAuth_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithFailedAuthentication()
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert
Assert.False(response.IsSuccessStatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when SSO config exists but is disabled.
*/
[Fact]
public async Task ExternalCallback_WithDisabledSsoConfig_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig(ssoConfig => ssoConfig!.Enabled = false)
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because SSO config is disabled
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Organization not found or SSO configuration not enabled", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
[Fact]
public async Task ExternalCallback_FindUserFromExternalProviderAsync_OrganizationOrSsoConfigNotFound_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because user has invalid status
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Organization not found or SSO configuration not enabled", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when SSO config expects an ACR value
* but the authentication response has a missing or invalid ACR claim.
*/
[Fact]
public async Task ExternalCallback_WithExpectedAcrValue_AndInvalidAcr_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig(ssoConfig => ssoConfig!.SetData(
new SsoConfigurationData
{
ExpectedReturnAcrValue = "urn:expected:acr:value"
}))
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because ACR claim is missing or invalid
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Expected authentication context class reference (acr) was not returned with the authentication response or is invalid", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when the authentication response
* does not contain any recognizable user ID claim (sub, NameIdentifier, uid, upn, eppn).
*/
[Fact]
public async Task ExternalCallback_WithNoUserIdClaim_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.OmitProviderUserId()
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback"); ;
// Assert - Should fail because no user ID claim was found
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Unknown userid", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when no email claim is found
* and the providerUserId cannot be used as a fallback email (doesn't contain @).
*/
[Fact]
public async Task ExternalCallback_WithNoEmailClaim_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithNullEmail()
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because no email claim was found
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Cannot find email claim", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when an existing user
* uses Key Connector but has no org user record (was removed from organization).
*/
[Fact]
public async Task ExternalCallback_WithExistingKeyConnectorUser_AndNoOrgUser_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser(user =>
{
user.UsesKeyConnector = true;
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because user uses Key Connector but has no org user record
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("You were removed from the organization managing single sign-on for your account", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when an existing user
* uses Key Connector and has an org user record in the invited status.
*/
[Fact]
public async Task ExternalCallback_WithExistingKeyConnectorUser_AndInvitedOrgUser_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig(ssoConfig => { })
.WithUser(user =>
{
user.UsesKeyConnector = true;
})
.WithOrganizationUser(orgUser =>
{
orgUser.Status = OrganizationUserStatusType.Invited;
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because user uses Key Connector but the Org user is in the invited status
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("You were removed from the organization managing single sign-on for your account", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when an existing user
* (not using Key Connector) has no org user record - they were removed from the organization.
*/
[Fact]
public async Task ExternalCallback_WithExistingUser_AndNoOrgUser_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because user exists but has no org user record
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("You were removed from the organization managing single sign-on for your account. Contact the organization administrator", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when an existing user
* has an org user record with Invited status - they must accept the invite first.
*/
[Fact]
public async Task ExternalCallback_WithExistingUser_AndInvitedOrgUserStatus_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser(orgUser =>
{
orgUser.Status = OrganizationUserStatusType.Invited;
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because user must accept invite before using SSO
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("you must first log in using your master password", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when organization has no available seats
* and cannot auto-scale because it's a self-hosted instance.
*/
[Fact]
public async Task ExternalCallback_WithNoAvailableSeats_OnSelfHosted_ReturnsError()
{
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithOrganization(org =>
{
org.Seats = 5; // Organization has seat limit
})
.AsSelfHosted()
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because no seats available and cannot auto-scale on self-hosted
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("No seats available for organization", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when organization has no available seats
* and auto-scaling fails (e.g., billing issue, max seats reached).
*/
[Fact]
public async Task ExternalCallback_WithNoAvailableSeats_AndAutoAddSeatsFails_ReturnsError()
{
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithOrganization(org =>
{
org.Seats = 5;
org.MaxAutoscaleSeats = 5;
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because auto-adding seats failed
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("No seats available for organization", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when email cannot be found
* during new user provisioning (Scenario 2) after bypassing the first email check
* via manual linking path (userIdentifier is set).
*/
[Fact]
public async Task ExternalCallback_WithUserIdentifier_AndNoEmail_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUserIdentifier("")
.WithNullEmail()
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because email cannot be found during new user provisioning
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Cannot find email claim", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when org user has an unknown/invalid status.
* This tests defensive code that handles future enum values or data corruption scenarios.
* We simulate this by casting an invalid integer to OrganizationUserStatusType.
*/
[Fact]
public async Task ExternalCallback_WithUnknownOrgUserStatus_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser(orgUser =>
{
orgUser.Status = (OrganizationUserStatusType)99; // Invalid enum value - simulates future status or data corruption
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because org user status is unknown/invalid
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("is in an unknown state", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
// Note: "User should be found ln 304" appears to be unreachable defensive code.
// CreateUserAndOrgUserConditionallyAsync always returns a non-null user or throws an exception,
// so possibleSsoLinkedUser cannot be null when the feature flag check executes.
/*
* Test to verify /Account/ExternalCallback returns error when userIdentifier
* is malformed (doesn't contain comma separator for userId,token format).
* There is only a single test case here but in the future we may need to expand the
* tests to cover other invalid formats.
*/
[Theory]
[BitAutoData("No-Comas-Identifier")]
public async Task ExternalCallback_WithInvalidUserIdentifierFormat_ReturnsError(
string UserIdentifier
)
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUserIdentifier(UserIdentifier)
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because userIdentifier format is invalid
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Invalid user identifier", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when userIdentifier
* contains valid userId but invalid/mismatched token.
*
* NOTE: This test uses the substitute pattern instead of SsoTestDataBuilder because:
* - The userIdentifier in the auth result must contain a userId that matches a user in the system
* - User.SetNewId() always overwrites the Id (unlike Organization.SetNewId() which has a guard)
* - This means we cannot pre-set a User.Id before database insertion
* - The auth mock must be configured BEFORE accessing factory.Services (required by SubstituteService)
* - Therefore, we cannot coordinate the userId between the auth mock and the seeded user
* - Using substitutes allows us to control the exact userId and mock UserManager.VerifyUserTokenAsync
*/
[Fact]
public async Task ExternalCallback_WithUserIdentifier_AndInvalidToken_ReturnsError()
{
// Arrange
var organizationId = Guid.NewGuid();
var providerUserId = Guid.NewGuid().ToString();
var userId = Guid.NewGuid();
var testEmail = "test_user@integration.test";
var testName = "Test User";
// Valid format but token won't verify
var userIdentifier = $"{userId},invalid-token";
var claimedUser = new User
{
Id = userId,
Email = testEmail,
Name = testName
};
var organization = new Organization
{
Id = organizationId,
Name = "Test Organization",
Enabled = true,
UseSso = true
};
var ssoConfig = new SsoConfig
{
OrganizationId = organizationId,
Enabled = true
};
ssoConfig.SetData(new SsoConfigurationData());
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true);
services.AddSingleton(featureService);
// Mock organization repository
var orgRepo = Substitute.For<IOrganizationRepository>();
orgRepo.GetByIdAsync(organizationId).Returns(organization);
orgRepo.GetByIdentifierAsync(organizationId.ToString()).Returns(organization);
services.AddSingleton(orgRepo);
// Mock SSO config repository
var ssoConfigRepo = Substitute.For<ISsoConfigRepository>();
ssoConfigRepo.GetByOrganizationIdAsync(organizationId).Returns(ssoConfig);
services.AddSingleton(ssoConfigRepo);
// Mock user repository - no existing user via SSO
var userRepo = Substitute.For<IUserRepository>();
userRepo.GetBySsoUserAsync(providerUserId, organizationId).Returns((User?)null);
services.AddSingleton(userRepo);
// Mock user service - returns user for manual linking lookup
var userService = Substitute.For<IUserService>();
userService.GetUserByIdAsync(userId.ToString()).Returns(claimedUser);
services.AddSingleton(userService);
// Mock UserManager to return false for token verification
var userManager = Substitute.For<UserManager<User>>(
Substitute.For<IUserStore<User>>(), null, null, null, null, null, null, null, null);
userManager.VerifyUserTokenAsync(
claimedUser,
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>())
.Returns(false);
services.AddSingleton(userManager);
// Mock authentication service with userIdentifier that has valid format but invalid token
var authService = Substitute.For<IAuthenticationService>();
authService.AuthenticateAsync(
Arg.Any<HttpContext>(),
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme)
.Returns(MockSuccessfulAuthResult.Build(organizationId, providerUserId, testEmail, testName, null, userIdentifier));
services.AddSingleton(authService);
});
}).CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because token verification failed
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Supplied userId and token did not match", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error for revoked org user when PM24579 feature flag is enabled.
*/
[Fact]
public async Task ExternalCallback_WithRevokedOrgUser_WithPM24579FeatureFlagEnabled_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser(orgUser =>
{
orgUser.Status = OrganizationUserStatusType.Revoked;
})
.WithFeatureFlags(factoryService =>
{
factoryService.SubstituteService<IFeatureService>(srv =>
{
srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true);
});
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because user state is invalid
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains(
$"Your access to organization {testData.Organization?.DisplayName()} has been revoked. Please contact your administrator for assistance.",
stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error for revoked org user when PM24579 feature flag is disabled.
*/
[Fact]
public async Task ExternalCallback_WithRevokedOrgUserStatus_WithPM24579FeatureFlagDisabled_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser(orgUser =>
{
orgUser.Status = OrganizationUserStatusType.Revoked;
})
.WithFeatureFlags(factoryService =>
{
factoryService.SubstituteService<IFeatureService>(srv =>
{
srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(false);
});
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because user has invalid status
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains(
$"Your access to organization {testData.Organization?.DisplayName()} has been revoked. Please contact your administrator for assistance.",
stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error for invited org user when PM24579 feature flag is disabled.
*/
[Fact]
public async Task ExternalCallback_WithInvitedOrgUserStatus_WithPM24579FeatureFlagDisabled_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser(orgUser =>
{
orgUser.Status = OrganizationUserStatusType.Invited;
})
.WithFeatureFlags(factoryService =>
{
factoryService.SubstituteService<IFeatureService>(srv =>
{
srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(false);
});
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because user has invalid status
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains(
$"To accept your invite to {testData.Organization?.DisplayName()}, you must first log in using your master password. Once your invite has been accepted, you will be able to log in using SSO.",
stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when user is found via SSO
* but has no organization user record (with feature flag enabled).
*/
[Fact]
public async Task ExternalCallback_WithSsoUser_AndNoOrgUser_WithFeatureFlagEnabled_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithSsoUser()
.WithFeatureFlags(factoryService =>
{
factoryService.SubstituteService<IFeatureService>(srv =>
{
srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true);
});
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because org user cannot be found
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Could not find organization user", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when the provider scheme
* is not a valid GUID (SSOProviderIsNotAnOrgId).
*
* NOTE: This test uses the substitute pattern instead of SsoTestDataBuilder because:
* - Organization.Id is of type Guid and cannot be set to a non-GUID value
* - The auth mock scheme must be a non-GUID string to trigger this error path
* - This cannot be tested since ln 438 in AccountController.FindUserFromExternalProviderAsync throws a different exception
* before reaching the organization lookup exception.
*/
[Fact(Skip = "This test cannot be executed because the organization ID must be a GUID. See note in test summary.")]
public async Task ExternalCallback_WithInvalidProviderGuid_ReturnsError()
{
// Arrange
var invalidScheme = "not-a-valid-guid";
var providerUserId = Guid.NewGuid().ToString();
var testEmail = "test@example.com";
var testName = "Test User";
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Mock authentication service with invalid (non-GUID) scheme
var authService = Substitute.For<IAuthenticationService>();
authService.AuthenticateAsync(
Arg.Any<HttpContext>(),
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme)
.Returns(MockSuccessfulAuthResult.Build(invalidScheme, providerUserId, testEmail, testName));
services.AddSingleton(authService);
});
}).CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because provider is not a valid organization GUID
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Organization not found from identifier.", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when the organization ID
* in the auth result does not match any organization in the database.
* NOTE: This code path is unreachable because the SsoConfig must exist to proceed, but there is a circular dependency:
* - SsoConfig cannot exist without a valid Organization but the test is testing that an Organization cannot be found.
*/
[Fact(Skip = "This code path is unreachable because the SsoConfig must exist to proceed. But the SsoConfig cannot exist without a valid Organization.")]
public async Task ExternalCallback_WithNonExistentOrganization_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithNonExistentOrganizationInAuth()
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because organization cannot be found by the ID in auth result
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Could not find organization", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing
* SSO-linked user logs in (user exists in SsoUser table).
*/
[Fact]
public async Task ExternalCallback_WithExistingSsoUser_ReturnsSuccess()
{
// Arrange - User with SSO link already exists
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser()
.WithSsoUser()
.BuildAsync();
var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false // Prevent auto-redirects to capture initial response
});
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should succeed and redirect
Assert.True(
response.StatusCode == HttpStatusCode.Redirect,
$"Expected success/redirect but got {response.StatusCode}");
Assert.NotNull(response.Headers.Location);
}
/*
* SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when JIT provisioning
* a new user (user doesn't exist, gets created automatically).
*/
[Fact]
public async Task ExternalCallback_WithJitProvisioning_ReturnsSuccess()
{
// Arrange - No user, no org user - JIT provisioning will create both
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.BuildAsync();
var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false // Prevent auto-redirects to capture initial response
});
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should succeed and redirect
Assert.True(
response.StatusCode == HttpStatusCode.Redirect,
$"Expected success/redirect but got {response.StatusCode}");
Assert.NotNull(response.Headers.Location);
}
/*
* SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing user
* with a valid (Confirmed) organization user status logs in via SSO for the first time.
*/
[Fact]
public async Task ExternalCallback_WithExistingUserAndConfirmedOrgUser_ReturnsSuccess()
{
// Arrange - Existing user with confirmed org user status, no SSO link yet
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser(orgUser =>
{
orgUser.Status = OrganizationUserStatusType.Confirmed;
})
.BuildAsync();
var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false // Prevent auto-redirects to capture initial response
});
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should succeed and redirect
Assert.True(
response.StatusCode == HttpStatusCode.Redirect,
$"Expected success/redirect but got {response.StatusCode}");
Assert.NotNull(response.Headers.Location);
}
/*
* SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing user
* with Accepted organization user status logs in via SSO.
*/
[Fact]
public async Task ExternalCallback_WithExistingUserAndAcceptedOrgUser_ReturnsSuccess()
{
// Arrange - Existing user with accepted org user status
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser(orgUser =>
{
orgUser.Status = OrganizationUserStatusType.Accepted;
})
.BuildAsync();
var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false // Prevent auto-redirects to capture initial response
});
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should succeed and redirect
Assert.True(
response.StatusCode == HttpStatusCode.Redirect,
$"Expected success/redirect but got {response.StatusCode}");
Assert.NotNull(response.Headers.Location);
}
/*
* SUCCESS PATH: Test to verify /Account/ExternalCallback returns a View with 200 status
* when the client is a native application (uses custom URI scheme like "bitwarden://callback").
* Native clients get a different response for better UX - a 200 with redirect view instead of 302.
* See AccountController lines 371-378.
*/
[Fact]
public async Task ExternalCallback_WithNativeClient_ReturnsViewWith200Status()
{
// Arrange - Existing SSO user with native client context
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser()
.WithSsoUser()
.AsNativeClient()
.BuildAsync();
var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Native clients get 200 status with a redirect view instead of 302
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// The Location header should be empty for native clients (set in controller)
// and the response should contain the redirect view
var content = await response.Content.ReadAsStringAsync();
Assert.NotEmpty(content); // View content should be present
}
}

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"Sso.IntegrationTest": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:59973;http://localhost:59974"
}
}
}

View File

@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="AutoFixture.Xunit2" Version="$(AutoFixtureXUnit2Version)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Sso\Sso.csproj" />
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
<ProjectReference Include="..\..\..\test\IntegrationTestCommon\IntegrationTestCommon.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="Properties\launchSettings.json">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,11 @@
using Bit.IntegrationTestCommon.Factories;
namespace Bit.Sso.IntegrationTest.Utilities;
public class SsoApplicationFactory : WebApplicationFactoryBase<Startup>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
}
}

View File

@@ -0,0 +1,327 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bitwarden.License.Test.Sso.IntegrationTest.Utilities;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Authentication;
using NSubstitute;
using AuthenticationSchemes = Bit.Core.AuthenticationSchemes;
namespace Bit.Sso.IntegrationTest.Utilities;
/// <summary>
/// Contains the factory and all entities created by <see cref="SsoTestDataBuilder"/> for use in integration tests.
/// </summary>
public record SsoTestData(
SsoApplicationFactory Factory,
Organization? Organization,
User? User,
OrganizationUser? OrganizationUser,
SsoConfig? SsoConfig,
SsoUser? SsoUser);
/// <summary>
/// Builder for creating SSO test data with seeded database entities.
/// </summary>
public class SsoTestDataBuilder
{
/// <summary>
/// This UserIdentifier is a mock for the UserIdentifier we get from the External Identity Provider.
/// </summary>
private string? _userIdentifier;
private Action<Organization>? _organizationConfig;
private Action<User>? _userConfig;
private Action<OrganizationUser>? _orgUserConfig;
private Action<SsoConfig>? _ssoConfigConfig;
private Action<SsoUser>? _ssoUserConfig;
private Action<SsoApplicationFactory>? _featureFlagConfig;
private bool _includeUser = false;
private bool _includeSsoUser = false;
private bool _includeOrganizationUser = false;
private bool _includeSsoConfig = false;
private bool _successfulAuth = true;
private bool _withNullEmail = false;
private bool _isSelfHosted = false;
private bool _includeProviderUserId = true;
private bool _useNonExistentOrgInAuth = false;
private bool _isNativeClient = false;
public SsoTestDataBuilder WithOrganization(Action<Organization> configure)
{
_organizationConfig = configure;
return this;
}
public SsoTestDataBuilder WithUser(Action<User>? configure = null)
{
_includeUser = true;
_userConfig = configure;
return this;
}
public SsoTestDataBuilder WithOrganizationUser(Action<OrganizationUser>? configure = null)
{
_includeOrganizationUser = true;
_orgUserConfig = configure;
return this;
}
public SsoTestDataBuilder WithSsoConfig(Action<SsoConfig>? configure = null)
{
_includeSsoConfig = true;
_ssoConfigConfig = configure;
return this;
}
public SsoTestDataBuilder WithSsoUser(Action<SsoUser>? configure = null)
{
_includeSsoUser = true;
_ssoUserConfig = configure;
return this;
}
public SsoTestDataBuilder WithFeatureFlags(Action<SsoApplicationFactory> configure)
{
_featureFlagConfig = configure;
return this;
}
public SsoTestDataBuilder WithFailedAuthentication()
{
_successfulAuth = false;
return this;
}
public SsoTestDataBuilder WithNullEmail()
{
_withNullEmail = true;
return this;
}
public SsoTestDataBuilder WithUserIdentifier(string userIdentifier)
{
_userIdentifier = userIdentifier;
return this;
}
public SsoTestDataBuilder OmitProviderUserId()
{
_includeProviderUserId = false;
return this;
}
public SsoTestDataBuilder AsSelfHosted()
{
_isSelfHosted = true;
return this;
}
/// <summary>
/// Causes the auth result to use a different (non-existent) organization ID than what is seeded
/// in the database. This simulates the "organization not found" scenario.
/// </summary>
public SsoTestDataBuilder WithNonExistentOrganizationInAuth()
{
_useNonExistentOrgInAuth = true;
return this;
}
/// <summary>
/// Configures the test to simulate a native client (non-browser) OIDC flow.
/// Native clients use custom URI schemes (e.g., "bitwarden://callback") instead of http/https.
/// This causes ExternalCallback to return a View with 200 status instead of a redirect.
/// </summary>
public SsoTestDataBuilder AsNativeClient()
{
_isNativeClient = true;
return this;
}
public async Task<SsoTestData> BuildAsync()
{
// Create factory
var factory = new SsoApplicationFactory();
// Pre-generate IDs and values needed for auth mock (before accessing Services)
var organizationId = Guid.NewGuid();
// Use a different org ID in auth if testing "organization not found" scenario
var authOrganizationId = _useNonExistentOrgInAuth ? Guid.NewGuid() : organizationId;
var providerUserId = _includeProviderUserId ? Guid.NewGuid().ToString() : "";
var userEmail = _withNullEmail ? null : $"user_{Guid.NewGuid()}@test.com";
var userName = "TestUser";
// 1. Configure mocked authentication service BEFORE accessing Services
factory.SubstituteService<IAuthenticationService>(authService =>
{
if (_successfulAuth)
{
authService.AuthenticateAsync(
Arg.Any<HttpContext>(),
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme)
.Returns(MockSuccessfulAuthResult.Build(
authOrganizationId,
providerUserId,
userEmail,
userName,
acrValue: null,
_userIdentifier));
}
else
{
authService.AuthenticateAsync(
Arg.Any<HttpContext>(),
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme)
.Returns(AuthenticateResult.Fail("External authentication error"));
}
});
// 1.a Configure GlobalSettings for Self-Hosted and seat limit
factory.SubstituteService<IGlobalSettings>(globalSettings =>
{
globalSettings.SelfHosted.Returns(_isSelfHosted);
});
// 1.b configure setting feature flags
_featureFlagConfig?.Invoke(factory);
// 1.c Configure IIdentityServerInteractionService for native client flow
if (_isNativeClient)
{
factory.SubstituteService<IIdentityServerInteractionService>(interaction =>
{
// Native clients have redirect URIs that don't start with http/https
// e.g., "bitwarden://callback" or "com.bitwarden.app://callback"
var authorizationRequest = new AuthorizationRequest
{
RedirectUri = "bitwarden://sso-callback"
};
interaction.GetAuthorizationContextAsync(Arg.Any<string>())
.Returns(authorizationRequest);
});
}
if (!_successfulAuth)
{
return new SsoTestData(factory, null!, null!, null!, null!, null!);
}
// 2. Create Organization with defaults (using pre-generated ID)
var organization = new Organization
{
Id = organizationId,
Name = "Test Organization",
BillingEmail = "billing@test.com",
Plan = "Enterprise",
Enabled = true,
UseSso = true
};
_organizationConfig?.Invoke(organization);
var orgRepo = factory.Services.GetRequiredService<IOrganizationRepository>();
organization = await orgRepo.CreateAsync(organization);
// 3. Create User with defaults (using pre-generated values)
User? user = null;
if (_includeUser)
{
user = new User
{
Email = userEmail ?? $"email_{Guid.NewGuid()}@test.dev",
Name = userName,
ApiKey = Guid.NewGuid().ToString(),
SecurityStamp = Guid.NewGuid().ToString()
};
_userConfig?.Invoke(user);
var userRepo = factory.Services.GetRequiredService<IUserRepository>();
user = await userRepo.CreateAsync(user);
}
// 4. Create OrganizationUser linking them
OrganizationUser? orgUser = null;
if (_includeOrganizationUser)
{
orgUser = new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user!.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.User
};
_orgUserConfig?.Invoke(orgUser);
var orgUserRepo = factory.Services.GetRequiredService<IOrganizationUserRepository>();
orgUser = await orgUserRepo.CreateAsync(orgUser);
}
// 4.a Create many OrganizationUser to test seat count logic
if (organization.Seats > 1)
{
var orgUserRepo = factory.Services.GetRequiredService<IOrganizationUserRepository>();
var userRepo = factory.Services.GetRequiredService<IUserRepository>();
var additionalOrgUsers = new List<OrganizationUser>();
for (var i = 1; i <= organization.Seats; i++)
{
var additionalUser = new User
{
Email = $"additional_user_{i}_{Guid.NewGuid()}@test.dev",
Name = $"AdditionalUser{i}",
ApiKey = Guid.NewGuid().ToString(),
SecurityStamp = Guid.NewGuid().ToString()
};
var createdAdditionalUser = await userRepo.CreateAsync(additionalUser);
var additionalOrgUser = new OrganizationUser
{
OrganizationId = organization.Id,
UserId = createdAdditionalUser.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.User
};
additionalOrgUsers.Add(additionalOrgUser);
}
await orgUserRepo.CreateManyAsync(additionalOrgUsers);
}
// 5. Create SsoConfig, if ssoConfigConfig is not null
SsoConfig? ssoConfig = null;
if (_includeSsoConfig)
{
ssoConfig = new SsoConfig
{
OrganizationId = authOrganizationId,
Enabled = true
};
ssoConfig.SetData(new SsoConfigurationData());
_ssoConfigConfig?.Invoke(ssoConfig);
var ssoConfigRepo = factory.Services.GetRequiredService<ISsoConfigRepository>();
ssoConfig = await ssoConfigRepo.CreateAsync(ssoConfig);
}
// 6. Optionally create SsoUser (using pre-generated providerUserId as ExternalId)
SsoUser? ssoUser = null;
if (_includeSsoUser)
{
ssoUser = new SsoUser
{
OrganizationId = organization.Id,
UserId = user!.Id,
ExternalId = providerUserId
};
_ssoUserConfig?.Invoke(ssoUser);
var ssoUserRepo = factory.Services.GetRequiredService<ISsoUserRepository>();
ssoUser = await ssoUserRepo.CreateAsync(ssoUser);
}
return new SsoTestData(factory, organization, user, orgUser, ssoConfig, ssoUser);
}
}

View File

@@ -0,0 +1,88 @@
using System.Security.Claims;
using Bit.Core;
using Duende.IdentityModel;
using Microsoft.AspNetCore.Authentication;
namespace Bitwarden.License.Test.Sso.IntegrationTest.Utilities;
/// <summary>
/// Creates a mock for use in tests requiring a valid external authentication result.
/// </summary>
internal static class MockSuccessfulAuthResult
{
/// <summary>
/// Since this tests the external Authentication flow, only the OrganizationId is strictly required.
/// However, some tests may require additional claims to be present, so they can be optionally added.
/// </summary>
/// <param name="organizationId"></param>
/// <param name="providerUserId"></param>
/// <param name="email"></param>
/// <param name="name"></param>
/// <param name="acrValue"></param>
/// <param name="userIdentifier"></param>
/// <returns></returns>
public static AuthenticateResult Build(
Guid organizationId,
string? providerUserId,
string? email,
string? name = null,
string? acrValue = null,
string? userIdentifier = null)
{
return Build(organizationId.ToString(), providerUserId, email, name, acrValue, userIdentifier);
}
/// <summary>
/// Overload that accepts a custom scheme string. Useful for testing invalid provider scenarios
/// where the scheme is not a valid GUID.
/// </summary>
public static AuthenticateResult Build(
string scheme,
string? providerUserId,
string? email,
string? name = null,
string? acrValue = null,
string? userIdentifier = null)
{
var claims = new List<Claim>();
if (!string.IsNullOrEmpty(email))
{
claims.Add(new Claim(JwtClaimTypes.Email, email));
}
if (!string.IsNullOrEmpty(providerUserId))
{
claims.Add(new Claim(JwtClaimTypes.Subject, providerUserId));
}
if (!string.IsNullOrEmpty(name))
{
claims.Add(new Claim(JwtClaimTypes.Name, name));
}
if (!string.IsNullOrEmpty(acrValue))
{
claims.Add(new Claim(JwtClaimTypes.AuthenticationContextClassReference, acrValue));
}
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "External"));
var properties = new AuthenticationProperties
{
Items =
{
["scheme"] = scheme,
["return_url"] = "~/",
["state"] = "test-state",
["user_identifier"] = userIdentifier ?? string.Empty
}
};
var ticket = new AuthenticationTicket(
principal,
properties,
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
return AuthenticateResult.Success(ticket);
}
}

23
dev/setup_secrets.ps1 Normal file → Executable file
View File

@@ -2,7 +2,7 @@
# Helper script for applying the same user secrets to each project
param (
[switch]$clear,
[Parameter(ValueFromRemainingArguments = $true, Position=1)]
[Parameter(ValueFromRemainingArguments = $true, Position = 1)]
$cmdArgs
)
@@ -16,17 +16,18 @@ if ($clear -eq $true) {
}
$projects = @{
Admin = "../src/Admin"
Api = "../src/Api"
Billing = "../src/Billing"
Events = "../src/Events"
EventsProcessor = "../src/EventsProcessor"
Icons = "../src/Icons"
Identity = "../src/Identity"
Notifications = "../src/Notifications"
Sso = "../bitwarden_license/src/Sso"
Scim = "../bitwarden_license/src/Scim"
Admin = "../src/Admin"
Api = "../src/Api"
Billing = "../src/Billing"
Events = "../src/Events"
EventsProcessor = "../src/EventsProcessor"
Icons = "../src/Icons"
Identity = "../src/Identity"
Notifications = "../src/Notifications"
Sso = "../bitwarden_license/src/Sso"
Scim = "../bitwarden_license/src/Scim"
IntegrationTests = "../test/Infrastructure.IntegrationTest"
SeederApi = "../util/SeederApi"
}
foreach ($key in $projects.keys) {

View File

@@ -2,6 +2,8 @@
<PropertyGroup>
<UserSecretsId>bitwarden-Admin</UserSecretsId>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Admin' " />

View File

@@ -1,5 +1,5 @@
using Bit.Core.Utilities;
using Microsoft.Data.SqlClient;
using System.Data.Common;
using Bit.Core.Utilities;
namespace Bit.Admin.HostedServices;
@@ -30,7 +30,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
// TODO: Maybe flip a flag somewhere to indicate migration is complete??
break;
}
catch (SqlException e)
catch (DbException e)
{
if (i >= maxMigrationAttempts)
{
@@ -40,7 +40,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
else
{
_logger.LogError(e,
"Database unavailable for migration. Trying again (attempt #{0})...", i + 1);
"Database unavailable for migration. Trying again (attempt #{AttemptNumber})...", i + 1);
await Task.Delay(20000, cancellationToken);
}
}

View File

@@ -65,6 +65,7 @@ public class Startup
default:
break;
}
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -18,9 +18,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.93.2",
"sass": "1.97.2",
"sass-loader": "16.0.5",
"webpack": "5.102.1",
"webpack": "5.104.1",
"webpack-cli": "5.1.4"
}
},
@@ -750,9 +750,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
"version": "2.9.13",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz",
"integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -793,9 +793,9 @@
}
},
"node_modules/browserslist": {
"version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
@@ -814,11 +814,11 @@
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
"electron-to-chromium": "^1.5.227",
"node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3"
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
@@ -835,9 +835,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"version": "1.0.30001763",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz",
"integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==",
"dev": true,
"funding": [
{
@@ -989,9 +989,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"dev": true,
"license": "ISC"
},
@@ -1023,9 +1023,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
@@ -1419,13 +1419,17 @@
}
},
"node_modules/loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.11.5"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/locate-path": {
@@ -1542,9 +1546,9 @@
"optional": true
},
"node_modules/node-releases": {
"version": "2.0.26",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true,
"license": "MIT"
},
@@ -1875,9 +1879,9 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -2110,9 +2114,9 @@
}
},
"node_modules/terser-webpack-plugin": {
"version": "5.3.14",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"version": "5.3.16",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2174,9 +2178,9 @@
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
@@ -2226,9 +2230,9 @@
}
},
"node_modules/webpack": {
"version": "5.102.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -2241,21 +2245,21 @@
"@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.15.0",
"acorn-import-phases": "^1.0.3",
"browserslist": "^4.26.3",
"browserslist": "^4.28.1",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.3",
"es-module-lexer": "^1.2.1",
"enhanced-resolve": "^5.17.4",
"es-module-lexer": "^2.0.0",
"eslint-scope": "5.1.1",
"events": "^3.2.0",
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.2.11",
"json-parse-even-better-errors": "^2.3.1",
"loader-runner": "^4.2.0",
"loader-runner": "^4.3.1",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^4.3.3",
"tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.11",
"terser-webpack-plugin": "^5.3.16",
"watchpack": "^2.4.4",
"webpack-sources": "^3.3.3"
},

View File

@@ -17,9 +17,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.93.2",
"sass": "1.97.2",
"sass-loader": "16.0.5",
"webpack": "5.102.1",
"webpack": "5.104.1",
"webpack-cli": "5.1.4"
}
}

View File

@@ -665,11 +665,6 @@ public class OrganizationUsersController : BaseAdminConsoleController
[Authorize<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2))
{
return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync);
}
var currentUserId = _userService.GetProperUserId(User);
if (currentUserId == null)
{

View File

@@ -4,6 +4,8 @@
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
<ANCMPreConfiguredForIIS>true</ANCMPreConfiguredForIIS>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">

View File

@@ -36,7 +36,7 @@ public class EmergencyAccessUpdateRequestModel
existingEmergencyAccess.KeyEncrypted = KeyEncrypted;
}
existingEmergencyAccess.Type = Type;
existingEmergencyAccess.WaitTimeDays = WaitTimeDays;
existingEmergencyAccess.WaitTimeDays = (short)WaitTimeDays;
return existingEmergencyAccess;
}
}

View File

@@ -22,7 +22,7 @@ public class AccountsController(
IFeatureService featureService,
ILicensingService licensingService) : Controller
{
// TODO: Migrate to Query / AccountBillingVNextController as part of Premium -> Organization upgrade work.
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
[HttpGet("subscription")]
public async Task<SubscriptionResponseModel> GetSubscriptionAsync(
[FromServices] GlobalSettings globalSettings,
@@ -61,7 +61,7 @@ public class AccountsController(
}
}
// TODO: Migrate to Command / AccountBillingVNextController as PUT /account/billing/vnext/subscription
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
[HttpPost("storage")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostStorageAsync([FromBody] StorageRequestModel model)
@@ -118,7 +118,7 @@ public class AccountsController(
user.IsExpired());
}
// TODO: Migrate to Command / AccountBillingVNextController as POST /account/billing/vnext/subscription/reinstate
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
[HttpPost("reinstate-premium")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostReinstateAsync()
@@ -131,10 +131,4 @@ public class AccountsController(
await userService.ReinstatePremiumAsync(user);
}
private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
{
var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId);
return organizationsClaimingUser.Select(o => o.Id);
}
}

View File

@@ -7,6 +7,8 @@ using Bit.Core.Billing.Licenses.Queries;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Subscriptions.Commands;
using Bit.Core.Billing.Subscriptions.Queries;
using Bit.Core.Entities;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
@@ -21,9 +23,11 @@ namespace Bit.Api.Billing.Controllers.VNext;
public class AccountBillingVNextController(
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand,
IGetBitwardenSubscriptionQuery getBitwardenSubscriptionQuery,
IGetCreditQuery getCreditQuery,
IGetPaymentMethodQuery getPaymentMethodQuery,
IGetUserLicenseQuery getUserLicenseQuery,
IReinstateSubscriptionCommand reinstateSubscriptionCommand,
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
IUpdatePremiumStorageCommand updatePremiumStorageCommand,
IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController
@@ -91,10 +95,30 @@ public class AccountBillingVNextController(
return TypedResults.Ok(response);
}
[HttpPut("storage")]
[HttpGet("subscription")]
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
[InjectUser]
public async Task<IResult> UpdateStorageAsync(
public async Task<IResult> GetSubscriptionAsync(
[BindNever] User user)
{
var subscription = await getBitwardenSubscriptionQuery.Run(user);
return TypedResults.Ok(subscription);
}
[HttpPost("subscription/reinstate")]
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
[InjectUser]
public async Task<IResult> ReinstateSubscriptionAsync(
[BindNever] User user)
{
var result = await reinstateSubscriptionCommand.Run(user);
return Handle(result);
}
[HttpPut("subscription/storage")]
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
[InjectUser]
public async Task<IResult> UpdateSubscriptionStorageAsync(
[BindNever] User user,
[FromBody] StorageUpdateRequest request)
{

View File

@@ -13,7 +13,6 @@ public class StorageUpdateRequest : IValidatableObject
/// Must be between 0 and the maximum allowed (minus base storage).
/// </summary>
[Required]
[Range(0, 99)]
public short AdditionalStorageGb { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
@@ -22,14 +21,14 @@ public class StorageUpdateRequest : IValidatableObject
{
yield return new ValidationResult(
"Additional storage cannot be negative.",
new[] { nameof(AdditionalStorageGb) });
[nameof(AdditionalStorageGb)]);
}
if (AdditionalStorageGb > 99)
{
yield return new ValidationResult(
"Maximum additional storage is 99 GB.",
new[] { nameof(AdditionalStorageGb) });
[nameof(AdditionalStorageGb)]);
}
}
}

View File

@@ -10,6 +10,7 @@ using Bit.Core.Utilities;
namespace Bit.Api.Models.Response;
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
public class SubscriptionResponseModel : ResponseModel
{

View File

@@ -85,6 +85,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -5,9 +5,11 @@ using Bit.Api.Tools.Models.Request;
using Bit.Api.Tools.Models.Response;
using Bit.Api.Utilities;
using Bit.Core;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.UserFeatures.SendAccess;
using Bit.Core.Exceptions;
using Bit.Core.Platform.Push;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
@@ -22,7 +24,6 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Tools.Controllers;
[Route("sends")]
[Authorize("Application")]
public class SendsController : Controller
{
private readonly ISendRepository _sendRepository;
@@ -31,11 +32,10 @@ public class SendsController : Controller
private readonly ISendFileStorageService _sendFileStorageService;
private readonly IAnonymousSendCommand _anonymousSendCommand;
private readonly INonAnonymousSendCommand _nonAnonymousSendCommand;
private readonly ISendOwnerQuery _sendOwnerQuery;
private readonly ILogger<SendsController> _logger;
private readonly GlobalSettings _globalSettings;
private readonly IFeatureService _featureService;
private readonly IPushNotificationService _pushNotificationService;
public SendsController(
ISendRepository sendRepository,
@@ -46,7 +46,8 @@ public class SendsController : Controller
ISendOwnerQuery sendOwnerQuery,
ISendFileStorageService sendFileStorageService,
ILogger<SendsController> logger,
GlobalSettings globalSettings)
IFeatureService featureService,
IPushNotificationService pushNotificationService)
{
_sendRepository = sendRepository;
_userService = userService;
@@ -56,10 +57,12 @@ public class SendsController : Controller
_sendOwnerQuery = sendOwnerQuery;
_sendFileStorageService = sendFileStorageService;
_logger = logger;
_globalSettings = globalSettings;
_featureService = featureService;
_pushNotificationService = pushNotificationService;
}
#region Anonymous endpoints
[AllowAnonymous]
[HttpPost("access/{id}")]
public async Task<IActionResult> Access(string id, [FromBody] SendAccessRequestModel model)
@@ -73,21 +76,32 @@ public class SendsController : Controller
var guid = new Guid(CoreHelpers.Base64UrlDecode(id));
var send = await _sendRepository.GetByIdAsync(guid);
if (send == null)
{
throw new BadRequestException("Could not locate send");
}
/* This guard can be removed once feature flag is retired*/
var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP);
if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null)
{
return new UnauthorizedResult();
}
var sendAuthResult =
await _sendAuthorizationService.AccessAsync(send, model.Password);
if (sendAuthResult.Equals(SendAccessResult.PasswordRequired))
{
return new UnauthorizedResult();
}
if (sendAuthResult.Equals(SendAccessResult.PasswordInvalid))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid password.");
}
if (sendAuthResult.Equals(SendAccessResult.Denied))
{
throw new NotFoundException();
@@ -99,6 +113,7 @@ public class SendsController : Controller
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
sendResponse.CreatorIdentifier = creator.Email;
}
return new ObjectResult(sendResponse);
}
@@ -122,6 +137,13 @@ public class SendsController : Controller
throw new BadRequestException("Could not locate send");
}
/* This guard can be removed once feature flag is retired*/
var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP);
if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null)
{
return new UnauthorizedResult();
}
var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId,
model.Password);
@@ -129,21 +151,19 @@ public class SendsController : Controller
{
return new UnauthorizedResult();
}
if (result.Equals(SendAccessResult.PasswordInvalid))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid password.");
}
if (result.Equals(SendAccessResult.Denied))
{
throw new NotFoundException();
}
return new ObjectResult(new SendFileDownloadDataResponseModel()
{
Id = fileId,
Url = url,
});
return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url, });
}
[AllowAnonymous]
@@ -157,7 +177,8 @@ public class SendsController : Controller
{
try
{
var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
var blobName =
eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName);
var send = await _sendRepository.GetByIdAsync(new Guid(sendId));
if (send == null)
@@ -166,6 +187,7 @@ public class SendsController : Controller
{
await azureSendFileStorageService.DeleteBlobAsync(blobName);
}
return;
}
@@ -173,7 +195,8 @@ public class SendsController : Controller
}
catch (Exception e)
{
_logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", JsonSerializer.Serialize(eventGridEvent));
_logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}",
JsonSerializer.Serialize(eventGridEvent));
return;
}
}
@@ -185,6 +208,7 @@ public class SendsController : Controller
#region Non-anonymous endpoints
[Authorize(Policies.Application)]
[HttpGet("{id}")]
public async Task<SendResponseModel> Get(string id)
{
@@ -193,6 +217,7 @@ public class SendsController : Controller
return new SendResponseModel(send);
}
[Authorize(Policies.Application)]
[HttpGet("")]
public async Task<ListResponseModel<SendResponseModel>> GetAll()
{
@@ -203,6 +228,67 @@ public class SendsController : Controller
return result;
}
[Authorize(Policy = Policies.Send)]
// [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */
[HttpPost("access/")]
public async Task<IActionResult> AccessUsingAuth()
{
var guid = User.GetSendId();
var send = await _sendRepository.GetByIdAsync(guid);
if (send == null)
{
throw new BadRequestException("Could not locate send");
}
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
send.DeletionDate < DateTime.UtcNow)
{
throw new NotFoundException();
}
var sendResponse = new SendAccessResponseModel(send);
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
{
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
sendResponse.CreatorIdentifier = creator.Email;
}
send.AccessCount++;
await _sendRepository.ReplaceAsync(send);
await _pushNotificationService.PushSyncSendUpdateAsync(send);
return new ObjectResult(sendResponse);
}
[Authorize(Policy = Policies.Send)]
// [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */
[HttpPost("access/file/{fileId}")]
public async Task<IActionResult> GetSendFileDownloadDataUsingAuth(string fileId)
{
var sendId = User.GetSendId();
var send = await _sendRepository.GetByIdAsync(sendId);
if (send == null)
{
throw new BadRequestException("Could not locate send");
}
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
send.DeletionDate < DateTime.UtcNow)
{
throw new NotFoundException();
}
var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId);
send.AccessCount++;
await _sendRepository.ReplaceAsync(send);
await _pushNotificationService.PushSyncSendUpdateAsync(send);
return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url });
}
[Authorize(Policies.Application)]
[HttpPost("")]
public async Task<SendResponseModel> Post([FromBody] SendRequestModel model)
{
@@ -213,6 +299,7 @@ public class SendsController : Controller
return new SendResponseModel(send);
}
[Authorize(Policies.Application)]
[HttpPost("file/v2")]
public async Task<SendFileUploadDataResponseModel> PostFile([FromBody] SendRequestModel model)
{
@@ -243,6 +330,7 @@ public class SendsController : Controller
};
}
[Authorize(Policies.Application)]
[HttpGet("{id}/file/{fileId}")]
public async Task<SendFileUploadDataResponseModel> RenewFileUpload(string id, string fileId)
{
@@ -267,6 +355,7 @@ public class SendsController : Controller
};
}
[Authorize(Policies.Application)]
[HttpPost("{id}/file/{fileId}")]
[SelfHosted(SelfHostedOnly = true)]
[RequestSizeLimit(Constants.FileSize501mb)]
@@ -283,12 +372,14 @@ public class SendsController : Controller
{
throw new BadRequestException("Could not locate send");
}
await Request.GetFileAsync(async (stream) =>
{
await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send);
});
}
[Authorize(Policies.Application)]
[HttpPut("{id}")]
public async Task<SendResponseModel> Put(string id, [FromBody] SendRequestModel model)
{
@@ -304,6 +395,7 @@ public class SendsController : Controller
return new SendResponseModel(send);
}
[Authorize(Policies.Application)]
[HttpPut("{id}/remove-password")]
public async Task<SendResponseModel> PutRemovePassword(string id)
{
@@ -322,6 +414,28 @@ public class SendsController : Controller
return new SendResponseModel(send);
}
// Removes ALL authentication (email or password) if any is present
[Authorize(Policies.Application)]
[HttpPut("{id}/remove-auth")]
public async Task<SendResponseModel> PutRemoveAuth(string id)
{
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
var send = await _sendRepository.GetByIdAsync(new Guid(id));
if (send == null || send.UserId != userId)
{
throw new NotFoundException();
}
// This endpoint exists because PUT preserves existing Password/Emails when not provided.
// This allows clients to update other fields without re-submitting sensitive auth data.
send.Password = null;
send.Emails = null;
send.AuthType = AuthType.None;
await _nonAnonymousSendCommand.SaveSendAsync(send);
return new SendResponseModel(send);
}
[Authorize(Policies.Application)]
[HttpDelete("{id}")]
public async Task Delete(string id)
{

View File

@@ -3,12 +3,13 @@
<PropertyGroup>
<UserSecretsId>bitwarden-Billing</UserSecretsId>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Label="Server SDK settings">
<!-- These features will be gradually turned on -->
<BitIncludeFeatures>false</BitIncludeFeatures>
<BitIncludeTelemetry>false</BitIncludeTelemetry>
<BitIncludeAuthentication>false</BitIncludeAuthentication>
</PropertyGroup>

View File

@@ -1,12 +1,13 @@
using Bit.Core.Billing.Constants;
using Bit.Core.Services;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class InvoiceCreatedHandler(
IBraintreeService braintreeService,
ILogger<InvoiceCreatedHandler> logger,
IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService,
IProviderEventService providerEventService)
: IInvoiceCreatedHandler
{
@@ -29,9 +30,9 @@ public class InvoiceCreatedHandler(
{
try
{
var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer"]);
var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer", "parent.subscription_details.subscription"]);
var usingPayPal = invoice.Customer?.Metadata.ContainsKey("btCustomerId") ?? false;
var usingPayPal = invoice.Customer.Metadata.ContainsKey("btCustomerId");
if (usingPayPal && invoice is
{
@@ -39,13 +40,12 @@ public class InvoiceCreatedHandler(
Status: not StripeConstants.InvoiceStatus.Paid,
CollectionMethod: "charge_automatically",
BillingReason:
"subscription_create" or
"subscription_cycle" or
"automatic_pending_invoice_item_invoice",
Parent.SubscriptionDetails: not null
Parent.SubscriptionDetails.Subscription: not null
})
{
await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
await braintreeService.PayInvoice(invoice.Parent.SubscriptionDetails.Subscription, invoice);
}
}
catch (Exception exception)

View File

@@ -48,6 +48,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// PayPal IPN Client
services.AddHttpClient<IPayPalIPNClient, PayPalIPNClient>();

View File

@@ -128,6 +128,7 @@ public class SelfHostedOrganizationDetails : Organization
UseApi = UseApi,
UseResetPassword = UseResetPassword,
UseSecretsManager = UseSecretsManager,
UsePasswordManager = UsePasswordManager,
SelfHost = SelfHost,
UsersGetPremium = UsersGetPremium,
UseCustomPermissions = UseCustomPermissions,
@@ -156,6 +157,8 @@ public class SelfHostedOrganizationDetails : Organization
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
UseDisableSmAdsForUsers = UseDisableSmAdsForUsers,
UsePhishingBlocker = UsePhishingBlocker,
UseOrganizationDomains = UseOrganizationDomains,
UseAutomaticUserConfirmation = UseAutomaticUserConfirmation,
};
}
}

View File

@@ -7,6 +7,4 @@ public interface IRevokeOrganizationUserCommand
{
Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId);
Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId);
}

View File

@@ -68,68 +68,4 @@ public class RevokeOrganizationUserCommand(
await organizationUserRepository.RevokeAsync(organizationUser.Id);
organizationUser.Status = OrganizationUserStatusType.Revoked;
}
public async Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId)
{
var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds);
var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId)
.ToList();
if (!filteredUsers.Any())
{
throw new BadRequestException("Users invalid.");
}
if (!await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, organizationUserIds))
{
throw new BadRequestException("Organization must have at least one confirmed owner.");
}
var deletingUserIsOwner = false;
if (revokingUserId.HasValue)
{
deletingUserIsOwner = await currentContext.OrganizationOwner(organizationId);
}
var result = new List<Tuple<OrganizationUser, string>>();
foreach (var organizationUser in filteredUsers)
{
try
{
if (organizationUser.Status == OrganizationUserStatusType.Revoked)
{
throw new BadRequestException("Already revoked.");
}
if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId)
{
throw new BadRequestException("You cannot revoke yourself.");
}
if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue &&
!deletingUserIsOwner)
{
throw new BadRequestException("Only owners can revoke other owners.");
}
await organizationUserRepository.RevokeAsync(organizationUser.Id);
organizationUser.Status = OrganizationUserStatusType.Revoked;
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
if (organizationUser.UserId.HasValue)
{
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
result.Add(Tuple.Create(organizationUser, ""));
}
catch (BadRequestException e)
{
result.Add(Tuple.Create(organizationUser, e.Message));
}
}
return result;
}
}

View File

@@ -74,8 +74,12 @@ public class AutomaticUserConfirmationPolicyEventHandler(
private async Task<string> ValidateUserComplianceWithSingleOrgAsync(Guid organizationId,
ICollection<OrganizationUserUserDetails> organizationUsers)
{
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(
organizationUsers.Select(ou => ou.UserId!.Value)))
var userIds = organizationUsers.Where(
u => u.UserId is not null &&
u.Status != OrganizationUserStatusType.Invited)
.Select(u => u.UserId!.Value);
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(userIds))
.Any(uo => uo.OrganizationId != organizationId
&& uo.Status != OrganizationUserStatusType.Invited);

View File

@@ -18,7 +18,7 @@ public class EmergencyAccess : ITableObject<Guid>
public string KeyEncrypted { get; set; }
public EmergencyAccessType Type { get; set; }
public EmergencyAccessStatusType Status { get; set; }
public int WaitTimeDays { get; set; }
public short WaitTimeDays { get; set; }
public DateTime? RecoveryInitiatedDate { get; set; }
public DateTime? LastNotificationDate { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;

View File

@@ -79,7 +79,7 @@ public class EmergencyAccessService : IEmergencyAccessService
Email = emergencyContactEmail.ToLowerInvariant(),
Status = EmergencyAccessStatusType.Invited,
Type = accessType,
WaitTimeDays = waitTime,
WaitTimeDays = (short)waitTime,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
};

View File

@@ -42,6 +42,7 @@ public static class StripeConstants
public static class ErrorCodes
{
public const string CustomerTaxLocationInvalid = "customer_tax_location_invalid";
public const string InvoiceUpcomingNone = "invoice_upcoming_none";
public const string PaymentMethodMicroDepositVerificationAttemptsExceeded = "payment_method_microdeposit_verification_attempts_exceeded";
public const string PaymentMethodMicroDepositVerificationDescriptorCodeMismatch = "payment_method_microdeposit_verification_descriptor_code_mismatch";
public const string PaymentMethodMicroDepositVerificationTimeout = "payment_method_microdeposit_verification_timeout";
@@ -65,8 +66,10 @@ public static class StripeConstants
public static class MetadataKeys
{
public const string BraintreeCustomerId = "btCustomerId";
public const string BraintreeTransactionId = "btTransactionId";
public const string InvoiceApproved = "invoice_approved";
public const string OrganizationId = "organizationId";
public const string PayPalTransactionId = "btPayPalTransactionId";
public const string PreviousAdditionalStorage = "previous_additional_storage";
public const string PreviousPeriodEndDate = "previous_period_end_date";
public const string PreviousPremiumPriceId = "previous_premium_price_id";

View File

@@ -1,7 +1,11 @@
namespace Bit.Core.Billing.Enums;
using System.Runtime.Serialization;
namespace Bit.Core.Billing.Enums;
public enum PlanCadenceType
{
[EnumMember(Value = "annually")]
Annually,
[EnumMember(Value = "monthly")]
Monthly
}

View File

@@ -0,0 +1,12 @@
using Stripe;
namespace Bit.Core.Billing.Extensions;
public static class DiscountExtensions
{
public static bool AppliesTo(this Discount discount, SubscriptionItem subscriptionItem)
=> discount.Coupon.AppliesTo.Products.Contains(subscriptionItem.Price.Product.Id);
public static bool IsValid(this Discount? discount)
=> discount?.Coupon?.Valid ?? false;
}

View File

@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using System.Globalization;
using System.Text.RegularExpressions;
using Stripe;
namespace Bit.Core.Billing.Extensions;
@@ -51,7 +52,7 @@ public static class InvoiceExtensions
if (string.IsNullOrEmpty(priceInfo) && line.Quantity > 0)
{
var pricePerItem = (line.Amount / 100m) / line.Quantity;
priceInfo = $"(at ${pricePerItem:F2} / month)";
priceInfo = string.Format(CultureInfo.InvariantCulture, "(at ${0:F2} / month)", pricePerItem);
}
var taxDescription = $"{line.Quantity} × Tax {priceInfo}";
@@ -70,7 +71,7 @@ public static class InvoiceExtensions
if (tax > 0)
{
var taxAmount = tax / 100m;
items.Add($"1 × Tax (at ${taxAmount:F2} / month)");
items.Add(string.Format(CultureInfo.InvariantCulture, "1 × Tax (at ${0:F2} / month)", taxAmount));
}
return items;

View File

@@ -12,8 +12,11 @@ using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations;
using Bit.Core.Billing.Subscriptions.Commands;
using Bit.Core.Billing.Subscriptions.Queries;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Billing.Tax.Services.Implementations;
using Bit.Core.Services;
using Bit.Core.Services.Implementations;
namespace Bit.Core.Billing.Extensions;
@@ -39,6 +42,9 @@ public static class ServiceCollectionExtensions
services.AddTransient<IGetOrganizationWarningsQuery, GetOrganizationWarningsQuery>();
services.AddTransient<IRestartSubscriptionCommand, RestartSubscriptionCommand>();
services.AddTransient<IPreviewOrganizationTaxCommand, PreviewOrganizationTaxCommand>();
services.AddTransient<IGetBitwardenSubscriptionQuery, GetBitwardenSubscriptionQuery>();
services.AddTransient<IReinstateSubscriptionCommand, ReinstateSubscriptionCommand>();
services.AddTransient<IBraintreeService, BraintreeService>();
}
private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services)

View File

@@ -1,9 +1,11 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Services;
@@ -46,6 +48,57 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
}
var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license);
// If the license has a Token (claims-based), extract all properties from claims BEFORE validation
// This ensures that CanUseLicense validation has access to the correct values from claims
// Otherwise, fall back to using the properties already on the license object (backward compatibility)
if (claimsPrincipal != null)
{
license.Name = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.Name);
license.BillingEmail = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.BillingEmail);
license.BusinessName = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.BusinessName);
license.PlanType = claimsPrincipal.GetValue<PlanType>(OrganizationLicenseConstants.PlanType);
license.Seats = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.Seats);
license.MaxCollections = claimsPrincipal.GetValue<short?>(OrganizationLicenseConstants.MaxCollections);
license.UsePolicies = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePolicies);
license.UseSso = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseSso);
license.UseKeyConnector = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseKeyConnector);
license.UseScim = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseScim);
license.UseGroups = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseGroups);
license.UseDirectory = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseDirectory);
license.UseEvents = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseEvents);
license.UseTotp = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseTotp);
license.Use2fa = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Use2fa);
license.UseApi = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseApi);
license.UseResetPassword = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseResetPassword);
license.Plan = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.Plan);
license.SelfHost = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.SelfHost);
license.UsersGetPremium = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsersGetPremium);
license.UseCustomPermissions = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseCustomPermissions);
license.Enabled = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Enabled);
license.Expires = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.Expires);
license.LicenseKey = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.LicenseKey);
license.UsePasswordManager = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePasswordManager);
license.UseSecretsManager = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseSecretsManager);
license.SmSeats = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmSeats);
license.SmServiceAccounts = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmServiceAccounts);
license.UseRiskInsights = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseRiskInsights);
license.UseOrganizationDomains = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseOrganizationDomains);
license.UseAdminSponsoredFamilies = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAdminSponsoredFamilies);
license.UseAutomaticUserConfirmation = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation);
license.UseDisableSmAdsForUsers = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseDisableSmAdsForUsers);
license.UsePhishingBlocker = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePhishingBlocker);
license.MaxStorageGb = claimsPrincipal.GetValue<short?>(OrganizationLicenseConstants.MaxStorageGb);
license.InstallationId = claimsPrincipal.GetValue<Guid>(OrganizationLicenseConstants.InstallationId);
license.LicenseType = claimsPrincipal.GetValue<LicenseType>(OrganizationLicenseConstants.LicenseType);
license.Issued = claimsPrincipal.GetValue<DateTime>(OrganizationLicenseConstants.Issued);
license.Refresh = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.Refresh);
license.ExpirationWithoutGracePeriod = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.ExpirationWithoutGracePeriod);
license.Trial = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Trial);
license.LimitCollectionCreationDeletion = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.LimitCollectionCreationDeletion);
license.AllowAdminAccessToAllCollectionItems = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems);
}
var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception) &&
selfHostedOrganization.CanUseLicense(license, out exception);
@@ -54,12 +107,6 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
throw new BadRequestException(exception);
}
var useAutomaticUserConfirmation = claimsPrincipal?
.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation) ?? false;
selfHostedOrganization.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
license.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
await WriteLicenseFileAsync(selfHostedOrganization, license);
await UpdateOrganizationAsync(selfHostedOrganization, license);
}

View File

@@ -7,6 +7,7 @@ using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Platform.Push;
@@ -49,6 +50,7 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand
public class CreatePremiumCloudHostedSubscriptionCommand(
IBraintreeGateway braintreeGateway,
IBraintreeService braintreeService,
IGlobalSettings globalSettings,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
@@ -300,6 +302,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
ValidateLocation = ValidateTaxLocationTiming.Immediately
}
};
return await stripeAdapter.UpdateCustomerAsync(customer.Id, options);
}
@@ -351,14 +354,19 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions);
if (usingPayPal)
if (!usingPayPal)
{
await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
{
AutoAdvance = false
});
return subscription;
}
var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
{
AutoAdvance = false,
Expand = ["customer"]
});
await braintreeService.PayInvoice(new UserId(userId), invoice);
return subscription;
}
}

View File

@@ -1,4 +1,5 @@
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
@@ -10,6 +11,8 @@ using Stripe;
namespace Bit.Core.Billing.Premium.Commands;
using static StripeConstants;
/// <summary>
/// Updates the storage allocation for a premium user's subscription.
/// Handles both increases and decreases in storage in an idempotent manner.
@@ -34,14 +37,14 @@ public class UpdatePremiumStorageCommand(
{
public Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb) => HandleAsync<None>(async () =>
{
if (!user.Premium)
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
{
return new BadRequest("User does not have a premium subscription.");
}
if (!user.MaxStorageGb.HasValue)
{
return new BadRequest("No access to storage.");
return new BadRequest("User has no access to storage.");
}
// Fetch all premium plans and the user's subscription to find which plan they're on
@@ -54,7 +57,7 @@ public class UpdatePremiumStorageCommand(
if (passwordManagerItem == null)
{
return new BadRequest("Premium subscription item not found.");
return new Conflict("Premium subscription does not have a Password Manager line item.");
}
var premiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
@@ -66,20 +69,20 @@ public class UpdatePremiumStorageCommand(
return new BadRequest("Additional storage cannot be negative.");
}
var newTotalStorageGb = (short)(baseStorageGb + additionalStorageGb);
var maxStorageGb = (short)(baseStorageGb + additionalStorageGb);
if (newTotalStorageGb > 100)
if (maxStorageGb > 100)
{
return new BadRequest("Maximum storage is 100 GB.");
}
// Idempotency check: if user already has the requested storage, return success
if (user.MaxStorageGb == newTotalStorageGb)
if (user.MaxStorageGb == maxStorageGb)
{
return new None();
}
var remainingStorage = user.StorageBytesRemaining(newTotalStorageGb);
var remainingStorage = user.StorageBytesRemaining(maxStorageGb);
if (remainingStorage < 0)
{
return new BadRequest(
@@ -124,21 +127,18 @@ public class UpdatePremiumStorageCommand(
});
}
// Update subscription with prorations
// Storage is billed annually, so we create prorations and invoice immediately
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
{
Items = subscriptionItemOptions,
ProrationBehavior = Core.Constants.CreateProrations
ProrationBehavior = ProrationBehavior.AlwaysInvoice
};
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, subscriptionUpdateOptions);
// Update the user's max storage
user.MaxStorageGb = newTotalStorageGb;
user.MaxStorageGb = maxStorageGb;
await userService.SaveUserAsync(user);
// No payment intent needed - the subscription update will automatically create and finalize the invoice
return new None();
});
}

View File

@@ -0,0 +1,42 @@
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Microsoft.Extensions.Logging;
using OneOf.Types;
using Stripe;
namespace Bit.Core.Billing.Subscriptions.Commands;
using static StripeConstants;
public interface IReinstateSubscriptionCommand
{
Task<BillingCommandResult<None>> Run(ISubscriber subscriber);
}
public class ReinstateSubscriptionCommand(
ILogger<ReinstateSubscriptionCommand> logger,
IStripeAdapter stripeAdapter) : BaseBillingCommand<ReinstateSubscriptionCommand>(logger), IReinstateSubscriptionCommand
{
public Task<BillingCommandResult<None>> Run(ISubscriber subscriber) => HandleAsync<None>(async () =>
{
var subscription = await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId);
if (subscription is not
{
Status: SubscriptionStatus.Trialing or SubscriptionStatus.Active,
CancelAt: not null
})
{
return new BadRequest("Subscription is not pending cancellation.");
}
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = false
});
return new None();
});
}

View File

@@ -0,0 +1,61 @@
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Bit.Core.Utilities;
using Stripe;
namespace Bit.Core.Billing.Subscriptions.Models;
/// <summary>
/// The type of discounts Bitwarden supports.
/// </summary>
public enum BitwardenDiscountType
{
[EnumMember(Value = "amount-off")]
AmountOff,
[EnumMember(Value = "percent-off")]
PercentOff
}
/// <summary>
/// A record representing a discount applied to a Bitwarden subscription.
/// </summary>
public record BitwardenDiscount
{
/// <summary>
/// The type of the discount.
/// </summary>
[JsonConverter(typeof(EnumMemberJsonConverter<BitwardenDiscountType>))]
public required BitwardenDiscountType Type { get; init; }
/// <summary>
/// The value of the discount.
/// </summary>
public required decimal Value { get; init; }
public static implicit operator BitwardenDiscount(Discount? discount)
{
if (discount is not
{
Coupon.Valid: true
})
{
return null!;
}
return discount.Coupon switch
{
{ AmountOff: > 0 } => new BitwardenDiscount
{
Type = BitwardenDiscountType.AmountOff,
Value = discount.Coupon.AmountOff.Value
},
{ PercentOff: > 0 } => new BitwardenDiscount
{
Type = BitwardenDiscountType.PercentOff,
Value = discount.Coupon.PercentOff.Value
},
_ => null!
};
}
}

View File

@@ -0,0 +1,52 @@
namespace Bit.Core.Billing.Subscriptions.Models;
public record BitwardenSubscription
{
/// <summary>
/// The status of the subscription.
/// </summary>
public required string Status { get; init; }
/// <summary>
/// The subscription's cart, including line items, any discounts, and estimated tax.
/// </summary>
public required Cart Cart { get; init; }
/// <summary>
/// The amount of storage available and used for the subscription.
/// <remarks>Allowed Subscribers: User, Organization</remarks>
/// </summary>
public Storage? Storage { get; init; }
/// <summary>
/// If the subscription is pending cancellation, the date at which the
/// subscription will be canceled.
/// <remarks>Allowed Statuses: 'trialing', 'active'</remarks>
/// </summary>
public DateTime? CancelAt { get; init; }
/// <summary>
/// The date the subscription was canceled.
/// <remarks>Allowed Statuses: 'canceled'</remarks>
/// </summary>
public DateTime? Canceled { get; init; }
/// <summary>
/// The date of the next charge for the subscription.
/// <remarks>Allowed Statuses: 'trialing', 'active'</remarks>
/// </summary>
public DateTime? NextCharge { get; init; }
/// <summary>
/// The date the subscription will be or was suspended due to lack of payment.
/// <remarks>Allowed Statuses: 'incomplete', 'incomplete_expired', 'past_due', 'unpaid'</remarks>
/// </summary>
public DateTime? Suspension { get; init; }
/// <summary>
/// The number of days after the subscription goes 'past_due' the subscriber has to resolve their
/// open invoices before the subscription is suspended.
/// <remarks>Allowed Statuses: 'past_due'</remarks>
/// </summary>
public int? GracePeriod { get; init; }
}

View File

@@ -0,0 +1,83 @@
using System.Text.Json.Serialization;
using Bit.Core.Billing.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.Billing.Subscriptions.Models;
public record CartItem
{
/// <summary>
/// The client-side translation key for the name of the cart item.
/// </summary>
public required string TranslationKey { get; init; }
/// <summary>
/// The quantity of the cart item.
/// </summary>
public required long Quantity { get; init; }
/// <summary>
/// The unit-cost of the cart item.
/// </summary>
public required decimal Cost { get; init; }
/// <summary>
/// An optional discount applied specifically to this cart item.
/// </summary>
public BitwardenDiscount? Discount { get; init; }
}
public record PasswordManagerCartItems
{
/// <summary>
/// The Password Manager seats in the cart.
/// </summary>
public required CartItem Seats { get; init; }
/// <summary>
/// The additional storage in the cart.
/// </summary>
public CartItem? AdditionalStorage { get; init; }
}
public record SecretsManagerCartItems
{
/// <summary>
/// The Secrets Manager seats in the cart.
/// </summary>
public required CartItem Seats { get; init; }
/// <summary>
/// The additional service accounts in the cart.
/// </summary>
public CartItem? AdditionalServiceAccounts { get; init; }
}
public record Cart
{
/// <summary>
/// The Password Manager items in the cart.
/// </summary>
public required PasswordManagerCartItems PasswordManager { get; init; }
/// <summary>
/// The Secrets Manager items in the cart.
/// </summary>
public SecretsManagerCartItems? SecretsManager { get; init; }
/// <summary>
/// The cart's billing cadence.
/// </summary>
[JsonConverter(typeof(EnumMemberJsonConverter<PlanCadenceType>))]
public PlanCadenceType Cadence { get; init; }
/// <summary>
/// An optional discount applied to the entire cart.
/// </summary>
public BitwardenDiscount? Discount { get; init; }
/// <summary>
/// The estimated tax for the cart.
/// </summary>
public required decimal EstimatedTax { get; init; }
}

View File

@@ -0,0 +1,52 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Bit.Core.Utilities;
using OneOf;
namespace Bit.Core.Billing.Subscriptions.Models;
public record Storage
{
private const double _bytesPerGibibyte = 1073741824D;
/// <summary>
/// The amount of storage the subscriber has available.
/// </summary>
public required short Available { get; init; }
/// <summary>
/// The amount of storage the subscriber has used.
/// </summary>
public required double Used { get; init; }
/// <summary>
/// The amount of storage the subscriber has used, formatted as a human-readable string.
/// </summary>
public required string ReadableUsed { get; init; }
public static implicit operator Storage(User user) => From(user);
public static implicit operator Storage(Organization organization) => From(organization);
private static Storage From(OneOf<User, Organization> subscriber)
{
var maxStorageGB = subscriber.Match(
user => user.MaxStorageGb,
organization => organization.MaxStorageGb);
if (maxStorageGB == null)
{
return null!;
}
var storage = subscriber.Match(
user => user.Storage,
organization => organization.Storage);
return new Storage
{
Available = maxStorageGB.Value,
Used = Math.Round((storage ?? 0) / _bytesPerGibibyte, 2),
ReadableUsed = CoreHelpers.ReadableBytesSize(storage ?? 0)
};
}
}

View File

@@ -0,0 +1,43 @@
using Bit.Core.Billing.Constants;
using Bit.Core.Exceptions;
using OneOf;
using Stripe;
namespace Bit.Core.Billing.Subscriptions.Models;
using static StripeConstants;
public record UserId(Guid Value);
public record OrganizationId(Guid Value);
public record ProviderId(Guid Value);
public class SubscriberId : OneOfBase<UserId, OrganizationId, ProviderId>
{
private SubscriberId(OneOf<UserId, OrganizationId, ProviderId> input) : base(input) { }
public static implicit operator SubscriberId(UserId value) => new(value);
public static implicit operator SubscriberId(OrganizationId value) => new(value);
public static implicit operator SubscriberId(ProviderId value) => new(value);
public static implicit operator SubscriberId(Subscription subscription)
{
if (subscription.Metadata.TryGetValue(MetadataKeys.UserId, out var userIdValue)
&& Guid.TryParse(userIdValue, out var userId))
{
return new UserId(userId);
}
if (subscription.Metadata.TryGetValue(MetadataKeys.OrganizationId, out var organizationIdValue)
&& Guid.TryParse(organizationIdValue, out var organizationId))
{
return new OrganizationId(organizationId);
}
return subscription.Metadata.TryGetValue(MetadataKeys.ProviderId, out var providerIdValue) &&
Guid.TryParse(providerIdValue, out var providerId)
? new ProviderId(providerId)
: throw new ConflictException("Subscription does not have a valid subscriber ID");
}
}

View File

@@ -0,0 +1,201 @@
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Models;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Microsoft.Extensions.Logging;
using OneOf;
using Stripe;
namespace Bit.Core.Billing.Subscriptions.Queries;
using static StripeConstants;
using static Utilities;
public interface IGetBitwardenSubscriptionQuery
{
/// <summary>
/// Retrieves detailed subscription information for a user, including subscription status,
/// cart items, discounts, and billing details.
/// </summary>
/// <param name="user">The user whose subscription information to retrieve.</param>
/// <returns>
/// A <see cref="BitwardenSubscription"/> containing the subscription details, or null if no
/// subscription is found or the subscription status is not recognized.
/// </returns>
/// <remarks>
/// Currently only supports <see cref="User"/> subscribers. Future versions will support all
/// <see cref="ISubscriber"/> types (User and Organization).
/// </remarks>
Task<BitwardenSubscription> Run(User user);
}
public class GetBitwardenSubscriptionQuery(
ILogger<GetBitwardenSubscriptionQuery> logger,
IPricingClient pricingClient,
IStripeAdapter stripeAdapter) : IGetBitwardenSubscriptionQuery
{
public async Task<BitwardenSubscription> Run(User user)
{
var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions
{
Expand =
[
"customer.discount.coupon.applies_to",
"discounts.coupon.applies_to",
"items.data.price.product",
"test_clock"
]
});
var cart = await GetPremiumCartAsync(subscription);
var baseSubscription = new BitwardenSubscription { Status = subscription.Status, Cart = cart, Storage = user };
switch (subscription.Status)
{
case SubscriptionStatus.Incomplete:
case SubscriptionStatus.IncompleteExpired:
return baseSubscription with { Suspension = subscription.Created.AddHours(23), GracePeriod = 1 };
case SubscriptionStatus.Trialing:
case SubscriptionStatus.Active:
return baseSubscription with
{
NextCharge = subscription.GetCurrentPeriodEnd(),
CancelAt = subscription.CancelAt
};
case SubscriptionStatus.PastDue:
case SubscriptionStatus.Unpaid:
var suspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription);
if (suspension == null)
{
return baseSubscription;
}
return baseSubscription with { Suspension = suspension.SuspensionDate, GracePeriod = suspension.GracePeriod };
case SubscriptionStatus.Canceled:
return baseSubscription with { Canceled = subscription.CanceledAt };
default:
{
logger.LogError("Subscription ({SubscriptionID}) has an unmanaged status ({Status})", subscription.Id, subscription.Status);
throw new ConflictException("Subscription is in an invalid state. Please contact support for assistance.");
}
}
}
private async Task<Cart> GetPremiumCartAsync(
Subscription subscription)
{
var plans = await pricingClient.ListPremiumPlans();
var passwordManagerSeatsItem = subscription.Items.FirstOrDefault(item =>
plans.Any(plan => plan.Seat.StripePriceId == item.Price.Id));
if (passwordManagerSeatsItem == null)
{
throw new ConflictException("Premium subscription does not have a Password Manager line item.");
}
var additionalStorageItem = subscription.Items.FirstOrDefault(item =>
plans.Any(plan => plan.Storage.StripePriceId == item.Price.Id));
var (cartLevelDiscount, productLevelDiscounts) = GetStripeDiscounts(subscription);
var passwordManagerSeats = new CartItem
{
TranslationKey = "premiumMembership",
Quantity = passwordManagerSeatsItem.Quantity,
Cost = GetCost(passwordManagerSeatsItem),
Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(passwordManagerSeatsItem))
};
var additionalStorage = additionalStorageItem != null
? new CartItem
{
TranslationKey = "additionalStorageGB",
Quantity = additionalStorageItem.Quantity,
Cost = GetCost(additionalStorageItem),
Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(additionalStorageItem))
}
: null;
var estimatedTax = await EstimateTaxAsync(subscription);
return new Cart
{
PasswordManager = new PasswordManagerCartItems
{
Seats = passwordManagerSeats,
AdditionalStorage = additionalStorage
},
Cadence = PlanCadenceType.Annually,
Discount = cartLevelDiscount,
EstimatedTax = estimatedTax
};
}
#region Utilities
private async Task<decimal> EstimateTaxAsync(Subscription subscription)
{
try
{
var invoice = await stripeAdapter.CreateInvoicePreviewAsync(new InvoiceCreatePreviewOptions
{
Customer = subscription.Customer.Id,
Subscription = subscription.Id
});
return GetCost(invoice.TotalTaxes);
}
catch (StripeException stripeException) when
(stripeException.StripeError.Code == ErrorCodes.InvoiceUpcomingNone)
{
return 0;
}
}
private static decimal GetCost(OneOf<SubscriptionItem, List<InvoiceTotalTax>> value) =>
value.Match(
item => (item.Price.UnitAmountDecimal ?? 0) / 100M,
taxes => taxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount) / 100M);
private static (Discount? CartLevel, List<Discount> ProductLevel) GetStripeDiscounts(
Subscription subscription)
{
var discounts = new List<Discount>();
if (subscription.Customer.Discount.IsValid())
{
discounts.Add(subscription.Customer.Discount);
}
discounts.AddRange(subscription.Discounts.Where(discount => discount.IsValid()));
var cartLevel = new List<Discount>();
var productLevel = new List<Discount>();
foreach (var discount in discounts)
{
switch (discount)
{
case { Coupon.AppliesTo.Products: null or { Count: 0 } }:
cartLevel.Add(discount);
break;
case { Coupon.AppliesTo.Products.Count: > 0 }:
productLevel.Add(discount);
break;
}
}
return (cartLevel.FirstOrDefault(), productLevel);
}
#endregion
}

View File

@@ -142,8 +142,7 @@ public static class FeatureFlagKeys
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud";
public const string BulkRevokeUsersV2 = "pm-28456-bulk-revoke-users-v2";
public const string PremiumAccessQuery = "pm-21411-premium-access-query";
public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface";
/* Architecture */
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";
@@ -159,14 +158,13 @@ public static class FeatureFlagKeys
public const string Otp6Digits = "pm-18612-otp-6-digits";
public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users";
public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods";
public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword =
"pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password";
public const string MJMLBasedEmailTemplates = "mjml-based-email-templates";
public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email";
public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template";
public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow";
public const string RedirectOnSsoRequired = "pm-1632-redirect-on-sso-required";
public const string PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin";
public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password";
/* Autofill Team */
public const string SSHAgent = "ssh-agent";
@@ -204,6 +202,7 @@ public static class FeatureFlagKeys
public const string V2RegistrationTDEJIT = "pm-27279-v2-registration-tde-jit";
public const string DataRecoveryTool = "pm-28813-data-recovery-tool";
public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration";
public const string SdkKeyRotation = "pm-30144-sdk-key-rotation";
public const string EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration";
/* Mobile Team */
@@ -231,23 +230,12 @@ public static class FeatureFlagKeys
/// Enable this flag to share the send view used by the web and browser clients
/// on the desktop client.
/// </summary>
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators";
public const string UseChromiumImporter = "pm-23982-chromium-importer";
public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe";
public const string SendUIRefresh = "pm-28175-send-ui-refresh";
public const string SendEmailOTP = "pm-19051-send-email-verification";
/// <summary>
/// Enable this flag to output email/OTP authenticated sends from the `GET sends` endpoint. When
/// this flag is disabled, the `GET sends` endpoint omits email/OTP authenticated sends.
/// </summary>
/// <remarks>
/// This flag is server-side only, and only inhibits the endpoint returning all sends.
/// Email/OTP sends can still be created and downloaded through other endpoints.
/// </remarks>
public const string PM19051_ListEmailOtpSends = "tools-send-email-otp-listing";
/* Vault Team */
public const string CipherKeyEncryption = "cipher-key-encryption";
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";

View File

@@ -3,6 +3,8 @@
<PropertyGroup>
<GenerateUserSecretsAttribute>false</GenerateUserSecretsAttribute>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
@@ -44,7 +46,7 @@
<PackageReference Include="Microsoft.Bot.Builder.Integration.AspNet.Core" Version="4.23.0" />
<PackageReference Include="Microsoft.Bot.Connector" Version="4.23.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.7.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.8.0" />
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />

View File

@@ -0,0 +1,60 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Utilities;
namespace Bit.Core.Entities;
/// <summary>
/// PlayItem is a join table tracking entities created during automated testing.
/// A `PlayId` is supplied by the clients in the `x-play-id` header to inform the server
/// that any data created should be associated with the play, and therefore cleaned up with it.
/// </summary>
public class PlayItem : ITableObject<Guid>
{
public Guid Id { get; set; }
[MaxLength(256)]
public required string PlayId { get; init; }
public Guid? UserId { get; init; }
public Guid? OrganizationId { get; init; }
public DateTime CreationDate { get; init; }
/// <summary>
/// Generates and sets a new COMB GUID for the Id property.
/// </summary>
public void SetNewId()
{
Id = CoreHelpers.GenerateComb();
}
/// <summary>
/// Creates a new PlayItem record associated with a User.
/// </summary>
/// <param name="user">The user entity created during the play.</param>
/// <param name="playId">The play identifier from the x-play-id header.</param>
/// <returns>A new PlayItem instance tracking the user.</returns>
public static PlayItem Create(User user, string playId)
{
return new PlayItem
{
PlayId = playId,
UserId = user.Id,
CreationDate = DateTime.UtcNow
};
}
/// <summary>
/// Creates a new PlayItem record associated with an Organization.
/// </summary>
/// <param name="organization">The organization entity created during the play.</param>
/// <param name="playId">The play identifier from the x-play-id header.</param>
/// <returns>A new PlayItem instance tracking the organization.</returns>
public static PlayItem Create(Organization organization, string playId)
{
return new PlayItem
{
PlayId = playId,
OrganizationId = organization.Id,
CreationDate = DateTime.UtcNow
};
}
}

View File

@@ -8,8 +8,8 @@
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
<mj-bw-hero
img-src="https://assets.bitwarden.com/email/v1/spot-enterprise.png"
title="You can now share passwords with members of {{OrganizationName}}!"
button-text="Log in"
title="You can now share passwords with members of <b>{{OrganizationName}}!</b>"
button-text="<b>Log in</b>"
button-url="{{WebVaultUrl}}"
/>
</mj-wrapper>
@@ -33,7 +33,7 @@
icon-alt="Group Users Icon"
text="You can easily access and share passwords with your team."
foot-url-text="Share passwords in Bitwarden"
foot-url="https://bitwarden.com/help/share-to-a-collection/"
foot-url="https://bitwarden.com/help/sharing"
/>
<mj-section background-color="#fff" padding="0 20px 20px 20px">
</mj-section>

View File

@@ -8,8 +8,8 @@
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
<mj-bw-hero
img-src="https://assets.bitwarden.com/email/v1/spot-family-homes.png"
title="You can now share passwords with members of {{OrganizationName}}!"
button-text="Log in"
title="You can now share passwords with members of <b>{{OrganizationName}}!</b>"
button-text="<b>Log in</b>"
button-url="{{WebVaultUrl}}"
/>
</mj-wrapper>
@@ -33,7 +33,7 @@
icon-alt="Group Users Icon"
text="You can easily share passwords with friends, family, or coworkers."
foot-url-text="Share passwords in Bitwarden"
foot-url="https://bitwarden.com/help/share-to-a-collection/"
foot-url="https://bitwarden.com/help/sharing"
/>
<mj-section background-color="#fff" padding="0 20px 20px 20px">
</mj-section>

View File

@@ -0,0 +1,11 @@
using Bit.Core.Entities;
#nullable enable
namespace Bit.Core.Repositories;
public interface IPlayItemRepository : IRepository<PlayItem, Guid>
{
Task<ICollection<PlayItem>> GetByPlayIdAsync(string playId);
Task DeleteByPlayIdAsync(string playId);
}

View File

@@ -0,0 +1,11 @@
using Bit.Core.Billing.Subscriptions.Models;
using Stripe;
namespace Bit.Core.Services;
public interface IBraintreeService
{
Task PayInvoice(
SubscriberId subscriberId,
Invoice invoice);
}

View File

@@ -0,0 +1,107 @@
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Models;
using Bit.Core.Exceptions;
using Bit.Core.Settings;
using Braintree;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Core.Services.Implementations;
using static StripeConstants;
public class BraintreeService(
IBraintreeGateway braintreeGateway,
IGlobalSettings globalSettings,
ILogger<BraintreeService> logger,
IMailService mailService,
IStripeAdapter stripeAdapter) : IBraintreeService
{
private readonly ConflictException _problemPayingInvoice = new("There was a problem paying for your invoice. Please contact customer support.");
public async Task PayInvoice(
SubscriberId subscriberId,
Invoice invoice)
{
if (invoice.Customer == null)
{
logger.LogError("Invoice's ({InvoiceID}) `customer` property must be expanded to be paid with Braintree",
invoice.Id);
throw _problemPayingInvoice;
}
if (!invoice.Customer.Metadata.TryGetValue(MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId))
{
logger.LogError(
"Cannot pay invoice ({InvoiceID}) with Braintree for Customer ({CustomerID}) that does not have a Braintree Customer ID",
invoice.Id, invoice.Customer.Id);
throw _problemPayingInvoice;
}
if (invoice is not
{
AmountDue: > 0,
Status: not InvoiceStatus.Paid,
CollectionMethod: CollectionMethod.ChargeAutomatically
})
{
logger.LogWarning("Attempted to pay invoice ({InvoiceID}) with Braintree that is not eligible for payment", invoice.Id);
return;
}
var amount = Math.Round(invoice.AmountDue / 100M, 2);
var idKey = subscriberId.Match(
_ => "user_id",
_ => "organization_id",
_ => "provider_id");
var idValue = subscriberId.Match(
userId => userId.Value,
organizationId => organizationId.Value,
providerId => providerId.Value);
var request = new TransactionRequest
{
Amount = amount,
CustomerId = braintreeCustomerId,
Options = new TransactionOptionsRequest
{
SubmitForSettlement = true,
PayPal = new TransactionOptionsPayPalRequest
{
CustomField = $"{idKey}:{idValue},region:{globalSettings.BaseServiceUri.CloudRegion}"
}
},
CustomFields = new Dictionary<string, string>
{
[idKey] = idValue.ToString(),
["region"] = globalSettings.BaseServiceUri.CloudRegion
}
};
var result = await braintreeGateway.Transaction.SaleAsync(request);
if (!result.IsSuccess())
{
if (invoice.AttemptCount < 4)
{
await mailService.SendPaymentFailedAsync(invoice.Customer.Email, amount, true);
}
return;
}
await stripeAdapter.UpdateInvoiceAsync(invoice.Id, new InvoiceUpdateOptions
{
Metadata = new Dictionary<string, string>
{
[MetadataKeys.BraintreeTransactionId] = result.Target.Id,
[MetadataKeys.PayPalTransactionId] = result.Target.PayPalDetails.AuthorizationId
}
});
await stripeAdapter.PayInvoiceAsync(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true });
}
}

View File

@@ -14,6 +14,8 @@ using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Models.Sales;
@@ -982,6 +984,16 @@ public class UserService : UserManager<User>, IUserService
throw new BadRequestException(exceptionMessage);
}
// If the license has a Token (claims-based), extract all properties from claims
// Otherwise, fall back to using the properties already on the license object (backward compatibility)
if (claimsPrincipal != null)
{
license.LicenseKey = claimsPrincipal.GetValue<string>(UserLicenseConstants.LicenseKey);
license.Premium = claimsPrincipal.GetValue<bool>(UserLicenseConstants.Premium);
license.MaxStorageGb = claimsPrincipal.GetValue<short?>(UserLicenseConstants.MaxStorageGb);
license.Expires = claimsPrincipal.GetValue<DateTime?>(UserLicenseConstants.Expires);
}
var dir = $"{_globalSettings.LicenseDirectory}/user";
Directory.CreateDirectory(dir);
using var fs = File.OpenWrite(Path.Combine(dir, $"{user.Id}.json"));
@@ -995,6 +1007,7 @@ public class UserService : UserManager<User>, IUserService
await SaveUserAsync(user);
}
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
public async Task<string> AdjustStorageAsync(User user, short storageAdjustmentGb)
{
if (user == null)
@@ -1040,6 +1053,7 @@ public class UserService : UserManager<User>, IUserService
await _paymentService.CancelSubscriptionAsync(user, eop);
}
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
public async Task ReinstatePremiumAsync(User user)
{
await _paymentService.ReinstateSubscriptionAsync(user);

View File

@@ -0,0 +1,23 @@
namespace Bit.Core.Services;
/// <summary>
/// Service for managing Play identifiers in automated testing infrastructure.
/// A "Play" is a test session that groups entities created during testing to enable cleanup.
/// The PlayId flows from client request (x-play-id header) through PlayIdMiddleware to this service,
/// which repositories query to create PlayItem tracking records via IPlayItemService. The SeederAPI uses these records
/// to bulk delete all entities associated with a PlayId. Only active in Development environments.
/// </summary>
public interface IPlayIdService
{
/// <summary>
/// Gets or sets the current Play identifier from the x-play-id request header.
/// </summary>
string? PlayId { get; set; }
/// <summary>
/// Checks whether the current request is part of an active Play session.
/// </summary>
/// <param name="playId">The Play identifier if active, otherwise empty string.</param>
/// <returns>True if in a Play session (has PlayId and in Development environment), otherwise false.</returns>
bool InPlay(out string playId);
}

View File

@@ -0,0 +1,27 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
namespace Bit.Core.Services;
/// <summary>
/// Service used to track added users and organizations during a Play session.
/// </summary>
public interface IPlayItemService
{
/// <summary>
/// Records a PlayItem entry for the given User created during a Play session.
///
/// Does nothing if no Play Id is set for this http scope.
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
Task Record(User user);
/// <summary>
/// Records a PlayItem entry for the given Organization created during a Play session.
///
/// Does nothing if no Play Id is set for this http scope.
/// </summary>
/// <param name="organization"></param>
/// <returns></returns>
Task Record(Organization organization);
}

View File

@@ -0,0 +1,16 @@
namespace Bit.Core.Services;
public class NeverPlayIdServices : IPlayIdService
{
public string? PlayId
{
get => null;
set { }
}
public bool InPlay(out string playId)
{
playId = string.Empty;
return false;
}
}

View File

@@ -0,0 +1,13 @@
using Microsoft.Extensions.Hosting;
namespace Bit.Core.Services;
public class PlayIdService(IHostEnvironment hostEnvironment) : IPlayIdService
{
public string? PlayId { get; set; }
public bool InPlay(out string playId)
{
playId = PlayId ?? string.Empty;
return !string.IsNullOrEmpty(PlayId) && hostEnvironment.IsDevelopment();
}
}

View File

@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Bit.Core.Services;
/// <summary>
/// Singleton wrapper service that bridges singleton-scoped service boundaries for PlayId tracking.
/// This allows singleton services to access the scoped PlayIdService via HttpContext.RequestServices.
///
/// Uses IHttpContextAccessor to retrieve the current request's scoped PlayIdService instance, enabling
/// singleton services to participate in Play session tracking without violating DI lifetime rules.
/// Falls back to NeverPlayIdServices when no HttpContext is available (e.g., background jobs).
/// </summary>
public class PlayIdSingletonService(IHttpContextAccessor httpContextAccessor, IHostEnvironment hostEnvironment) : IPlayIdService
{
private IPlayIdService Current
{
get
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext == null)
{
return new NeverPlayIdServices();
}
return httpContext.RequestServices.GetRequiredService<PlayIdService>();
}
}
public string? PlayId
{
get => Current.PlayId;
set => Current.PlayId = value;
}
public bool InPlay(out string playId)
{
if (hostEnvironment.IsDevelopment())
{
return Current.InPlay(out playId);
}
else
{
playId = string.Empty;
return false;
}
}
}

View File

@@ -0,0 +1,26 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Bit.Core.Repositories;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Services;
public class PlayItemService(IPlayIdService playIdService, IPlayItemRepository playItemRepository, ILogger<PlayItemService> logger) : IPlayItemService
{
public async Task Record(User user)
{
if (playIdService.InPlay(out var playId))
{
logger.LogInformation("Associating user {UserId} with Play ID {PlayId}", user.Id, playId);
await playItemRepository.CreateAsync(PlayItem.Create(user, playId));
}
}
public async Task Record(Organization organization)
{
if (playIdService.InPlay(out var playId))
{
logger.LogInformation("Associating organization {OrganizationId} with Play ID {PlayId}", organization.Id, playId);
await playItemRepository.CreateAsync(PlayItem.Create(organization, playId));
}
}
}

View File

@@ -0,0 +1,27 @@
# Play Services
## Overview
The Play services provide automated testing infrastructure for tracking and cleaning up test data in development
environments. A "Play" is a test session that groups entities (users, organizations, etc.) created during testing to
enable bulk cleanup via the SeederAPI.
## How It Works
1. Test client sends `x-play-id` header with a unique Play identifier
2. `PlayIdMiddleware` extracts the header and sets it on `IPlayIdService`
3. Repositories check `IPlayIdService.InPlay()` when creating entities
4. `IPlayItemService` records PlayItem entries for tracked entities
5. SeederAPI uses PlayItem records to bulk delete all entities associated with a PlayId
Play services are **only active in Development environments**.
## Classes
- **`IPlayIdService`** - Interface for managing Play identifiers in the current request scope
- **`IPlayItemService`** - Interface for tracking entities created during a Play session
- **`PlayIdService`** - Default scoped implementation for tracking Play sessions per HTTP request
- **`NeverPlayIdServices`** - No-op implementation used as fallback when no HttpContext is available
- **`PlayIdSingletonService`** - Singleton wrapper that allows singleton services to access scoped PlayIdService via
HttpContext
- **`PlayItemService`** - Implementation that records PlayItem entries for entities created during Play sessions

View File

@@ -44,6 +44,7 @@ public class GlobalSettings : IGlobalSettings
public virtual bool EnableCloudCommunication { get; set; } = false;
public virtual int OrganizationInviteExpirationHours { get; set; } = 120; // 5 days
public virtual string EventGridKey { get; set; }
public virtual bool TestPlayIdTrackingEnabled { get; set; } = false;
public virtual IInstallationSettings Installation { get; set; } = new InstallationSettings();
public virtual IBaseServiceUriSettings BaseServiceUri { get; set; }
public virtual string DatabaseProvider { get; set; }

View File

@@ -1,11 +1,8 @@
using System.Text.Json.Serialization;
namespace Bit.Core.Tools.Enums;
namespace Bit.Core.Tools.Enums;
/// <summary>
/// Specifies the authentication method required to access a Send.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum AuthType : byte
{
/// <summary>

View File

@@ -1,4 +1,5 @@
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
@@ -37,8 +38,8 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
{
null => NEVER_AUTHENTICATE,
var s when s.AccessCount >= s.MaxAccessCount => NEVER_AUTHENTICATE,
var s when s.Emails is not null => emailOtp(s.Emails),
var s when s.Password is not null => new ResourcePassword(s.Password),
var s when s.AuthType == AuthType.Email && s.Emails is not null => emailOtp(s.Emails),
var s when s.AuthType == AuthType.Password && s.Password is not null => new ResourcePassword(s.Password),
_ => NOT_AUTHENTICATED
};

View File

@@ -12,7 +12,6 @@ namespace Bit.Core.Tools.SendFeatures.Queries;
public class SendOwnerQuery : ISendOwnerQuery
{
private readonly ISendRepository _repository;
private readonly IFeatureService _features;
private readonly IUserService _users;
/// <summary>
@@ -24,10 +23,9 @@ public class SendOwnerQuery : ISendOwnerQuery
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="sendRepository"/> is <see langword="null"/>.
/// </exception>
public SendOwnerQuery(ISendRepository sendRepository, IFeatureService features, IUserService users)
public SendOwnerQuery(ISendRepository sendRepository, IUserService users)
{
_repository = sendRepository;
_features = features ?? throw new ArgumentNullException(nameof(features));
_users = users ?? throw new ArgumentNullException(nameof(users));
}
@@ -51,16 +49,6 @@ public class SendOwnerQuery : ISendOwnerQuery
var userId = _users.GetProperUserId(user) ?? throw new BadRequestException("invalid user.");
var sends = await _repository.GetManyByUserIdAsync(userId);
var removeEmailOtp = !_features.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends);
if (removeEmailOtp)
{
// reify list to avoid invalidating the enumerator
foreach (var s in sends.Where(s => s.Emails != null).ToList())
{
sends.Remove(s);
}
}
return sends;
}
}

View File

@@ -0,0 +1,52 @@
using System.Reflection;
using System.Runtime.Serialization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Bit.Core.Utilities;
/// <summary>
/// A custom JSON converter for enum types that respects the <see cref="EnumMemberAttribute"/> when serializing and deserializing.
/// </summary>
/// <typeparam name="T">The enum type to convert. Must be a struct and implement Enum.</typeparam>
/// <remarks>
/// This converter builds lookup dictionaries at initialization to efficiently map between enum values and their
/// string representations. If an enum value has an <see cref="EnumMemberAttribute"/>, the attribute's Value
/// property is used as the JSON string; otherwise, the enum's ToString() value is used.
/// </remarks>
public class EnumMemberJsonConverter<T> : JsonConverter<T> where T : struct, Enum
{
private readonly Dictionary<T, string> _enumToString = new();
private readonly Dictionary<string, T> _stringToEnum = new();
public EnumMemberJsonConverter()
{
var type = typeof(T);
var values = Enum.GetValues<T>();
foreach (var value in values)
{
var fieldInfo = type.GetField(value.ToString());
var attribute = fieldInfo?.GetCustomAttribute<EnumMemberAttribute>();
var stringValue = attribute?.Value ?? value.ToString();
_enumToString[value] = stringValue;
_stringToEnum[stringValue] = value;
}
}
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var stringValue = reader.GetString();
if (!string.IsNullOrEmpty(stringValue) && _stringToEnum.TryGetValue(stringValue, out var enumValue))
{
return enumValue;
}
throw new JsonException($"Unable to convert '{stringValue}' to {typeof(T).Name}");
}
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
=> writer.WriteStringValue(_enumToString[value]);
}

View File

@@ -36,6 +36,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -30,6 +30,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Add event integration services
services.AddDistributedCache(globalSettings);

View File

@@ -3,6 +3,8 @@
<PropertyGroup>
<UserSecretsId>bitwarden-Icons</UserSecretsId>
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Icons' " />

View File

@@ -3,6 +3,8 @@
<PropertyGroup>
<UserSecretsId>bitwarden-Identity</UserSecretsId>
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Identity' " />

View File

@@ -1,5 +1,4 @@
using Bit.Core;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Auth.Utilities;
@@ -8,7 +7,6 @@ using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Models.Api.Response;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Identity.Utilities;
@@ -26,8 +24,6 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
private readonly IDeviceRepository _deviceRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly ILoginApprovingClientTypes _loginApprovingClientTypes;
private readonly IFeatureService _featureService;
private UserDecryptionOptions _options = new UserDecryptionOptions();
private User _user = null!;
private SsoConfig? _ssoConfig;
@@ -37,15 +33,13 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
ICurrentContext currentContext,
IDeviceRepository deviceRepository,
IOrganizationUserRepository organizationUserRepository,
ILoginApprovingClientTypes loginApprovingClientTypes,
IFeatureService featureService
ILoginApprovingClientTypes loginApprovingClientTypes
)
{
_currentContext = currentContext;
_deviceRepository = deviceRepository;
_organizationUserRepository = organizationUserRepository;
_loginApprovingClientTypes = loginApprovingClientTypes;
_featureService = featureService;
}
public IUserDecryptionOptionsBuilder ForUser(User user)
@@ -145,34 +139,7 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
// In the TDE flow, the users will have been JIT-provisioned at SSO callback time, and the relationship between
// user and organization user will have been codified.
var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id);
var hasManageResetPasswordPermission = false;
if (_featureService.IsEnabled(FeatureFlagKeys.PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword))
{
hasManageResetPasswordPermission = await EvaluateHasManageResetPasswordPermission();
}
else
{
// TODO: PM-26065 remove use of above feature flag from the server, and remove this branching logic, which
// has been replaced by EvaluateHasManageResetPasswordPermission.
// Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP.
// When removing feature flags, please also see notes and removals intended for test suite in
// Build_WhenManageResetPasswordPermissions_ShouldReturnHasManageResetPasswordPermissionTrue.
// when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here
if (_currentContext.Organizations != null && _currentContext.Organizations.Any(o => o.Id == _ssoConfig.OrganizationId))
{
// TDE requires single org so grabbing first org & id is fine.
hasManageResetPasswordPermission = await _currentContext.ManageResetPassword(_ssoConfig!.OrganizationId);
}
// If sso configuration data is not null then I know for sure that ssoConfiguration isn't null
// NOTE: Commented from original impl because the organization user repository call has been hoisted to support
// branching paths through flagging.
//organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id);
hasManageResetPasswordPermission |= organizationUser != null && (organizationUser.Type == OrganizationUserType.Owner || organizationUser.Type == OrganizationUserType.Admin);
}
var hasManageResetPasswordPermission = await EvaluateHasManageResetPasswordPermission();
// They are only able to be approved by an admin if they have enrolled is reset password
var hasAdminApproval = organizationUser != null && !string.IsNullOrEmpty(organizationUser.ResetPasswordKey);
@@ -186,10 +153,10 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
encryptedUserKey);
return;
/// Determine if the user has manage reset password permission,
/// as post-SSO logic requires it for forcing users with this permission to set a password.
async Task<bool> EvaluateHasManageResetPasswordPermission()
{
// PM-23174
// Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP
if (organizationUser == null)
{
return false;

View File

@@ -49,6 +49,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -17,7 +17,7 @@ namespace Bit.Infrastructure.Dapper.Repositories;
public class OrganizationRepository : Repository<Organization, Guid>, IOrganizationRepository
{
private readonly ILogger<OrganizationRepository> _logger;
protected readonly ILogger<OrganizationRepository> _logger;
public OrganizationRepository(
GlobalSettings globalSettings,

View File

@@ -51,6 +51,7 @@ public static class DapperServiceCollectionExtensions
services.AddSingleton<IOrganizationRepository, OrganizationRepository>();
services.AddSingleton<IOrganizationSponsorshipRepository, OrganizationSponsorshipRepository>();
services.AddSingleton<IOrganizationUserRepository, OrganizationUserRepository>();
services.AddSingleton<IPlayItemRepository, PlayItemRepository>();
services.AddSingleton<IPolicyRepository, PolicyRepository>();
services.AddSingleton<IProviderOrganizationRepository, ProviderOrganizationRepository>();
services.AddSingleton<IProviderRepository, ProviderRepository>();

View File

@@ -1,5 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,45 @@
using System.Data;
using Bit.Core.Entities;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Dapper;
using Microsoft.Data.SqlClient;
#nullable enable
namespace Bit.Infrastructure.Dapper.Repositories;
public class PlayItemRepository : Repository<PlayItem, Guid>, IPlayItemRepository
{
public PlayItemRepository(GlobalSettings globalSettings)
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
{ }
public PlayItemRepository(string connectionString, string readOnlyConnectionString)
: base(connectionString, readOnlyConnectionString)
{ }
public async Task<ICollection<PlayItem>> GetByPlayIdAsync(string playId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<PlayItem>(
"[dbo].[PlayItem_ReadByPlayId]",
new { PlayId = playId },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
public async Task DeleteByPlayIdAsync(string playId)
{
using (var connection = new SqlConnection(ConnectionString))
{
await connection.ExecuteAsync(
"[dbo].[PlayItem_DeleteByPlayId]",
new { PlayId = playId },
commandType: CommandType.StoredProcedure);
}
}
}

View File

@@ -20,7 +20,7 @@ namespace Bit.Infrastructure.EntityFramework.Repositories;
public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Organization, Organization, Guid>, IOrganizationRepository
{
private readonly ILogger<OrganizationRepository> _logger;
protected readonly ILogger<OrganizationRepository> _logger;
public OrganizationRepository(
IServiceScopeFactory serviceScopeFactory,

View File

@@ -0,0 +1,46 @@
using Bit.Infrastructure.EntityFramework.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Bit.Infrastructure.EntityFramework.Configurations;
public class PlayItemEntityTypeConfiguration : IEntityTypeConfiguration<PlayItem>
{
public void Configure(EntityTypeBuilder<PlayItem> builder)
{
builder
.Property(pd => pd.Id)
.ValueGeneratedNever();
builder
.HasIndex(pd => pd.PlayId)
.IsClustered(false);
builder
.HasIndex(pd => pd.UserId)
.IsClustered(false);
builder
.HasIndex(pd => pd.OrganizationId)
.IsClustered(false);
builder
.HasOne(pd => pd.User)
.WithMany()
.HasForeignKey(pd => pd.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder
.HasOne(pd => pd.Organization)
.WithMany()
.HasForeignKey(pd => pd.OrganizationId)
.OnDelete(DeleteBehavior.Cascade);
builder
.ToTable(nameof(PlayItem))
.HasCheckConstraint(
"CK_PlayItem_UserOrOrganization",
"(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"
);
}
}

View File

@@ -88,6 +88,7 @@ public static class EntityFrameworkServiceCollectionExtensions
services.AddSingleton<IOrganizationRepository, OrganizationRepository>();
services.AddSingleton<IOrganizationSponsorshipRepository, OrganizationSponsorshipRepository>();
services.AddSingleton<IOrganizationUserRepository, OrganizationUserRepository>();
services.AddSingleton<IPlayItemRepository, PlayItemRepository>();
services.AddSingleton<IPolicyRepository, PolicyRepository>();
services.AddSingleton<IProviderOrganizationRepository, ProviderOrganizationRepository>();
services.AddSingleton<IProviderRepository, ProviderRepository>();

View File

@@ -1,4 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />

View File

@@ -0,0 +1,19 @@
#nullable enable
using AutoMapper;
namespace Bit.Infrastructure.EntityFramework.Models;
public class PlayItem : Core.Entities.PlayItem
{
public virtual User? User { get; set; }
public virtual AdminConsole.Models.Organization? Organization { get; set; }
}
public class PlayItemMapperProfile : Profile
{
public PlayItemMapperProfile()
{
CreateMap<Core.Entities.PlayItem, PlayItem>().ReverseMap();
}
}

View File

@@ -57,6 +57,7 @@ public class DatabaseContext : DbContext
public DbSet<OrganizationApiKey> OrganizationApiKeys { get; set; }
public DbSet<OrganizationSponsorship> OrganizationSponsorships { get; set; }
public DbSet<OrganizationConnection> OrganizationConnections { get; set; }
public DbSet<PlayItem> PlayItem { get; set; }
public DbSet<OrganizationIntegration> OrganizationIntegrations { get; set; }
public DbSet<OrganizationIntegrationConfiguration> OrganizationIntegrationConfigurations { get; set; }
public DbSet<OrganizationUser> OrganizationUsers { get; set; }

View File

@@ -0,0 +1,42 @@
using AutoMapper;
using Bit.Core.Repositories;
using Bit.Infrastructure.EntityFramework.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
#nullable enable
namespace Bit.Infrastructure.EntityFramework.Repositories;
public class PlayItemRepository : Repository<Core.Entities.PlayItem, PlayItem, Guid>, IPlayItemRepository
{
public PlayItemRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
: base(serviceScopeFactory, mapper, (DatabaseContext context) => context.PlayItem)
{ }
public async Task<ICollection<Core.Entities.PlayItem>> GetByPlayIdAsync(string playId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var playItemEntities = await GetDbSet(dbContext)
.Where(pd => pd.PlayId == playId)
.ToListAsync();
return Mapper.Map<List<Core.Entities.PlayItem>>(playItemEntities);
}
}
public async Task DeleteByPlayIdAsync(string playId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var entities = await GetDbSet(dbContext)
.Where(pd => pd.PlayId == playId)
.ToListAsync();
dbContext.PlayItem.RemoveRange(entities);
await dbContext.SaveChangesAsync();
}
}
}

Some files were not shown because too many files have changed in this diff Show More