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;
+ }
+ }
+}