Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
40 changes: 39 additions & 1 deletion src/Core/Auth/Identity/Claims.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
ο»Ώnamespace Bit.Core.Auth.Identity;
ο»Ώusing Duende.IdentityModel;

namespace Bit.Core.Auth.Identity;

public static class Claims
{
Expand Down Expand Up @@ -39,6 +41,42 @@ public static class CustomPermissions
public const string ManageResetPassword = "manageresetpassword";
public const string ManageScim = "managescim";
}

/// <summary>
/// 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 <see cref="Bit.Core.Utilities.CoreHelpers.BuildIdentityClaims"/>, 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.
/// <para>
/// This is an allowlist: a new claim type added to <c>BuildIdentityClaims</c> (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.
/// </para>
/// </summary>
public static readonly IReadOnlySet<string> UserIdentityClaimTypes = new HashSet<string>
{
// 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";
Expand Down
17 changes: 10 additions & 7 deletions src/Identity/IdentityServer/ProfileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Claim>();
var user = await _userService.GetUserByPrincipalAsync(context.Subject);
Expand All @@ -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();

Expand All @@ -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;
Expand Down
23 changes: 0 additions & 23 deletions test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
{
{ "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)
{
Expand Down
Loading
Loading