From 4bad008085f6ab6d3f850404f182c256d8d07834 Mon Sep 17 00:00:00 2001
From: Todd Martin <106564991+trmartin4@users.noreply.github.com>
Date: Thu, 14 Aug 2025 12:08:41 -0400
Subject: [PATCH] chore(comments): [PM-24624] Add more comments to
AutoProvisionUserAsync
* Comments in auto-provisioning logic.
* More clarifications.
* Changed method name.
* Updated response from method.
* Clarified message.
---
.../src/Sso/Controllers/AccountController.cs | 166 ++++++++++++------
1 file changed, 112 insertions(+), 54 deletions(-)
diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs
index 5776912bd3..7fadc8cb27 100644
--- a/bitwarden_license/src/Sso/Controllers/AccountController.cs
+++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs
@@ -3,6 +3,7 @@
using System.Security.Claims;
using Bit.Core;
+using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
@@ -254,25 +255,25 @@ public class AccountController : Controller
var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}");
_logger.LogDebug("External claims: {@claims}", externalClaims);
- // Lookup our user and external provider info
- // Note: the user will only exist if the user has already been provisioned and exists in the User table and the SSO user table.
+ // See if the user has logged in with this SSO provider before and has already been provisioned.
+ // This is signified by the user existing in the User table and the SSOUser table for the SSO provider they're using.
var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);
+
+ // The user has not authenticated with this SSO provider before.
+ // They could have an existing Bitwarden account in the User table though.
if (user == null)
{
- // User does not exist in SSO User table. They could have an existing BW account in the User table.
-
- // This might be where you might initiate a custom workflow for user registration
- // in this sample we don't show how that would be done, as our sample implementation
- // simply auto-provisions new external user
+ // If we're manually linking to SSO, the user's external identifier will be passed as query string parameter.
var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier") ?
result.Properties.Items["user_identifier"] : null;
user = await AutoProvisionUserAsync(provider, providerUserId, claims, userIdentifier, ssoConfigData);
}
+ // Either the user already authenticated with the SSO provider, or we've just provisioned them.
+ // Either way, we have associated the SSO login with a Bitwarden user.
+ // We will now sign the Bitwarden user in.
if (user != null)
{
- // User was JIT provisioned (this could be an existing user or a new user)
-
// This allows us to collect any additional claims or properties
// for the specific protocols used and store them in the local auth cookie.
// this is typically used to store data needed for signout from those protocols.
@@ -350,6 +351,10 @@ public class AccountController : Controller
}
}
+ ///
+ /// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`.
+ /// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records.
+ ///
private async Task<(User user, string provider, string providerUserId, IEnumerable claims, SsoConfigurationData config)>
FindUserFromExternalProviderAsync(AuthenticateResult result)
{
@@ -407,6 +412,23 @@ public class AccountController : Controller
return (user, provider, providerUserId, claims, ssoConfigData);
}
+ ///
+ /// Provision an SSO-linked Bitwarden user.
+ /// This handles three different scenarios:
+ /// 1. Creating an SsoUser link for an existing User and OrganizationUser
+ /// - User is a member of the organization, but hasn't authenticated with the org's SSO provider before.
+ /// 2. Creating a new User and a new OrganizationUser, then establishing an SsoUser link
+ /// - User is joining the organization through JIT provisioning, without a pending invitation
+ /// 3. Creating a new User for an existing OrganizationUser (created by invitation), then establishing an SsoUser link
+ /// - User is signing in with a pending invitation.
+ ///
+ /// The external identity provider.
+ /// The external identity provider's user identifier.
+ /// The claims from the external IdP.
+ /// The user identifier used for manual SSO linking.
+ /// The SSO configuration for the organization.
+ /// The User to sign in.
+ /// An exception if the user cannot be provisioned as requested.
private async Task AutoProvisionUserAsync(string provider, string providerUserId,
IEnumerable claims, string userIdentifier, SsoConfigurationData config)
{
@@ -434,50 +456,15 @@ public class AccountController : Controller
}
else
{
- var split = userIdentifier.Split(",");
- if (split.Length < 2)
- {
- throw new Exception(_i18nService.T("InvalidUserIdentifier"));
- }
- var userId = split[0];
- var token = split[1];
-
- var tokenOptions = new TokenOptions();
-
- var claimedUser = await _userService.GetUserByIdAsync(userId);
- if (claimedUser != null)
- {
- var tokenIsValid = await _userManager.VerifyUserTokenAsync(
- claimedUser, tokenOptions.PasswordResetTokenProvider, TokenPurposes.LinkSso, token);
- if (tokenIsValid)
- {
- existingUser = claimedUser;
- }
- else
- {
- throw new Exception(_i18nService.T("UserIdAndTokenMismatch"));
- }
- }
+ existingUser = await GetUserFromManualLinkingData(userIdentifier);
}
- OrganizationUser orgUser = null;
- var organization = await _organizationRepository.GetByIdAsync(orgId);
- if (organization == null)
- {
- throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId));
- }
+ // Try to find the OrganizationUser if it exists.
+ var (organization, orgUser) = await FindOrganizationUser(existingUser, email, orgId);
- // Try to find OrgUser via existing User Id (accepted/confirmed user)
- if (existingUser != null)
- {
- var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(existingUser.Id);
- orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId);
- }
-
- // If no Org User found by Existing User Id - search all organization users via email
- orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(orgId, email);
-
- // All Existing User flows handled below
+ //----------------------------------------------------
+ // Scenario 1: We've found the user in the User table
+ //----------------------------------------------------
if (existingUser != null)
{
if (existingUser.UsesKeyConnector &&
@@ -486,6 +473,8 @@ public class AccountController : Controller
throw new Exception(_i18nService.T("UserAlreadyExistsKeyConnector"));
}
+ // If the user already exists in Bitwarden, we require that the user already be in the org,
+ // and that they are either Accepted or Confirmed.
if (orgUser == null)
{
// Org User is not created - no invite has been sent
@@ -495,7 +484,11 @@ public class AccountController : Controller
EnsureOrgUserStatusAllowed(orgUser.Status, organization.DisplayName(),
allowedStatuses: [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]);
- // Accepted or Confirmed - create SSO link and return;
+
+ // 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
+ // with authentication.
await CreateSsoUserRecord(providerUserId, existingUser.Id, orgId, orgUser);
return existingUser;
}
@@ -538,7 +531,9 @@ public class AccountController : Controller
emailVerified = organizationDomain?.VerifiedDate.HasValue ?? false;
}
- // Create user record - all existing user flows are handled above
+ //--------------------------------------------------
+ // Scenarios 2 and 3: We need to register a new user
+ //--------------------------------------------------
var user = new User
{
Name = name,
@@ -564,7 +559,11 @@ public class AccountController : Controller
await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email);
}
- // Create Org User if null or else update existing Org User
+ //-----------------------------------------------------------------
+ // Scenario 2: We also need to create an OrganizationUser
+ // This means that an invitation was not sent for this user and we
+ // need to establish their invited status now.
+ //-----------------------------------------------------------------
if (orgUser == null)
{
orgUser = new OrganizationUser
@@ -576,18 +575,77 @@ public class AccountController : Controller
};
await _organizationUserRepository.CreateAsync(orgUser);
}
+ //-----------------------------------------------------------------
+ // Scenario 3: There is already an existing OrganizationUser
+ // That was established through an invitation. We just need to
+ // update the UserId now that we have created a User record.
+ //-----------------------------------------------------------------
else
{
orgUser.UserId = user.Id;
await _organizationUserRepository.ReplaceAsync(orgUser);
}
- // Create sso user record
+ // Create the SsoUser record to link the user to the SSO provider.
await CreateSsoUserRecord(providerUserId, user.Id, orgId, orgUser);
return user;
}
+ private async Task GetUserFromManualLinkingData(string userIdentifier)
+ {
+ User user = null;
+ var split = userIdentifier.Split(",");
+ if (split.Length < 2)
+ {
+ throw new Exception(_i18nService.T("InvalidUserIdentifier"));
+ }
+ var userId = split[0];
+ var token = split[1];
+
+ var tokenOptions = new TokenOptions();
+
+ var claimedUser = await _userService.GetUserByIdAsync(userId);
+ if (claimedUser != null)
+ {
+ var tokenIsValid = await _userManager.VerifyUserTokenAsync(
+ claimedUser, tokenOptions.PasswordResetTokenProvider, TokenPurposes.LinkSso, token);
+ if (tokenIsValid)
+ {
+ user = claimedUser;
+ }
+ else
+ {
+ throw new Exception(_i18nService.T("UserIdAndTokenMismatch"));
+ }
+ }
+ return user;
+ }
+
+ private async Task<(Organization, OrganizationUser)> FindOrganizationUser(User existingUser, string email, Guid orgId)
+ {
+ OrganizationUser orgUser = null;
+ var organization = await _organizationRepository.GetByIdAsync(orgId);
+ if (organization == null)
+ {
+ throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId));
+ }
+
+ // Try to find OrgUser via existing User Id.
+ // This covers any OrganizationUser state after they have accepted an invite.
+ if (existingUser != null)
+ {
+ var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(existingUser.Id);
+ orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId);
+ }
+
+ // If no Org User found by Existing User Id - search all the organization's users via email.
+ // This covers users who are Invited but haven't accepted their invite yet.
+ orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(orgId, email);
+
+ return (organization, orgUser);
+ }
+
private void EnsureOrgUserStatusAllowed(
OrganizationUserStatusType status,
string organizationDisplayName,