diff --git a/src/NuGetGallery.Services/Authentication/Federated/FederatedCredentialConfiguration.cs b/src/NuGetGallery.Services/Authentication/Federated/FederatedCredentialConfiguration.cs index ab95bf6e5f..f4c3965e24 100644 --- a/src/NuGetGallery.Services/Authentication/Federated/FederatedCredentialConfiguration.cs +++ b/src/NuGetGallery.Services/Authentication/Federated/FederatedCredentialConfiguration.cs @@ -3,6 +3,8 @@ #nullable enable +using System; + namespace NuGetGallery.Services.Authentication { public interface IFederatedCredentialConfiguration @@ -12,10 +14,17 @@ public interface IFederatedCredentialConfiguration /// service itself (not shared between multiple services). This is used only for Entra ID token validation. /// string? EntraIdAudience { get; } + + /// + /// How long the short lived API keys should last. + /// + TimeSpan ShortLivedApiKeyDuration { get; } } public class FederatedCredentialConfiguration : IFederatedCredentialConfiguration { public string? EntraIdAudience { get; set; } + + public TimeSpan ShortLivedApiKeyDuration { get; set; } = TimeSpan.FromMinutes(15); } } diff --git a/src/NuGetGallery.Services/Authentication/Federated/FederatedCredentialService.cs b/src/NuGetGallery.Services/Authentication/Federated/FederatedCredentialService.cs new file mode 100644 index 0000000000..b25e508b0f --- /dev/null +++ b/src/NuGetGallery.Services/Authentication/Federated/FederatedCredentialService.cs @@ -0,0 +1,208 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Data; +using System.Threading.Tasks; +using NuGet.Services.Entities; +using NuGetGallery.Authentication; +using NuGetGallery.Infrastructure.Authentication; + +#nullable enable + +namespace NuGetGallery.Services.Authentication +{ + public interface IFederatedCredentialService + { + /// + /// Generates a short-lived API key for the user based on the provided bearer token. The user's federated + /// credential policies are used to evaluate the bearer token and find desired API key settings. + /// + /// The username of the user account that owns the federated credential policy. + /// The bearer token to use for federated credential evaluation. + /// The result, successful if is . + Task GenerateApiKeyAsync(string username, string bearerToken); + } + + public class FederatedCredentialService : IFederatedCredentialService + { + private readonly IUserService _userService; + private readonly IFederatedCredentialRepository _repository; + private readonly IFederatedCredentialEvaluator _evaluator; + private readonly ICredentialBuilder _credentialBuilder; + private readonly IAuthenticationService _authenticationService; + private readonly IDateTimeProvider _dateTimeProvider; + private readonly IFeatureFlagService _featureFlagService; + private readonly IFederatedCredentialConfiguration _configuration; + + public FederatedCredentialService( + IUserService userService, + IFederatedCredentialRepository repository, + IFederatedCredentialEvaluator evaluator, + ICredentialBuilder credentialBuilder, + IAuthenticationService authenticationService, + IDateTimeProvider dateTimeProvider, + IFeatureFlagService featureFlagService, + IFederatedCredentialConfiguration configuration) + { + _userService = userService ?? throw new ArgumentNullException(nameof(userService)); + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator)); + _credentialBuilder = credentialBuilder ?? throw new ArgumentNullException(nameof(credentialBuilder)); + _authenticationService = authenticationService ?? throw new ArgumentNullException(nameof(authenticationService)); + _dateTimeProvider = dateTimeProvider ?? throw new ArgumentNullException(nameof(dateTimeProvider)); + _featureFlagService = featureFlagService ?? throw new ArgumentNullException(nameof(featureFlagService)); + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + public async Task GenerateApiKeyAsync(string username, string bearerToken) + { + var currentUser = _userService.FindByUsername(username, includeDeleted: false); + if (currentUser is null) + { + return NoMatchingPolicy(username); + } + + var policies = _repository.GetPoliciesCreatedByUser(currentUser.Key); + var policyEvaluation = await _evaluator.GetMatchingPolicyAsync(policies, bearerToken); + switch (policyEvaluation.Type) + { + case EvaluatedFederatedCredentialPoliciesType.BadToken: + return GenerateApiKeyResult.Unauthorized(policyEvaluation.UserError); + case EvaluatedFederatedCredentialPoliciesType.NoMatchingPolicy: + return NoMatchingPolicy(username); + case EvaluatedFederatedCredentialPoliciesType.MatchedPolicy: + break; + default: + throw new NotImplementedException("Unexpected result type: " + policyEvaluation.Type); + } + + // perform validations after the policy evaluation to avoid leaking information about the related users + + var currentUserError = ValidateCurrentUser(currentUser); + if (currentUserError != null) + { + return currentUserError; + } + + var packageOwner = _userService.FindByKey(policyEvaluation.MatchedPolicy.PackageOwnerUserKey); + policyEvaluation.MatchedPolicy.PackageOwner = packageOwner; + var packageOwnerError = ValidatePackageOwner(packageOwner); + if (packageOwnerError != null) + { + return packageOwnerError; + } + + var apiKeyCredential = _credentialBuilder.CreateShortLivedApiKey( + _configuration.ShortLivedApiKeyDuration, + policyEvaluation.MatchedPolicy, + out var plaintextApiKey); + if (!_credentialBuilder.VerifyScopes(currentUser, apiKeyCredential.Scopes)) + { + return GenerateApiKeyResult.BadRequest( + $"The scopes on the generated API key are not valid. " + + $"Confirm that you still have permissions to operate on behalf of package owner '{packageOwner.Username}'."); + } + + var saveError = await SaveAndRejectReplayAsync(currentUser, policyEvaluation, apiKeyCredential); + if (saveError is not null) + { + return saveError; + } + + return GenerateApiKeyResult.Created(plaintextApiKey, apiKeyCredential.Expires!.Value); + } + + private static GenerateApiKeyResult NoMatchingPolicy(string username) + { + return GenerateApiKeyResult.Unauthorized($"No matching federated credential trust policy owned by user '{username}' was found."); + } + + private async Task SaveAndRejectReplayAsync( + User currentUser, + EvaluatedFederatedCredentialPolicies evaluation, + Credential apiKeyCredential) + { + evaluation.MatchedPolicy.LastMatched = _dateTimeProvider.UtcNow; + + await _repository.SaveFederatedCredentialAsync(evaluation.FederatedCredential, saveChanges: false); + + try + { + await _authenticationService.AddCredential(currentUser, apiKeyCredential); + } + catch (DataException ex) when (ex.IsSqlUniqueConstraintViolation()) + { + return GenerateApiKeyResult.Unauthorized("This bearer token has already been used. A new bearer token must be used for each request."); + } + + return null; + } + + private static GenerateApiKeyResult? ValidateCurrentUser(User currentUser) + { + if (currentUser is Organization) + { + return GenerateApiKeyResult.BadRequest( + "Generating fetching tokens directly for organizations is not supported. " + + "The federated credential trust policy is created on the profile of one of the organization's administrators and is scoped to the organization in the policy."); + } + + var error = GetUserStateError(currentUser); + if (error != null) + { + return error; + } + + return null; + } + + private GenerateApiKeyResult? ValidatePackageOwner(User? packageOwner) + { + if (packageOwner is null) + { + return GenerateApiKeyResult.BadRequest("The package owner of the match federated credential trust policy not longer exists."); + } + + var error = GetUserStateError(packageOwner); + if (error != null) + { + return error; + } + + if (!_featureFlagService.CanUseFederatedCredentials(packageOwner)) + { + return GenerateApiKeyResult.BadRequest(NotInFlightMessage(packageOwner)); + } + + return null; + } + + private static string NotInFlightMessage(User packageOwner) + { + return $"The package owner '{packageOwner.Username}' is not enabled to use federated credentials."; + } + + private static GenerateApiKeyResult? GetUserStateError(User user) + { + var orgOrUser = user is Organization ? "organization" : "user"; + + if (user.IsDeleted) + { + return GenerateApiKeyResult.BadRequest($"The {orgOrUser} '{user.Username}' is deleted."); + } + + if (user.IsLocked) + { + return GenerateApiKeyResult.BadRequest($"The {orgOrUser} '{user.Username}' is locked."); + } + + if (!user.Confirmed) + { + return GenerateApiKeyResult.BadRequest($"The {orgOrUser} '{user.Username}' does not have a confirmed email address."); + } + + return null; + } + } +} diff --git a/src/NuGetGallery.Services/Authentication/Federated/GenerateApiKeyResult.cs b/src/NuGetGallery.Services/Authentication/Federated/GenerateApiKeyResult.cs new file mode 100644 index 0000000000..3fdf76e353 --- /dev/null +++ b/src/NuGetGallery.Services/Authentication/Federated/GenerateApiKeyResult.cs @@ -0,0 +1,47 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +#nullable enable + +namespace NuGetGallery.Services.Authentication +{ + public enum GenerateApiKeyResultType + { + Created, + BadRequest, + Unauthorized, + } + + public class GenerateApiKeyResult + { + private readonly string? _userMessage; + private readonly string? _plaintextApiKey; + private readonly DateTimeOffset? _expires; + + private GenerateApiKeyResult( + GenerateApiKeyResultType type, + string? userMessage = null, + string? plaintextApiKey = null, + DateTimeOffset? expires = null) + { + Type = type; + _userMessage = userMessage; + _plaintextApiKey = plaintextApiKey; + _expires = expires; + } + + public GenerateApiKeyResultType Type { get; } + public string UserMessage => _userMessage ?? throw new InvalidOperationException(); + public string PlaintextApiKey => _plaintextApiKey ?? throw new InvalidOperationException(); + public DateTimeOffset Expires => _expires ?? throw new InvalidOperationException(); + + public static GenerateApiKeyResult Created(string plaintextApiKey, DateTimeOffset expires) + => new(GenerateApiKeyResultType.Created, plaintextApiKey: plaintextApiKey, expires: expires); + + public static GenerateApiKeyResult BadRequest(string userMessage) => new(GenerateApiKeyResultType.BadRequest, userMessage); + public static GenerateApiKeyResult Unauthorized(string userMessage) => new(GenerateApiKeyResultType.Unauthorized, userMessage); + } +} diff --git a/src/NuGetGallery/App_Start/DefaultDependenciesModule.cs b/src/NuGetGallery/App_Start/DefaultDependenciesModule.cs index 06f1ead018..4d4a96e046 100644 --- a/src/NuGetGallery/App_Start/DefaultDependenciesModule.cs +++ b/src/NuGetGallery/App_Start/DefaultDependenciesModule.cs @@ -588,6 +588,11 @@ private static void ConfigureFederatedCredentials(ContainerBuilder builder, Conf .RegisterType() .As() .InstancePerLifetimeScope(); + + builder + .RegisterType() + .As() + .InstancePerLifetimeScope(); } // Internal for testing purposes diff --git a/src/NuGetGallery/Web.config b/src/NuGetGallery/Web.config index dd5d2bdfbd..86fc8533cc 100644 --- a/src/NuGetGallery/Web.config +++ b/src/NuGetGallery/Web.config @@ -203,6 +203,7 @@ + diff --git a/tests/NuGetGallery.Facts/Authentication/Federated/FederatedCredentialServiceFacts.cs b/tests/NuGetGallery.Facts/Authentication/Federated/FederatedCredentialServiceFacts.cs new file mode 100644 index 0000000000..035dfd4aa1 --- /dev/null +++ b/tests/NuGetGallery.Facts/Authentication/Federated/FederatedCredentialServiceFacts.cs @@ -0,0 +1,369 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Data.Entity.Infrastructure; +using System.Data.SqlClient; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using Moq; +using NuGet.Services.Entities; +using NuGetGallery.Authentication; +using NuGetGallery.Infrastructure.Authentication; +using Xunit; + +#nullable enable + +namespace NuGetGallery.Services.Authentication +{ + public class FederatedCredentialServiceFacts + { + public class TheGenerateApiKeyAsyncMethod : FederatedCredentialServiceFacts + { + [Fact] + public async Task NoMatchingPolicyForNonExistentUser() + { + // Act + var result = await Target.GenerateApiKeyAsync("someone else", BearerToken); + + // Assert + Assert.Equal(GenerateApiKeyResultType.Unauthorized, result.Type); + Assert.Equal("No matching federated credential trust policy owned by user 'someone else' was found.", result.UserMessage); + } + + [Fact] + public async Task NoMatchingPolicyWhenEvaluatorFindsNoMatch() + { + // Arrange + FederatedCredentialEvaluator + .Setup(x => x.GetMatchingPolicyAsync(Policies, BearerToken)) + .ReturnsAsync(() => EvaluatedFederatedCredentialPolicies.NoMatchingPolicy([])); + + // Act + var result = await Target.GenerateApiKeyAsync(CurrentUser.Username, BearerToken); + + // Assert + Assert.Equal(GenerateApiKeyResultType.Unauthorized, result.Type); + Assert.Equal("No matching federated credential trust policy owned by user 'jim' was found.", result.UserMessage); + } + + [Fact] + public async Task UnauthorizedWhenEvaluatorReturnsBadToken() + { + // Arrange + FederatedCredentialEvaluator + .Setup(x => x.GetMatchingPolicyAsync(Policies, BearerToken)) + .ReturnsAsync(() => EvaluatedFederatedCredentialPolicies.BadToken("That token is missing a thing or two.")); + + // Act + var result = await Target.GenerateApiKeyAsync(CurrentUser.Username, BearerToken); + + // Assert + Assert.Equal(GenerateApiKeyResultType.Unauthorized, result.Type); + Assert.Equal("That token is missing a thing or two.", result.UserMessage); + } + + [Fact] + public async Task RejectsOrganizationCurrentUser() + { + // Arrange + CurrentUser = new Organization { Key = CurrentUser.Key, Username = CurrentUser.Username }; + + // Act + var result = await Target.GenerateApiKeyAsync(CurrentUser.Username, BearerToken); + + // Assert + Assert.Equal(GenerateApiKeyResultType.BadRequest, result.Type); + Assert.StartsWith("Generating fetching tokens directly for organizations is not supported.", result.UserMessage); + } + + [Fact] + public async Task RejectsDeletedUser() + { + // Arrange + CurrentUser.IsDeleted = true; + + // Act + var result = await Target.GenerateApiKeyAsync(CurrentUser.Username, BearerToken); + + // Assert + Assert.Equal(GenerateApiKeyResultType.BadRequest, result.Type); + Assert.Equal("The user 'jim' is deleted.", result.UserMessage); + } + + [Fact] + public async Task RejectsLockedUser() + { + // Arrange + CurrentUser.UserStatusKey = UserStatus.Locked; + + // Act + var result = await Target.GenerateApiKeyAsync(CurrentUser.Username, BearerToken); + + // Assert + Assert.Equal(GenerateApiKeyResultType.BadRequest, result.Type); + Assert.Equal("The user 'jim' is locked.", result.UserMessage); + } + + [Fact] + public async Task RejectsUnconfirmedUser() + { + // Arrange + CurrentUser.EmailAddress = null; + + // Act + var result = await Target.GenerateApiKeyAsync(CurrentUser.Username, BearerToken); + + // Assert + Assert.Equal(GenerateApiKeyResultType.BadRequest, result.Type); + Assert.Equal("The user 'jim' does not have a confirmed email address.", result.UserMessage); + } + + [Fact] + public async Task RejectsMissingPackageOwner() + { + // Arrange + UserService.Setup(x => x.FindByKey(PackageOwner.Key, false)).Returns(() => null!); + + // Act + var result = await Target.GenerateApiKeyAsync(CurrentUser.Username, BearerToken); + + // Assert + Assert.Equal(GenerateApiKeyResultType.BadRequest, result.Type); + Assert.Equal("The package owner of the match federated credential trust policy not longer exists.", result.UserMessage); + } + + [Fact] + public async Task RejectsDeletedPackageOwner() + { + // Arrange + PackageOwner.IsDeleted = true; + + // Act + var result = await Target.GenerateApiKeyAsync(CurrentUser.Username, BearerToken); + + // Assert + Assert.Equal(GenerateApiKeyResultType.BadRequest, result.Type); + Assert.Equal("The organization 'jim-org' is deleted.", result.UserMessage); + } + + [Fact] + public async Task RejectsLockedPackageOwner() + { + // Arrange + PackageOwner.UserStatusKey = UserStatus.Locked; + + // Act + var result = await Target.GenerateApiKeyAsync(CurrentUser.Username, BearerToken); + + // Assert + Assert.Equal(GenerateApiKeyResultType.BadRequest, result.Type); + Assert.Equal("The organization 'jim-org' is locked.", result.UserMessage); + } + + [Fact] + public async Task RejectsUnconfirmedPackageOwner() + { + // Arrange + PackageOwner.UserStatusKey = UserStatus.Locked; + + // Act + var result = await Target.GenerateApiKeyAsync(CurrentUser.Username, BearerToken); + + // Assert + Assert.Equal(GenerateApiKeyResultType.BadRequest, result.Type); + Assert.Equal("The organization 'jim-org' is locked.", result.UserMessage); + } + + [Fact] + public async Task RejectsPackageOwnerNotInFlight() + { + // Arrange + FeatureFlagService.Setup(x => x.CanUseFederatedCredentials(PackageOwner)).Returns(false); + + // Act + var result = await Target.GenerateApiKeyAsync(CurrentUser.Username, BearerToken); + + // Assert + Assert.Equal(GenerateApiKeyResultType.BadRequest, result.Type); + Assert.Equal("The package owner 'jim-org' is not enabled to use federated credentials.", result.UserMessage); + } + + [Fact] + public async Task RejectsCredentialWithInvalidScopes() + { + // Arrange + CredentialBuilder.Setup(x => x.VerifyScopes(CurrentUser, Credential.Scopes)).Returns(false); + + // Act + var result = await Target.GenerateApiKeyAsync(CurrentUser.Username, BearerToken); + + // Assert + Assert.Equal(GenerateApiKeyResultType.BadRequest, result.Type); + Assert.StartsWith("The scopes on the generated API key are not valid.", result.UserMessage); + CredentialBuilder.Verify(x => x.VerifyScopes(CurrentUser, Credential.Scopes), Times.Once); + + Assert.Null(Evaluation.MatchedPolicy.LastMatched); + } + + /// + /// See + /// for error codes. + /// + [Theory] + [InlineData(547)] + [InlineData(2601)] + [InlineData(2627)] + public async Task RejectsSaveViolatingUniqueConstraint(int sqlErrorCode) + { + // Arrange + var sqlException = GetSqlException(sqlErrorCode); + AuthenticationService + .Setup(x => x.AddCredential(CurrentUser, Credential)) + .ThrowsAsync(new DbUpdateException("Fail!", sqlException)); + + // Act + var result = await Target.GenerateApiKeyAsync(CurrentUser.Username, BearerToken); + + // Assert + Assert.Equal(GenerateApiKeyResultType.Unauthorized, result.Type); + Assert.Equal("This bearer token has already been used. A new bearer token must be used for each request.", result.UserMessage); + FederatedCredentialRepository.Verify(x => x.SaveFederatedCredentialAsync(Evaluation.FederatedCredential, false), Times.Once); + + Assert.Equal(new DateTime(2024, 10, 12, 12, 30, 0, DateTimeKind.Utc), Evaluation.MatchedPolicy.LastMatched); + } + + [Fact] + public async Task DoesNotHandleOtherSqlExceptions() + { + // Arrange + var exception = new DbUpdateException("Fail!", GetSqlException(123)); + AuthenticationService + .Setup(x => x.AddCredential(CurrentUser, Credential)) + .ThrowsAsync(exception); + + // Act + var actual = await Assert.ThrowsAsync(() => Target.GenerateApiKeyAsync(CurrentUser.Username, BearerToken)); + Assert.Same(actual, exception); + } + + [Fact] + public async Task ReturnsCreatedApiKey() + { + // Act + var result = await Target.GenerateApiKeyAsync(CurrentUser.Username, BearerToken); + + // Assert + Assert.Equal(GenerateApiKeyResultType.Created, result.Type); + Assert.Equal("secret", result.PlaintextApiKey); + Assert.Equal(new DateTimeOffset(2024, 10, 11, 9, 30, 0, TimeSpan.Zero), result.Expires); + + Assert.Same(PackageOwner, Evaluation.MatchedPolicy.PackageOwner); + Assert.Equal(new DateTime(2024, 10, 12, 12, 30, 0, DateTimeKind.Utc), Evaluation.MatchedPolicy.LastMatched); + + UserService.Verify(x => x.FindByUsername(CurrentUser.Username, false), Times.Once); + FederatedCredentialRepository.Verify(x => x.GetPoliciesCreatedByUser(CurrentUser.Key), Times.Once); + FederatedCredentialEvaluator.Verify(x => x.GetMatchingPolicyAsync(Policies, BearerToken), Times.Once); + UserService.Verify(x => x.FindByKey(PackageOwner.Key, false), Times.Once); + CredentialBuilder.Verify(x => x.CreateShortLivedApiKey(TimeSpan.FromMinutes(15), Evaluation.MatchedPolicy, out PlaintextApiKey), Times.Once); + CredentialBuilder.Verify(x => x.VerifyScopes(CurrentUser, Credential.Scopes), Times.Once); + FederatedCredentialRepository.Verify(x => x.SaveFederatedCredentialAsync(Evaluation.FederatedCredential, false), Times.Once); + AuthenticationService.Verify(x => x.AddCredential(CurrentUser, Credential), Times.Once); + } + } + + public FederatedCredentialServiceFacts() + { + UserService = new Mock(); + FederatedCredentialRepository = new Mock(); + FederatedCredentialEvaluator = new Mock(); + CredentialBuilder = new Mock(); + AuthenticationService = new Mock(); + FeatureFlagService = new Mock(); + DateTimeProvider = new Mock(); + Configuration = new Mock(); + + BearerToken = "my-token"; + CurrentUser = new User { Key = 1, Username = "jim", EmailAddress = "jim@localhost" }; + PackageOwner = new Organization { Key = 2, Username = "jim-org", EmailAddress = "jim-org@localhost" }; + Policies = new List(); + Evaluation = EvaluatedFederatedCredentialPolicies.NewMatchedPolicy( + results: [], + matchedPolicy: new FederatedCredentialPolicy { PackageOwnerUserKey = PackageOwner.Key }, + federatedCredential: new FederatedCredential()); + PlaintextApiKey = null; + Credential = new Credential { Scopes = [], Expires = new DateTime(2024, 10, 11, 9, 30, 0, DateTimeKind.Utc) }; + + UserService.Setup(x => x.FindByUsername(CurrentUser.Username, false)).Returns(() => CurrentUser); + UserService.Setup(x => x.FindByKey(PackageOwner.Key, false)).Returns(() => PackageOwner); + FederatedCredentialRepository.Setup(x => x.GetPoliciesCreatedByUser(CurrentUser.Key)).Returns(() => Policies); + FederatedCredentialEvaluator.Setup(x => x.GetMatchingPolicyAsync(Policies, BearerToken)).ReturnsAsync(() => Evaluation); + FeatureFlagService.Setup(x => x.CanUseFederatedCredentials(PackageOwner)).Returns(true); + CredentialBuilder + .Setup(x => x.CreateShortLivedApiKey(TimeSpan.FromMinutes(15), Evaluation.MatchedPolicy, out It.Ref.IsAny)) + .Returns(new CreateShortLivedApiKey((TimeSpan expires, FederatedCredentialPolicy policy, out string plaintextApiKey) => + { + plaintextApiKey = "secret"; + return Credential; + })); + CredentialBuilder.Setup(x => x.VerifyScopes(CurrentUser, Credential.Scopes)).Returns(true); + Configuration.Setup(x => x.ShortLivedApiKeyDuration).Returns(TimeSpan.FromMinutes(15)); + DateTimeProvider.Setup(x => x.UtcNow).Returns(new DateTime(2024, 10, 12, 12, 30, 0, DateTimeKind.Utc)); + + Target = new FederatedCredentialService( + UserService.Object, + FederatedCredentialRepository.Object, + FederatedCredentialEvaluator.Object, + CredentialBuilder.Object, + AuthenticationService.Object, + DateTimeProvider.Object, + FeatureFlagService.Object, + Configuration.Object); + } + + delegate Credential CreateShortLivedApiKey(TimeSpan expires, FederatedCredentialPolicy policy, out string plaintextApiKey); + + public Mock UserService { get; } + public Mock FederatedCredentialRepository { get; } + public Mock FederatedCredentialEvaluator { get; } + public Mock CredentialBuilder { get; } + public Mock AuthenticationService { get; } + public Mock FeatureFlagService { get; } + public Mock DateTimeProvider { get; } + public Mock Configuration { get; } + public string BearerToken { get; } + public User CurrentUser { get; set; } + public User PackageOwner { get; } + public List Policies { get; } + public EvaluatedFederatedCredentialPolicies Evaluation { get; } + public string? PlaintextApiKey; + public Credential Credential { get; } + public FederatedCredentialService Target { get; } + + public static SqlException GetSqlException(int sqlErrorCode) + { + var sqlError = Activator.CreateInstance( + typeof(SqlError), + BindingFlags.NonPublic | BindingFlags.Instance, + binder: null, + args: [sqlErrorCode, (byte)2, (byte)3, "server", "error", "procedure", 4], + culture: null); + var sqlErrorCollection = (SqlErrorCollection)Activator.CreateInstance(typeof(SqlErrorCollection), nonPublic: true); + typeof(SqlErrorCollection) + .GetMethod("Add", BindingFlags.NonPublic | BindingFlags.Instance) + .Invoke(sqlErrorCollection, [sqlError]); + var sqlException = (SqlException)typeof(SqlException) + .GetMethod( + "CreateException", + BindingFlags.Static | BindingFlags.NonPublic, + binder: null, + types: [typeof(SqlErrorCollection), typeof(string)], + modifiers: null) + .Invoke(null, [sqlErrorCollection, "16.0"]); + return sqlException; + } + } +}