diff --git a/src/Core/Auth/Identity/Claims.cs b/src/Core/Auth/Identity/Claims.cs
index ac78e987ae3e..c64b14eb7047 100644
--- a/src/Core/Auth/Identity/Claims.cs
+++ b/src/Core/Auth/Identity/Claims.cs
@@ -1,4 +1,6 @@
-namespace Bit.Core.Auth.Identity;
+using Duende.IdentityModel;
+
+namespace Bit.Core.Auth.Identity;
public static class Claims
{
@@ -39,6 +41,42 @@ public static class CustomPermissions
public const string ManageResetPassword = "manageresetpassword";
public const string ManageScim = "managescim";
}
+
+ ///
+ /// The claim types the Identity Profile Service will carry over from the existing subject when refreshing a
+ /// token. Everything not in this set is dropped by default — including membership and authorization claim
+ /// types produced by , which are rebuilt from
+ /// the database on every token issuance, so a refreshed token always reflects the member's current
+ /// organization and provider membership rather than a stale grant of access.
+ ///
+ /// This is an allowlist: a new claim type added to BuildIdentityClaims (or elsewhere) is dropped on
+ /// refresh by default unless it is deliberately added here, which fails closed. Only add a claim type here if
+ /// it is a user-identity claim that is safe to persist across a refresh regardless of the member's current
+ /// authorization state.
+ ///
+ ///
+ public static readonly IReadOnlySet UserIdentityClaimTypes = new HashSet
+ {
+ // Added by Duende's GrantValidationResult when the subject is first authenticated; not produced by
+ // BuildIdentityClaims, but required for the refreshed token to keep resolving to the correct principal.
+ JwtClaimTypes.Subject,
+ JwtClaimTypes.AuthenticationMethod,
+ JwtClaimTypes.IdentityProvider,
+ JwtClaimTypes.AuthenticationTime,
+
+ // User-identity claims rebuilt by BuildIdentityClaims on every issuance; safe to carry over as a fallback
+ // for issuances where the user cannot be looked up (see ProfileService.GetProfileDataAsync).
+ JwtClaimTypes.Email,
+ JwtClaimTypes.EmailVerified,
+ JwtClaimTypes.Name,
+ Premium,
+ SecurityStamp,
+
+ // Device-binding claims added when the subject is first authenticated; not produced by BuildIdentityClaims.
+ Device,
+ DeviceType,
+ };
+
public static class SendAccessClaims
{
public const string SendId = "send_id";
diff --git a/src/Identity/IdentityServer/ProfileService.cs b/src/Identity/IdentityServer/ProfileService.cs
index 9ea8fcf471a9..a13e3010cb62 100644
--- a/src/Identity/IdentityServer/ProfileService.cs
+++ b/src/Identity/IdentityServer/ProfileService.cs
@@ -53,7 +53,7 @@ public async Task GetProfileDataAsync(ProfileDataRequestContext context)
// Whenever IdentityServer issues a new access token or services a UserInfo request, it calls
// GetProfileDataAsync to determine which claims to include in the token or response.
// In normal user identity scenarios, we have to look up the user to get their claims and update
- // the issued claims collection as claim info can have changed since the last time the user logged in or the
+ // the issued claims collection as claim info may have changed since the last time the user logged in or the
// last time the token was issued.
var newClaims = new List();
var user = await _userService.GetUserByPrincipalAsync(context.Subject);
@@ -73,13 +73,15 @@ public async Task GetProfileDataAsync(ProfileDataRequestContext context)
}
}
- // filter out any of the new claims
+ // Determine which of the existingClaims to carry over onto the refreshed token.
+ // Only known-safe user-identity claims are carried over; everything else (including membership and
+ // authorization claims) is dropped so each refresh token reflects the user's current membership and
+ // authorization state.
var existingClaimsToKeep = existingClaims
.Where(c =>
- // Drop any org claims
- !c.Type.StartsWith("org") &&
- // If we have no new claims, then keep the existing claims
- // If we have new claims, then keep the existing claim if it does not match a new claim type
+ // only allow user-identity claims to be carried over from the existing subject
+ Claims.UserIdentityClaimTypes.Contains(c.Type) &&
+ // keep the existing claim only when it is not being overwritten by a freshly built claim of the same type
(newClaims.Count == 0 || !newClaims.Any(nc => nc.Type == c.Type))
).ToList();
@@ -92,7 +94,8 @@ public async Task GetProfileDataAsync(ProfileDataRequestContext context)
public async Task IsActiveAsync(IsActiveContext context)
{
- // Send Tokens are not refreshed so when the token has expired the user must request a new one via the authentication method assigned to the send.
+ // Send Tokens are not refreshed so when the token has expired the user must request a new one via
+ // the authentication method assigned to the send.
if (context.Client.ClientId == BitwardenClient.Send)
{
context.IsActive = true;
diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs
index 281f2eb2328c..68c3a0f197f5 100644
--- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs
+++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs
@@ -216,29 +216,6 @@ public async Task TokenEndpoint_GrantTypePassword_WithNonOwnerOrAdmin_WithSsoPol
await AssertRequiredSsoAuthenticationResponseAsync(context);
}
- [Theory, BitAutoData, RegisterFinishRequestModelCustomize]
- public async Task TokenEndpoint_GrantTypeRefreshToken_Success(RegisterFinishRequestModel requestModel)
- {
- requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS;
- var localFactory = new IdentityApplicationFactory();
-
- var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel);
-
- var (_, refreshToken) = await localFactory.TokenFromPasswordAsync(
- requestModel.Email, requestModel.MasterPasswordHash);
-
- var context = await localFactory.Server.PostAsync("/connect/token",
- new FormUrlEncodedContent(new Dictionary
- {
- { "grant_type", "refresh_token" },
- { "client_id", "web" },
- { "refresh_token", refreshToken },
- }));
-
- using var body = await AssertDefaultTokenBodyAsync(context);
- AssertRefreshTokenExists(body.RootElement);
- }
-
[Theory, BitAutoData, RegisterFinishRequestModelCustomize]
public async Task TokenEndpoint_GrantTypeClientCredentials_Success(RegisterFinishRequestModel model)
{
diff --git a/test/Identity.IntegrationTest/Grants/RefreshTokenGrantTests.cs b/test/Identity.IntegrationTest/Grants/RefreshTokenGrantTests.cs
new file mode 100644
index 000000000000..6f2ce3277c93
--- /dev/null
+++ b/test/Identity.IntegrationTest/Grants/RefreshTokenGrantTests.cs
@@ -0,0 +1,342 @@
+using System.Text;
+using System.Text.Json;
+using Bit.Core.AdminConsole.Entities;
+using Bit.Core.Auth.Identity;
+using Bit.Core.Auth.Models.Api.Request.Accounts;
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Models.Data;
+using Bit.Core.Repositories;
+using Bit.Core.Test.Auth.AutoFixture;
+using Bit.Identity.IdentityServer;
+using Bit.Identity.IdentityServer.RequestValidators;
+using Bit.IntegrationTestCommon.Factories;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Bit.Test.Common.Helpers;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Identity.IntegrationTest.Grants;
+
+///
+/// Integration tests for the refresh_token grant: redeeming a refresh token at /connect/token to
+/// obtain a new access token. On this flow IdentityServer re-invokes the Profile Service, which rebuilds the
+/// user's membership claims from the database, so a refreshed token always reflects the member's current state.
+///
+public class RefreshTokenGrantTests : IClassFixture
+{
+ private static readonly KeysRequestModel TEST_ACCOUNT_KEYS = new KeysRequestModel
+ {
+ AccountKeys = null,
+ PublicKey = "public-key",
+ EncryptedPrivateKey = "encrypted-private-key",
+ };
+
+ private const int SecondsInMinute = 60;
+ private const int MinutesInHour = 60;
+ private const int SecondsInHour = SecondsInMinute * MinutesInHour;
+ private readonly IdentityApplicationFactory _factory;
+
+ public RefreshTokenGrantTests(IdentityApplicationFactory factory)
+ {
+ _factory = factory;
+
+ // Bypass client version gating to isolate refresh-token grant behavior.
+ _factory.SubstituteService(svc =>
+ {
+ svc.Validate(Arg.Any(), Arg.Any())
+ .Returns(true);
+ });
+
+ ReinitializeDbForTests(_factory);
+ }
+
+ [Theory, BitAutoData, RegisterFinishRequestModelCustomize]
+ public async Task RefreshToken_Success(RegisterFinishRequestModel requestModel)
+ {
+ requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS;
+ var localFactory = new IdentityApplicationFactory();
+
+ await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel);
+
+ var (_, refreshToken) = await localFactory.TokenFromPasswordAsync(
+ requestModel.Email, requestModel.MasterPasswordHash);
+
+ using var formContent = new FormUrlEncodedContent(new Dictionary
+ {
+ { "grant_type", "refresh_token" },
+ { "client_id", "web" },
+ { "refresh_token", refreshToken },
+ });
+ var context = await localFactory.Server.PostAsync("/connect/token", formContent);
+
+ using var body = await AssertDefaultTokenBodyAsync(context);
+ AssertRefreshTokenExists(body.RootElement);
+ }
+
+ ///
+ /// Membership claims are rebuilt from the database on every token issuance, so a refreshed access token
+ /// reflects the member's current organization membership. When a user is removed from an organization, the
+ /// organization claim present on the earlier token is not re-issued on refresh.
+ ///
+ [Theory, BitAutoData, RegisterFinishRequestModelCustomize]
+ public async Task RefreshToken_ReflectsCurrentOrganizationMembership(
+ RegisterFinishRequestModel requestModel)
+ {
+ requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS;
+ var localFactory = new IdentityApplicationFactory();
+
+ var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel);
+
+ // Give the user an organization membership so the issued token carries an organization claim.
+ var organizationId = Guid.NewGuid();
+ await CreateOrganizationMembershipAsync(localFactory, organizationId, user.Email, OrganizationUserType.Owner);
+
+ // Initial password login: the access token reflects the current (present) membership.
+ var (accessToken, refreshToken) = await localFactory.TokenFromPasswordAsync(
+ requestModel.Email, requestModel.MasterPasswordHash);
+ var initialClaims = ReadJwtPayload(accessToken);
+ Assert.True(JwtPayloadContainsClaimValue(initialClaims, Claims.OrganizationOwner, organizationId.ToString()));
+
+ // The user is removed from the organization.
+ var organizationUserRepository = localFactory.Services.GetRequiredService();
+ var membership = await organizationUserRepository.GetByOrganizationAsync(organizationId, user.Id);
+ Assert.NotNull(membership);
+ await organizationUserRepository.DeleteAsync(membership);
+
+ // Refresh the token: the rebuilt token reflects current membership, so the organization claim is gone.
+ using var formContent = new FormUrlEncodedContent(new Dictionary
+ {
+ { "grant_type", "refresh_token" },
+ { "client_id", "web" },
+ { "refresh_token", refreshToken },
+ });
+ var context = await localFactory.Server.PostAsync("/connect/token", formContent);
+
+ using var body = await AssertDefaultTokenBodyAsync(context);
+ var refreshedAccessToken = AssertAccessTokenExists(body.RootElement);
+ var refreshedClaims = ReadJwtPayload(refreshedAccessToken);
+ Assert.False(refreshedClaims.TryGetProperty(Claims.OrganizationOwner, out _));
+ }
+
+ ///
+ /// Membership claims are rebuilt from the database on every token issuance. A Custom-role member's granted
+ /// permissions are carried as individual claims; when a permission is removed, the corresponding claim must
+ /// not be re-issued on refresh.
+ ///
+ [Theory, BitAutoData, RegisterFinishRequestModelCustomize]
+ public async Task RefreshToken_ReflectsRemovedCustomPermission(
+ RegisterFinishRequestModel requestModel)
+ {
+ requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS;
+ var localFactory = new IdentityApplicationFactory();
+
+ var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel);
+
+ // Give the user a Custom membership with the ManageUsers permission granted.
+ var organizationId = Guid.NewGuid();
+ await CreateOrganizationMembershipAsync(localFactory, organizationId, user.Email, OrganizationUserType.Custom,
+ permissions: new Permissions { ManageUsers = true });
+
+ // Initial password login: the access token reflects the granted permission.
+ var (accessToken, refreshToken) = await localFactory.TokenFromPasswordAsync(
+ requestModel.Email, requestModel.MasterPasswordHash);
+ var initialClaims = ReadJwtPayload(accessToken);
+ Assert.True(JwtPayloadContainsClaimValue(initialClaims, Claims.CustomPermissions.ManageUsers, organizationId.ToString()));
+
+ // The ManageUsers permission is removed.
+ var organizationUserRepository = localFactory.Services.GetRequiredService();
+ var membership = await organizationUserRepository.GetByOrganizationAsync(organizationId, user.Id);
+ Assert.NotNull(membership);
+ membership.SetPermissions(new Permissions { ManageUsers = false });
+ await organizationUserRepository.ReplaceAsync(membership);
+
+ // Refresh the token: the rebuilt token reflects current permissions, so the ManageUsers claim is gone.
+ using var formContent = new FormUrlEncodedContent(new Dictionary
+ {
+ { "grant_type", "refresh_token" },
+ { "client_id", "web" },
+ { "refresh_token", refreshToken },
+ });
+ var context = await localFactory.Server.PostAsync("/connect/token", formContent);
+
+ using var body = await AssertDefaultTokenBodyAsync(context);
+ var refreshedAccessToken = AssertAccessTokenExists(body.RootElement);
+ var refreshedClaims = ReadJwtPayload(refreshedAccessToken);
+ Assert.False(refreshedClaims.TryGetProperty(Claims.CustomPermissions.ManageUsers, out _));
+ }
+
+ ///
+ /// Membership claims are rebuilt from the database on every token issuance. Secrets Manager access is granted
+ /// per-organization; when access is removed, the corresponding claim must not be re-issued on refresh.
+ ///
+ [Theory, BitAutoData, RegisterFinishRequestModelCustomize]
+ public async Task RefreshToken_ReflectsRemovedSecretsManagerAccess(
+ RegisterFinishRequestModel requestModel)
+ {
+ requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS;
+ var localFactory = new IdentityApplicationFactory();
+
+ var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel);
+
+ // Give the user Secrets Manager access on the organization.
+ var organizationId = Guid.NewGuid();
+ await CreateOrganizationMembershipAsync(localFactory, organizationId, user.Email, OrganizationUserType.User,
+ accessSecretsManager: true);
+
+ // Initial password login: the access token reflects Secrets Manager access.
+ var (accessToken, refreshToken) = await localFactory.TokenFromPasswordAsync(
+ requestModel.Email, requestModel.MasterPasswordHash);
+ var initialClaims = ReadJwtPayload(accessToken);
+ Assert.True(JwtPayloadContainsClaimValue(initialClaims, Claims.SecretsManagerAccess, organizationId.ToString()));
+
+ // Secrets Manager access is removed.
+ var organizationUserRepository = localFactory.Services.GetRequiredService();
+ var membership = await organizationUserRepository.GetByOrganizationAsync(organizationId, user.Id);
+ Assert.NotNull(membership);
+ membership.AccessSecretsManager = false;
+ await organizationUserRepository.ReplaceAsync(membership);
+
+ // Refresh the token: the rebuilt token reflects current access, so the Secrets Manager claim is gone.
+ using var formContent = new FormUrlEncodedContent(new Dictionary
+ {
+ { "grant_type", "refresh_token" },
+ { "client_id", "web" },
+ { "refresh_token", refreshToken },
+ });
+ var context = await localFactory.Server.PostAsync("/connect/token", formContent);
+
+ using var body = await AssertDefaultTokenBodyAsync(context);
+ var refreshedAccessToken = AssertAccessTokenExists(body.RootElement);
+ var refreshedClaims = ReadJwtPayload(refreshedAccessToken);
+ Assert.False(refreshedClaims.TryGetProperty(Claims.SecretsManagerAccess, out _));
+ }
+
+ private static async Task CreateOrganizationMembershipAsync(
+ IdentityApplicationFactory localFactory,
+ Guid organizationId,
+ string username,
+ OrganizationUserType organizationUserType,
+ Permissions permissions = null,
+ bool accessSecretsManager = false)
+ {
+ var userRepository = localFactory.Services.GetRequiredService();
+ var organizationRepository = localFactory.Services.GetRequiredService();
+ var organizationUserRepository = localFactory.Services.GetRequiredService();
+
+ await organizationRepository.CreateAsync(new Organization
+ {
+ Id = organizationId,
+ Name = $"Org Name | {organizationId}",
+ Enabled = true,
+ Plan = "Enterprise",
+ BillingEmail = $"billing-email+{organizationId}@example.com",
+ UseSecretsManager = accessSecretsManager,
+ });
+
+ var user = await userRepository.GetByEmailAsync(username);
+ var organizationUser = new OrganizationUser
+ {
+ OrganizationId = organizationId,
+ UserId = user.Id,
+ Status = OrganizationUserStatusType.Confirmed,
+ Type = organizationUserType,
+ AccessSecretsManager = accessSecretsManager,
+ };
+ if (permissions != null)
+ {
+ organizationUser.SetPermissions(permissions);
+ }
+
+ await organizationUserRepository.CreateAsync(organizationUser);
+ }
+
+ ///
+ /// Decodes the payload segment of a JWT into its JSON claims for inspection.
+ ///
+ private static JsonElement ReadJwtPayload(string jwt)
+ {
+ var payload = jwt.Split('.')[1].Replace('-', '+').Replace('_', '/');
+ payload += (payload.Length % 4) switch
+ {
+ 2 => "==",
+ 3 => "=",
+ _ => string.Empty
+ };
+
+ var json = Encoding.UTF8.GetString(Convert.FromBase64String(payload));
+ return JsonDocument.Parse(json).RootElement.Clone();
+ }
+
+ ///
+ /// A claim may be serialized on the token as a single value or as an array of values, so both shapes are checked.
+ ///
+ private static bool JwtPayloadContainsClaimValue(JsonElement payload, string claimType, string value)
+ {
+ if (!payload.TryGetProperty(claimType, out var claim))
+ {
+ return false;
+ }
+
+ return claim.ValueKind == JsonValueKind.Array
+ ? claim.EnumerateArray().Any(element => element.GetString() == value)
+ : claim.GetString() == value;
+ }
+
+ private static async Task AssertDefaultTokenBodyAsync(HttpContext httpContext, string expectedScope = "api offline_access", int expectedExpiresIn = SecondsInHour * 1)
+ {
+ var body = await AssertHelper.AssertResponseTypeIs(httpContext);
+ var root = body.RootElement;
+
+ Assert.Equal(JsonValueKind.Object, root.ValueKind);
+ AssertAccessTokenExists(root);
+ AssertExpiresIn(root, expectedExpiresIn);
+ AssertTokenType(root);
+ AssertScope(root, expectedScope);
+ return body;
+ }
+
+ private static void AssertTokenType(JsonElement tokenResponse)
+ {
+ var tokenTypeProperty = AssertHelper.AssertJsonProperty(tokenResponse, "token_type", JsonValueKind.String).GetString();
+ Assert.Equal("Bearer", tokenTypeProperty);
+ }
+
+ private static int AssertExpiresIn(JsonElement tokenResponse, int expectedExpiresIn = 3600)
+ {
+ var expiresIn = AssertHelper.AssertJsonProperty(tokenResponse, "expires_in", JsonValueKind.Number).GetInt32();
+ Assert.Equal(expectedExpiresIn, expiresIn);
+ return expiresIn;
+ }
+
+ private static string AssertAccessTokenExists(JsonElement tokenResponse)
+ {
+ return AssertHelper.AssertJsonProperty(tokenResponse, "access_token", JsonValueKind.String).GetString();
+ }
+
+ private static string AssertRefreshTokenExists(JsonElement tokenResponse)
+ {
+ return AssertHelper.AssertJsonProperty(tokenResponse, "refresh_token", JsonValueKind.String).GetString();
+ }
+
+ private static string AssertScopeExists(JsonElement tokenResponse)
+ {
+ return AssertHelper.AssertJsonProperty(tokenResponse, "scope", JsonValueKind.String).GetString();
+ }
+
+ private static void AssertScope(JsonElement tokenResponse, string expectedScope)
+ {
+ var actualScope = AssertScopeExists(tokenResponse);
+ Assert.Equal(expectedScope, actualScope);
+ }
+
+ private void ReinitializeDbForTests(IdentityApplicationFactory factory)
+ {
+ var databaseContext = factory.GetDatabaseContext();
+ databaseContext.Policies.RemoveRange(databaseContext.Policies);
+ databaseContext.OrganizationUsers.RemoveRange(databaseContext.OrganizationUsers);
+ databaseContext.Organizations.RemoveRange(databaseContext.Organizations);
+ databaseContext.Users.RemoveRange(databaseContext.Users);
+ databaseContext.SaveChanges();
+ }
+}
diff --git a/test/Identity.Test/IdentityServer/ProfileServiceTests.cs b/test/Identity.Test/IdentityServer/ProfileServiceTests.cs
index 3ff458c6c179..8b482819806f 100644
--- a/test/Identity.Test/IdentityServer/ProfileServiceTests.cs
+++ b/test/Identity.Test/IdentityServer/ProfileServiceTests.cs
@@ -430,10 +430,104 @@ public async Task GetProfileDataAsync_WithProviders_IncludesProviderClaims(
}
///
- /// Evaluates the happy path for the core session invalidation mechanism.
- /// Critical events (e.g., password change) update the security stamp, and any subsequent request through
- /// this service should expose the stamp as invalid. A found user and matching security stamp
- /// prove out an active session.
+ /// Provider claims are membership-derived and, like organization claims, are rebuilt fresh on every token
+ /// issuance. A provider claim carried on the existing subject (e.g. from the refresh-token flow) is not
+ /// re-issued, so a refreshed token reflects the user's current provider membership.
+ ///
+ ///
+ [Theory]
+ [BitAutoData(BitwardenClient.Web)]
+ [BitAutoData(BitwardenClient.Browser)]
+ [BitAutoData(BitwardenClient.Cli)]
+ [BitAutoData(BitwardenClient.Desktop)]
+ [BitAutoData(BitwardenClient.Mobile)]
+ [BitAutoData(BitwardenClient.DirectoryConnector)]
+ public async Task GetProfileDataAsync_FiltersOutProviderClaimsFromExisting(
+ string client,
+ [AuthFixtures.ProfileDataRequestContext]
+ ProfileDataRequestContext context,
+ User user)
+ {
+ context.Client.ClientId = client;
+ user.Id = Guid.Parse(context.Subject.FindFirst("sub").Value);
+
+ var existingClaims = new[]
+ {
+ new Claim(Claims.ProviderAdmin, Guid.NewGuid().ToString()),
+ new Claim(Claims.ProviderServiceUser, Guid.NewGuid().ToString()), new Claim("email", "test@example.com"),
+ new Claim("name", "Test User")
+ };
+ context.Subject = new ClaimsPrincipal(new ClaimsIdentity(existingClaims));
+
+ _userService.GetUserByPrincipalAsync(context.Subject).Returns(user);
+ _licensingService.ValidateUserPremiumAsync(user).Returns(false);
+ _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)
+ .Returns(new List());
+ _currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id)
+ .Returns(new List());
+
+ await _sut.GetProfileDataAsync(context);
+
+ Assert.DoesNotContain(context.IssuedClaims, issuedClaim => issuedClaim.Type.StartsWith("provider"));
+ Assert.Contains(context.IssuedClaims, issuedClaim => issuedClaim.Type == "email");
+ Assert.Contains(context.IssuedClaims, issuedClaim => issuedClaim.Type == "name");
+ }
+
+ ///
+ /// When a provider user's role changes (e.g. from ProviderAdmin to ServiceUser), the freshly issued token
+ /// reflects only their current role. A provider claim for the prior role carried on the existing subject is not
+ /// re-issued alongside the newly built claim, so the refreshed token matches current provider membership.
+ ///
+ ///
+ [Theory]
+ [BitAutoData(BitwardenClient.Web)]
+ [BitAutoData(BitwardenClient.Browser)]
+ [BitAutoData(BitwardenClient.Cli)]
+ [BitAutoData(BitwardenClient.Desktop)]
+ [BitAutoData(BitwardenClient.Mobile)]
+ [BitAutoData(BitwardenClient.DirectoryConnector)]
+ public async Task GetProfileDataAsync_ProviderRoleChange_ReflectsCurrentRole(
+ string client,
+ [AuthFixtures.ProfileDataRequestContext]
+ ProfileDataRequestContext context,
+ User user)
+ {
+ context.Client.ClientId = client;
+ user.Id = Guid.Parse(context.Subject.FindFirst("sub").Value);
+
+ var providerId = Guid.NewGuid();
+ var existingClaims = new[]
+ {
+ // The user was previously a ProviderAdmin for this provider.
+ new Claim(Claims.ProviderAdmin, providerId.ToString())
+ };
+ context.Subject = new ClaimsPrincipal(new ClaimsIdentity(existingClaims));
+
+ // The user has since been demoted to ServiceUser for the same provider.
+ var providerMemberships = new List
+ {
+ new() { Id = providerId, Type = ProviderUserType.ServiceUser }
+ };
+
+ _userService.GetUserByPrincipalAsync(context.Subject).Returns(user);
+ _licensingService.ValidateUserPremiumAsync(user).Returns(false);
+ _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)
+ .Returns(new List());
+ _currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id)
+ .Returns(providerMemberships);
+
+ await _sut.GetProfileDataAsync(context);
+
+ Assert.DoesNotContain(context.IssuedClaims,
+ issuedClaim => issuedClaim.Type == Claims.ProviderAdmin && issuedClaim.Value == providerId.ToString());
+ Assert.Contains(context.IssuedClaims,
+ issuedClaim => issuedClaim.Type == Claims.ProviderServiceUser && issuedClaim.Value == providerId.ToString());
+ }
+
+ ///
+ /// Evaluates the happy path for refresh-token validation via the security stamp.
+ /// Certain account changes (e.g., password change) update the security stamp, so a request whose stamp no
+ /// longer matches is treated as inactive. A found user and matching security stamp prove out an active session.
///
[Theory]
[BitAutoData(BitwardenClient.Web)]
@@ -464,9 +558,9 @@ public async Task IsActiveAsync_SecurityStampMatches_ReturnsTrue(
}
///
- /// Critical events (e.g., password change) update the security stamp, and any subsequent request through
- /// this service should expose the stamp as invalid.
- /// See also examples for stamp invalidation (non-exhaustive):
+ /// Certain account changes (e.g., password change) update the security stamp, so a request whose stamp no
+ /// longer matches the user's current stamp is treated as inactive.
+ /// See also examples that update the security stamp (non-exhaustive):
///
///
///