1
0
mirror of https://github.com/bitwarden/server synced 2026-01-15 15:03:34 +00:00

Merge branch 'main' into renovate/microsoft.extensions.caching.cosmos-1.x

This commit is contained in:
Ike
2026-01-14 17:24:47 -05:00
committed by GitHub
549 changed files with 74329 additions and 4248 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

View File

@@ -31,7 +31,7 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
- name: Verify format
run: dotnet format --verify-no-changes
@@ -39,8 +39,7 @@ jobs:
build-artifacts:
name: Build Docker images
runs-on: ubuntu-22.04
needs:
- lint
needs: lint
outputs:
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
permissions:
@@ -120,7 +119,7 @@ jobs:
fi
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
@@ -271,7 +270,7 @@ jobs:
output-format: sarif
- name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
sarif_file: ${{ steps.container-scan.outputs.sarif }}
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
@@ -295,7 +294,7 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
@@ -401,8 +400,7 @@ jobs:
build-mssqlmigratorutility:
name: Build MSSQL migrator utility
runs-on: ubuntu-22.04
needs:
- lint
needs: lint
defaults:
run:
shell: bash
@@ -422,7 +420,7 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
- name: Print environment
run: |
@@ -452,14 +450,13 @@ jobs:
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
if-no-files-found: error
self-host-build:
name: Trigger self-host build
bitwarden-lite-build:
name: Trigger Bitwarden lite build
if: |
github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
runs-on: ubuntu-22.04
needs:
- build-artifacts
needs: build-artifacts
permissions:
id-token: write
steps:
@@ -505,11 +502,10 @@ jobs:
});
trigger-k8s-deploy:
name: Trigger k8s deploy
name: Trigger K8s deploy
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04
needs:
- build-artifacts
needs: build-artifacts
permissions:
id-token: write
steps:
@@ -539,7 +535,7 @@ jobs:
owner: ${{ github.repository_owner }}
repositories: devops
- name: Trigger k8s deploy
- name: Trigger K8s deploy
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.app-token.outputs.token }}
@@ -557,8 +553,7 @@ jobs:
setup-ephemeral-environment:
name: Setup Ephemeral Environment
needs:
- build-artifacts
needs: build-artifacts
if: |
needs.build-artifacts.outputs.has_secrets == 'true'
&& github.event_name == 'pull_request'
@@ -581,7 +576,7 @@ jobs:
- build-artifacts
- upload
- build-mssqlmigratorutility
- self-host-build
- bitwarden-lite-build
- trigger-k8s-deploy
permissions:
id-token: write

View File

@@ -1,71 +0,0 @@
name: Container registry cleanup
on:
pull_request:
types: [closed]
env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
jobs:
build-docker:
name: Remove branch-specific Docker images
runs-on: ubuntu-22.04
permissions:
id-token: write
steps:
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Log in to Azure ACR
run: az acr login -n "$_AZ_REGISTRY" --only-show-errors
########## Remove Docker images ##########
- name: Remove the Docker image from ACR
env:
REF: ${{ github.event.pull_request.head.ref }}
SERVICES: |
services:
- Admin
- Api
- Attachments
- Events
- EventsProcessor
- Icons
- Identity
- K8S-Proxy
- MsSql
- Nginx
- Notifications
- Server
- Setup
- Sso
run: |
for SERVICE in $(echo "${SERVICES}" | yq e ".services[]" - )
do
SERVICE_NAME=$(echo "$SERVICE" | awk '{print tolower($0)}')
IMAGE_TAG=$(echo "${REF}" | sed "s#/#-#g") # slash safe branch name
echo "[*] Checking if remote exists: $_AZ_REGISTRY/$SERVICE_NAME:$IMAGE_TAG"
TAG_EXISTS=$(
az acr repository show-tags --name "$_AZ_REGISTRY" --repository "$SERVICE_NAME" \
| jq --arg TAG "$IMAGE_TAG" -e '. | any(. == $TAG)'
)
if [[ "$TAG_EXISTS" == "true" ]]; then
echo "[*] Tag exists. Removing tag"
az acr repository delete --name "$_AZ_REGISTRY" --image "$SERVICE_NAME:$IMAGE_TAG" --yes
else
echo "[*] Tag does not exist. No action needed"
fi
done
- name: Log out of Docker
run: docker logout
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main

View File

@@ -2,7 +2,7 @@ name: Code Review
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
types: [opened, synchronize, reopened]
permissions: {}

View File

@@ -49,7 +49,7 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
- name: Restore tools
run: dotnet tool restore
@@ -183,7 +183,7 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
- name: Print environment
run: |

View File

@@ -32,10 +32,10 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
- name: Install rust
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # stable
with:
toolchain: stable

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,16 @@ 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}"
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
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -350,10 +356,22 @@ 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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -410,7 +428,10 @@ 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}
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);
@@ -680,22 +681,10 @@ public class AccountController : Controller
ApiKey = CoreHelpers.SecureRandomString(30)
};
/*
The feature flag is checked here so that we can send the new MJML welcome email templates.
The other organization invites flows have an OrganizationUser allowing the RegisterUserCommand the ability
to fetch the Organization. The old method RegisterUser(User) here does not have that context, so we need
to use a new method RegisterSSOAutoProvisionedUserAsync(User, Organization) to send the correct email.
[PM-28057]: Prefer RegisterSSOAutoProvisionedUserAsync for SSO auto-provisioned users.
TODO: Remove Feature flag: PM-28221
*/
if (_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates))
{
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
}
else
{
await _registerUserCommand.RegisterUser(newUser);
}
// Always use RegisterSSOAutoProvisionedUserAsync to ensure organization context is available
// for domain validation (BlockClaimedDomainAccountCreation policy) and welcome emails.
// The feature flag logic for welcome email templates is handled internally by RegisterUserCommand.
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
var twoFactorPolicy =

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

