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,