using System.Diagnostics; using System.Security.Claims; using Bit.Core; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.IdentityServer; using Bit.Core.Platform.Installations; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Validation; using HandlebarsDotNet; using IdentityModel; using Microsoft.AspNetCore.Identity; #nullable enable namespace Bit.Identity.IdentityServer.RequestValidators; public class CustomTokenRequestValidator : BaseRequestValidator, ICustomTokenRequestValidator { private readonly UserManager _userManager; private readonly IUpdateInstallationCommand _updateInstallationCommand; public CustomTokenRequestValidator( UserManager userManager, IUserService userService, IEventService eventService, IDeviceValidator deviceValidator, ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator, IOrganizationUserRepository organizationUserRepository, IMailService mailService, ILogger logger, ICurrentContext currentContext, GlobalSettings globalSettings, IUserRepository userRepository, IPolicyService policyService, IFeatureService featureService, ISsoConfigRepository ssoConfigRepository, IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder, IUpdateInstallationCommand updateInstallationCommand ) : base( userManager, userService, eventService, deviceValidator, twoFactorAuthenticationValidator, organizationUserRepository, mailService, logger, currentContext, globalSettings, userRepository, policyService, featureService, ssoConfigRepository, userDecryptionOptionsBuilder) { _userManager = userManager; _updateInstallationCommand = updateInstallationCommand; } public async Task ValidateAsync(CustomTokenRequestValidationContext context) { Debug.Assert(context.Result is not null); if (context.Result.ValidatedRequest.GrantType == "refresh_token") { // Force legacy users to the web for migration if (await _userService.IsLegacyUser(GetSubject(context)?.GetSubjectId()) && context.Result.ValidatedRequest.ClientId != "web") { await FailAuthForLegacyUserAsync(null, context); return; } } string[] allowedGrantTypes = ["authorization_code", "client_credentials"]; string clientId = context.Result.ValidatedRequest.ClientId; if (!allowedGrantTypes.Contains(context.Result.ValidatedRequest.GrantType) || clientId.StartsWith("organization") || clientId.StartsWith("installation") || clientId.StartsWith("internal") || context.Result.ValidatedRequest.Client.AllowedScopes.Contains(ApiScopes.ApiSecrets)) { if (context.Result.ValidatedRequest.Client.Properties.TryGetValue("encryptedPayload", out var payload) && !string.IsNullOrWhiteSpace(payload)) { context.Result.CustomResponse = new Dictionary { { "encrypted_payload", payload } }; } if (FeatureService.IsEnabled(FeatureFlagKeys.RecordInstallationLastActivityDate) && context.Result.ValidatedRequest.ClientId.StartsWith("installation")) { var installationIdPart = clientId.Split(".")[1]; await RecordActivityForInstallation(clientId.Split(".")[1]); } return; } await ValidateAsync(context, context.Result.ValidatedRequest, new CustomValidatorRequestContext { }); } protected async override Task ValidateContextAsync(CustomTokenRequestValidationContext context, CustomValidatorRequestContext validatorContext) { Debug.Assert(context.Result is not null); var email = context.Result.ValidatedRequest.Subject?.GetDisplayName() ?? context.Result.ValidatedRequest.ClientClaims ?.FirstOrDefault(claim => claim.Type == JwtClaimTypes.Email)?.Value; if (!string.IsNullOrWhiteSpace(email)) { validatorContext.User = await _userManager.FindByEmailAsync(email); } return validatorContext.User != null; } protected override Task SetSuccessResult(CustomTokenRequestValidationContext context, User user, List claims, Dictionary customResponse) { Debug.Assert(context.Result is not null); context.Result.CustomResponse = customResponse; if (claims?.Any() ?? false) { context.Result.ValidatedRequest.Client.AlwaysSendClientClaims = true; context.Result.ValidatedRequest.Client.ClientClaimsPrefix = string.Empty; foreach (var claim in claims) { context.Result.ValidatedRequest.ClientClaims.Add(claim); } } if (context.Result.CustomResponse == null || user.MasterPassword != null) { return Task.CompletedTask; } // KeyConnector responses below // Apikey login if (context.Result.ValidatedRequest.GrantType == "client_credentials") { if (user.UsesKeyConnector) { // KeyConnectorUrl is configured in the CLI client, we just need to tell the client to use it context.Result.CustomResponse["ApiUseKeyConnector"] = true; context.Result.CustomResponse["ResetMasterPassword"] = false; } return Task.CompletedTask; } // Key connector data should have already been set in the decryption options // for backwards compatibility we set them this way too. We can eventually get rid of this // when all clients don't read them from the existing locations. if (!context.Result.CustomResponse.TryGetValue("UserDecryptionOptions", out var userDecryptionOptionsObj) || userDecryptionOptionsObj is not UserDecryptionOptions userDecryptionOptions) { return Task.CompletedTask; } if (userDecryptionOptions is { KeyConnectorOption: { } }) { context.Result.CustomResponse["KeyConnectorUrl"] = userDecryptionOptions.KeyConnectorOption.KeyConnectorUrl; context.Result.CustomResponse["ResetMasterPassword"] = false; } return Task.CompletedTask; } protected override ClaimsPrincipal? GetSubject(CustomTokenRequestValidationContext context) { Debug.Assert(context.Result is not null); return context.Result.ValidatedRequest.Subject; } [Obsolete("Consider using SetGrantValidationErrorResult instead.")] protected override void SetTwoFactorResult(CustomTokenRequestValidationContext context, Dictionary customResponse) { Debug.Assert(context.Result is not null); context.Result.Error = "invalid_grant"; context.Result.ErrorDescription = "Two factor required."; context.Result.IsError = true; context.Result.CustomResponse = customResponse; } [Obsolete("Consider using SetGrantValidationErrorResult instead.")] protected override void SetSsoResult(CustomTokenRequestValidationContext context, Dictionary customResponse) { Debug.Assert(context.Result is not null); context.Result.Error = "invalid_grant"; context.Result.ErrorDescription = "Sso authentication required."; context.Result.IsError = true; context.Result.CustomResponse = customResponse; } [Obsolete("Consider using SetGrantValidationErrorResult instead.")] protected override void SetErrorResult(CustomTokenRequestValidationContext context, Dictionary customResponse) { Debug.Assert(context.Result is not null); context.Result.Error = "invalid_grant"; context.Result.IsError = true; context.Result.CustomResponse = customResponse; } protected override void SetValidationErrorResult( CustomTokenRequestValidationContext context, CustomValidatorRequestContext requestContext) { Debug.Assert(context.Result is not null); context.Result.Error = requestContext.ValidationErrorResult.Error; context.Result.IsError = requestContext.ValidationErrorResult.IsError; context.Result.ErrorDescription = requestContext.ValidationErrorResult.ErrorDescription; context.Result.CustomResponse = requestContext.CustomResponse; } /// /// To help mentally separate organizations that self host from abandoned /// organizations we hook in to the token refresh event for installations /// to write a simple `DateTime.Now` to the database. /// /// /// This works well because installations don't phone home very often. /// Currently self hosted installations only refresh tokens every 24 /// hours or so for the sake of hooking in to cloud's push relay service. /// If installations ever start refreshing tokens more frequently we may need to /// adjust this to avoid making a bunch of unnecessary database calls! /// private async Task RecordActivityForInstallation(string? installationIdString) { if (!Guid.TryParse(installationIdString, out var installationId)) { return; } await _updateInstallationCommand.UpdateLastActivityDateAsync(installationId); } }