Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
9510637
feat(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non โ€ฆ
Patrick-Pimentel-Bitwarden Sep 17, 2025
a5d64a3
Merge branch 'main' into auth/pm-24579/prevent-existing-sso-rejected-โ€ฆ
Patrick-Pimentel-Bitwarden Sep 18, 2025
864fafe
feat(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non โ€ฆ
Patrick-Pimentel-Bitwarden Sep 18, 2025
ea048ef
test(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non โ€ฆ
Patrick-Pimentel-Bitwarden Sep 25, 2025
266e13a
Merge branch 'main' into auth/pm-24579/prevent-existing-sso-rejected-โ€ฆ
Patrick-Pimentel-Bitwarden Sep 25, 2025
395bdb6
test(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non โ€ฆ
Patrick-Pimentel-Bitwarden Sep 25, 2025
4d707b3
test(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non โ€ฆ
Patrick-Pimentel-Bitwarden Sep 25, 2025
0a6a2f2
test(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non โ€ฆ
Patrick-Pimentel-Bitwarden Sep 25, 2025
d1f15c9
Merge branch 'main' into auth/pm-24579/prevent-existing-sso-rejected-โ€ฆ
Patrick-Pimentel-Bitwarden Sep 25, 2025
dddf8e9
test(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non โ€ฆ
Patrick-Pimentel-Bitwarden Sep 29, 2025
c80097a
test(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non โ€ฆ
Patrick-Pimentel-Bitwarden Sep 29, 2025
1077d73
test(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non โ€ฆ
Patrick-Pimentel-Bitwarden Sep 29, 2025
6a74289
test(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non โ€ฆ
Patrick-Pimentel-Bitwarden Sep 29, 2025
597eb69
test(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non โ€ฆ
Patrick-Pimentel-Bitwarden Sep 29, 2025
f06c889
test(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non โ€ฆ
Patrick-Pimentel-Bitwarden Sep 29, 2025
811eb0b
test(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non โ€ฆ
Patrick-Pimentel-Bitwarden Sep 29, 2025
b173cd3
fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Cโ€ฆ
Patrick-Pimentel-Bitwarden Sep 29, 2025
5845ca0
fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Cโ€ฆ
Patrick-Pimentel-Bitwarden Sep 29, 2025
26f0b66
fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Cโ€ฆ
Patrick-Pimentel-Bitwarden Oct 6, 2025
401253c
fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Cโ€ฆ
Patrick-Pimentel-Bitwarden Oct 6, 2025
204f25a
Merge branch 'main' into auth/pm-24579/prevent-existing-sso-rejected-โ€ฆ
Patrick-Pimentel-Bitwarden Oct 7, 2025
f2f538a
fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Cโ€ฆ
Patrick-Pimentel-Bitwarden Oct 7, 2025
14ac002
fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Cโ€ฆ
Patrick-Pimentel-Bitwarden Oct 7, 2025
dcca55b
Merge remote-tracking branch 'origin' into auth/pm-24579/prevent-exisโ€ฆ
Patrick-Pimentel-Bitwarden Oct 9, 2025
dc624dc
fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Cโ€ฆ
Patrick-Pimentel-Bitwarden Oct 22, 2025
615ec4b
fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Cโ€ฆ
Patrick-Pimentel-Bitwarden Oct 22, 2025
4da7606
Merge branch 'main' into auth/pm-24579/prevent-existing-sso-rejected-โ€ฆ
Patrick-Pimentel-Bitwarden Oct 22, 2025
10aa23b
Merge branch 'main' into auth/pm-24579/prevent-existing-sso-rejected-โ€ฆ
Patrick-Pimentel-Bitwarden Oct 22, 2025
4bffbae
fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Cโ€ฆ
Patrick-Pimentel-Bitwarden Oct 24, 2025
78a3e1d
docs(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non โ€ฆ
Patrick-Pimentel-Bitwarden Oct 24, 2025
87258c7
Merge branch 'main' into auth/pm-24579/prevent-existing-sso-rejected-โ€ฆ
Patrick-Pimentel-Bitwarden Oct 24, 2025
346c5b4
feat(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non โ€ฆ
Patrick-Pimentel-Bitwarden Oct 27, 2025
16a0810
Merge branch 'main' into auth/pm-24579/prevent-existing-sso-rejected-โ€ฆ
Patrick-Pimentel-Bitwarden Oct 27, 2025
1960621
fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Cโ€ฆ
Patrick-Pimentel-Bitwarden Oct 27, 2025
41bb741
fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Cโ€ฆ
Patrick-Pimentel-Bitwarden Oct 27, 2025
e5f7d4b
fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Cโ€ฆ
Patrick-Pimentel-Bitwarden Oct 27, 2025
b3eade0
test(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non โ€ฆ
Patrick-Pimentel-Bitwarden Oct 27, 2025
434709c
fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Cโ€ฆ
Patrick-Pimentel-Bitwarden Oct 27, 2025
01eb918
fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Cโ€ฆ
Patrick-Pimentel-Bitwarden Oct 27, 2025
969720f
fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Cโ€ฆ
Patrick-Pimentel-Bitwarden Oct 27, 2025
88488bd
Merge branch 'main' into auth/pm-24579/prevent-existing-sso-rejected-โ€ฆ
Patrick-Pimentel-Bitwarden Oct 28, 2025
e106401
fix(prevent-null) [PM-27510] AccountController Prevent Null Values - โ€ฆ
Patrick-Pimentel-Bitwarden Oct 28, 2025
dbf9424
Merge branch 'main' into auth/pm-27510/prevent-existing-sso-rejected-โ€ฆ
Patrick-Pimentel-Bitwarden Oct 28, 2025
4a9b2f3
fix(prevent-null) [PM-27510] AccountController Prevent Null Values - โ€ฆ
Patrick-Pimentel-Bitwarden Oct 28, 2025
90db2fc
fix(prevent-null) [PM-27510] AccountController Prevent Null Values - โ€ฆ
Patrick-Pimentel-Bitwarden Oct 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 61 additions & 49 deletions bitwarden_license/src/Sso/Controllers/AccountController.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
๏ปฟ// FIXME: Update this file to be null safe and then delete the line below
#nullable disable

using System.Security.Claims;
๏ปฟusing System.Security.Claims;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
Expand Down Expand Up @@ -167,6 +164,8 @@ public async Task<IActionResult> LoginAsync(string returnUrl)
{
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);

if (context == null) throw new Exception(_i18nService.T("AuthorizationContextMissing"));

if (!context.Parameters.AllKeys.Contains("domain_hint") ||
string.IsNullOrWhiteSpace(context.Parameters["domain_hint"]))
{
Expand All @@ -181,7 +180,7 @@ public async Task<IActionResult> LoginAsync(string returnUrl)
}

var domainHint = context.Parameters["domain_hint"];
var organization = await _organizationRepository.GetByIdentifierAsync(domainHint);
var organization = await _organizationRepository.GetByIdentifierAsync(domainHint ?? throw new Exception(_i18nService.T("NoDomainHintFromAuthorizationContext")));

if (organization == null)
{
Expand Down Expand Up @@ -266,17 +265,18 @@ public async Task<IActionResult> ExternalCallback()
var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);

// We will look these up as required (lazy resolution) to avoid multiple DB hits.
Organization organization = null;
OrganizationUser orgUser = null;
Organization? organization = null;
OrganizationUser? orgUser = null;

// 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)
{
// 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;
string? userIdentifier =
result.Properties?.Items != null && result.Properties.Items.ContainsKey("user_identifier")
? result.Properties.Items["user_identifier"]
: null;

var (provisionedUser, foundOrganization, foundOrCreatedOrgUser) =
await AutoProvisionUserAsync(
Expand Down Expand Up @@ -354,7 +354,13 @@ await HttpContext.SignInAsync(
await HttpContext.SignOutAsync(AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);

// Retrieve return URL
var returnUrl = result.Properties.Items["return_url"] ?? "~/";
var returnUrl = "~/";
if (result.Properties?.Items != null
&& result.Properties.Items.TryGetValue("return_url", out var storedReturnUrl)
&& !string.IsNullOrWhiteSpace(storedReturnUrl))
{
returnUrl = storedReturnUrl;
}

// Check if external login is in the context of an OIDC request
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
Expand All @@ -379,7 +385,7 @@ public async Task<IActionResult> LogoutAsync(string logoutId)
// Build a model so the logged out page knows what to display
var (updatedLogoutId, redirectUri, externalAuthenticationScheme) = await GetLoggedOutDataAsync(logoutId);

if (User?.Identity.IsAuthenticated == true)
if (User.Identity!.IsAuthenticated)
{
// Delete local authentication cookie
await HttpContext.SignOutAsync();
Expand Down Expand Up @@ -412,11 +418,14 @@ public async Task<IActionResult> LogoutAsync(string logoutId)
/// 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.
/// </summary>
private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> claims,
SsoConfigurationData config)>
FindUserFromExternalProviderAsync(AuthenticateResult result)
private async Task<(User? user, string provider, string providerUserId, List<Claim> claims, SsoConfigurationData ssoConfigData)> FindUserFromExternalProviderAsync(AuthenticateResult result)
{
var provider = result.Properties.Items["scheme"];
if (result.Properties?.Items == null ||
!result.Properties.Items.TryGetValue("scheme", out var provider) ||
string.IsNullOrWhiteSpace(provider))
{
throw new Exception(_i18nService.T("NoProviderOnAuthenticateResult"));
}
var orgId = new Guid(provider);
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId);
if (ssoConfig == null || !ssoConfig.Enabled)
Expand All @@ -430,7 +439,7 @@ public async Task<IActionResult> LogoutAsync(string logoutId)
// Validate acr claim against expectation before going further
if (!string.IsNullOrWhiteSpace(ssoConfigData.ExpectedReturnAcrValue))
{
var acrClaim = externalUser.FindFirst(JwtClaimTypes.AuthenticationContextClassReference);
var acrClaim = externalUser!.FindFirst(JwtClaimTypes.AuthenticationContextClassReference);
if (acrClaim?.Value != ssoConfigData.ExpectedReturnAcrValue)
{
throw new Exception(_i18nService.T("AcrMissingOrInvalid"));
Expand All @@ -449,7 +458,7 @@ static bool nameIdIsNotTransient(Claim c) => c.Type == ClaimTypes.NameIdentifier
// the most common claim type for that are the sub claim and the NameIdentifier
// depending on the external provider, some other claim type might be used
var customUserIdClaimTypes = ssoConfigData.GetAdditionalUserIdClaimTypes();
var userIdClaim = externalUser.FindFirst(c => customUserIdClaimTypes.Contains(c.Type)) ??
var userIdClaim = externalUser!.FindFirst(c => customUserIdClaimTypes.Contains(c.Type)) ??
externalUser.FindFirst(JwtClaimTypes.Subject) ??
externalUser.FindFirst(nameIdIsNotTransient) ??
// Some SAML providers may use the `uid` attribute for this
Expand Down Expand Up @@ -493,14 +502,14 @@ static bool nameIdIsNotTransient(Claim c) => c.Type == ClaimTypes.NameIdentifier
string provider,
string providerUserId,
IEnumerable<Claim> claims,
string userIdentifier,
string? userIdentifier,
SsoConfigurationData ssoConfigData
)
{
var name = GetName(claims, ssoConfigData.GetAdditionalNameClaimTypes());
var email = TryGetEmailAddress(claims, ssoConfigData, providerUserId);
string? email = TryGetEmailAddress(claims, ssoConfigData, providerUserId);

User existingUser = null;
User? existingUser;
if (string.IsNullOrWhiteSpace(userIdentifier))
{
if (string.IsNullOrWhiteSpace(email))
Expand Down Expand Up @@ -585,7 +594,8 @@ await _organizationService.AdjustSeatsAsync(organization.Id,

// If the email domain is verified, we can mark the email as verified
var emailVerified = false;
var emailDomain = CoreHelpers.GetEmailDomain(email);
// We cannot reach this code with email currently being null so there is no way to provoke this exception.
var emailDomain = CoreHelpers.GetEmailDomain(email ?? throw new Exception(_i18nService.T("NoEmailFoundWhenMarkingDomainAsVerified")));
if (!string.IsNullOrWhiteSpace(emailDomain))
{
var organizationDomain =
Expand All @@ -596,29 +606,29 @@ await _organizationService.AdjustSeatsAsync(organization.Id,
//--------------------------------------------------
// Scenarios 2 and 3: We need to register a new user
//--------------------------------------------------
var user = new User
var newUser = new User
{
Name = name,
Email = email,
EmailVerified = emailVerified,
ApiKey = CoreHelpers.SecureRandomString(30)
};
await _registerUserCommand.RegisterUser(user);
await _registerUserCommand.RegisterUser(newUser);

// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
var twoFactorPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication);
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
{
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
newUser.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
{
[TwoFactorProviderType.Email] = new TwoFactorProvider
{
MetaData = new Dictionary<string, object> { ["Email"] = user.Email.ToLowerInvariant() },
MetaData = new Dictionary<string, object> { ["Email"] = newUser.Email.ToLowerInvariant() },
Enabled = true
}
});
await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email);
await _userService.UpdateTwoFactorProviderAsync(newUser, TwoFactorProviderType.Email);
}

//-----------------------------------------------------------------
Expand All @@ -631,7 +641,7 @@ await _organizationService.AdjustSeatsAsync(organization.Id,
orgUser = new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user.Id,
UserId = newUser.Id,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Invited
};
Expand All @@ -645,14 +655,14 @@ await _organizationService.AdjustSeatsAsync(organization.Id,
//-----------------------------------------------------------------
else
{
orgUser.UserId = user.Id;
orgUser.UserId = newUser.Id;
await _organizationUserRepository.ReplaceAsync(orgUser);
}

// Create the SsoUser record to link the user to the SSO provider.
await CreateSsoUserRecordAsync(providerUserId, user.Id, organization.Id, orgUser);
await CreateSsoUserRecordAsync(providerUserId, newUser.Id, organization.Id, orgUser);

return (user, organization, orgUser);
return (newUser, organization, orgUser);
}

/// <summary>
Expand All @@ -666,9 +676,9 @@ await _organizationService.AdjustSeatsAsync(organization.Id,
/// <exception cref="Exception">Thrown if the organization cannot be resolved from provider;
/// the organization user cannot be found; or the organization user status is not allowed.</exception>
private async Task PreventOrgUserLoginIfStatusInvalidAsync(
Organization organization,
Organization? organization,
string provider,
OrganizationUser orgUser,
OrganizationUser? orgUser,
User user)
{
// Lazily get organization if not already known
Expand All @@ -690,9 +700,9 @@ private async Task PreventOrgUserLoginIfStatusInvalidAsync(
}
}

private async Task<User> GetUserFromManualLinkingDataAsync(string userIdentifier)
private async Task<User?> GetUserFromManualLinkingDataAsync(string userIdentifier)
{
User user = null;
User? user = null;
var split = userIdentifier.Split(",");
if (split.Length < 2)
{
Expand Down Expand Up @@ -755,12 +765,12 @@ private async Task<Organization> TryGetOrganizationByProviderAsync(string provid
/// <param name="organizationId">Organization id from the provider data.</param>
/// <param name="email">Email to use as a fallback in case of an invited user not in the Org Users
/// table yet.</param>
private async Task<OrganizationUser> TryGetOrganizationUserByUserAndOrgOrEmail(
User user,
private async Task<OrganizationUser?> TryGetOrganizationUserByUserAndOrgOrEmail(
User? user,
Guid organizationId,
string email)
string? email)
{
OrganizationUser orgUser = null;
OrganizationUser? orgUser = null;

// Try to find OrgUser via existing User Id.
// This covers any OrganizationUser state after they have accepted an invite.
Expand All @@ -772,6 +782,8 @@ private async Task<OrganizationUser> TryGetOrganizationUserByUserAndOrgOrEmail(

// 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.
if (email == null) throw new Exception(_i18nService.T("NoEmailFoundWhenFallingBackToEmailForOrgUserLookup"));

orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(organizationId, email);

return orgUser;
Expand Down Expand Up @@ -809,7 +821,7 @@ private void EnsureAcceptedOrConfirmedOrgUserStatus(
}
}

private IActionResult InvalidJson(string errorMessageKey, Exception ex = null)
private IActionResult InvalidJson(string errorMessageKey, Exception? ex = null)
{
Response.StatusCode = ex == null ? 400 : 500;
return Json(new ErrorResponseModel(_i18nService.T(errorMessageKey))
Expand All @@ -820,7 +832,7 @@ private IActionResult InvalidJson(string errorMessageKey, Exception ex = null)
});
}

private string TryGetEmailAddressFromClaims(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
private string? TryGetEmailAddressFromClaims(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
{
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@"));

Expand All @@ -842,7 +854,7 @@ private string TryGetEmailAddressFromClaims(IEnumerable<Claim> claims, IEnumerab
return null;
}

private string GetName(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
private string? GetName(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
{
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value));

Expand Down Expand Up @@ -891,27 +903,27 @@ private void ProcessLoginCallback(AuthenticateResult externalResult,
{
// If the external system sent a session id claim, copy it over
// so we can use it for single sign-out
var sid = externalResult.Principal.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.SessionId);
var sid = externalResult.Principal!.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.SessionId);
if (sid != null)
{
localClaims.Add(new Claim(JwtClaimTypes.SessionId, sid.Value));
}

// If the external provider issued an idToken, we'll keep it for signout
var idToken = externalResult.Properties.GetTokenValue("id_token");
var idToken = externalResult.Properties!.GetTokenValue("id_token");
if (idToken != null)
{
localSignInProps.StoreTokens(
new[] { new AuthenticationToken { Name = "id_token", Value = idToken } });
}
}

private async Task<(string, string, string)> GetLoggedOutDataAsync(string logoutId)
private async Task<(string? logoutId, string? PostLogoutRedirectUri, string? externalAuthenticationScheme)> GetLoggedOutDataAsync(string? logoutId)
{
// Get context information (client name, post logout redirect URI and iframe for federated signout)
var logout = await _interaction.GetLogoutContextAsync(logoutId);
string externalAuthenticationScheme = null;
if (User?.Identity.IsAuthenticated == true)
string? externalAuthenticationScheme = null;
if (User.Identity!.IsAuthenticated)
{
var idp = User.FindFirst(JwtClaimTypes.IdentityProvider)?.Value;
if (idp != null && idp != IdentityServerConstants.LocalIdentityProvider)
Expand Down Expand Up @@ -941,12 +953,12 @@ private void ProcessLoginCallback(AuthenticateResult externalResult,
* Tries to get a user's email from the claims and SSO configuration data or the provider user id if
* the claims email extraction returns null.
*/
private string TryGetEmailAddress(
private string? TryGetEmailAddress(
IEnumerable<Claim> claims,
SsoConfigurationData config,
string providerUserId)
{
var email = TryGetEmailAddressFromClaims(claims, config.GetAdditionalEmailClaimTypes());
string? email = TryGetEmailAddressFromClaims(claims, config.GetAdditionalEmailClaimTypes());

// If email isn't populated from claims and providerUserId has @, assume it is the email.
if (string.IsNullOrWhiteSpace(email) && providerUserId.Contains("@"))
Expand Down
Loading
Loading