@@ -6,7 +6,6 @@ using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.Registration;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
@@ -21,7 +20,6 @@ using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
@@ -1013,133 +1011,6 @@ public class AccountControllerTest
}
}
[Theory, BitAutoData]
public async Task AutoProvisionUserAsync_WithFeatureFlagEnabled_CallsRegisterSSOAutoProvisionedUser(
SutProvider<AccountController> sutProvider)
{
// Arrange
var orgId = Guid.NewGuid();
var providerUserId = "ext-new-user";
var email = "newuser@example.com";
var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null };
// No existing user (JIT provisioning scenario)
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns((User?)null);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationEmailAsync(orgId, email)
.Returns((OrganizationUser?)null);
// Feature flag enabled
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
// Mock the RegisterSSOAutoProvisionedUserAsync to return success
sutProvider.GetDependency<IRegisterUserCommand>()
.RegisterSSOAutoProvisionedUserAsync(Arg.Any<User>(), Arg.Any<Organization>())
.Returns(IdentityResult.Success);
var claims = new[]
{
new Claim(JwtClaimTypes.Email, email),
new Claim(JwtClaimTypes.Name, "New User")
} as IEnumerable<Claim>;
var config = new SsoConfigurationData();
var method = typeof(AccountController).GetMethod(
"CreateUserAndOrgUserConditionallyAsync",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
// Act
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(
sutProvider.Sut,
new object[]
{
orgId.ToString(),
providerUserId,
claims,
null!,
config
})!;
var result = await task;
// Assert
await sutProvider.GetDependency<IRegisterUserCommand>().Received(1)
.RegisterSSOAutoProvisionedUserAsync(
Arg.Is<User>(u => u.Email == email && u.Name == "New User"),
Arg.Is<Organization>(o => o.Id == orgId && o.Name == "Test Org"));
Assert.NotNull(result.user);
Assert.Equal(email, result.user.Email);
Assert.Equal(organization.Id, result.organization.Id);
}
[Theory, BitAutoData]
public async Task AutoProvisionUserAsync_WithFeatureFlagDisabled_CallsRegisterUserInstead(
SutProvider<AccountController> sutProvider)
{
// Arrange
var orgId = Guid.NewGuid();
var providerUserId = "ext-legacy-user";
var email = "legacyuser@example.com";
var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null };
// No existing user (JIT provisioning scenario)
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns((User?)null);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationEmailAsync(orgId, email)
.Returns((OrganizationUser?)null);
// Feature flag disabled
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(false);
// Mock the RegisterUser to return success
sutProvider.GetDependency<IRegisterUserCommand>()
.RegisterUser(Arg.Any<User>())
.Returns(IdentityResult.Success);
var claims = new[]
{
new Claim(JwtClaimTypes.Email, email),
new Claim(JwtClaimTypes.Name, "Legacy User")
} as IEnumerable<Claim>;
var config = new SsoConfigurationData();
var method = typeof(AccountController).GetMethod(
"CreateUserAndOrgUserConditionallyAsync",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
// Act
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(
sutProvider.Sut,
new object[]
{
orgId.ToString(),
providerUserId,
claims,
null!,
config
})!;
var result = await task;
// Assert
await sutProvider.GetDependency<IRegisterUserCommand>().Received(1)
.RegisterUser(Arg.Is<User>(u => u.Email == email && u.Name == "Legacy User"));
// Verify the new method was NOT called
await sutProvider.GetDependency<IRegisterUserCommand>().DidNotReceive()
.RegisterSSOAutoProvisionedUserAsync(Arg.Any<User>(), Arg.Any<Organization>());
Assert.NotNull(result.user);
Assert.Equal(email, result.user.Email);
}
[Theory, BitAutoData]
public void ExternalChallenge_WithMatchingOrgId_Succeeds(
SutProvider<AccountController> sutProvider,

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

@@ -41,7 +41,7 @@ $migrationPath = "util/Migrator/DbScripts"
# Get list of migrations from base reference
try {
$baseMigrations = git ls-tree -r --name-only $BaseRef -- "$migrationPath/*.sql" 2>$null | Sort-Object
$baseMigrations = git ls-tree -r --name-only $BaseRef -- "$migrationPath/" 2>$null | Where-Object { $_ -like "*.sql" } | Sort-Object
if ($LASTEXITCODE -ne 0) {
Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'"
$baseMigrations = @()
@@ -53,7 +53,7 @@ catch {
}
# Get list of migrations from current reference
$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/*.sql" | Sort-Object
$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/" | Where-Object { $_ -like "*.sql" } | Sort-Object
# Find added migrations
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }

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

@@ -496,6 +496,7 @@ public class OrganizationsController : Controller
organization.UseOrganizationDomains = model.UseOrganizationDomains;
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation;
organization.UseDisableSmAdsForUsers = model.UseDisableSmAdsForUsers;
organization.UsePhishingBlocker = model.UsePhishingBlocker;
//secrets

View File

@@ -107,6 +107,7 @@ public class OrganizationEditModel : OrganizationViewModel
MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts;
UseOrganizationDomains = org.UseOrganizationDomains;
UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation;
UseDisableSmAdsForUsers = org.UseDisableSmAdsForUsers;
UsePhishingBlocker = org.UsePhishingBlocker;
_plans = plans;
@@ -196,6 +197,8 @@ public class OrganizationEditModel : OrganizationViewModel
public int? MaxAutoscaleSmServiceAccounts { get; set; }
[Display(Name = "Use Organization Domains")]
public bool UseOrganizationDomains { get; set; }
[Display(Name = "Disable SM Ads For Users")]
public new bool UseDisableSmAdsForUsers { get; set; }
[Display(Name = "Automatic User Confirmation")]
public bool UseAutomaticUserConfirmation { get; set; }
@@ -330,6 +333,7 @@ public class OrganizationEditModel : OrganizationViewModel
existingOrganization.SmServiceAccounts = SmServiceAccounts;
existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts;
existingOrganization.UseOrganizationDomains = UseOrganizationDomains;
existingOrganization.UseDisableSmAdsForUsers = UseDisableSmAdsForUsers;
existingOrganization.UsePhishingBlocker = UsePhishingBlocker;
return existingOrganization;
}

View File

@@ -76,6 +76,7 @@ public class OrganizationViewModel
public bool UseSecretsManager => Organization.UseSecretsManager;
public bool UseRiskInsights => Organization.UseRiskInsights;
public bool UsePhishingBlocker => Organization.UsePhishingBlocker;
public bool UseDisableSmAdsForUsers => Organization.UseDisableSmAdsForUsers;
public IEnumerable<OrganizationUserUserDetails> OwnersDetails { get; set; }
public IEnumerable<OrganizationUserUserDetails> AdminsDetails { get; set; }
}

View File

@@ -185,6 +185,13 @@
<input type="checkbox" class="form-check-input" asp-for="UseSecretsManager" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseSecretsManager"></label>
</div>
@if (FeatureService.IsEnabled(FeatureFlagKeys.SM1719_RemoveSecretsManagerAds))
{
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseDisableSmAdsForUsers" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseDisableSmAdsForUsers"></label>
</div>
}
</div>
<div class="col-2">
<h3>Access Intelligence</h3>

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

@@ -0,0 +1,14 @@
using Bit.Core.Context;
namespace Bit.Api.AdminConsole.Authorization.Requirements;
/// <summary>
/// Requires that the user is a member of the organization.
/// </summary>
public class MemberRequirement : IOrganizationRequirement
{
public Task<bool> AuthorizeAsync(
CurrentContextOrganization? organizationClaims,
Func<Task<bool>> isProviderUserForOrg)
=> Task.FromResult(organizationClaims is not null);
}

View File

@@ -19,6 +19,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimed
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
@@ -81,6 +82,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
private readonly V1_RevokeOrganizationUserCommand _revokeOrganizationUserCommand;
private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand;
private readonly ISelfRevokeOrganizationUserCommand _selfRevokeOrganizationUserCommand;
public OrganizationUsersController(IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
@@ -112,7 +114,8 @@ public class OrganizationUsersController : BaseAdminConsoleController
IBulkResendOrganizationInvitesCommand bulkResendOrganizationInvitesCommand,
IAdminRecoverAccountCommand adminRecoverAccountCommand,
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand,
V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext)
V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext,
ISelfRevokeOrganizationUserCommand selfRevokeOrganizationUserCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@@ -145,6 +148,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
_initPendingOrganizationCommand = initPendingOrganizationCommand;
_revokeOrganizationUserCommand = revokeOrganizationUserCommand;
_adminRecoverAccountCommand = adminRecoverAccountCommand;
_selfRevokeOrganizationUserCommand = selfRevokeOrganizationUserCommand;
}
[HttpGet("{id}")]
@@ -635,6 +639,20 @@ public class OrganizationUsersController : BaseAdminConsoleController
await RestoreOrRevokeUserAsync(orgId, id, _revokeOrganizationUserCommand.RevokeUserAsync);
}
[HttpPut("revoke-self")]
[Authorize<MemberRequirement>]
public async Task<IResult> RevokeSelfAsync(Guid orgId)
{
var userId = _userService.GetProperUserId(User);
if (!userId.HasValue)
{
throw new UnauthorizedAccessException();
}
var result = await _selfRevokeOrganizationUserCommand.SelfRevokeUserAsync(orgId, userId.Value);
return Handle(result);
}
[HttpPatch("{id}/revoke")]
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
[Authorize<ManageUsersRequirement>]
@@ -647,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

@@ -7,7 +7,6 @@ using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.AdminConsole.Models.Response.Helpers;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
@@ -212,7 +211,6 @@ public class PoliciesController : Controller
}
[HttpPut("{type}/vnext")]
[RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)]
[Authorize<ManagePoliciesRequirement>]
public async Task<PolicyResponseModel> PutVNext(Guid orgId, PolicyType type, [FromBody] SavePolicyRequest model)
{

View File

@@ -48,6 +48,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation;
UseSecretsManager = organizationDetails.UseSecretsManager;
UsePhishingBlocker = organizationDetails.UsePhishingBlocker;
UseDisableSMAdsForUsers = organizationDetails.UseDisableSMAdsForUsers;
UsePasswordManager = organizationDetails.UsePasswordManager;
SelfHost = organizationDetails.SelfHost;
Seats = organizationDetails.Seats;
@@ -100,6 +101,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool UseDisableSMAdsForUsers { get; set; }
public bool UsePhishingBlocker { get; set; }
public bool SelfHost { get; set; }
public int? Seats { get; set; }

View File

@@ -74,6 +74,7 @@ public class OrganizationResponseModel : ResponseModel
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
UseDisableSmAdsForUsers = organization.UseDisableSmAdsForUsers;
UsePhishingBlocker = organization.UsePhishingBlocker;
}
@@ -124,6 +125,7 @@ public class OrganizationResponseModel : ResponseModel
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool UseDisableSmAdsForUsers { get; set; }
public bool UsePhishingBlocker { get; set; }
}

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

@@ -38,7 +38,9 @@ public class AccountsController : Controller
private readonly IProviderUserRepository _providerUserRepository;
private readonly IUserService _userService;
private readonly IPolicyService _policyService;
private readonly ISetInitialMasterPasswordCommandV1 _setInitialMasterPasswordCommandV1;
private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand;
private readonly ITdeSetPasswordCommand _tdeSetPasswordCommand;
private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IFeatureService _featureService;
@@ -54,6 +56,8 @@ public class AccountsController : Controller
IUserService userService,
IPolicyService policyService,
ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand,
ISetInitialMasterPasswordCommandV1 setInitialMasterPasswordCommandV1,
ITdeSetPasswordCommand tdeSetPasswordCommand,
ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IFeatureService featureService,
@@ -69,6 +73,8 @@ public class AccountsController : Controller
_userService = userService;
_policyService = policyService;
_setInitialMasterPasswordCommand = setInitialMasterPasswordCommand;
_setInitialMasterPasswordCommandV1 = setInitialMasterPasswordCommandV1;
_tdeSetPasswordCommand = tdeSetPasswordCommand;
_tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_featureService = featureService;
@@ -208,7 +214,7 @@ public class AccountsController : Controller
}
[HttpPost("set-password")]
public async Task PostSetPasswordAsync([FromBody] SetPasswordRequestModel model)
public async Task PostSetPasswordAsync([FromBody] SetInitialPasswordRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
@@ -216,33 +222,48 @@ public class AccountsController : Controller
throw new UnauthorizedAccessException();
}
try
if (model.IsV2Request())
{
user = model.ToUser(user);
if (model.IsTdeSetPasswordRequest())
{
await _tdeSetPasswordCommand.SetMasterPasswordAsync(user, model.ToData());
}
else
{
await _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(user, model.ToData());
}
}
catch (Exception e)
else
{
ModelState.AddModelError(string.Empty, e.Message);
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27327
try
{
user = model.ToUser(user);
}
catch (Exception e)
{
ModelState.AddModelError(string.Empty, e.Message);
throw new BadRequestException(ModelState);
}
var result = await _setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync(
user,
model.MasterPasswordHash,
model.Key,
model.OrgIdentifier);
if (result.Succeeded)
{
return;
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
throw new BadRequestException(ModelState);
}
var result = await _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(
user,
model.MasterPasswordHash,
model.Key,
model.OrgIdentifier);
if (result.Succeeded)
{
return;
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
throw new BadRequestException(ModelState);
}
[HttpPost("verify-password")]

View File

@@ -21,7 +21,6 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Auth.Controllers;
[Route("webauthn")]
[Authorize(Policies.Web)]
public class WebAuthnController : Controller
{
private readonly IUserService _userService;
@@ -62,6 +61,7 @@ public class WebAuthnController : Controller
_featureService = featureService;
}
[Authorize(Policies.Web)]
[HttpGet("")]
public async Task<ListResponseModel<WebAuthnCredentialResponseModel>> Get()
{
@@ -71,6 +71,7 @@ public class WebAuthnController : Controller
return new ListResponseModel<WebAuthnCredentialResponseModel>(credentials.Select(c => new WebAuthnCredentialResponseModel(c)));
}
[Authorize(Policies.Application)]
[HttpPost("attestation-options")]
public async Task<WebAuthnCredentialCreateOptionsResponseModel> AttestationOptions([FromBody] SecretVerificationRequestModel model)
{
@@ -88,6 +89,7 @@ public class WebAuthnController : Controller
};
}
[Authorize(Policies.Web)]
[HttpPost("assertion-options")]
public async Task<WebAuthnLoginAssertionOptionsResponseModel> AssertionOptions([FromBody] SecretVerificationRequestModel model)
{
@@ -104,6 +106,7 @@ public class WebAuthnController : Controller
};
}
[Authorize(Policies.Application)]
[HttpPost("")]
public async Task Post([FromBody] WebAuthnLoginCredentialCreateRequestModel model)
{
@@ -149,6 +152,7 @@ public class WebAuthnController : Controller
}
}
[Authorize(Policies.Application)]
[HttpPut()]
public async Task UpdateCredential([FromBody] WebAuthnLoginCredentialUpdateRequestModel model)
{
@@ -172,6 +176,7 @@ public class WebAuthnController : Controller
await _credentialRepository.UpdateAsync(credential);
}
[Authorize(Policies.Web)]
[HttpPost("{id}/delete")]
public async Task Delete(Guid id, [FromBody] SecretVerificationRequestModel model)
{

View File

@@ -0,0 +1,160 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.KeyManagement.Models.Requests;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Models.Api.Request;
using Bit.Core.Utilities;
namespace Bit.Api.Auth.Models.Request.Accounts;
public class SetInitialPasswordRequestModel : IValidatableObject
{
// TODO will be removed with https://bitwarden.atlassian.net/browse/PM-27327
[Obsolete("Use MasterPasswordAuthentication instead")]
[StringLength(300)]
public string? MasterPasswordHash { get; set; }
[Obsolete("Use MasterPasswordUnlock instead")]
public string? Key { get; set; }
[Obsolete("Use AccountKeys instead")]
public KeysRequestModel? Keys { get; set; }
[Obsolete("Use MasterPasswordAuthentication instead")]
public KdfType? Kdf { get; set; }
[Obsolete("Use MasterPasswordAuthentication instead")]
public int? KdfIterations { get; set; }
[Obsolete("Use MasterPasswordAuthentication instead")]
public int? KdfMemory { get; set; }
[Obsolete("Use MasterPasswordAuthentication instead")]
public int? KdfParallelism { get; set; }
public MasterPasswordAuthenticationDataRequestModel? MasterPasswordAuthentication { get; set; }
public MasterPasswordUnlockDataRequestModel? MasterPasswordUnlock { get; set; }
public AccountKeysRequestModel? AccountKeys { get; set; }
[StringLength(50)]
public string? MasterPasswordHint { get; set; }
[Required]
public required string OrgIdentifier { get; set; }
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27327
public User ToUser(User existingUser)
{
existingUser.MasterPasswordHint = MasterPasswordHint;
existingUser.Kdf = Kdf!.Value;
existingUser.KdfIterations = KdfIterations!.Value;
existingUser.KdfMemory = KdfMemory;
existingUser.KdfParallelism = KdfParallelism;
existingUser.Key = Key;
Keys?.ToUser(existingUser);
return existingUser;
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (IsV2Request())
{
// V2 registration
// Validate Kdf
var authenticationKdf = MasterPasswordAuthentication!.Kdf.ToData();
var unlockKdf = MasterPasswordUnlock!.Kdf.ToData();
// Currently, KDF settings are not saved separately for authentication and unlock and must therefore be equal
if (!authenticationKdf.Equals(unlockKdf))
{
yield return new ValidationResult("KDF settings must be equal for authentication and unlock.",
[$"{nameof(MasterPasswordAuthentication)}.{nameof(MasterPasswordAuthenticationDataRequestModel.Kdf)}",
$"{nameof(MasterPasswordUnlock)}.{nameof(MasterPasswordUnlockDataRequestModel.Kdf)}"]);
}
var authenticationValidationErrors = KdfSettingsValidator.Validate(authenticationKdf).ToList();
if (authenticationValidationErrors.Count != 0)
{
yield return authenticationValidationErrors.First();
}
var unlockValidationErrors = KdfSettingsValidator.Validate(unlockKdf).ToList();
if (unlockValidationErrors.Count != 0)
{
yield return unlockValidationErrors.First();
}
yield break;
}
// V1 registration
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27327
if (string.IsNullOrEmpty(MasterPasswordHash))
{
yield return new ValidationResult("MasterPasswordHash must be supplied.");
}
if (string.IsNullOrEmpty(Key))
{
yield return new ValidationResult("Key must be supplied.");
}
if (Kdf == null)
{
yield return new ValidationResult("Kdf must be supplied.");
yield break;
}
if (KdfIterations == null)
{
yield return new ValidationResult("KdfIterations must be supplied.");
yield break;
}
if (Kdf == KdfType.Argon2id)
{
if (KdfMemory == null)
{
yield return new ValidationResult("KdfMemory must be supplied when Kdf is Argon2id.");
}
if (KdfParallelism == null)
{
yield return new ValidationResult("KdfParallelism must be supplied when Kdf is Argon2id.");
}
}
var validationErrors = KdfSettingsValidator
.Validate(Kdf!.Value, KdfIterations!.Value, KdfMemory, KdfParallelism).ToList();
if (validationErrors.Count != 0)
{
yield return validationErrors.First();
}
}
public bool IsV2Request()
{
// AccountKeys can be null for TDE users, so we don't check that here
return MasterPasswordAuthentication != null && MasterPasswordUnlock != null;
}
public bool IsTdeSetPasswordRequest()
{
return AccountKeys == null;
}
public SetInitialMasterPasswordDataModel ToData()
{
return new SetInitialMasterPasswordDataModel
{
MasterPasswordAuthentication = MasterPasswordAuthentication!.ToData(),
MasterPasswordUnlock = MasterPasswordUnlock!.ToData(),
OrgSsoIdentifier = OrgIdentifier,
AccountKeys = AccountKeys?.ToAccountKeysData(),
MasterPasswordHint = MasterPasswordHint
};
}
}

View File

@@ -1,40 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Entities;
using Bit.Core.Enums;
namespace Bit.Api.Auth.Models.Request.Accounts;
public class SetPasswordRequestModel
{
[Required]
[StringLength(300)]
public string MasterPasswordHash { get; set; }
[Required]
public string Key { get; set; }
[StringLength(50)]
public string MasterPasswordHint { get; set; }
public KeysRequestModel Keys { get; set; }
[Required]
public KdfType Kdf { get; set; }
[Required]
public int KdfIterations { get; set; }
public int? KdfMemory { get; set; }
public int? KdfParallelism { get; set; }
public string OrgIdentifier { get; set; }
public User ToUser(User existingUser)
{
existingUser.MasterPasswordHint = MasterPasswordHint;
existingUser.Kdf = Kdf;
existingUser.KdfIterations = KdfIterations;
existingUser.KdfMemory = KdfMemory;
existingUser.KdfParallelism = KdfParallelism;
existingUser.Key = Key;
Keys?.ToUser(existingUser);
return existingUser;
}
}

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

@@ -3,13 +3,10 @@ using Bit.Api.Models.Request.Accounts;
using Bit.Api.Models.Response;
using Bit.Api.Utilities;
using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Services;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.Models.Business;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
@@ -22,60 +19,10 @@ namespace Bit.Api.Billing.Controllers;
[Authorize("Application")]
public class AccountsController(
IUserService userService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IUserAccountKeysQuery userAccountKeysQuery,
IFeatureService featureService,
ILicensingService licensingService) : Controller
{
// TODO: Remove when pm-24996-implement-upgrade-from-free-dialog is removed
[HttpPost("premium")]
public async Task<PaymentResponseModel> PostPremiumAsync(
PremiumRequestModel model,
[FromServices] GlobalSettings globalSettings)
{
var user = await userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var valid = model.Validate(globalSettings);
UserLicense? license = null;
if (valid && globalSettings.SelfHosted)
{
license = await ApiHelpers.ReadJsonFileFromBody<UserLicense>(HttpContext, model.License);
}
if (!valid && !globalSettings.SelfHosted && string.IsNullOrWhiteSpace(model.Country))
{
throw new BadRequestException("Country is required.");
}
if (!valid || (globalSettings.SelfHosted && license == null))
{
throw new BadRequestException("Invalid license.");
}
var result = await userService.SignUpPremiumAsync(user, model.PaymentToken,
model.PaymentMethodType!.Value, model.AdditionalStorageGb.GetValueOrDefault(0), license,
new TaxInfo { BillingAddressCountry = model.Country, BillingAddressPostalCode = model.PostalCode });
var userTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user);
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
var accountKeys = await userAccountKeysQuery.Run(user);
var profile = new ProfileResponseModel(user, accountKeys, null, null, null, userTwoFactorEnabled,
userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
return new PaymentResponseModel
{
UserProfile = profile,
PaymentIntentClientSecret = result.Item2,
Success = result.Item1
};
}
// 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,
@@ -114,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)
@@ -171,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()
@@ -184,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

@@ -1,10 +1,14 @@
using Bit.Api.Billing.Attributes;
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Api.Billing.Models.Requests.Premium;
using Bit.Api.Billing.Models.Requests.Storage;
using Bit.Core;
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;
@@ -19,9 +23,14 @@ namespace Bit.Api.Billing.Controllers.VNext;
public class AccountBillingVNextController(
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand,
IGetBitwardenSubscriptionQuery getBitwardenSubscriptionQuery,
IGetCreditQuery getCreditQuery,
IGetPaymentMethodQuery getPaymentMethodQuery,
IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController
IGetUserLicenseQuery getUserLicenseQuery,
IReinstateSubscriptionCommand reinstateSubscriptionCommand,
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
IUpdatePremiumStorageCommand updatePremiumStorageCommand,
IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController
{
[HttpGet("credit")]
[InjectUser]
@@ -66,7 +75,6 @@ public class AccountBillingVNextController(
}
[HttpPost("subscription")]
[RequireFeature(FeatureFlagKeys.PM24996ImplementUpgradeFromFreeDialog)]
[InjectUser]
public async Task<IResult> CreateSubscriptionAsync(
[BindNever] User user,
@@ -77,4 +85,55 @@ public class AccountBillingVNextController(
user, paymentMethod, billingAddress, additionalStorageGb);
return Handle(result);
}
[HttpGet("license")]
[InjectUser]
public async Task<IResult> GetLicenseAsync(
[BindNever] User user)
{
var response = await getUserLicenseQuery.Run(user);
return TypedResults.Ok(response);
}
[HttpGet("subscription")]
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
[InjectUser]
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)
{
var result = await updatePremiumStorageCommand.Run(user, request.AdditionalStorageGb);
return Handle(result);
}
[HttpPost("upgrade")]
[InjectUser]
public async Task<IResult> UpgradePremiumToOrganizationAsync(
[BindNever] User user,
[FromBody] UpgradePremiumToOrganizationRequest request)
{
var (organizationName, key, planType) = request.ToDomain();
var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType);
return Handle(result);
}
}

View File

@@ -1,7 +1,6 @@
using Bit.Api.Billing.Attributes;
using Bit.Api.Billing.Models.Requests.Premium;
using Bit.Api.Utilities;
using Bit.Core;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Entities;
@@ -20,7 +19,6 @@ public class SelfHostedAccountBillingVNextController(
ICreatePremiumSelfHostedSubscriptionCommand createPremiumSelfHostedSubscriptionCommand) : BaseBillingController
{
[HttpPost("license")]
[RequireFeature(FeatureFlagKeys.PM24996ImplementUpgradeFromFreeDialog)]
[InjectUser]
public async Task<IResult> UploadLicenseAsync(
[BindNever] User user,

View File

@@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Core.Billing.Enums;
namespace Bit.Api.Billing.Models.Requests.Premium;
public class UpgradePremiumToOrganizationRequest
{
[Required]
public string OrganizationName { get; set; } = null!;
[Required]
public string Key { get; set; } = null!;
[Required]
[JsonConverter(typeof(JsonStringEnumConverter))]
public ProductTierType Tier { get; set; }
[Required]
[JsonConverter(typeof(JsonStringEnumConverter))]
public PlanCadenceType Cadence { get; set; }
private PlanType PlanType =>
Tier switch
{
ProductTierType.Families => PlanType.FamiliesAnnually,
ProductTierType.Teams => Cadence == PlanCadenceType.Monthly
? PlanType.TeamsMonthly
: PlanType.TeamsAnnually,
ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly
? PlanType.EnterpriseMonthly
: PlanType.EnterpriseAnnually,
_ => throw new InvalidOperationException("Cannot upgrade to an Organization subscription that isn't Families, Teams or Enterprise.")
};
public (string OrganizationName, string Key, PlanType PlanType) ToDomain() => (OrganizationName, Key, PlanType);
}

View File

@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Api.Billing.Models.Requests.Storage;
/// <summary>
/// Request model for updating storage allocation on a user's premium subscription.
/// Allows for both increasing and decreasing storage in an idempotent manner.
/// </summary>
public class StorageUpdateRequest : IValidatableObject
{
/// <summary>
/// The additional storage in GB beyond the base storage.
/// Must be between 0 and the maximum allowed (minus base storage).
/// </summary>
[Required]
public short AdditionalStorageGb { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (AdditionalStorageGb < 0)
{
yield return new ValidationResult(
"Additional storage cannot be negative.",
[nameof(AdditionalStorageGb)]);
}
if (AdditionalStorageGb > 99)
{
yield return new ValidationResult(
"Maximum additional storage is 99 GB.",
[nameof(AdditionalStorageGb)]);
}
}
}

View File

@@ -1,12 +1,12 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
using Bit.Api.Dirt.Models.Request;
using Bit.Api.Dirt.Models.Response;
using Bit.Core.Context;
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
using Bit.Core.Exceptions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
namespace Bit.Api.Dirt.Controllers;
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
[Authorize("Application")]

View File

@@ -1,12 +1,12 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Api.Dirt.Models.Request;
using Bit.Api.Dirt.Models.Response;
using Bit.Core.Context;
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Exceptions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
namespace Bit.Api.Dirt.Controllers;
[Route("organizations/{organizationId:guid}/integrations")]
[Authorize("Application")]

View File

@@ -1,16 +1,16 @@
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Api.Dirt.Models.Response;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Dirt.Services;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
namespace Bit.Api.Dirt.Controllers;
[Route("organizations")]
[Authorize("Application")]

View File

@@ -1,18 +1,18 @@
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Api.Dirt.Models.Response;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Dirt.Services;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Integration.AspNet.Core;
namespace Bit.Api.AdminConsole.Controllers;
namespace Bit.Api.Dirt.Controllers;
[Route("organizations")]
[Authorize("Application")]

View File

@@ -1,8 +1,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Dirt.Entities;
using Bit.Core.Enums;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
namespace Bit.Api.Dirt.Models.Request;
public class OrganizationIntegrationConfigurationRequestModel
{

View File

@@ -1,10 +1,10 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
namespace Bit.Api.Dirt.Models.Request;
public class OrganizationIntegrationRequestModel : IValidatableObject
{

View File

@@ -1,8 +1,8 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Dirt.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
namespace Bit.Api.Dirt.Models.Response;
public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
{

View File

@@ -1,10 +1,10 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Models.Api;
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
namespace Bit.Api.Dirt.Models.Response;
public class OrganizationIntegrationResponseModel : ResponseModel
{

View File

@@ -6,8 +6,11 @@ namespace Bit.Api.KeyManagement.Models.Requests;
public class MasterPasswordAuthenticationDataRequestModel
{
public required KdfRequestModel Kdf { get; init; }
[Required]
public required string MasterPasswordAuthenticationHash { get; init; }
[StringLength(256)] public required string Salt { get; init; }
[Required]
[StringLength(256)]
public required string Salt { get; init; }
public MasterPasswordAuthenticationData ToData()
{

View File

@@ -7,8 +7,12 @@ namespace Bit.Api.KeyManagement.Models.Requests;
public class MasterPasswordUnlockDataRequestModel
{
public required KdfRequestModel Kdf { get; init; }
[EncryptedString] public required string MasterKeyWrappedUserKey { get; init; }
[StringLength(256)] public required string Salt { get; init; }
[Required]
[EncryptedString]
public required string MasterKeyWrappedUserKey { get; init; }
[Required]
[StringLength(256)]
public required string Salt { get; init; }
public MasterPasswordUnlockData ToData()
{

View File

@@ -44,7 +44,7 @@ public class SendRotationValidator : IRotationValidator<IEnumerable<SendWithIdRe
throw new BadRequestException("All existing sends must be included in the rotation.");
}
result.Add(send.ToSend(existing, _sendAuthorizationService));
result.Add(send.UpdateSend(existing, _sendAuthorizationService));
}
return result;

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

@@ -1,6 +1,5 @@
using Bit.Api.Tools.Authorization;
using Bit.Api.Tools.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
@@ -21,7 +20,6 @@ public class OrganizationExportController : Controller
private readonly IAuthorizationService _authorizationService;
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
private readonly ICollectionRepository _collectionRepository;
private readonly IFeatureService _featureService;
public OrganizationExportController(
IUserService userService,
@@ -36,7 +34,6 @@ public class OrganizationExportController : Controller
_authorizationService = authorizationService;
_organizationCiphersQuery = organizationCiphersQuery;
_collectionRepository = collectionRepository;
_featureService = featureService;
}
[HttpGet("export")]
@@ -46,33 +43,20 @@ public class OrganizationExportController : Controller
VaultExportOperations.ExportWholeVault);
var canExportManaged = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(organizationId),
VaultExportOperations.ExportManagedCollections);
var createDefaultLocationEnabled = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation);
if (canExportAll.Succeeded)
{
if (createDefaultLocationEnabled)
{
var allOrganizationCiphers =
await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(
organizationId);
var allOrganizationCiphers =
await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(
organizationId);
var allCollections = await _collectionRepository
.GetManySharedCollectionsByOrganizationIdAsync(
organizationId);
var allCollections = await _collectionRepository
.GetManySharedCollectionsByOrganizationIdAsync(
organizationId);
return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections,
_globalSettings));
}
else
{
var allOrganizationCiphers = await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId);
var allCollections = await _collectionRepository.GetManyByOrganizationIdAsync(organizationId);
return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections,
_globalSettings));
}
return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections,
_globalSettings));
}
if (canExportManaged.Succeeded)

View File

@@ -1,21 +1,21 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Text.Json;
using System.Text.Json;
using Azure.Messaging.EventGrid;
using Bit.Api.Models.Response;
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;
using Bit.Core.Tools.SendFeatures;
using Bit.Core.Tools.SendFeatures.Commands.Interfaces;
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
@@ -24,7 +24,6 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Tools.Controllers;
[Route("sends")]
[Authorize("Application")]
public class SendsController : Controller
{
private readonly ISendRepository _sendRepository;
@@ -33,8 +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,
@@ -42,21 +43,26 @@ public class SendsController : Controller
ISendAuthorizationService sendAuthorizationService,
IAnonymousSendCommand anonymousSendCommand,
INonAnonymousSendCommand nonAnonymousSendCommand,
ISendOwnerQuery sendOwnerQuery,
ISendFileStorageService sendFileStorageService,
ILogger<SendsController> logger,
GlobalSettings globalSettings)
IFeatureService featureService,
IPushNotificationService pushNotificationService)
{
_sendRepository = sendRepository;
_userService = userService;
_sendAuthorizationService = sendAuthorizationService;
_anonymousSendCommand = anonymousSendCommand;
_nonAnonymousSendCommand = nonAnonymousSendCommand;
_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)
@@ -70,28 +76,44 @@ public class SendsController : Controller
var guid = new Guid(CoreHelpers.Base64UrlDecode(id));
var send = await _sendRepository.GetByIdAsync(guid);
SendAccessResult sendAuthResult =
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();
}
var sendResponse = new SendAccessResponseModel(send, _globalSettings);
var sendResponse = new SendAccessResponseModel(send);
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
{
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
sendResponse.CreatorIdentifier = creator.Email;
}
return new ObjectResult(sendResponse);
}
@@ -115,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);
@@ -122,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]
@@ -150,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)
@@ -159,6 +187,7 @@ public class SendsController : Controller
{
await azureSendFileStorageService.DeleteBlobAsync(blobName);
}
return;
}
@@ -166,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;
}
}
@@ -178,38 +208,98 @@ public class SendsController : Controller
#region Non-anonymous endpoints
[Authorize(Policies.Application)]
[HttpGet("{id}")]
public async Task<SendResponseModel> Get(string id)
{
var userId = _userService.GetProperUserId(User).Value;
var send = await _sendRepository.GetByIdAsync(new Guid(id));
if (send == null || send.UserId != userId)
var sendId = new Guid(id);
var send = await _sendOwnerQuery.Get(sendId, User);
return new SendResponseModel(send);
}
[Authorize(Policies.Application)]
[HttpGet("")]
public async Task<ListResponseModel<SendResponseModel>> GetAll()
{
var sends = await _sendOwnerQuery.GetOwned(User);
var responses = sends.Select(s => new SendResponseModel(s));
var result = new ListResponseModel<SendResponseModel>(responses);
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();
}
return new SendResponseModel(send, _globalSettings);
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);
}
[HttpGet("")]
public async Task<ListResponseModel<SendResponseModel>> GetAll()
[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 userId = _userService.GetProperUserId(User).Value;
var sends = await _sendRepository.GetManyByUserIdAsync(userId);
var responses = sends.Select(s => new SendResponseModel(s, _globalSettings));
return new ListResponseModel<SendResponseModel>(responses);
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)
{
model.ValidateCreation();
var userId = _userService.GetProperUserId(User).Value;
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
var send = model.ToSend(userId, _sendAuthorizationService);
await _nonAnonymousSendCommand.SaveSendAsync(send);
return new SendResponseModel(send, _globalSettings);
return new SendResponseModel(send);
}
[Authorize(Policies.Application)]
[HttpPost("file/v2")]
public async Task<SendFileUploadDataResponseModel> PostFile([FromBody] SendRequestModel model)
{
@@ -229,27 +319,28 @@ public class SendsController : Controller
}
model.ValidateCreation();
var userId = _userService.GetProperUserId(User).Value;
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
var (send, data) = model.ToSend(userId, model.File.FileName, _sendAuthorizationService);
var uploadUrl = await _nonAnonymousSendCommand.SaveFileSendAsync(send, data, model.FileLength.Value);
return new SendFileUploadDataResponseModel
{
Url = uploadUrl,
FileUploadType = _sendFileStorageService.FileUploadType,
SendResponse = new SendResponseModel(send, _globalSettings)
SendResponse = new SendResponseModel(send)
};
}
[Authorize(Policies.Application)]
[HttpGet("{id}/file/{fileId}")]
public async Task<SendFileUploadDataResponseModel> RenewFileUpload(string id, string fileId)
{
var userId = _userService.GetProperUserId(User).Value;
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
var sendId = new Guid(id);
var send = await _sendRepository.GetByIdAsync(sendId);
var fileData = JsonSerializer.Deserialize<SendFileData>(send?.Data);
var fileData = JsonSerializer.Deserialize<SendFileData>(send?.Data ?? string.Empty);
if (send == null || send.Type != SendType.File || (send.UserId.HasValue && send.UserId.Value != userId) ||
!send.UserId.HasValue || fileData.Id != fileId || fileData.Validated)
!send.UserId.HasValue || fileData?.Id != fileId || fileData.Validated)
{
// Not found if Send isn't found, user doesn't have access, request is faulty,
// or we've already validated the file. This last is to emulate create-only blob permissions for Azure
@@ -260,62 +351,95 @@ public class SendsController : Controller
{
Url = await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId),
FileUploadType = _sendFileStorageService.FileUploadType,
SendResponse = new SendResponseModel(send, _globalSettings),
SendResponse = new SendResponseModel(send),
};
}
[Authorize(Policies.Application)]
[HttpPost("{id}/file/{fileId}")]
[SelfHosted(SelfHostedOnly = true)]
[RequestSizeLimit(Constants.FileSize501mb)]
[DisableFormValueModelBinding]
public async Task PostFileForExistingSend(string id, string fileId)
{
if (!Request?.ContentType.Contains("multipart/") ?? true)
if (!Request?.ContentType?.Contains("multipart/") ?? true)
{
throw new BadRequestException("Invalid content.");
}
var send = await _sendRepository.GetByIdAsync(new Guid(id));
if (send == null)
{
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)
{
model.ValidateEdit();
var userId = _userService.GetProperUserId(User).Value;
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();
}
await _nonAnonymousSendCommand.SaveSendAsync(model.ToSend(send, _sendAuthorizationService));
return new SendResponseModel(send, _globalSettings);
await _nonAnonymousSendCommand.SaveSendAsync(model.UpdateSend(send, _sendAuthorizationService));
return new SendResponseModel(send);
}
[Authorize(Policies.Application)]
[HttpPut("{id}/remove-password")]
public async Task<SendResponseModel> PutRemovePassword(string id)
{
var userId = _userService.GetProperUserId(User).Value;
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.AuthType = AuthType.None;
await _nonAnonymousSendCommand.SaveSendAsync(send);
return new SendResponseModel(send, _globalSettings);
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)
{
var userId = _userService.GetProperUserId(User).Value;
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)
{

View File

@@ -3,6 +3,7 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Api.Tools.Utilities;
using Bit.Core.Exceptions;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums;
@@ -10,35 +11,119 @@ using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
using static System.StringSplitOptions;
namespace Bit.Api.Tools.Models.Request;
/// <summary>
/// A send request issued by a Bitwarden client
/// </summary>
public class SendRequestModel
{
/// <summary>
/// Indicates whether the send contains text or file data.
/// </summary>
public SendType Type { get; set; }
/// <summary>
/// Specifies the authentication method required to access this Send.
/// </summary>
public AuthType? AuthType { get; set; }
/// <summary>
/// Estimated length of the file accompanying the send. <see langword="null"/> when
/// <see cref="Type"/> is <see cref="SendType.Text"/>.
/// </summary>
public long? FileLength { get; set; } = null;
/// <summary>
/// Label for the send.
/// </summary>
[EncryptedString]
[EncryptedStringLength(1000)]
public string Name { get; set; }
/// <summary>
/// Notes for the send. This is only visible to the owner of the send.
/// </summary>
[EncryptedString]
[EncryptedStringLength(1000)]
public string Notes { get; set; }
/// <summary>
/// A base64-encoded byte array containing the Send's encryption key. This key is
/// also provided to send recipients in the Send's URL.
/// </summary>
[Required]
[EncryptedString]
[EncryptedStringLength(1000)]
public string Key { get; set; }
/// <summary>
/// The maximum number of times a send can be accessed before it expires.
/// When this value is <see langword="null" />, there is no limit.
/// </summary>
[Range(1, int.MaxValue)]
public int? MaxAccessCount { get; set; }
/// <summary>
/// The date after which a send cannot be accessed. When this value is
/// <see langword="null"/>, there is no expiration date.
/// </summary>
public DateTime? ExpirationDate { get; set; }
/// <summary>
/// The date after which a send may be automatically deleted from the server.
/// When this is <see langword="null" />, the send may be deleted after it has
/// exceeded the global send timeout limit.
/// </summary>
[Required]
public DateTime? DeletionDate { get; set; }
/// <summary>
/// Contains file metadata uploaded with the send.
/// The file content is uploaded separately.
/// </summary>
public SendFileModel File { get; set; }
/// <summary>
/// Contains text data uploaded with the send.
/// </summary>
public SendTextModel Text { get; set; }
/// <summary>
/// Base64-encoded byte array of a password hash that grants access to the send.
/// Mutually exclusive with <see cref="Emails"/>.
/// </summary>
[StringLength(1000)]
public string Password { get; set; }
/// <summary>
/// Comma-separated list of emails that may access the send using OTP
/// authentication. Mutually exclusive with <see cref="Password"/>.
/// </summary>
[StringLength(4000)]
public string Emails { get; set; }
/// <summary>
/// When <see langword="true"/>, send access is disabled.
/// Defaults to <see langword="false"/>.
/// </summary>
[Required]
public bool? Disabled { get; set; }
/// <summary>
/// When <see langword="true"/> send access hides the user's email address
/// and displays a confirmation message instead. Defaults to <see langword="false"/>.
/// </summary>
public bool? HideEmail { get; set; }
/// <summary>
/// Transforms the request into a send object.
/// </summary>
/// <param name="userId">The user that owns the send.</param>
/// <param name="sendAuthorizationService">Hashes the send password.</param>
/// <returns>The send object</returns>
public Send ToSend(Guid userId, ISendAuthorizationService sendAuthorizationService)
{
var send = new Send
@@ -46,12 +131,21 @@ public class SendRequestModel
Type = Type,
UserId = (Guid?)userId
};
ToSend(send, sendAuthorizationService);
send = UpdateSend(send, sendAuthorizationService);
return send;
}
/// <summary>
/// Transforms the request into a send object and file data.
/// </summary>
/// <param name="userId">The user that owns the send.</param>
/// <param name="fileName">Name of the file uploaded with the send.</param>
/// <param name="sendAuthorizationService">Hashes the send password.</param>
/// <returns>The send object and file data.</returns>
public (Send, SendFileData) ToSend(Guid userId, string fileName, ISendAuthorizationService sendAuthorizationService)
{
// FIXME: This method does two things: creates a send and a send file data.
// It should only do one thing.
var send = ToSendBase(new Send
{
Type = Type,
@@ -61,7 +155,13 @@ public class SendRequestModel
return (send, data);
}
public Send ToSend(Send existingSend, ISendAuthorizationService sendAuthorizationService)
/// <summary>
/// Update a send object with request content
/// </summary>
/// <param name="existingSend">The send to update</param>
/// <param name="sendAuthorizationService">Hashes the send password.</param>
/// <returns>The send object</returns>
public Send UpdateSend(Send existingSend, ISendAuthorizationService sendAuthorizationService)
{
existingSend = ToSendBase(existingSend, sendAuthorizationService);
switch (existingSend.Type)
@@ -81,6 +181,12 @@ public class SendRequestModel
return existingSend;
}
/// <summary>
/// Validates that the request is internally consistent for send creation.
/// </summary>
/// <exception cref="BadRequestException">
/// Thrown when the send's expiration date has already expired.
/// </exception>
public void ValidateCreation()
{
var now = DateTime.UtcNow;
@@ -94,6 +200,13 @@ public class SendRequestModel
ValidateEdit();
}
/// <summary>
/// Validates that the request is internally consistent for send administration.
/// </summary>
/// <exception cref="BadRequestException">
/// Thrown when the send's deletion date has already expired or when its
/// expiration occurs after its deletion.
/// </exception>
public void ValidateEdit()
{
var now = DateTime.UtcNow;
@@ -134,12 +247,30 @@ public class SendRequestModel
existingSend.ExpirationDate = ExpirationDate;
existingSend.DeletionDate = DeletionDate.Value;
existingSend.MaxAccessCount = MaxAccessCount;
if (!string.IsNullOrWhiteSpace(Password))
if (!string.IsNullOrWhiteSpace(Emails))
{
// normalize encoding
var emails = Emails.Split(',', RemoveEmptyEntries | TrimEntries);
existingSend.Emails = string.Join(",", emails);
existingSend.Password = null;
existingSend.AuthType = Core.Tools.Enums.AuthType.Email;
}
else if (!string.IsNullOrWhiteSpace(Password))
{
existingSend.Password = authorizationService.HashPassword(Password);
existingSend.Emails = null;
existingSend.AuthType = Core.Tools.Enums.AuthType.Password;
}
else
{
// Neither Password nor Emails provided - preserve existing values and infer AuthType
existingSend.AuthType = SendUtilities.InferAuthType(existingSend);
}
existingSend.Disabled = Disabled.GetValueOrDefault();
existingSend.HideEmail = HideEmail.GetValueOrDefault();
return existingSend;
}
@@ -149,8 +280,15 @@ public class SendRequestModel
}
}
/// <summary>
/// A send request issued by a Bitwarden client
/// </summary>
public class SendWithIdRequestModel : SendRequestModel
{
/// <summary>
/// Identifies the send. When this is <see langword="null" />, the client is requesting
/// a new send.
/// </summary>
[Required]
public Guid? Id { get; set; }
}

View File

@@ -3,7 +3,6 @@
using System.Text.Json;
using Bit.Core.Models.Api;
using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
@@ -11,9 +10,22 @@ using Bit.Core.Utilities;
namespace Bit.Api.Tools.Models.Response;
/// <summary>
/// A response issued to a Bitwarden client in response to access operations.
/// </summary>
public class SendAccessResponseModel : ResponseModel
{
public SendAccessResponseModel(Send send, GlobalSettings globalSettings)
/// <summary>
/// Instantiates a send access response model
/// </summary>
/// <param name="send">Content to transmit to the client.</param>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="send"/> is <see langword="null" />
/// </exception>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="send" /> has an invalid <see cref="Send.Type"/>.
/// </exception>
public SendAccessResponseModel(Send send)
: base("send-access")
{
if (send == null)
@@ -23,6 +35,7 @@ public class SendAccessResponseModel : ResponseModel
Id = CoreHelpers.Base64UrlEncode(send.Id.ToByteArray());
Type = send.Type;
AuthType = send.AuthType;
SendData sendData;
switch (send.Type)
@@ -45,11 +58,52 @@ public class SendAccessResponseModel : ResponseModel
ExpirationDate = send.ExpirationDate;
}
/// <summary>
/// Identifies the send in a send URL
/// </summary>
public string Id { get; set; }
/// <summary>
/// Indicates whether the send contains text or file data.
/// </summary>
public SendType Type { get; set; }
/// <summary>
/// Specifies the authentication method required to access this Send.
/// </summary>
public AuthType? AuthType { get; set; }
/// <summary>
/// Label for the send. This is only visible to the owner of the send.
/// </summary>
/// <remarks>
/// This field contains a base64-encoded byte array. The array contains
/// the E2E-encrypted encrypted content.
/// </remarks>
public string Name { get; set; }
/// <summary>
/// Describes the file attached to the send.
/// </summary>
/// <remarks>
/// File content is downloaded separately using
/// <see cref="Bit.Api.Tools.Controllers.SendsController.GetSendFileDownloadData" />
/// </remarks>
public SendFileModel File { get; set; }
/// <summary>
/// Contains text data uploaded with the send.
/// </summary>
public SendTextModel Text { get; set; }
/// <summary>
/// The date after which a send cannot be accessed. When this value is
/// <see langword="null"/>, there is no expiration date.
/// </summary>
public DateTime? ExpirationDate { get; set; }
/// <summary>
/// Indicates the person that created the send to the accessor.
/// </summary>
public string CreatorIdentifier { get; set; }
}

View File

@@ -2,8 +2,8 @@
#nullable disable
using System.Text.Json;
using Bit.Api.Tools.Utilities;
using Bit.Core.Models.Api;
using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
@@ -11,9 +11,23 @@ using Bit.Core.Utilities;
namespace Bit.Api.Tools.Models.Response;
/// <summary>
/// A response issued to a Bitwarden client in response to ownership operations.
/// </summary>
/// <seealso cref="SendAccessResponseModel" />
public class SendResponseModel : ResponseModel
{
public SendResponseModel(Send send, GlobalSettings globalSettings)
/// <summary>
/// Instantiates a send response model
/// </summary>
/// <param name="send">Content to transmit to the client.</param>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="send"/> is <see langword="null" />
/// </exception>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="send" /> has an invalid <see cref="Send.Type"/>.
/// </exception>
public SendResponseModel(Send send)
: base("send")
{
if (send == null)
@@ -24,6 +38,7 @@ public class SendResponseModel : ResponseModel
Id = send.Id;
AccessId = CoreHelpers.Base64UrlEncode(send.Id.ToByteArray());
Type = send.Type;
AuthType = send.AuthType ?? SendUtilities.InferAuthType(send);
Key = send.Key;
MaxAccessCount = send.MaxAccessCount;
AccessCount = send.AccessCount;
@@ -31,6 +46,7 @@ public class SendResponseModel : ResponseModel
ExpirationDate = send.ExpirationDate;
DeletionDate = send.DeletionDate;
Password = send.Password;
Emails = send.Emails;
Disabled = send.Disabled;
HideEmail = send.HideEmail.GetValueOrDefault();
@@ -55,20 +71,113 @@ public class SendResponseModel : ResponseModel
Notes = sendData.Notes;
}
/// <summary>
/// Identifies the send to its owner
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Identifies the send in a send URL
/// </summary>
public string AccessId { get; set; }
/// <summary>
/// Indicates whether the send contains text or file data.
/// </summary>
public SendType Type { get; set; }
/// <summary>
/// Specifies the authentication method required to access this Send.
/// </summary>
public AuthType? AuthType { get; set; }
/// <summary>
/// Label for the send.
/// </summary>
/// <remarks>
/// This field contains a base64-encoded byte array. The array contains
/// the E2E-encrypted encrypted content.
/// </remarks>
public string Name { get; set; }
/// <summary>
/// Notes for the send. This is only visible to the owner of the send.
/// This field is encrypted.
/// </summary>
/// <remarks>
/// This field contains a base64-encoded byte array. The array contains
/// the E2E-encrypted encrypted content.
/// </remarks>
public string Notes { get; set; }
/// <summary>
/// Contains file metadata uploaded with the send.
/// The file content is uploaded separately.
/// </summary>
public SendFileModel File { get; set; }
/// <summary>
/// Contains text data uploaded with the send.
/// </summary>
public SendTextModel Text { get; set; }
/// <summary>
/// A base64-encoded byte array containing the Send's encryption key.
/// It's also provided to send recipients in the Send's URL.
/// </summary>
/// <remarks>
/// This field contains a base64-encoded byte array. The array contains
/// the E2E-encrypted content.
/// </remarks>
public string Key { get; set; }
/// <summary>
/// The maximum number of times a send can be accessed before it expires.
/// When this value is <see langword="null" />, there is no limit.
/// </summary>
public int? MaxAccessCount { get; set; }
/// <summary>
/// The number of times a send has been accessed since it was created.
/// </summary>
public int AccessCount { get; set; }
/// <summary>
/// Base64-encoded byte array of a password hash that grants access to the send.
/// Mutually exclusive with <see cref="Emails"/>.
/// </summary>
public string Password { get; set; }
/// <summary>
/// Comma-separated list of emails that may access the send using OTP
/// authentication. Mutually exclusive with <see cref="Password"/>.
/// </summary>
public string Emails { get; set; }
/// <summary>
/// When <see langword="true"/>, send access is disabled.
/// </summary>
public bool Disabled { get; set; }
/// <summary>
/// The last time this send's data changed.
/// </summary>
public DateTime RevisionDate { get; set; }
/// <summary>
/// The date after which a send cannot be accessed. When this value is
/// <see langword="null"/>, there is no expiration date.
/// </summary>
public DateTime? ExpirationDate { get; set; }
/// <summary>
/// The date after which a send may be automatically deleted from the server.
/// </summary>
public DateTime DeletionDate { get; set; }
/// <summary>
/// When <see langword="true"/> send access hides the user's email address
/// and displays a confirmation message instead.
/// </summary>
public bool HideEmail { get; set; }
}

View File

@@ -0,0 +1,23 @@
namespace Bit.Api.Tools.Utilities;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums;
public class SendUtilities
{
public static AuthType InferAuthType(Send send)
{
if (!string.IsNullOrWhiteSpace(send.Password))
{
return AuthType.Password;
}
if (!string.IsNullOrWhiteSpace(send.Emails))
{
return AuthType.Email;
}
return AuthType.None;
}
}

View File

@@ -10,7 +10,6 @@ using Bit.Api.Utilities;
using Bit.Api.Vault.Models.Request;
using Bit.Api.Vault.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -43,7 +42,6 @@ public class CiphersController : Controller
private readonly ICipherService _cipherService;
private readonly IUserService _userService;
private readonly IAttachmentStorageService _attachmentStorageService;
private readonly IProviderService _providerService;
private readonly ICurrentContext _currentContext;
private readonly ILogger<CiphersController> _logger;
private readonly GlobalSettings _globalSettings;
@@ -52,7 +50,6 @@ public class CiphersController : Controller
private readonly ICollectionRepository _collectionRepository;
private readonly IArchiveCiphersCommand _archiveCiphersCommand;
private readonly IUnarchiveCiphersCommand _unarchiveCiphersCommand;
private readonly IFeatureService _featureService;
public CiphersController(
ICipherRepository cipherRepository,
@@ -60,7 +57,6 @@ public class CiphersController : Controller
ICipherService cipherService,
IUserService userService,
IAttachmentStorageService attachmentStorageService,
IProviderService providerService,
ICurrentContext currentContext,
ILogger<CiphersController> logger,
GlobalSettings globalSettings,
@@ -68,15 +64,13 @@ public class CiphersController : Controller
IApplicationCacheService applicationCacheService,
ICollectionRepository collectionRepository,
IArchiveCiphersCommand archiveCiphersCommand,
IUnarchiveCiphersCommand unarchiveCiphersCommand,
IFeatureService featureService)
IUnarchiveCiphersCommand unarchiveCiphersCommand)
{
_cipherRepository = cipherRepository;
_collectionCipherRepository = collectionCipherRepository;
_cipherService = cipherService;
_userService = userService;
_attachmentStorageService = attachmentStorageService;
_providerService = providerService;
_currentContext = currentContext;
_logger = logger;
_globalSettings = globalSettings;
@@ -85,7 +79,6 @@ public class CiphersController : Controller
_collectionRepository = collectionRepository;
_archiveCiphersCommand = archiveCiphersCommand;
_unarchiveCiphersCommand = unarchiveCiphersCommand;
_featureService = featureService;
}
[HttpGet("{id}")]
@@ -344,8 +337,7 @@ public class CiphersController : Controller
throw new NotFoundException();
}
bool excludeDefaultUserCollections = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation) && !includeMemberItems;
var allOrganizationCiphers = excludeDefaultUserCollections
var allOrganizationCiphers = !includeMemberItems
?
await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(organizationId)
:
@@ -911,7 +903,7 @@ public class CiphersController : Controller
[HttpPut("{id}/archive")]
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
public async Task<CipherMiniResponseModel> PutArchive(Guid id)
public async Task<CipherResponseModel> PutArchive(Guid id)
{
var userId = _userService.GetProperUserId(User).Value;
@@ -922,12 +914,16 @@ public class CiphersController : Controller
throw new BadRequestException("Cipher was not archived. Ensure the provided ID is correct and you have permission to archive it.");
}
return new CipherMiniResponseModel(archivedCipherOrganizationDetails.First(), _globalSettings, archivedCipherOrganizationDetails.First().OrganizationUseTotp);
return new CipherResponseModel(archivedCipherOrganizationDetails.First(),
await _userService.GetUserByPrincipalAsync(User),
await _applicationCacheService.GetOrganizationAbilitiesAsync(),
_globalSettings
);
}
[HttpPut("archive")]
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
public async Task<ListResponseModel<CipherMiniResponseModel>> PutArchiveMany([FromBody] CipherBulkArchiveRequestModel model)
public async Task<ListResponseModel<CipherResponseModel>> PutArchiveMany([FromBody] CipherBulkArchiveRequestModel model)
{
if (!_globalSettings.SelfHosted && model.Ids.Count() > 500)
{
@@ -935,6 +931,7 @@ public class CiphersController : Controller
}
var userId = _userService.GetProperUserId(User).Value;
var user = await _userService.GetUserByPrincipalAsync(User);
var cipherIdsToArchive = new HashSet<Guid>(model.Ids);
@@ -945,9 +942,14 @@ public class CiphersController : Controller
throw new BadRequestException("No ciphers were archived. Ensure the provided IDs are correct and you have permission to archive them.");
}
var responses = archivedCiphers.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp));
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
var responses = archivedCiphers.Select(c => new CipherResponseModel(c,
user,
organizationAbilities,
_globalSettings
));
return new ListResponseModel<CipherMiniResponseModel>(responses);
return new ListResponseModel<CipherResponseModel>(responses);
}
[HttpDelete("{id}")]
@@ -1109,7 +1111,7 @@ public class CiphersController : Controller
[HttpPut("{id}/unarchive")]
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
public async Task<CipherMiniResponseModel> PutUnarchive(Guid id)
public async Task<CipherResponseModel> PutUnarchive(Guid id)
{
var userId = _userService.GetProperUserId(User).Value;
@@ -1120,12 +1122,16 @@ public class CiphersController : Controller
throw new BadRequestException("Cipher was not unarchived. Ensure the provided ID is correct and you have permission to archive it.");
}
return new CipherMiniResponseModel(unarchivedCipherDetails.First(), _globalSettings, unarchivedCipherDetails.First().OrganizationUseTotp);
return new CipherResponseModel(unarchivedCipherDetails.First(),
await _userService.GetUserByPrincipalAsync(User),
await _applicationCacheService.GetOrganizationAbilitiesAsync(),
_globalSettings
);
}
[HttpPut("unarchive")]
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
public async Task<ListResponseModel<CipherMiniResponseModel>> PutUnarchiveMany([FromBody] CipherBulkUnarchiveRequestModel model)
public async Task<ListResponseModel<CipherResponseModel>> PutUnarchiveMany([FromBody] CipherBulkUnarchiveRequestModel model)
{
if (!_globalSettings.SelfHosted && model.Ids.Count() > 500)
{
@@ -1133,6 +1139,8 @@ public class CiphersController : Controller
}
var userId = _userService.GetProperUserId(User).Value;
var user = await _userService.GetUserByPrincipalAsync(User);
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
var cipherIdsToUnarchive = new HashSet<Guid>(model.Ids);
@@ -1143,9 +1151,9 @@ public class CiphersController : Controller
throw new BadRequestException("Ciphers were not unarchived. Ensure the provided ID is correct and you have permission to archive it.");
}
var responses = unarchivedCipherOrganizationDetails.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp));
var responses = unarchivedCipherOrganizationDetails.Select(c => new CipherResponseModel(c, user, organizationAbilities, _globalSettings));
return new ListResponseModel<CipherMiniResponseModel>(responses);
return new ListResponseModel<CipherResponseModel>(responses);
}
[HttpPut("{id}/restore")]

View File

@@ -80,6 +80,7 @@ public class CipherRequestModel
{
existingCipher.FolderId = string.IsNullOrWhiteSpace(FolderId) ? null : (Guid?)new Guid(FolderId);
existingCipher.Favorite = Favorite;
existingCipher.ArchivedDate = ArchivedDate;
ToCipher(existingCipher);
return existingCipher;
}
@@ -127,9 +128,9 @@ public class CipherRequestModel
var userIdKey = userId.HasValue ? userId.ToString().ToUpperInvariant() : null;
existingCipher.Reprompt = Reprompt;
existingCipher.Key = Key;
existingCipher.ArchivedDate = ArchivedDate;
existingCipher.Folders = UpdateUserSpecificJsonField(existingCipher.Folders, userIdKey, FolderId);
existingCipher.Favorites = UpdateUserSpecificJsonField(existingCipher.Favorites, userIdKey, Favorite);
existingCipher.Archives = UpdateUserSpecificJsonField(existingCipher.Archives, userIdKey, ArchivedDate);
var hasAttachments2 = (Attachments2?.Count ?? 0) > 0;
var hasAttachments = (Attachments?.Count ?? 0) > 0;

View File

@@ -70,7 +70,6 @@ public class CipherMiniResponseModel : ResponseModel
DeletedDate = cipher.DeletedDate;
Reprompt = cipher.Reprompt.GetValueOrDefault(CipherRepromptType.None);
Key = cipher.Key;
ArchivedDate = cipher.ArchivedDate;
}
public Guid Id { get; set; }
@@ -111,7 +110,6 @@ public class CipherMiniResponseModel : ResponseModel
public DateTime? DeletedDate { get; set; }
public CipherRepromptType Reprompt { get; set; }
public string Key { get; set; }
public DateTime? ArchivedDate { get; set; }
}
public class CipherResponseModel : CipherMiniResponseModel
@@ -127,6 +125,7 @@ public class CipherResponseModel : CipherMiniResponseModel
FolderId = cipher.FolderId;
Favorite = cipher.Favorite;
Edit = cipher.Edit;
ArchivedDate = cipher.ArchivedDate;
ViewPassword = cipher.ViewPassword;
Permissions = new CipherPermissionsResponseModel(user, cipher, organizationAbilities);
}
@@ -135,6 +134,7 @@ public class CipherResponseModel : CipherMiniResponseModel
public bool Favorite { get; set; }
public bool Edit { get; set; }
public bool ViewPassword { get; set; }
public DateTime? ArchivedDate { get; set; }
public CipherPermissionsResponseModel Permissions { get; set; }
}

View File

@@ -56,7 +56,7 @@ public class SyncResponseModel() : ResponseModel("sync")
c => new CollectionDetailsResponseModel(c)) ?? new List<CollectionDetailsResponseModel>();
Domains = excludeDomains ? null : new DomainsResponseModel(user, false);
Policies = policies?.Select(p => new PolicyResponseModel(p)) ?? new List<PolicyResponseModel>();
Sends = sends.Select(s => new SendResponseModel(s, globalSettings));
Sends = sends.Select(s => new SendResponseModel(s));
UserDecryption = new UserDecryptionResponseModel
{
MasterPasswordUnlock = user.HasMasterPassword()

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

@@ -4,6 +4,7 @@ using Bit.Billing.Services;
using Bit.Core;
using Bit.Core.Billing.Constants;
using Bit.Core.Jobs;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Quartz;
using Stripe;
@@ -13,12 +14,23 @@ namespace Bit.Billing.Jobs;
public class ReconcileAdditionalStorageJob(
IStripeFacade stripeFacade,
ILogger<ReconcileAdditionalStorageJob> logger,
IFeatureService featureService) : BaseJob(logger)
IFeatureService featureService,
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IStripeEventUtilityService stripeEventUtilityService) : BaseJob(logger)
{
private const string _storageGbMonthlyPriceId = "storage-gb-monthly";
private const string _storageGbAnnuallyPriceId = "storage-gb-annually";
private const string _personalStorageGbAnnuallyPriceId = "personal-storage-gb-annually";
private const int _storageGbToRemove = 4;
private const short _includedStorageGb = 5;
public enum SubscriptionPlanTier
{
Personal,
Organization,
Unknown
}
protected override async Task ExecuteJobAsync(IJobExecutionContext context)
{
@@ -34,6 +46,7 @@ public class ReconcileAdditionalStorageJob(
var subscriptionsFound = 0;
var subscriptionsUpdated = 0;
var subscriptionsWithErrors = 0;
var databaseUpdatesFailed = 0;
var failures = new List<string>();
logger.LogInformation("Starting ReconcileAdditionalStorageJob (live mode: {LiveMode})", liveMode);
@@ -51,11 +64,13 @@ public class ReconcileAdditionalStorageJob(
{
logger.LogWarning(
"Job cancelled!! Exiting. Progress at time of cancellation: Subscriptions found: {SubscriptionsFound}, " +
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
"Stripe updates: {StripeUpdates}, Database updates: {DatabaseFailed} failed, " +
"Errors: {SubscriptionsWithErrors}{Failures}",
subscriptionsFound,
liveMode
? subscriptionsUpdated
: $"(In live mode, would have updated) {subscriptionsUpdated}",
databaseUpdatesFailed,
subscriptionsWithErrors,
failures.Count > 0
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
@@ -99,20 +114,68 @@ public class ReconcileAdditionalStorageJob(
subscriptionsUpdated++;
if (!liveMode)
// Now, prepare the database update so we can log details out if not in live mode
var (organizationId, userId, _) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata ?? new Dictionary<string, string>());
var subscriptionPlanTier = DetermineSubscriptionPlanTier(userId, organizationId);
if (subscriptionPlanTier == SubscriptionPlanTier.Unknown)
{
logger.LogInformation(
"Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}",
subscription.Id,
Environment.NewLine,
JsonSerializer.Serialize(updateOptions));
logger.LogError(
"Cannot determine subscription plan tier for {SubscriptionId}. Skipping subscription. ",
subscription.Id);
subscriptionsWithErrors++;
continue;
}
var entityId =
subscriptionPlanTier switch
{
SubscriptionPlanTier.Personal => userId!.Value,
SubscriptionPlanTier.Organization => organizationId!.Value,
_ => throw new ArgumentOutOfRangeException(nameof(subscriptionPlanTier), subscriptionPlanTier, null)
};
// Calculate new MaxStorageGb
var currentStorageQuantity = GetCurrentStorageQuantityFromSubscription(subscription, priceId);
var newMaxStorageGb = CalculateNewMaxStorageGb(currentStorageQuantity, updateOptions);
if (!liveMode)
{
logger.LogInformation(
"Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}" +
"{NewLine2}And would have updated database record tier: {Tier} to new MaxStorageGb: {MaxStorageGb}",
subscription.Id,
Environment.NewLine,
JsonSerializer.Serialize(updateOptions),
Environment.NewLine,
subscriptionPlanTier,
newMaxStorageGb);
continue;
}
// Live mode enabled - continue with updates to stripe and database
try
{
await stripeFacade.UpdateSubscription(subscription.Id, updateOptions);
logger.LogInformation("Successfully updated subscription: {SubscriptionId}", subscription.Id);
logger.LogInformation("Successfully updated Stripe subscription: {SubscriptionId}", subscription.Id);
logger.LogInformation(
"Updating MaxStorageGb in database for subscription {SubscriptionId} ({Type}): New MaxStorageGb: {MaxStorage}",
subscription.Id,
subscriptionPlanTier,
newMaxStorageGb);
var dbUpdateSuccess = await UpdateDatabaseMaxStorageAsync(
subscriptionPlanTier,
entityId,
newMaxStorageGb,
subscription.Id);
if (!dbUpdateSuccess)
{
databaseUpdatesFailed++;
failures.Add($"Subscription {subscription.Id}: Database update failed");
}
}
catch (Exception ex)
{
@@ -125,12 +188,14 @@ public class ReconcileAdditionalStorageJob(
}
logger.LogInformation(
"ReconcileAdditionalStorageJob completed. Subscriptions found: {SubscriptionsFound}, " +
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
"ReconcileAdditionalStorageJob FINISHED. Subscriptions found: {SubscriptionsFound}, " +
"Subscriptions updated: {SubscriptionsUpdated}, Database failures: {DatabaseFailed}, " +
"Total Subscriptions With Errors: {SubscriptionsWithErrors}{Failures}",
subscriptionsFound,
liveMode
? subscriptionsUpdated
: $"(In live mode, would have updated) {subscriptionsUpdated}",
databaseUpdatesFailed,
subscriptionsWithErrors,
failures.Count > 0
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
@@ -182,6 +247,117 @@ public class ReconcileAdditionalStorageJob(
return hasUpdates ? updateOptions : null;
}
public SubscriptionPlanTier DetermineSubscriptionPlanTier(
Guid? userId,
Guid? organizationId)
{
return userId.HasValue
? SubscriptionPlanTier.Personal
: organizationId.HasValue
? SubscriptionPlanTier.Organization
: SubscriptionPlanTier.Unknown;
}
public long GetCurrentStorageQuantityFromSubscription(
Subscription subscription,
string storagePriceId)
{
return subscription.Items?.Data?.FirstOrDefault(item => item?.Price?.Id == storagePriceId)?.Quantity ?? 0;
}
public short CalculateNewMaxStorageGb(
long currentQuantity,
SubscriptionUpdateOptions? updateOptions)
{
if (updateOptions?.Items == null)
{
return (short)(_includedStorageGb + currentQuantity);
}
// If the update marks item as deleted, new quantity is whatever the base storage gb
if (updateOptions.Items.Any(i => i.Deleted == true))
{
return _includedStorageGb;
}
// If the update has a new quantity, use it to calculate the new max
var updatedItem = updateOptions.Items.FirstOrDefault(i => i.Quantity.HasValue);
if (updatedItem?.Quantity != null)
{
return (short)(_includedStorageGb + updatedItem.Quantity.Value);
}
// Otherwise, no change
return (short)(_includedStorageGb + currentQuantity);
}
public async Task<bool> UpdateDatabaseMaxStorageAsync(
SubscriptionPlanTier subscriptionPlanTier,
Guid entityId,
short newMaxStorageGb,
string subscriptionId)
{
try
{
switch (subscriptionPlanTier)
{
case SubscriptionPlanTier.Personal:
{
var user = await userRepository.GetByIdAsync(entityId);
if (user == null)
{
logger.LogError(
"User not found for subscription {SubscriptionId}. Database not updated.",
subscriptionId);
return false;
}
user.MaxStorageGb = newMaxStorageGb;
await userRepository.ReplaceAsync(user);
logger.LogInformation(
"Successfully updated User {UserId} MaxStorageGb to {MaxStorageGb} for subscription {SubscriptionId}",
user.Id,
newMaxStorageGb,
subscriptionId);
return true;
}
case SubscriptionPlanTier.Organization:
{
var organization = await organizationRepository.GetByIdAsync(entityId);
if (organization == null)
{
logger.LogError(
"Organization not found for subscription {SubscriptionId}. Database not updated.",
subscriptionId);
return false;
}
organization.MaxStorageGb = newMaxStorageGb;
await organizationRepository.ReplaceAsync(organization);
logger.LogInformation(
"Successfully updated Organization {OrganizationId} MaxStorageGb to {MaxStorageGb} for subscription {SubscriptionId}",
organization.Id,
newMaxStorageGb,
subscriptionId);
return true;
}
case SubscriptionPlanTier.Unknown:
default:
return false;
}
}
catch (Exception ex)
{
logger.LogError(ex,
"Failed to update database MaxStorageGb for subscription {SubscriptionId} (Plan Tier: {SubscriptionType})",
subscriptionId,
subscriptionPlanTier);
return false;
}
}
public static ITrigger GetTrigger()
{
return TriggerBuilder.Create()

View File

@@ -43,7 +43,7 @@ public class PayPalIPNTransactionModel
var merchantGross = Extract(data, "mc_gross");
if (!string.IsNullOrEmpty(merchantGross))
{
MerchantGross = decimal.Parse(merchantGross);
MerchantGross = decimal.Parse(merchantGross, CultureInfo.InvariantCulture);
}
MerchantCurrency = Extract(data, "mc_currency");

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

@@ -134,6 +134,11 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
/// </summary>
public bool UseAutomaticUserConfirmation { get; set; }
/// <summary>
/// If set to true, disables Secrets Manager ads for users in the organization
/// </summary>
public bool UseDisableSmAdsForUsers { get; set; }
/// <summary>
/// If set to true, the organization has phishing protection enabled.
/// </summary>
@@ -338,6 +343,7 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
UseRiskInsights = license.UseRiskInsights;
UseOrganizationDomains = license.UseOrganizationDomains;
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies;
UseDisableSmAdsForUsers = license.UseDisableSmAdsForUsers;
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation;
UsePhishingBlocker = license.UsePhishingBlocker;
}

View File

@@ -1,3 +0,0 @@
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record DatadogIntegration(string ApiKey, Uri Uri);

View File

@@ -1,3 +0,0 @@
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record SlackIntegration(string Token);

View File

@@ -1,3 +0,0 @@
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record SlackIntegrationConfiguration(string ChannelId);

View File

@@ -53,5 +53,7 @@ public interface IProfileOrganizationDetails
bool UseAdminSponsoredFamilies { get; set; }
bool UseOrganizationDomains { get; set; }
bool UseAutomaticUserConfirmation { get; set; }
bool UseDisableSMAdsForUsers { get; set; }
bool UsePhishingBlocker { get; set; }
}

View File

@@ -65,5 +65,6 @@ public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails
public bool UseAdminSponsoredFamilies { get; set; }
public bool? IsAdminInitiated { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool UseDisableSMAdsForUsers { get; set; }
public bool UsePhishingBlocker { get; set; }
}

View File

@@ -128,6 +128,7 @@ public class SelfHostedOrganizationDetails : Organization
UseApi = UseApi,
UseResetPassword = UseResetPassword,
UseSecretsManager = UseSecretsManager,
UsePasswordManager = UsePasswordManager,
SelfHost = SelfHost,
UsersGetPremium = UsersGetPremium,
UseCustomPermissions = UseCustomPermissions,
@@ -154,7 +155,10 @@ public class SelfHostedOrganizationDetails : Organization
Status = Status,
UseRiskInsights = UseRiskInsights,
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
UseDisableSmAdsForUsers = UseDisableSmAdsForUsers,
UsePhishingBlocker = UsePhishingBlocker,
UseOrganizationDomains = UseOrganizationDomains,
UseAutomaticUserConfirmation = UseAutomaticUserConfirmation,
};
}
}

View File

@@ -56,5 +56,6 @@ public class ProviderUserOrganizationDetails : IProfileOrganizationDetails
public string? SsoExternalId { get; set; }
public string? Permissions { get; set; }
public string? ResetPasswordKey { get; set; }
public bool UseDisableSMAdsForUsers { get; set; }
public bool UsePhishingBlocker { get; set; }
}

View File

@@ -0,0 +1,12 @@
using Bit.Core.Platform.Mail.Mailer;
namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation;
public abstract class OrganizationConfirmationBaseView : BaseMailView
{
public required string OrganizationName { get; set; }
public required string TitleFirst { get; set; }
public required string TitleSecondBold { get; set; }
public required string TitleThird { get; set; }
public required string WebVaultUrl { get; set; }
}

View File

@@ -0,0 +1,12 @@
using Bit.Core.Platform.Mail.Mailer;
namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation;
public class OrganizationConfirmationEnterpriseTeamsView : OrganizationConfirmationBaseView
{
}
public class OrganizationConfirmationEnterpriseTeams : BaseMail<OrganizationConfirmationEnterpriseTeamsView>
{
public override required string Subject { get; set; }
}

View File

@@ -0,0 +1,814 @@
<!doctype html>
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a { padding:0; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500,700);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-70 { width:70% !important; max-width: 70%; }
.mj-column-per-30 { width:30% !important; max-width: 30%; }
.mj-column-per-100 { width:100% !important; max-width: 100%; }
.mj-column-per-15 { width:15% !important; max-width: 15%; }
.mj-column-per-85 { width:85% !important; max-width: 85%; }
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-70 { width:70% !important; max-width: 70%; }
.moz-text-html .mj-column-per-30 { width:30% !important; max-width: 30%; }
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
.moz-text-html .mj-column-per-15 { width:15% !important; max-width: 15%; }
.moz-text-html .mj-column-per-85 { width:85% !important; max-width: 85%; }
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
.mj-bw-hero-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:480px) {
.mj-bw-learn-more-footer-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:479px) {
table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; }
}
@media only screen and (max-width:480px) {
.mj-bw-icon-row-text {
padding-left: 5px !important;
line-height: 20px;
}
.mj-bw-icon-row {
padding: 10px 15px;
width: fit-content !important;
}
}
</style>
<style type="text/css">
.border-fix > table {
border-collapse: separate !important;
}
.border-fix > table > tbody > tr > td {
border-radius: 3px;
}
</style>
</head>
<body style="word-spacing:normal;background-color:#e6e9ef;">
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
<!-- Blue Header Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div class="border-fix" style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:150px;">
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
You can now share passwords with members of <b>{{OrganizationName}}!</b>
</h1></div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tbody>
<tr>
<td align="center" bgcolor="#ffffff" role="presentation" style="border:none;border-radius:20px;cursor:auto;mso-padding-alt:10px 25px;background:#ffffff;" valign="middle">
<a href="{{WebVaultUrl}}" style="display:inline-block;background:#ffffff;color:#1A41AC;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:20px;" target="_blank">
<b>Log in</b>
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
<tbody>
<tr>
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:155px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-enterprise.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Main Content -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:15px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">As a member of <b>{{OrganizationName}}</b>:</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:48px;">
<img alt="Organization Icon" src="https://assets.bitwarden.com/email/v1/icon-enterprise.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;">Your account is owned by {{OrganizationName}} and is subject to their security and management policies.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:48px;">
<img alt="Group Users Icon" src="https://assets.bitwarden.com/email/v1/icon-account-switching-new.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;">You can easily access and share passwords with your team.</div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;"><a href="https://bitwarden.com/help/sharing" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">
Share passwords in Bitwarden
<span style="text-decoration: none">
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png" alt="External Link Icon" width="16px" style="vertical-align: middle;">
</span>
</a></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 20px 20px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Learn More Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#F3F6F9" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#F3F6F9;background-color:#F3F6F9;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#F3F6F9;background-color:#F3F6F9;width:100%;border-radius:0px 0px 4px 4px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:420px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
Learn more about Bitwarden
</p>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:180px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" class="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:94px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Footer -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://x.com/bitwarden" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-x-twitter.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-reddit.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://community.bitwarden.com/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-discourse.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/bitwarden" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-github.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-youtube.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-linkedin.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.facebook.com/bitwarden/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-facebook.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px; margin-top: 5px">
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
Barbara, CA, USA
</p>
<p style="margin-top: 5px">
Always confirm you are on a trusted Bitwarden domain before logging
in:<br>
<a href="https://bitwarden.com/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">Learn why we include this</a>
</p></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@@ -0,0 +1,12 @@
using Bit.Core.Platform.Mail.Mailer;
namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation;
public class OrganizationConfirmationFamilyFreeView : OrganizationConfirmationBaseView
{
}
public class OrganizationConfirmationFamilyFree : BaseMail<OrganizationConfirmationFamilyFreeView>
{
public override required string Subject { get; set; }
}

View File

@@ -0,0 +1,983 @@
<!doctype html>
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a { padding:0; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500,700);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-70 { width:70% !important; max-width: 70%; }
.mj-column-per-30 { width:30% !important; max-width: 30%; }
.mj-column-per-100 { width:100% !important; max-width: 100%; }
.mj-column-per-15 { width:15% !important; max-width: 15%; }
.mj-column-per-85 { width:85% !important; max-width: 85%; }
.mj-column-px-120 { width:120px !important; max-width: 120px; }
.mj-column-px-150 { width:150px !important; max-width: 150px; }
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-70 { width:70% !important; max-width: 70%; }
.moz-text-html .mj-column-per-30 { width:30% !important; max-width: 30%; }
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
.moz-text-html .mj-column-per-15 { width:15% !important; max-width: 15%; }
.moz-text-html .mj-column-per-85 { width:85% !important; max-width: 85%; }
.moz-text-html .mj-column-px-120 { width:120px !important; max-width: 120px; }
.moz-text-html .mj-column-px-150 { width:150px !important; max-width: 150px; }
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
.mj-bw-hero-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:480px) {
.mj-bw-learn-more-footer-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:479px) {
table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; }
}
@media only screen and (max-width:480px) {
.mj-bw-icon-row-text {
padding-left: 5px !important;
line-height: 20px;
}
.mj-bw-icon-row {
padding: 10px 15px;
width: fit-content !important;
}
}
</style>
<style type="text/css">
.border-fix > table {
border-collapse: separate !important;
}
.border-fix > table > tbody > tr > td {
border-radius: 3px;
}
</style>
</head>
<body style="word-spacing:normal;background-color:#e6e9ef;">
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
<!-- Blue Header Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div class="border-fix" style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:150px;">
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
You can now share passwords with members of <b>{{OrganizationName}}!</b>
</h1></div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tbody>
<tr>
<td align="center" bgcolor="#ffffff" role="presentation" style="border:none;border-radius:20px;cursor:auto;mso-padding-alt:10px 25px;background:#ffffff;" valign="middle">
<a href="{{WebVaultUrl}}" style="display:inline-block;background:#ffffff;color:#1A41AC;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:20px;" target="_blank">
<b>Log in</b>
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
<tbody>
<tr>
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:155px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-family-homes.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Main Content -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:15px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">As a member of <b>{{OrganizationName}}</b>:</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:48px;">
<img alt="Collections Icon" src="https://assets.bitwarden.com/email/v1/icon-item-type.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;">You can access passwords {{OrganizationName}} has shared with you.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:10px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="mj-bw-icon-row-outlook" style="width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix mj-bw-icon-row" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:top;width:90px;" ><![endif]-->
<div class="mj-column-per-15 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:15%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:48px;">
<img alt="Group Users Icon" src="https://assets.bitwarden.com/email/v1/icon-account-switching-new.png" style="border:0;border-radius:8px;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="48" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td style="vertical-align:top;width:510px;" ><![endif]-->
<div class="mj-column-per-85 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:85%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;">You can easily share passwords with friends, family, or coworkers.</div>
</td>
</tr>
<tr>
<td align="left" class="mj-bw-icon-row-text" style="font-size:0px;padding:5px 10px 0px 10px;word-break:break-word;">
<div style="font-family:Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:400;line-height:24px;text-align:left;color:#1B2029;"><a href="https://bitwarden.com/help/sharing" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">
Share passwords in Bitwarden
<span style="text-decoration: none">
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png" alt="External Link Icon" width="16px" style="vertical-align: middle;">
</span>
</a></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 20px 20px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Download Mobile Apps Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:30px 30px 10px 30px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:560px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:0 0 15px 0;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:18px;font-weight:700;line-height:32px;text-align:left;color:#1B2029;">Download Bitwarden on all devices</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:0 0 20px 0;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:15px;line-height:16px;text-align:left;color:#1B2029;">Already using the <a href="https://bitwarden.com/download/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">browser extension</a>?
Download the Bitwarden mobile app from the
<a href="https://apps.apple.com/us/app/bitwarden-password-manager/id1137397744" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">App Store</a>
or <a href="https://play.google.com/store/apps/details?id=com.x8bit.bitwarden" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">Google Play</a>
to quickly save logins and autofill forms on the go.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 30px 20px 30px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="width:560px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td style="vertical-align:middle;width:120px;" ><![endif]-->
<div class="mj-column-px-120 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:21.428571428571427%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:120px;">
<a href="https://apps.apple.com/us/app/bitwarden-password-manager/id1137397744" target="_blank">
<img alt="Download on the App Store" src="https://assets.bitwarden.com/email/v1/App-store.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="120" height="auto">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td style="vertical-align:middle;width:150px;" ><![endif]-->
<div class="mj-column-px-150 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:26.785714285714285%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0 0 0 10px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:140px;">
<a href="https://play.google.com/store/apps/details?id=com.x8bit.bitwarden" target="_blank">
<img alt="Get it on Google Play" src="https://assets.bitwarden.com/email/v1/google-play-badge.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="140" height="auto">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Learn More Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#F3F6F9" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#F3F6F9;background-color:#F3F6F9;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#F3F6F9;background-color:#F3F6F9;width:100%;border-radius:0px 0px 4px 4px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:420px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
Learn more about Bitwarden
</p>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:180px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" class="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:94px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Footer -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://x.com/bitwarden" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-x-twitter.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-reddit.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://community.bitwarden.com/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-discourse.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/bitwarden" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-github.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-youtube.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-linkedin.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.facebook.com/bitwarden/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-facebook.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px; margin-top: 5px">
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
Barbara, CA, USA
</p>
<p style="margin-top: 5px">
Always confirm you are on a trusted Bitwarden domain before logging
in:<br>
<a href="https://bitwarden.com/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">Learn why we include this</a>
</p></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@@ -29,6 +29,7 @@ public class OrganizationAbility
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
UseDisableSmAdsForUsers = organization.UseDisableSmAdsForUsers;
UsePhishingBlocker = organization.UsePhishingBlocker;
}
@@ -52,5 +53,6 @@ public class OrganizationAbility
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool UseDisableSmAdsForUsers { get; set; }
public bool UsePhishingBlocker { get; set; }
}

View File

@@ -0,0 +1,141 @@
# Organization Ability Flags
## Overview
Many Bitwarden features are tied to specific subscription plans. For example, SCIM and SSO are Enterprise features,
while Event Logs are available to Teams and Enterprise plans. When developing features that require plan-based access
control, we use **Organization Ability Flags** (or simply _abilities_) — explicit boolean properties on the Organization
entity that indicate whether an organization can use a specific feature.
## The Rule
**Never check plan types to control feature access.** Always use a dedicated ability flag on the Organization entity.
### ❌ Don't Do This
```csharp
// Checking plan type directly
if (organization.PlanType == PlanType.Enterprise ||
organization.PlanType == PlanType.Teams ||
organization.PlanType == PlanType.Family)
{
// allow feature...
}
```
### ❌ Don't Do This
```csharp
// Piggybacking off another feature's ability
if (organization.PlanType == PlanType.Enterprise && organization.UseEvents)
{
// assume they can use some other feature...
}
```
### ✅ Do This Instead
```csharp
// Check the explicit ability flag
if (organization.UseEvents)
{
// allow UseEvents feature...
}
```
## Why This Pattern Matters
Using explicit ability flags instead of plan type checks provides several benefits:
1. **Simplicity** — A single boolean check is cleaner and less error-prone than maintaining lists of plan types.
2. **Centralized Control** — Feature access is managed in one place: the ability assignment during organization
creation/upgrade. No need to hunt through the codebase for scattered plan type checks.
3. **Flexibility** — Abilities can be set independently of plan type, enabling:
- Early access programs for features not yet tied to a plan
- Trial access to help customers evaluate a feature before upgrading
- Custom arrangements for specific customers
- A/B testing of features across different cohorts
4. **Safe Refactoring** — When plans change (e.g., adding a new plan tier, renaming plans, or moving features between
tiers), we only update the ability assignment logic—not every place the feature is used.
5. **Graceful Downgrades** — When an organization downgrades, we update their abilities. All feature checks
automatically respect the new access level.
## How It Works
### Ability Assignment at Signup/Upgrade
When an organization is created or changes plans, the ability flags are set based on the plan's capabilities:
```csharp
// During organization creation or plan change
organization.UseGroups = plan.HasGroups;
organization.UseSso = plan.HasSso;
organization.UseScim = plan.HasScim;
organization.UsePolicies = plan.HasPolicies;
organization.UseEvents = plan.HasEvents;
// ... etc
```
### Modifying Abilities for Existing Organizations
To change abilities for existing organizations (e.g., rolling out a feature to a new plan tier), create a database
migration that updates the relevant flag:
```sql
-- Example: Enable UseEvents for all Teams organizations
UPDATE [dbo].[Organization]
SET UseEvents = 1
WHERE PlanType IN (17, 18) -- TeamsMonthly = 17, TeamsAnnually = 18
```
Then update the plan-to-ability assignment code so new organizations get the correct value.
## Adding a New Ability
When developing a new plan-gated feature:
1. **Add the ability to the Organization and OrganizationAbility entities** — Create a `Use[FeatureName]` boolean
property.
2. **Add a database migration** — Add the new column to the Organization table.
3. **Update plan definitions** — Add a corresponding `Has[FeatureName]` property to the Plan model and configure which
plans include it.
4. **Update organization creation/upgrade logic** — Ensure the ability is set based on the plan.
5. **Update the organization license claims** (if applicable) - to make the feature available on self-hosted instances.
6. **Implement checks throughout client and server** — Use the ability consistently everywhere the feature is accessed.
- Clients: get the organization object from `OrganizationService`.
- Server: if you already have the full `Organization` object in scope, you can use it directly. If not, use the
`IApplicationCacheService` to retrieve the `OrganizationAbility`, which is a simplified, cached representation
of the organization ability flags. Note that some older flags may be missing from `OrganizationAbility` but
can be added if needed.
## Existing Abilities
For reference, here are some current organization ability flags (not a complete list):
| Ability | Description | Plans |
|--------------------------|-------------------------------|-------------------|
| `UseGroups` | Group-based collection access | Teams, Enterprise |
| `UseDirectory` | Directory Connector sync | Teams, Enterprise |
| `UseEvents` | Event logging | Teams, Enterprise |
| `UseTotp` | Authenticator (TOTP) | Teams, Enterprise |
| `UseSso` | Single Sign-On | Enterprise |
| `UseScim` | SCIM provisioning | Teams, Enterprise |
| `UsePolicies` | Enterprise policies | Enterprise |
| `UseResetPassword` | Admin password reset | Enterprise |
| `UseOrganizationDomains` | Domain verification/claiming | Enterprise |
## Questions?
If you're unsure whether your feature needs a new ability or which existing ability to use, reach out to your team lead
or members of the Admin Console or Architecture teams. When in doubt, adding an explicit ability is almost always the
right choice—it's easy to do and keeps our access control clean and maintainable.

View File

@@ -1,5 +1,7 @@
using Bit.Core.AdminConsole.Models.Data.OrganizationUsers;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.OrganizationUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.Entities;
@@ -25,6 +27,8 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi
IPushNotificationService pushNotificationService,
IPolicyRequirementQuery policyRequirementQuery,
ICollectionRepository collectionRepository,
IFeatureService featureService,
ISendOrganizationConfirmationCommand sendOrganizationConfirmationCommand,
TimeProvider timeProvider,
ILogger<AutomaticallyConfirmOrganizationUserCommand> logger) : IAutomaticallyConfirmOrganizationUserCommand
{
@@ -143,9 +147,7 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi
{
var user = await userRepository.GetByIdAsync(request.OrganizationUser!.UserId!.Value);
await mailService.SendOrganizationConfirmedEmailAsync(request.Organization!.Name,
user!.Email,
request.OrganizationUser.AccessSecretsManager);
await SendOrganizationConfirmedEmailAsync(request.Organization!, user!.Email, request.OrganizationUser.AccessSecretsManager);
}
catch (Exception ex)
{
@@ -183,4 +185,23 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi
Organization = await organizationRepository.GetByIdAsync(request.OrganizationId)
};
}
/// <summary>
/// Sends the organization confirmed email using either the new mailer pattern or the legacy mail service,
/// depending on the feature flag.
/// </summary>
/// <param name="organization">The organization the user was confirmed to.</param>
/// <param name="userEmail">The email address of the confirmed user.</param>
/// <param name="accessSecretsManager">Whether the user has access to Secrets Manager.</param>
internal async Task SendOrganizationConfirmedEmailAsync(Organization organization, string userEmail, bool accessSecretsManager)
{
if (featureService.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail))
{
await sendOrganizationConfirmationCommand.SendConfirmationAsync(organization, userEmail, accessSecretsManager);
}
else
{
await mailService.SendOrganizationConfirmedEmailAsync(organization.Name, userEmail, accessSecretsManager);
}
}
}

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