From fe6181f55f2066f4b164bb98749565f1d29b5dc2 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Wed, 28 May 2025 16:44:18 -0400 Subject: [PATCH] fix(identity): [PM-21975] Add Security Stamp claim to persisted grant * Added Security Stamp claim to refresh_token * Linting * Added better comments. * Added clarification to naming of new method. * Updated comments. * Added more comments. * Misspelling --- src/Core/Utilities/CoreHelpers.cs | 1 + src/Identity/IdentityServer/ProfileService.cs | 4 + .../RequestValidators/BaseRequestValidator.cs | 111 ++++++++++++------ 3 files changed, 83 insertions(+), 33 deletions(-) diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index eebcb00738..ab1537afd5 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -712,6 +712,7 @@ public static class CoreHelpers new(Claims.Premium, isPremium ? "true" : "false"), new(JwtClaimTypes.Email, user.Email), new(JwtClaimTypes.EmailVerified, user.EmailVerified ? "true" : "false"), + // TODO: [https://bitwarden.atlassian.net/browse/PM-22171] Remove this since it is already added from the persisted grant new(Claims.SecurityStamp, user.SecurityStamp), }; diff --git a/src/Identity/IdentityServer/ProfileService.cs b/src/Identity/IdentityServer/ProfileService.cs index 09866c6b57..d7d6708374 100644 --- a/src/Identity/IdentityServer/ProfileService.cs +++ b/src/Identity/IdentityServer/ProfileService.cs @@ -72,6 +72,10 @@ public class ProfileService : IProfileService public async Task IsActiveAsync(IsActiveContext context) { + // We add the security stamp claim to the persisted grant when we issue the refresh token. + // IdentityServer will add this claim to the subject, and here we evaluate whether the security stamp that + // was persisted matches the current security stamp of the user. If it does not match, then the user has performed + // an operation that we want to invalidate the refresh token. var securityTokenClaim = context.Subject?.Claims.FirstOrDefault(c => c.Type == Claims.SecurityStamp); var user = await _userService.GetUserByPrincipalAsync(context.Subject); diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index 9afdcacf14..45c0c26b17 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -199,46 +199,26 @@ public abstract class BaseRequestValidator where T : class protected abstract Task ValidateContextAsync(T context, CustomValidatorRequestContext validatorContext); + + /// + /// Responsible for building the response to the client when the user has successfully authenticated. + /// + /// The authenticated user. + /// The current request context. + /// The device used for authentication. + /// Whether to send a 2FA remember token. protected async Task BuildSuccessResultAsync(User user, T context, Device device, bool sendRememberToken) { await _eventService.LogUserEventAsync(user.Id, EventType.User_LoggedIn); - var claims = new List(); + var claims = this.BuildSubjectClaims(user, context, device); - if (device != null) - { - claims.Add(new Claim(Claims.Device, device.Identifier)); - claims.Add(new Claim(Claims.DeviceType, device.Type.ToString())); - } - - var customResponse = new Dictionary(); - if (!string.IsNullOrWhiteSpace(user.PrivateKey)) - { - customResponse.Add("PrivateKey", user.PrivateKey); - } - - if (!string.IsNullOrWhiteSpace(user.Key)) - { - customResponse.Add("Key", user.Key); - } - - customResponse.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user)); - customResponse.Add("ForcePasswordReset", user.ForcePasswordReset); - customResponse.Add("ResetMasterPassword", string.IsNullOrWhiteSpace(user.MasterPassword)); - customResponse.Add("Kdf", (byte)user.Kdf); - customResponse.Add("KdfIterations", user.KdfIterations); - customResponse.Add("KdfMemory", user.KdfMemory); - customResponse.Add("KdfParallelism", user.KdfParallelism); - customResponse.Add("UserDecryptionOptions", await CreateUserDecryptionOptionsAsync(user, device, GetSubject(context))); - - if (sendRememberToken) - { - var token = await _userManager.GenerateTwoFactorTokenAsync(user, - CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember)); - customResponse.Add("TwoFactorToken", token); - } + var customResponse = await BuildCustomResponse(user, context, device, sendRememberToken); await ResetFailedAuthDetailsAsync(user); + + // Once we've built the claims and custom response, we can set the success result. + // We delegate this to the derived classes, as the implementation varies based on the grant type. await SetSuccessResult(context, user, claims, customResponse); } @@ -392,6 +372,71 @@ public abstract class BaseRequestValidator where T : class return new MasterPasswordPolicyResponseModel(await PolicyService.GetMasterPasswordPolicyForUserAsync(user)); } + /// + /// Builds the claims that will be stored on the persisted grant. + /// These claims are supplemented by the claims in the ProfileService when the access token is returned to the client. + /// + /// The authenticated user. + /// The current request context. + /// The device used for authentication. + private List BuildSubjectClaims(User user, T context, Device device) + { + // We are adding the security stamp claim to the list of claims that will be stored in the persisted grant. + // We need this because we check for changes in the stamp to determine if we need to invalidate token refresh requests, + // in the `ProfileService.IsActiveAsync` method. + // If we don't store the security stamp in the persisted grant, we won't have the previous value to compare against. + var claims = new List + { + new Claim(Claims.SecurityStamp, user.SecurityStamp) + }; + + if (device != null) + { + claims.Add(new Claim(Claims.Device, device.Identifier)); + claims.Add(new Claim(Claims.DeviceType, device.Type.ToString())); + } + return claims; + } + + /// + /// Builds the custom response that will be sent to the client upon successful authentication, which + /// includes the information needed for the client to initialize the user's account in state. + /// + /// The authenticated user. + /// The current request context. + /// The device used for authentication. + /// Whether to send a 2FA remember token. + private async Task> BuildCustomResponse(User user, T context, Device device, bool sendRememberToken) + { + var customResponse = new Dictionary(); + if (!string.IsNullOrWhiteSpace(user.PrivateKey)) + { + customResponse.Add("PrivateKey", user.PrivateKey); + } + + if (!string.IsNullOrWhiteSpace(user.Key)) + { + customResponse.Add("Key", user.Key); + } + + customResponse.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user)); + customResponse.Add("ForcePasswordReset", user.ForcePasswordReset); + customResponse.Add("ResetMasterPassword", string.IsNullOrWhiteSpace(user.MasterPassword)); + customResponse.Add("Kdf", (byte)user.Kdf); + customResponse.Add("KdfIterations", user.KdfIterations); + customResponse.Add("KdfMemory", user.KdfMemory); + customResponse.Add("KdfParallelism", user.KdfParallelism); + customResponse.Add("UserDecryptionOptions", await CreateUserDecryptionOptionsAsync(user, device, GetSubject(context))); + + if (sendRememberToken) + { + var token = await _userManager.GenerateTwoFactorTokenAsync(user, + CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember)); + customResponse.Add("TwoFactorToken", token); + } + return customResponse; + } + #nullable enable /// /// Used to create a list of all possible ways the newly authenticated user can decrypt their vault contents